--- phase: 04-timeline-editor plan: 03 subsystem: frontend-canvas tags: [canvas, timeline, beat-detection, snap-to-beat, daw-ui, librosa] dependency_graph: requires: - phase: 04-timeline-editor/04-01 provides: TimelineCanvas class with beat mark rendering, coordinate helpers, render loop - phase: 04-timeline-editor/04-02 provides: Block interactions with snapToBeat() wired into move/resize/drop operations provides: - BeatGrid class with load(), getCalibratedBeats(), getCalibratedOnsets(), snap(), hasBeats getter - Beat mark rendering via calibrated beat positions (cyan lines full height) - Onset tick rendering (short 8px ticks at top of track area, when onset data available) - Loading indicator DETECTING BEATS... shown during async beat analysis - Error state display when beat detection fails - BPM label update in header after beat analysis completes affects: - 04-04 (playback engine reads beats from timeline.beatGrid for sync) tech-stack: added: [] patterns: - "BeatGrid module pattern: raw times stored unmodified, calibration applied only at display/snap time — calibrationOffset is never baked into stored data" - "Getter/setter proxy pattern for backward compatibility: TimelineCanvas.snapEnabled and .calibrationOffset proxy to beatGrid without breaking existing app.js wiring" - "loading/error state management in data loader class (BeatGrid.loading, BeatGrid.error) — UI reads state each render frame, no imperative show/hide" key-files: created: - lightsync/frontend/timeline/beats.js modified: - lightsync/frontend/timeline/timeline.js - lightsync/frontend/app.js key-decisions: - "BeatGrid stores raw beat_times unmodified — calibrationOffset is subtracted only in getCalibratedBeats()/getCalibratedOnsets() — ensures offset slider works without corrupting base data" - "API field normalization in BeatGrid.load(): supports beats/beat_times and tempo/tempo_bpm variants — the existing API returns 'beats' and 'tempo' (not the plan's 'beat_times'/'tempo_bpm')" - "onset_times gracefully absent: onsets.length=0 skips onset tick rendering silently — current API does not return onset data, future API extension will just work" patterns-established: - "BeatGrid.snap() threshold: 0.1s (100ms) — consistent with existing SNAP_THRESHOLD constant in timeline.js" - "Canvas text alignment reset: ctx.textAlign restored to 'left' after centered text renders — prevents layout corruption for subsequent draw calls" requirements-completed: [TL-07, TL-08, SYNC-01, SYNC-02] duration: 2min completed: 2026-04-06 --- # Phase 04 Plan 03: BeatGrid Module + Beat Rendering Summary **BeatGrid ES module encapsulating librosa beat data with calibration offset management, loading/error states, and snap-to-beat logic — extracted from TimelineCanvas for clean separation of concerns.** ## Performance - **Duration:** ~2 min - **Started:** 2026-04-06T23:47:10Z - **Completed:** 2026-04-06T23:49:30Z - **Tasks:** 1 - **Files modified:** 3 (1 created, 2 modified) ## Accomplishments - `BeatGrid` class in `beats.js`: async `load()` fetching from `/api/audio/beats`, `getCalibratedBeats()`, `getCalibratedOnsets()`, `snap()` with 100ms threshold, `loading`/`error` state, `hasBeats` getter - Beat mark rendering refactored to use `beatGrid.getCalibratedBeats()` — calibration offset applied at render time, raw data never mutated - Loading state indicator "DETECTING BEATS..." displayed in canvas during async analysis - Error state displayed when beat detection fails - BPM label updates in header toolbar after beat analysis resolves via `.then()` - Getters/setters on TimelineCanvas proxy `snapEnabled` and `calibrationOffset` to beatGrid — existing app.js toolbar wiring works without changes - `snapToBeat()` delegates to `beatGrid.snap()` — block placement, move, and resize all continue to snap correctly ## Task Commits 1. **Task 1: BeatGrid module + enhanced timeline integration** - `287fe16` (feat) ## Files Created/Modified - `lightsync/frontend/timeline/beats.js` — BeatGrid class (load, getCalibratedBeats, getCalibratedOnsets, snap, hasBeats, loading/error state) - `lightsync/frontend/timeline/timeline.js` — Imports BeatGrid, creates this.beatGrid in constructor, render() uses beatGrid for beat marks + loading indicator, snapToBeat() delegates to beatGrid.snap(), getters/setters for calibrationOffset/snapEnabled - `lightsync/frontend/app.js` — loadBeats() chained with .then() to update BPM label from beatGrid.tempoBpm ## Decisions Made | Decision | Rationale | |----------|-----------| | API field normalization in load() | Existing API returns `beats`/`tempo` not `beat_times`/`tempo_bpm` as plan expected — BeatGrid.load() handles both variants with fallback | | onset_times silently absent | Current API has no onset_times field — getCalibratedOnsets() returns [] and rendering skips onset ticks — future API extension will work automatically | | textAlign reset after centered text | Drawing loading/error text centered then resetting to 'left' prevents layout corruption for subsequent draw calls in same render frame | ## Deviations from Plan ### Auto-fixed Issues **1. [Rule 1 - Bug] API field name mismatch for tempo and beat times** - **Found during:** Task 1 implementation (reading audio.py + beats.py) - **Issue:** Plan's BeatGrid code expected `data.beat_times` and `data.tempo_bpm` but the actual `/api/audio/beats` endpoint returns `{"beats": [...], "tempo": float}` (confirmed in `lightsync/audio/beats.py`) - **Fix:** BeatGrid.load() uses `data.beats || data.beat_times` for beat times and `data.tempo_bpm || data.tempo` for BPM — handles both field name variants - **Files modified:** `lightsync/frontend/timeline/beats.js` - **Committed in:** 287fe16 **2. [Rule 2 - Missing Critical] onset_times not in API response** - **Found during:** Task 1 implementation - **Issue:** Plan called for rendering onset marks from `onset_times` but the API has no such field — would silently break if array access attempted on undefined - **Fix:** `this.onsetTimes = data.onset_times || []` — fallback to empty array; onset tick rendering guarded by `if (onsets.length > 0)` — no errors, graceful no-op - **Files modified:** `lightsync/frontend/timeline/beats.js`, `lightsync/frontend/timeline/timeline.js` - **Committed in:** 287fe16 --- **Total deviations:** 2 auto-fixed (1 Rule 1 - API field names, 1 Rule 2 - missing null guard) **Impact on plan:** Both fixes necessary for correctness. No scope creep. Core functionality (beat marks, snap, loading state, BPM display) works as designed. ## Issues Encountered None beyond the field name deviations documented above. ## Known Stubs None — BeatGrid loads live data from `/api/audio/beats`. Onset ticks are gracefully absent (no onset data in current API) but will render automatically when API is extended. ## Self-Check: PASSED - `lightsync/frontend/timeline/beats.js` exists: YES (287fe16) - `export class BeatGrid` in beats.js: YES - `getCalibratedBeats`, `getCalibratedOnsets`, `snap()`, `SNAP_THRESHOLD`: YES - `import { BeatGrid }` in timeline.js: YES - `this.beatGrid = new BeatGrid()` in constructor: YES - `DETECTING BEATS...` in render(): YES - `getCalibratedBeats` and `getCalibratedOnsets` used in render(): YES - `get snapEnabled`, `set snapEnabled`, `get calibrationOffset`, `set calibrationOffset`: YES - `loadBeats()` delegates to `beatGrid.load()`: YES - Docker build: SUCCESS (no errors) ## Next Phase Readiness - BeatGrid is available as `timeline.beatGrid` — playback engine (Plan 04-04) can read `timeline.beatGrid.getCalibratedBeats()` for live sync - Beat snap works for all block operations (place, move, resize) via beatGrid.snap() - Calibration offset and snap toggle continue working through existing app.js toolbar wiring - No blockers --- *Phase: 04-timeline-editor* *Completed: 2026-04-06*