Feature: complex BPM detection via multiple approaches

This commit is contained in:
Dejvino 2026-01-04 22:17:41 +00:00
parent 48fa197b69
commit 89dc5db53c

View File

@ -38,6 +38,17 @@ export class MusicVisualizer extends SceneFeature {
this.beatPhase = 0;
this.measurePhase = 0;
this.averageLoudness = 0;
this.prevLoudness = 0;
this.prevLoudnessLows = 0;
this.prevLoudnessHighs = 0;
this.beatSignalAvg = 0;
// 3 Detectors: Lows (Bass), Highs (Snare/Hats), Flux (All)
this.detectors = [
{ name: 'low', lastTime: 0, threshold: 0.3, intervals: [], bpm: 120, weight: 1.0 },
{ name: 'high', lastTime: 0, threshold: 0.3, intervals: [], bpm: 120, weight: 0.5 },
{ name: 'flux', lastTime: 0, threshold: 0.3, intervals: [], bpm: 120, weight: 0.8 }
];
}
update(deltaTime) {
@ -95,40 +106,77 @@ export class MusicVisualizer extends SceneFeature {
state.music.averageLoudness = this.averageLoudness;
// --- Beat Detection & Auto-BPM ---
// Use Lows (Bass) for clearer beat detection
const beatSignal = state.music.loudnessLows || 0;
const beatAvg = state.music.loudnessLowsAverage || 0;
// Decay threshold based on time
this.beatThreshold -= deltaTime * 0.5;
const thresholdFloor = Math.max(0.15, beatAvg * 1.1);
if (this.beatThreshold < thresholdFloor) this.beatThreshold = thresholdFloor;
state.music.beatThreshold = this.beatThreshold;
// 1. Calculate derivatives for 3 bands
const dLows = Math.max(0, state.music.loudnessLows - this.prevLoudnessLows);
const dHighs = Math.max(0, state.music.loudnessHighs - this.prevLoudnessHighs);
const dAll = Math.max(0, state.music.loudness - this.prevLoudness);
this.prevLoudnessLows = state.music.loudnessLows;
this.prevLoudnessHighs = state.music.loudnessHighs;
this.prevLoudness = state.music.loudness;
const signals = [dLows * 10.0, dHighs * 10.0, dAll * 10.0];
const now = time;
const lastBeatInterval = now - this.lastBeatTime;
if (beatSignal > this.beatThreshold) {
if (lastBeatInterval > 0.25) { // Min interval (~240 BPM)
this.lastBeatTime = now;
this.beatThreshold = beatSignal * 1.2; // Bump threshold
let mainTrigger = false;
// Add only valid BPM range into history of beats
if (lastBeatInterval >= 0.2 && lastBeatInterval <= 1.5) {
this.beatIntervals.push(lastBeatInterval);
if (this.beatIntervals.length > 8) this.beatIntervals.shift();
// 2. Update Detectors
this.detectors.forEach((d, i) => {
const signal = signals[i];
// Decay threshold
d.threshold -= deltaTime * 1.5;
const floor = 0.15;
if (d.threshold < floor) d.threshold = floor;
const avgInterval = this.beatIntervals.reduce((a, b) => a + b, 0) / this.beatIntervals.length;
if (signal > d.threshold) {
const interval = now - d.lastTime;
// Debounce (Max 240 BPM)
if (interval > 0.25) {
d.lastTime = now;
d.threshold = signal * 1.2;
// Valid interval (30 - 300 BPM)
if (interval < 2.0) {
d.intervals.push(interval);
if (d.intervals.length > 8) d.intervals.shift();
const avgInterval = d.intervals.reduce((a, b) => a + b, 0) / d.intervals.length;
d.bpm = 60 / avgInterval;
}
// Smoothly adjust beat duration
state.music.beatDuration = THREE.MathUtils.lerp(state.music.beatDuration, avgInterval, 0.1);
state.music.measureDuration = state.music.beatDuration * 4;
state.music.bpm = Math.round(60 / state.music.beatDuration);
if (i === 0) mainTrigger = true; // Bass triggers visual beat
}
// Sync phase on beat
this.beatPhase = 0;
}
});
// 3. Combine Results
let bpmSum = 0;
let weightSum = 0;
this.detectors.forEach(d => {
let bpm = d.bpm;
// Normalize to 90-180 range for consensus
if (bpm > 0) {
while (bpm < 90) bpm *= 2;
while (bpm > 180) bpm /= 2;
bpmSum += bpm * d.weight;
weightSum += d.weight;
}
});
if (weightSum > 0) {
const targetBPM = bpmSum / weightSum;
const targetDuration = 60 / targetBPM;
state.music.beatDuration = THREE.MathUtils.lerp(state.music.beatDuration, targetDuration, deltaTime * 0.5);
state.music.measureDuration = state.music.beatDuration * 4;
state.music.bpm = Math.round(60 / state.music.beatDuration);
}
// Visuals use the Bass detector for immediate feedback
state.music.beatThreshold = this.detectors[0].threshold;
const lastBeatInterval = now - this.detectors[0].lastTime;
if (mainTrigger) {
this.beatPhase = 0;
}
// --- Phase Accumulation ---