--- phase: 04-timeline-editor plan: 03 type: execute wave: 3 depends_on: [04-01, 04-02] files_modified: - lightsync/frontend/timeline/beats.js - lightsync/frontend/timeline/timeline.js - lightsync/frontend/app.js autonomous: true requirements: [TL-07, TL-08, SYNC-01, SYNC-02] must_haves: truths: - "Beat marks from librosa appear as thin cyan lines on the timeline canvas" - "Blocks snap to calibrated beat positions when placed, moved, or resized" - "Beat calibration offset slider adjusts beat mark display in realtime" - "Snap toggle enables/disables snapping behavior" - "DETECTING BEATS... indicator shows while beat analysis is loading" artifacts: - path: "lightsync/frontend/timeline/beats.js" provides: "BeatGrid class for beat data management, calibration, and snap logic" exports: ["BeatGrid"] min_lines: 40 key_links: - from: "lightsync/frontend/timeline/beats.js" to: "/api/audio/beats" via: "fetch beat_times and onset_times from backend" pattern: "api/audio/beats" - from: "lightsync/frontend/timeline/timeline.js" to: "lightsync/frontend/timeline/beats.js" via: "timeline.beatGrid.snap() called during block placement/move/resize" pattern: "beatGrid\\.snap" --- Extract beat management into a dedicated BeatGrid module, enhance beat mark rendering with loading states and onset marks, and ensure snap-to-beat is properly integrated with all block interactions. Purpose: Beat detection and snap are core requirements (TL-07, TL-08, SYNC-01, SYNC-02). Extracting into a module makes calibration offset management clean and testable. Output: BeatGrid module with snap logic, enhanced beat rendering, loading indicator. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/phases/04-timeline-editor/04-CONTEXT.md @.planning/phases/04-timeline-editor/04-RESEARCH.md @.planning/phases/04-timeline-editor/04-UI-SPEC.md @.planning/phases/04-timeline-editor/04-01-SUMMARY.md @.planning/phases/04-timeline-editor/04-02-SUMMARY.md @lightsync/frontend/timeline/timeline.js @lightsync/frontend/app.js export class TimelineCanvas { beatTimes: float[] calibrationOffset: float snapEnabled: boolean snapToBeat(t): float // basic snap method from Plan 02 loadBeats(audioPath): Promise render(): void // draws beat marks in layer 4 } GET /api/audio/beats?path=/app/shows/song.mp3 Response: { beat_times: float[], onset_times: float[], tempo_bpm: float } Task 1: BeatGrid module + enhanced timeline integration lightsync/frontend/timeline/beats.js, lightsync/frontend/timeline/timeline.js, lightsync/frontend/app.js lightsync/frontend/timeline/timeline.js, lightsync/frontend/app.js, lightsync/api/audio.py **1. Create `lightsync/frontend/timeline/beats.js` — BeatGrid class:** ```javascript export class BeatGrid { constructor() { this.beatTimes = []; // raw beat times from librosa (never mutated) this.onsetTimes = []; // raw onset times this.tempoBpm = null; this.calibrationOffset = 0.03; // seconds, default 30ms this.snapEnabled = true; this.loading = false; this.error = null; } /** Load beat data from backend API */ async load(audioPath) { this.loading = true; this.error = null; this.beatTimes = []; this.onsetTimes = []; this.tempoBpm = null; try { const res = await fetch(`/api/audio/beats?path=${encodeURIComponent(audioPath)}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); this.beatTimes = data.beat_times || []; this.onsetTimes = data.onset_times || []; this.tempoBpm = data.tempo_bpm || null; } catch (e) { this.error = 'BEAT DETECTION FAILED'; console.warn('[beats]', e); } finally { this.loading = false; } } /** Get calibrated beat times for display */ getCalibratedBeats() { return this.beatTimes.map(t => t - this.calibrationOffset); } /** Get calibrated onset times for display */ getCalibratedOnsets() { return this.onsetTimes.map(t => t - this.calibrationOffset); } /** Snap a time value to nearest calibrated beat within threshold */ snap(t) { if (!this.snapEnabled || this.beatTimes.length === 0) return t; const SNAP_THRESHOLD = 0.1; // 100ms const calibrated = this.getCalibratedBeats(); let nearest = null; let minDist = Infinity; for (const beat of calibrated) { const d = Math.abs(t - beat); if (d < minDist) { minDist = d; nearest = beat; } } return (nearest !== null && minDist < SNAP_THRESHOLD) ? nearest : t; } /** Check if beats are loaded and available */ get hasBeats() { return this.beatTimes.length > 0; } } ``` **2. Refactor timeline.js to use BeatGrid:** At the top of timeline.js, add import: ```javascript import { BeatGrid } from './beats.js'; ``` In the constructor, replace the individual beat-related properties: - Remove: `this.beatTimes = []`, `this.calibrationOffset = 0.03`, `this.snapEnabled = true` - Add: `this.beatGrid = new BeatGrid();` Update `snapToBeat(t)` method to delegate to beatGrid: ```javascript snapToBeat(t) { return this.beatGrid.snap(t); } ``` Update `loadBeats(audioPath)` to delegate: ```javascript async loadBeats(audioPath) { await this.beatGrid.load(audioPath); } ``` Update beat mark rendering in `render()` (layer 4) to use `this.beatGrid.getCalibratedBeats()` instead of `this.beatTimes`: ```javascript // Beat marks if (this.beatGrid.hasBeats) { const calibrated = this.beatGrid.getCalibratedBeats(); ctx.strokeStyle = 'rgba(0, 255, 255, 0.4)'; ctx.lineWidth = 1; for (const bt of calibrated) { const x = this.timeToX(bt); if (x < HEADER_WIDTH || x > width) continue; ctx.beginPath(); ctx.moveTo(x, TIME_AXIS_HEIGHT); ctx.lineTo(x, height); ctx.stroke(); } // Also draw onset marks as dimmer, shorter ticks const onsets = this.beatGrid.getCalibratedOnsets(); ctx.strokeStyle = 'rgba(0, 255, 255, 0.15)'; for (const ot of onsets) { const x = this.timeToX(ot); if (x < HEADER_WIDTH || x > width) continue; ctx.beginPath(); ctx.moveTo(x, TIME_AXIS_HEIGHT); ctx.lineTo(x, TIME_AXIS_HEIGHT + 8); // short tick at top ctx.stroke(); } } else if (this.beatGrid.loading) { // Loading indicator ctx.fillStyle = '#555555'; ctx.font = '10px monospace'; ctx.textAlign = 'center'; ctx.fillText('DETECTING BEATS...', HEADER_WIDTH + (width - HEADER_WIDTH) / 2, TIME_AXIS_HEIGHT + 14); } else if (this.beatGrid.error) { ctx.fillStyle = '#555555'; ctx.font = '10px monospace'; ctx.textAlign = 'center'; ctx.fillText(this.beatGrid.error, HEADER_WIDTH + (width - HEADER_WIDTH) / 2, TIME_AXIS_HEIGHT + 14); } ``` Add getters/setters to TimelineCanvas for backward compatibility with app.js toolbar wiring: ```javascript get snapEnabled() { return this.beatGrid.snapEnabled; } set snapEnabled(v) { this.beatGrid.snapEnabled = v; } get calibrationOffset() { return this.beatGrid.calibrationOffset; } set calibrationOffset(v) { this.beatGrid.calibrationOffset = v; } ``` **3. Update app.js:** The existing toolbar wiring (zoom-slider, beat-offset, btn-snap) from Plan 01 should continue to work through the getters/setters. No changes needed for those. If tempo is available after beat loading, show it in the header or toolbar. Add after `timeline.loadBeats(path)`: ```javascript // After beats load, update tempo display if available timeline.loadBeats(path).then(() => { if (timeline.beatGrid.tempoBpm) { const label = document.getElementById('beat-label'); if (label) label.textContent = `${Math.round(timeline.beatGrid.tempoBpm)} BPM`; } }); ``` Replace the direct `timeline.loadBeats(path)` call in the audio loadedmetadata handler with the above pattern (call loadBeats and chain the tempo display update). cd /home/claude/led2 && test -f lightsync/frontend/timeline/beats.js && grep -q "export class BeatGrid" lightsync/frontend/timeline/beats.js && grep -q "getCalibratedBeats" lightsync/frontend/timeline/beats.js && grep -q "snap(" lightsync/frontend/timeline/beats.js && grep -q "SNAP_THRESHOLD" lightsync/frontend/timeline/beats.js && grep -q "calibrationOffset" lightsync/frontend/timeline/beats.js && grep -q "import.*BeatGrid" lightsync/frontend/timeline/timeline.js && grep -q "beatGrid" lightsync/frontend/timeline/timeline.js && grep -q "DETECTING BEATS" lightsync/frontend/timeline/timeline.js && grep -q "getCalibratedBeats\|getCalibratedOnsets" lightsync/frontend/timeline/timeline.js && echo "ALL PASS" - lightsync/frontend/timeline/beats.js exports BeatGrid class - BeatGrid has load(audioPath) async method fetching from /api/audio/beats - BeatGrid has getCalibratedBeats() returning beat_times adjusted by calibrationOffset - BeatGrid has getCalibratedOnsets() returning onset_times adjusted by calibrationOffset - BeatGrid has snap(t) method with 0.1s threshold using calibrated beats - BeatGrid has loading, error state properties - BeatGrid stores raw beatTimes unmodified (calibration applied only at display/snap time) - timeline.js imports BeatGrid and creates this.beatGrid in constructor - timeline.js render() draws calibrated beat marks in rgba(0,255,255,0.4) - timeline.js render() draws onset marks in rgba(0,255,255,0.15) as short ticks - timeline.js render() shows "DETECTING BEATS..." when beatGrid.loading is true - timeline.js render() shows beatGrid.error message when error is set - timeline.js snapToBeat() delegates to this.beatGrid.snap() - timeline.js has snapEnabled and calibrationOffset getters/setters proxying to beatGrid - BPM display updates in header after beat analysis completes BeatGrid module manages all beat data with calibration offset. Beat marks and onset marks render on timeline. Snap-to-beat works through BeatGrid. Loading/error states displayed. BPM shown in header. 1. Docker rebuild and restart 2. Load an audio file 3. Verify "DETECTING BEATS..." appears briefly during analysis 4. After analysis: cyan beat marks appear as vertical lines on timeline 5. Shorter/dimmer onset ticks appear at top of tracks 6. BPM label updates in header (e.g. "120 BPM") 7. Adjust BEAT OFFSET slider: beat marks shift left/right in realtime 8. Place a block near a beat: block snaps to beat position 9. Toggle SNAP off: block places at exact drop position without snapping 10. Toggle SNAP on: snapping resumes - Beat marks render on timeline from librosa data - Calibration offset shifts beat display without modifying raw data - Snap-to-beat works for place, move, and resize operations - Snap can be toggled on/off - Loading and error states shown for beat detection After completion, create `.planning/phases/04-timeline-editor/04-03-SUMMARY.md`