--- phase: 04-timeline-editor plan: 01 subsystem: frontend-canvas tags: [canvas, timeline, daw-ui, audio, waveform] dependency_graph: requires: [Phase 03 communication protocol — UDP sender, animation encoders] provides: [TimelineCanvas class, timeline rendering foundation] affects: [lightsync/frontend, lightsync/models/show.py] tech_stack: added: [HTML5 Canvas 2D API, ES module import/export, requestAnimationFrame render loop] patterns: [HiDPI canvas with devicePixelRatio, coordinate system with HEADER_WIDTH/TIME_AXIS_HEIGHT offsets, layer-ordered canvas render] key_files: created: - lightsync/frontend/timeline/timeline.js modified: - lightsync/models/show.py - lightsync/frontend/index.html - lightsync/frontend/style.css - lightsync/frontend/app.js decisions: - beats API returns 'beats' field (not 'beat_times') — loadBeats() uses data.beats with data.beat_times as fallback - Auto-scroll threshold at 80% canvas width, cursor repositioned to 20% — smooth follow during playback - Empty state messages rendered inside canvas (not DOM overlay) — avoids z-index issues metrics: duration: 4min completed: 2026-04-06 tasks: 2 files: 5 --- # Phase 04 Plan 01: Timeline Canvas Foundation Summary **One-liner:** HTML5 canvas DAW timeline with per-device tracks, waveform background, beat marks, and realtime playback cursor synced to audio.currentTime via requestAnimationFrame. ## What Was Built - **CueModel.duration** (float = 4.0) — block length in seconds, Pydantic default handles old show files - **AnalysisBlock.calibration_offset** (float = 0.03) — compensates librosa latency bias, applied when rendering beat marks - **TimelineCanvas class** (`lightsync/frontend/timeline/timeline.js`) — 280+ line canvas engine: - Coordinate system: `timeToX()`, `xToTime()`, `trackIndexToY()`, `yToTrackIndex()` - HiDPI rendering via `devicePixelRatio` in `_resizeCanvas()` - Layer render order: background → track banding → waveform → beat marks → cue blocks → drag ghost → headers → time axis → cursor - Auto-scroll: cursor triggers viewport shift when it reaches 80% of canvas width - Mouse wheel horizontal scroll - Empty state messages for no-devices and no-audio cases - **HTML layout restructured** — main-area now contains toolbar → canvas → inspector strip; ANIMATIONS panel moved to sidebar - **CSS added** — timeline toolbar, canvas fill, inspector strip, snap button active state - **app.js wired** — imports TimelineCanvas, creates instance, wires zoom/beat-offset/snap controls, populates animation palette dynamically ## Decisions Made | Decision | Rationale | |----------|-----------| | beats API 'beats' field (not 'beat_times') | API returns `{"beats": [...]}` — plan expected `beat_times` — auto-fixed (Rule 1) | | Auto-scroll at 80%/20% threshold | Standard DAW follow behavior — keeps cursor in view without jerky jumps | | Empty state in canvas | Simpler than DOM overlay, no z-index conflicts with toolbar/inspector | ## Deviations from Plan ### Auto-fixed Issues **1. [Rule 1 - Bug] beats API field name mismatch** - **Found during:** Task 2 implementation - **Issue:** Plan specified `data.beat_times` but `/api/audio/beats` returns `{"beats": [...]}` (from `analyze()` in `beats.py`) - **Fix:** `loadBeats()` uses `data.beats || data.beat_times || []` — handles both field names - **Files modified:** `lightsync/frontend/timeline/timeline.js` - **Commit:** efa6123 ## Known Stubs None — timeline renders live data from `/api/devices`, `/api/audio/waveform`, and `/api/audio/beats`. Cue blocks will be empty (`track.cues = []`) until Plan 04-02 adds block drag-and-drop, but that is expected for this phase. ## Self-Check: PASSED - `lightsync/frontend/timeline/timeline.js` exists: YES (efa6123) - `lightsync/models/show.py` has `duration: float = 4.0`: YES (454fddc) - `lightsync/models/show.py` has `calibration_offset: float = 0.03`: YES (454fddc) - `lightsync/frontend/index.html` has `id="timeline-canvas"`: YES - `lightsync/frontend/style.css` has `#timeline-canvas`, `.block-inspector`: YES - `lightsync/frontend/app.js` imports TimelineCanvas: YES - Docker build and startup: CLEAN (no errors) - Python model import test: PASSED (`CueModel(timestamp=1.0).duration == 4.0`)