---
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