- 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
68 lines
2.5 KiB
JavaScript
68 lines
2.5 KiB
JavaScript
// 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;
|
|
}
|
|
}
|