From 89dc5db53c73a6fe73c3207e7daca5c9de0b6054 Mon Sep 17 00:00:00 2001 From: Dejvino Date: Sun, 4 Jan 2026 22:17:41 +0000 Subject: [PATCH] Feature: complex BPM detection via multiple approaches --- party-stage/src/scene/music-visualizer.js | 102 ++++++++++++++++------ 1 file changed, 75 insertions(+), 27 deletions(-) diff --git a/party-stage/src/scene/music-visualizer.js b/party-stage/src/scene/music-visualizer.js index df3002d..84b1e27 100644 --- a/party-stage/src/scene/music-visualizer.js +++ b/party-stage/src/scene/music-visualizer.js @@ -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 ---