docs(04): create phase plan
This commit is contained in:
290
.planning/phases/04-timeline-editor/04-03-PLAN.md
Normal file
290
.planning/phases/04-timeline-editor/04-03-PLAN.md
Normal file
@@ -0,0 +1,290 @@
|
||||
---
|
||||
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>
|
||||
Reference in New Issue
Block a user