From 287fe16a562ebf215dea8a34c617c3452c735d16 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Apr 2026 23:48:27 +0000 Subject: [PATCH] feat(04-03): BeatGrid module + enhanced beat rendering + loading states - Create beats.js with BeatGrid class: load(), getCalibratedBeats(), getCalibratedOnsets(), snap(), hasBeats getter - BeatGrid manages calibrationOffset and snapEnabled state - snap() uses 100ms threshold against calibrated beat positions - Handles API field variants: beats/beat_times, tempo/tempo_bpm - Refactor timeline.js: replace beatTimes/calibrationOffset/snapEnabled with this.beatGrid - Add getters/setters for calibrationOffset and snapEnabled (backward compat with app.js) - render() now shows DETECTING BEATS... loading indicator and error state - render() draws beat marks via getCalibratedBeats(), onset ticks via getCalibratedOnsets() - snapToBeat() delegates to beatGrid.snap() - app.js chains BPM label update after loadBeats() resolves --- lightsync/frontend/app.js | 8 ++- lightsync/frontend/timeline/beats.js | 67 +++++++++++++++++++++++++ lightsync/frontend/timeline/timeline.js | 64 ++++++++++++++--------- 3 files changed, 115 insertions(+), 24 deletions(-) create mode 100644 lightsync/frontend/timeline/beats.js diff --git a/lightsync/frontend/app.js b/lightsync/frontend/app.js index 494e5da..a758570 100644 --- a/lightsync/frontend/app.js +++ b/lightsync/frontend/app.js @@ -273,7 +273,13 @@ function initTimeline() { const path = `/app/shows/${filename}`; timeline.audioDuration = audio.duration; timeline.loadWaveform(path); - timeline.loadBeats(path); + // Load beats and update BPM display when analysis completes + timeline.loadBeats(path).then(() => { + if (timeline.beatGrid.tempoBpm) { + const label = document.getElementById('beat-label'); + if (label) label.textContent = `${Math.round(timeline.beatGrid.tempoBpm)} BPM`; + } + }); }); // Zoom slider diff --git a/lightsync/frontend/timeline/beats.js b/lightsync/frontend/timeline/beats.js new file mode 100644 index 0000000..4c4a6a6 --- /dev/null +++ b/lightsync/frontend/timeline/beats.js @@ -0,0 +1,67 @@ +// BeatGrid — beat data management, calibration, and snap logic for LightSync +// Fetches beat data from /api/audio/beats and provides calibrated snap-to-beat. + +export class BeatGrid { + constructor() { + this.beatTimes = []; // raw beat times from librosa (never mutated) + this.onsetTimes = []; // raw onset times (empty — API does not provide onset data yet) + this.tempoBpm = null; // detected tempo in BPM + this.calibrationOffset = 0.03; // seconds, default 30ms latency compensation + 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(); + // API returns { beats: [...], tempo: float } + // Support both field name variants for forward compatibility + this.beatTimes = data.beats || data.beat_times || []; + this.onsetTimes = data.onset_times || []; + this.tempoBpm = data.tempo_bpm || data.tempo || null; + } catch (e) { + this.error = 'BEAT DETECTION FAILED'; + console.warn('[beats]', e); + } finally { + this.loading = false; + } + } + + /** Get calibrated beat times for display (offset subtracted) */ + getCalibratedBeats() { + return this.beatTimes.map(t => t - this.calibrationOffset); + } + + /** Get calibrated onset times for display (offset subtracted) */ + 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; + } +} diff --git a/lightsync/frontend/timeline/timeline.js b/lightsync/frontend/timeline/timeline.js index 0e73a87..ec236e7 100644 --- a/lightsync/frontend/timeline/timeline.js +++ b/lightsync/frontend/timeline/timeline.js @@ -4,6 +4,7 @@ import { CommandHistory } from './history.js'; import { PlaceBlockCommand, MoveBlockCommand, ResizeBlockCommand, DeleteBlockCommand, setCurrentShowId } from './commands.js'; +import { BeatGrid } from './beats.js'; const PIXELS_PER_SECOND = 100; // default, adjustable via zoom const TRACK_HEIGHT = 48; @@ -24,9 +25,7 @@ class TimelineCanvas { this.tracks = []; // [{device_id, device_name, strip_type, cues: []}] this.waveformPeaks = []; this.audioDuration = 0; - this.beatTimes = []; - this.calibrationOffset = 0.03; - this.snapEnabled = true; + this.beatGrid = new BeatGrid(); this.selectedBlock = null; this._dragState = null; this._onSelectBlock = null; // callback(cue) for inspector @@ -137,18 +136,44 @@ class TimelineCanvas { } // 4. Beat marks - if (this.beatTimes.length > 0) { + if (this.beatGrid.hasBeats) { + const calibrated = this.beatGrid.getCalibratedBeats(); ctx.strokeStyle = 'rgba(0, 255, 255, 0.4)'; ctx.lineWidth = 1; - for (const beatTime of this.beatTimes) { - const displayTime = beatTime - this.calibrationOffset; - const x = this.timeToX(displayTime); + for (const bt of calibrated) { + const x = this.timeToX(bt); if (x < HEADER_WIDTH || x > W) continue; ctx.beginPath(); ctx.moveTo(x, TIME_AXIS_HEIGHT); ctx.lineTo(x, H); ctx.stroke(); } + // Also draw onset marks as dimmer, shorter ticks (if onset data available) + const onsets = this.beatGrid.getCalibratedOnsets(); + if (onsets.length > 0) { + ctx.strokeStyle = 'rgba(0, 255, 255, 0.15)'; + for (const ot of onsets) { + const x = this.timeToX(ot); + if (x < HEADER_WIDTH || x > W) 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 + (W - HEADER_WIDTH) / 2, TIME_AXIS_HEIGHT + 14); + ctx.textAlign = 'left'; + } else if (this.beatGrid.error) { + ctx.fillStyle = '#555555'; + ctx.font = '10px monospace'; + ctx.textAlign = 'center'; + ctx.fillText(this.beatGrid.error, HEADER_WIDTH + (W - HEADER_WIDTH) / 2, TIME_AXIS_HEIGHT + 14); + ctx.textAlign = 'left'; } // 5. Block rendering @@ -349,16 +374,16 @@ class TimelineCanvas { // ── Snap to beat ────────────────────────────────────────────────────────── snapToBeat(t) { - if (!this.snapEnabled || this.beatTimes.length === 0) return t; - let nearest = null, minDist = Infinity; - for (const beat of this.beatTimes) { - const adjusted = beat - this.calibrationOffset; - const d = Math.abs(t - adjusted); - if (d < minDist) { minDist = d; nearest = adjusted; } - } - return (nearest !== null && minDist < SNAP_THRESHOLD) ? nearest : t; + return this.beatGrid.snap(t); } + // ── Getters/setters 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; } + // ── Interaction setup ───────────────────────────────────────────────────── _setupInteractions() { @@ -587,14 +612,7 @@ class TimelineCanvas { } async loadBeats(audioPath) { - try { - const res = await fetch(`/api/audio/beats?path=${encodeURIComponent(audioPath)}`); - const data = await res.json(); - // API returns 'beats' field (list of timestamps in seconds) - this.beatTimes = data.beats || data.beat_times || []; - } catch (e) { - console.warn('[beats]', e); - } + await this.beatGrid.load(audioPath); } }