// 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; } }