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
This commit is contained in:
@@ -273,7 +273,13 @@ function initTimeline() {
|
|||||||
const path = `/app/shows/${filename}`;
|
const path = `/app/shows/${filename}`;
|
||||||
timeline.audioDuration = audio.duration;
|
timeline.audioDuration = audio.duration;
|
||||||
timeline.loadWaveform(path);
|
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
|
// Zoom slider
|
||||||
|
|||||||
67
lightsync/frontend/timeline/beats.js
Normal file
67
lightsync/frontend/timeline/beats.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import { CommandHistory } from './history.js';
|
import { CommandHistory } from './history.js';
|
||||||
import { PlaceBlockCommand, MoveBlockCommand, ResizeBlockCommand, DeleteBlockCommand, setCurrentShowId } from './commands.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 PIXELS_PER_SECOND = 100; // default, adjustable via zoom
|
||||||
const TRACK_HEIGHT = 48;
|
const TRACK_HEIGHT = 48;
|
||||||
@@ -24,9 +25,7 @@ class TimelineCanvas {
|
|||||||
this.tracks = []; // [{device_id, device_name, strip_type, cues: []}]
|
this.tracks = []; // [{device_id, device_name, strip_type, cues: []}]
|
||||||
this.waveformPeaks = [];
|
this.waveformPeaks = [];
|
||||||
this.audioDuration = 0;
|
this.audioDuration = 0;
|
||||||
this.beatTimes = [];
|
this.beatGrid = new BeatGrid();
|
||||||
this.calibrationOffset = 0.03;
|
|
||||||
this.snapEnabled = true;
|
|
||||||
this.selectedBlock = null;
|
this.selectedBlock = null;
|
||||||
this._dragState = null;
|
this._dragState = null;
|
||||||
this._onSelectBlock = null; // callback(cue) for inspector
|
this._onSelectBlock = null; // callback(cue) for inspector
|
||||||
@@ -137,18 +136,44 @@ class TimelineCanvas {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. Beat marks
|
// 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.strokeStyle = 'rgba(0, 255, 255, 0.4)';
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
for (const beatTime of this.beatTimes) {
|
for (const bt of calibrated) {
|
||||||
const displayTime = beatTime - this.calibrationOffset;
|
const x = this.timeToX(bt);
|
||||||
const x = this.timeToX(displayTime);
|
|
||||||
if (x < HEADER_WIDTH || x > W) continue;
|
if (x < HEADER_WIDTH || x > W) continue;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(x, TIME_AXIS_HEIGHT);
|
ctx.moveTo(x, TIME_AXIS_HEIGHT);
|
||||||
ctx.lineTo(x, H);
|
ctx.lineTo(x, H);
|
||||||
ctx.stroke();
|
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
|
// 5. Block rendering
|
||||||
@@ -349,16 +374,16 @@ class TimelineCanvas {
|
|||||||
// ── Snap to beat ──────────────────────────────────────────────────────────
|
// ── Snap to beat ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
snapToBeat(t) {
|
snapToBeat(t) {
|
||||||
if (!this.snapEnabled || this.beatTimes.length === 0) return t;
|
return this.beatGrid.snap(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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 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 ─────────────────────────────────────────────────────
|
// ── Interaction setup ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
_setupInteractions() {
|
_setupInteractions() {
|
||||||
@@ -587,14 +612,7 @@ class TimelineCanvas {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async loadBeats(audioPath) {
|
async loadBeats(audioPath) {
|
||||||
try {
|
await this.beatGrid.load(audioPath);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user