12 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 04-timeline-editor | 03 | execute | 3 |
|
|
true |
|
|
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.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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
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:**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:
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:
snapToBeat(t) {
return this.beatGrid.snap(t);
}
Update loadBeats(audioPath) to delegate:
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:
// 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:
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):
// 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"
<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>
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.
<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>