291 lines
12 KiB
Markdown
291 lines
12 KiB
Markdown
---
|
|
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"
|
|
---
|
|
|
|
<objective>
|
|
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.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.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
|
|
|
|
<interfaces>
|
|
<!-- From timeline.js (after Plan 01+02) -->
|
|
export class TimelineCanvas {
|
|
beatTimes: float[]
|
|
calibrationOffset: float
|
|
snapEnabled: boolean
|
|
snapToBeat(t): float // basic snap method from Plan 02
|
|
loadBeats(audioPath): Promise<void>
|
|
render(): void // draws beat marks in layer 4
|
|
}
|
|
|
|
<!-- From /api/audio/beats endpoint (Phase 2) -->
|
|
GET /api/audio/beats?path=/app/shows/song.mp3
|
|
Response: { beat_times: float[], onset_times: float[], tempo_bpm: float }
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: BeatGrid module + enhanced timeline integration</name>
|
|
<files>lightsync/frontend/timeline/beats.js, lightsync/frontend/timeline/timeline.js, lightsync/frontend/app.js</files>
|
|
<read_first>lightsync/frontend/timeline/timeline.js, lightsync/frontend/app.js, lightsync/api/audio.py</read_first>
|
|
<action>
|
|
**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).
|
|
</action>
|
|
<verify>
|
|
<automated>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"</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- 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
|
|
</acceptance_criteria>
|
|
<done>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.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
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
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- 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
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/04-timeline-editor/04-03-SUMMARY.md`
|
|
</output>
|