Files
led2/.planning/phases/04-timeline-editor/04-03-PLAN.md
2026-04-06 23:24:11 +00:00

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
04-01
04-02
lightsync/frontend/timeline/beats.js
lightsync/frontend/timeline/timeline.js
lightsync/frontend/app.js
true
TL-07
TL-08
SYNC-01
SYNC-02
truths artifacts key_links
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
path provides exports min_lines
lightsync/frontend/timeline/beats.js BeatGrid class for beat data management, calibration, and snap logic
BeatGrid
40
from to via pattern
lightsync/frontend/timeline/beats.js /api/audio/beats fetch beat_times and onset_times from backend api/audio/beats
from to via pattern
lightsync/frontend/timeline/timeline.js lightsync/frontend/timeline/beats.js timeline.beatGrid.snap() called during block placement/move/resize 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.

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

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

<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>
After completion, create `.planning/phases/04-timeline-editor/04-03-SUMMARY.md`