Tweak: Beat detection and blackout detection algos

This commit is contained in:
Dejvino 2026-01-04 15:54:45 +00:00
parent 7c18a3db11
commit cae867fd9f
8 changed files with 366 additions and 92 deletions

View File

@ -103,6 +103,12 @@ export class ConfigUI extends SceneFeature {
rightContainer.appendChild(row);
};
// Debug Panel Toggle
createToggle('Debug Panel', 'debugPanelEnabled', (enabled) => {
const debugPanel = sceneFeatureManager.features.find(f => f.constructor.name === 'DebugPanel');
if (debugPanel) debugPanel.setVisibility(enabled);
});
// Blackout Toggle
createToggle('BLACKOUT', 'blackout', (enabled) => {
state.blackoutMode = enabled;
@ -481,7 +487,8 @@ export class ConfigUI extends SceneFeature {
laserColorMode: 'RUNNING',
guestCount: 150,
blackout: false,
djHat: 'None'
djHat: 'None',
debugPanelEnabled: false
};
for (const key in defaults) {
state.config[key] = defaults[key];

View File

@ -1,7 +1,8 @@
import { SceneFeature } from './SceneFeature.js';
import sceneFeatureManager from './SceneFeatureManager.js';
import { state } from '../state.js';
export class FPSCounter extends SceneFeature {
export class DebugPanel extends SceneFeature {
constructor() {
super();
this.frames = 0;
@ -15,10 +16,17 @@ export class FPSCounter extends SceneFeature {
this.memCanvas = null;
this.memCtx = null;
this.memHistory = [];
this.musicTextElement = null;
this.musicCanvas = null;
this.musicCtx = null;
this.musicHistory = [];
sceneFeatureManager.register(this);
}
init() {
const graphWidth = 150;
const graphHeight = 80;
// Create the FPS display container
this.fpsElement = document.createElement('div');
Object.assign(this.fpsElement.style, {
@ -29,6 +37,7 @@ export class FPSCounter extends SceneFeature {
padding: '5px',
background: 'rgba(0, 0, 0, 0.5)',
pointerEvents: 'none',
// Allow pointer events on the panel itself if we want to interact, but usually debug panels are pass-through
userSelect: 'none',
display: 'flex',
flexDirection: 'column',
@ -49,10 +58,10 @@ export class FPSCounter extends SceneFeature {
// Graph Canvas
this.canvas = document.createElement('canvas');
this.canvas.width = 100;
this.canvas.width = graphWidth;
this.canvas.height = 40;
Object.assign(this.canvas.style, {
width: '100px',
width: graphWidth + 'px',
height: '40px',
background: '#222',
border: '1px solid #444'
@ -75,10 +84,10 @@ export class FPSCounter extends SceneFeature {
// Memory Graph Canvas
this.memCanvas = document.createElement('canvas');
this.memCanvas.width = 100;
this.memCanvas.width = graphWidth;
this.memCanvas.height = 40;
Object.assign(this.memCanvas.style, {
width: '100px',
width: graphWidth + 'px',
height: '40px',
background: '#222',
border: '1px solid #444'
@ -86,10 +95,42 @@ export class FPSCounter extends SceneFeature {
this.memCtx = this.memCanvas.getContext('2d');
this.fpsElement.appendChild(this.memCanvas);
// Music Text Display
this.musicTextElement = document.createElement('div');
Object.assign(this.musicTextElement.style, {
color: '#ff00ff',
fontFamily: 'monospace',
fontSize: '16px',
fontWeight: 'bold',
marginBottom: '2px',
marginTop: '5px'
});
this.musicTextElement.innerText = 'MUSIC';
this.fpsElement.appendChild(this.musicTextElement);
// Music Graph Canvas
this.musicCanvas = document.createElement('canvas');
this.musicCanvas.width = graphWidth;
this.musicCanvas.height = graphHeight;
Object.assign(this.musicCanvas.style, {
width: graphWidth + 'px',
height: graphHeight + 'px',
background: '#222',
border: '1px solid #444'
});
this.musicCtx = this.musicCanvas.getContext('2d');
this.fpsElement.appendChild(this.musicCanvas);
document.body.appendChild(this.fpsElement);
this.history = new Array(this.canvas.width).fill(0);
this.memHistory = new Array(this.memCanvas.width).fill(0);
this.musicHistory = new Array(this.musicCanvas.width).fill({l:0, low:0, high:0, b:0, lt:0, qt:0, m:0.5, bo: false});
// Initialize visibility based on config
if (state.config) {
this.setVisibility(state.config.debugPanelEnabled);
}
}
update(deltaTime) {
@ -99,16 +140,7 @@ export class FPSCounter extends SceneFeature {
const segment = 0.25;
if (this.timeAccumulator >= segment) {
const fps = Math.round(this.frames / this.timeAccumulator);
if (this.textElement) {
this.textElement.innerText = `FPS: ${fps}`;
if (fps >= 58) {
this.textElement.style.color = '#00ff00';
} else if (fps >= 55) {
this.textElement.style.color = '#ffff00';
} else {
this.textElement.style.color = '#ff0000';
}
}
if (this.textElement) this.textElement.innerText = `FPS: ${fps}`;
// Update Graph
this.history.push(fps);
@ -127,14 +159,52 @@ export class FPSCounter extends SceneFeature {
this.memHistory.shift();
}
this.drawMemGraph();
} else if (state.renderer) {
// Fallback for Firefox: Visualize Scene Complexity (Triangles)
const tris = state.renderer.info.render.triangles;
const kTris = Math.round(tris / 1000);
if (this.memTextElement) this.memTextElement.innerText = `TRIS: ${kTris}k`;
this.memHistory.push(kTris);
if (this.memHistory.length > this.memCanvas.width) {
this.memHistory.shift();
}
this.drawMemGraph();
} else {
if (this.memTextElement) this.memTextElement.style.display = 'none';
if (this.memCanvas) this.memCanvas.style.display = 'none';
if (this.memTextElement) this.memTextElement.innerText = `MEM: N/A`;
}
this.frames = 0;
this.timeAccumulator = 0;
}
// Update Music Graph (Every frame for smoothness)
if (state.music) {
const loudness = state.music.loudness || 0;
const loudnessAvg = state.music.loudnessAverage || 0;
const lows = state.music.loudnessLows || 0;
const highs = state.music.loudnessHighs || 0;
const beat = state.music.beatIntensity || 0;
const thresholds = state.music.thresholds || { loud: 0, quiet: 0 };
let modeVal = 0.5;
if (state.music.mode === 'Loud') modeVal = 1.0;
else if (state.music.mode === 'Quiet') modeVal = 0.0;
this.musicHistory.push({ l: loudness, la: loudnessAvg, low: lows, high: highs, b: beat, lt: thresholds.loud, qt: thresholds.quiet, m: modeVal, bo: state.blackoutMode });
if (this.musicHistory.length > this.musicCanvas.width) {
this.musicHistory.shift();
}
this.drawMusicGraph();
if (this.musicTextElement) {
this.musicTextElement.innerText = `Volume:${loudness.toFixed(2)}\nMode:${state.music.mode}\nBPM:${state.music.bpm}`;
}
}
}
setVisibility(visible) {
if (!this.fpsElement) return;
this.fpsElement.style.display = visible ? 'flex' : 'none';
}
drawGraph() {
@ -155,25 +225,18 @@ export class FPSCounter extends SceneFeature {
ctx.stroke();
// Draw Graph
ctx.beginPath();
ctx.strokeStyle = '#00ff00';
ctx.lineWidth = 1.5;
for (let i = 0; i < this.history.length - 1; i++) {
const val = this.history[i + 1];
const x1 = i;
const y1 = h - (this.history[i] / maxFps) * h;
const x2 = i + 1;
const y2 = h - (val / maxFps) * h;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
if (val >= 58) ctx.strokeStyle = '#00ff00';
else if (val >= 55) ctx.strokeStyle = '#ffff00';
else ctx.strokeStyle = '#ff0000';
ctx.stroke();
for (let i = 0; i < this.history.length; i++) {
const val = this.history[i];
const x = i;
const y = h - (val / maxFps) * h;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
}
drawMemGraph() {
@ -200,6 +263,118 @@ export class FPSCounter extends SceneFeature {
}
ctx.stroke();
}
drawMusicGraph() {
if (!this.musicCtx) return;
const ctx = this.musicCtx;
const w = this.musicCanvas.width;
const h = this.musicCanvas.height;
ctx.clearRect(0, 0, w, h);
// Draw Lows (Brown)
ctx.beginPath();
ctx.strokeStyle = '#D2691E'; // Chocolate Brown
ctx.lineWidth = 1;
for (let i = 0; i < this.musicHistory.length; i++) {
const val = this.musicHistory[i].low !== undefined ? this.musicHistory[i].low : 0;
const x = i;
const y = h - (val * h);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
// Draw All (Blue)
ctx.beginPath();
ctx.strokeStyle = '#66aaff'; // Visible Blue
ctx.lineWidth = 1;
for (let i = 0; i < this.musicHistory.length; i++) {
const val = this.musicHistory[i].l;
const x = i;
const y = h - (val * h);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
// Draw Highs (White)
ctx.beginPath();
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 1;
for (let i = 0; i < this.musicHistory.length; i++) {
const val = this.musicHistory[i].high !== undefined ? this.musicHistory[i].high : 0;
const x = i;
const y = h - (val * h);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
// Draw All Avg (Blue)
ctx.beginPath();
ctx.strokeStyle = '#8866ff';
ctx.lineWidth = 1;
for (let i = 0; i < this.musicHistory.length; i++) {
const val = this.musicHistory[i].la;
const x = i;
const y = h - (val * h);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
// Draw Beat Intensity (Magenta)
ctx.beginPath();
ctx.strokeStyle = '#ff00ff';
ctx.lineWidth = 1;
for (let i = 0; i < this.musicHistory.length; i++) {
const val = this.musicHistory[i].b;
const x = i;
const y = h - (val * h);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
// Draw Active Threshold (Green for Loud/Blackout, Red for Quiet/Normal)
ctx.lineWidth = 1;
for (let i = 0; i < this.musicHistory.length - 1; i++) {
const curr = this.musicHistory[i];
const next = this.musicHistory[i+1];
ctx.beginPath();
if (curr.bo) {
// Blackout Mode: Draw Loud Threshold (Green Dashed)
ctx.strokeStyle = '#00ff00';
ctx.setLineDash([2, 2]);
ctx.moveTo(i, h - (curr.lt * h));
ctx.lineTo(i + 1, h - (next.lt * h));
} else {
// Normal Mode: Draw Quiet Threshold (Red Solid)
ctx.strokeStyle = '#ff0000';
ctx.setLineDash([]);
ctx.moveTo(i, h - (curr.qt * h));
ctx.lineTo(i + 1, h - (next.qt * h));
}
ctx.stroke();
}
new FPSCounter();
// Draw Mode (Yellow)
ctx.beginPath();
ctx.strokeStyle = '#ffff00';
ctx.setLineDash([1, 2]);
for (let i = 0; i < this.musicHistory.length; i++) {
const val = this.musicHistory[i].m !== undefined ? this.musicHistory[i].m : 0.5;
const x = i;
const y = h - (val * h);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
ctx.setLineDash([]); // Reset dash
}
}
new DebugPanel();

View File

@ -11,15 +11,13 @@ export class MusicPlayer extends SceneFeature {
this.analyser = null;
this.source = null;
this.dataArray = null;
this.loudnessHistory = [];
sceneFeatureManager.register(this);
}
init() {
state.music.player = document.getElementById('audioPlayer');
state.music.loudness = 0;
state.music.isLoudEnough = false;
state.music.loudnessAverage = 0;
const loadButton = document.getElementById('loadMusicButton');
const fileInput = document.getElementById('musicFileInput');
@ -58,6 +56,7 @@ export class MusicPlayer extends SceneFeature {
this.source.connect(this.analyser);
this.analyser.connect(this.audioContext.destination);
this.dataArray = new Uint8Array(this.analyser.frequencyBinCount);
state.music.frequencyData = this.dataArray;
}
// Update State
@ -99,9 +98,6 @@ export class MusicPlayer extends SceneFeature {
document.getElementById('ui-container').style.display = 'none';
state.partyStarted = true;
// You could add BPM detection here in the future
// For now, we use the fixed BPM
// Trigger 'start' event for other features
this.notifyFeatures('onPartyStart');
}
@ -125,26 +121,6 @@ export class MusicPlayer extends SceneFeature {
if (!state.partyStarted || !this.analyser) return;
this.analyser.getByteFrequencyData(this.dataArray);
// --- Calculate current loudness ---
let sum = 0;
for (let i = 0; i < this.dataArray.length; i++) {
sum += this.dataArray[i];
}
const average = sum / this.dataArray.length;
state.music.loudness = average / 255; // Normalize to 0-1 range
// --- Track loudness over the last 2 seconds ---
this.loudnessHistory.push(state.music.loudness);
if (this.loudnessHistory.length > 120) { // Assuming ~60fps, 2 seconds of history
this.loudnessHistory.shift();
}
// --- Determine if it's loud enough to jump ---
const avgLoudness = this.loudnessHistory.reduce((a, b) => a + b, 0) / this.loudnessHistory.length;
const quietThreshold = 0.1; // Adjust this value based on testing
state.music.isLoudEnough = avgLoudness > quietThreshold;
}
}

View File

@ -1,3 +1,4 @@
import * as THREE from 'three';
import { state } from '../state.js';
import { SceneFeature } from './SceneFeature.js';
import sceneFeatureManager from './SceneFeatureManager.js';
@ -7,53 +8,158 @@ export class MusicVisualizer extends SceneFeature {
super();
this.lastStateChangeTime = 0;
this.lastBlackoutMode = false;
this.lastBeatTime = 0;
this.beatIntervals = [];
this.beatThreshold = 0.3;
this.loudnessHistory = [];
this.loudnessLowsHistory = [];
sceneFeatureManager.register(this);
}
init() {
// Initialize music state
state.music = {
bpm: 120,
beatDuration: 60 / 120,
measureDuration: (60 / 120) * 4,
beatIntensity: 0,
measurePulse: 0,
isLoudEnough: false,
thresholds: { loud: 0, quiet: 0 },
mode: 'Normal',
bpm: 50,
loudness: 0,
loudnessLows: 0,
loudnessHighs: 0,
loudnessAverage: 0,
loudnessLowsAverage: 0,
frequencyData: null
};
this.beatPhase = 0;
this.measurePhase = 0;
this.averageLoudness = 0;
}
update(deltaTime) {
if (!state.music || !state.partyStarted) return;
// --- Calculate Loudness from Frequency Data ---
if (state.music.frequencyData) {
const dataArray = state.music.frequencyData;
let sumLows = 0;
let sumHighs = 0;
let sumAll = 0;
const countLows = Math.min(dataArray.length * 0.2, dataArray.length);
const countHighs = Math.min(dataArray.length * 0.6, dataArray.length);
for (let i = 0; i < dataArray.length; i++) {
sumAll += dataArray[i];
if (i < countLows) {
sumLows += dataArray[i];
}
if (i > dataArray.length - countHighs) {
sumHighs += dataArray[i];
}
}
const averageLows = sumLows / countLows;
const averageHighs = sumHighs / countHighs;
const averageAll = sumAll / dataArray.length;
// Normalize to 0-1 range
state.music.loudness = averageAll / 255;
state.music.loudnessLows = averageLows / 255;
state.music.loudnessHighs = averageHighs / 255;
// --- Track loudness over the last 2 seconds ---
this.loudnessHistory.push(state.music.loudness);
if (this.loudnessHistory.length > 120) this.loudnessHistory.shift();
this.loudnessLowsHistory.push(state.music.loudnessLows);
if (this.loudnessLowsHistory.length > 120) this.loudnessLowsHistory.shift();
const avgLoudness = this.loudnessHistory.reduce((a, b) => a + b, 0) / this.loudnessHistory.length;
const avgLowsLoudness = this.loudnessLowsHistory.reduce((a, b) => a + b, 0) / this.loudnessLowsHistory.length;
state.music.loudnessAverage = avgLoudness;
state.music.loudnessLowsAverage = avgLowsLoudness;
state.music.isLoudEnough = avgLoudness > 0.1;
}
const time = state.clock.getElapsedTime();
const loudness = state.music.loudness || 0;
const loudnessAvg = state.music.loudnessAverage || 0;
this.averageLoudness = loudnessAvg;
// --- Calculate Beat Intensity (pulses every beat) ---
// This creates a sharp attack and slower decay (0 -> 1 -> 0)
const beatProgress = (time % state.music.beatDuration) / state.music.beatDuration;
state.music.beatIntensity = Math.pow(1.0 - beatProgress, 2);
// --- Beat Detection & Auto-BPM ---
this.beatThreshold = Math.max(loudnessAvg * 1.01, this.beatThreshold * 0.96);
// --- Calculate Measure Pulse (spikes every 4 beats) ---
// This creates a very sharp spike for the torch flame effect
const measureProgress = (time % state.music.measureDuration) / state.music.measureDuration;
state.music.measurePulse = measureProgress < 0.2 ? Math.sin(measureProgress * Math.PI * 5) : 0;
if (loudness > this.beatThreshold) {
const now = time;
if (now - this.lastBeatTime > 0.3) { // Min interval (~200 BPM)
const interval = now - this.lastBeatTime;
this.lastBeatTime = now;
this.beatThreshold = loudness * 1.2; // Bump threshold
// Valid BPM range: 60-180 (interval 1.0s - 0.33s)
if (interval >= 0.33 && interval <= 1.0) {
this.beatIntervals.push(interval);
if (this.beatIntervals.length > 8) this.beatIntervals.shift();
const avgInterval = this.beatIntervals.reduce((a, b) => a + b, 0) / this.beatIntervals.length;
// 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);
}
// Sync phase on beat
this.beatPhase = 0;
}
}
// --- Phase Accumulation ---
this.beatPhase += deltaTime / state.music.beatDuration;
if (this.beatPhase >= 1) this.beatPhase -= 1;
this.measurePhase += deltaTime / state.music.measureDuration;
if (this.measurePhase >= 1) this.measurePhase -= 1;
// --- Calculate Beat Intensity ---
state.music.beatIntensity = Math.pow(1.0 - this.beatPhase, 2);
// --- Calculate Measure Pulse ---
state.music.measurePulse = this.measurePhase < 0.2 ? Math.sin(this.measurePhase * Math.PI * 5) : 0;
// --- Auto-Blackout Logic ---
// If blackout is active, monitor for loud events (The Drop) to disable it.
if (state.config.blackout) {
if (state.blackoutMode !== this.lastBlackoutMode) {
this.lastStateChangeTime = time;
this.lastBlackoutMode = state.blackoutMode;
}
const timeInState = time - this.lastStateChangeTime;
let timeInStateLoud = 0;
let timeInStateQuiet = 0;
if (state.blackoutMode) {
timeInStateQuiet = timeInState;
} else {
timeInStateLoud = timeInState;
}
// Dynamic Thresholds
// Exit blackout: Start high 0.8 and decrease over time
let loudThreshold = Math.max(0.4, 0.8 - (timeInState * 0.05));
// The threshold to EXIT blackout. It should be a significant spike above the average loudness.
// It starts high and decays, making it easier to exit the longer we're in blackout.
const loudSpikeModif = 1.2; // How much louder than average a "drop" needs to be.
let loudThreshold = this.averageLoudness * loudSpikeModif;
loudThreshold = Math.max(this.averageLoudness + 0.1, loudThreshold - (timeInStateQuiet * 0.05));
// Enter blackout: Start low and increase over time
let quietThreshold = Math.min(0.2, 0.05 + (timeInState * 0.01));
// The threshold to ENTER blackout, based on a percentage of the song's average loudness.
let quietThreshold = this.averageLoudness * 0.75;
quietThreshold = THREE.MathUtils.clamp(quietThreshold, 0.02, 0.3); // Clamp to a reasonable range.
// --- Auto-Blackout Logic ---
// If blackout is active, monitor for loud events (The Drop) to disable it.
if (state.config.blackout) {
const beatThreshold = 0.8;
if (state.blackoutMode) {
@ -67,7 +173,16 @@ export class MusicVisualizer extends SceneFeature {
}
} else {
state.blackoutMode = false;
this.lastBlackoutMode = false;
}
state.music.thresholds = { loud: loudThreshold, quiet: quietThreshold };
if (loudness > loudThreshold) {
state.music.mode = 'Loud';
} else if (loudness < quietThreshold) {
state.music.mode = 'Quiet';
} else {
state.music.mode = 'Normal';
}
}
}

View File

@ -20,7 +20,7 @@ import { ProjectionScreen } from './projection-screen.js';
import { StageLasers } from './stage-lasers.js';
import { ConfigUI } from './config-ui.js';
import { StageLightBars } from './stage-light-bars.js';
import { FPSCounter } from './fps-counter.js';
import { DebugPanel } from './fps-counter.js';
// Scene Features ^^^
// --- Scene Modeling Function ---

View File

@ -132,7 +132,7 @@ export class StageLasers extends SceneFeature {
if (this.activationState === 'IDLE') {
// Wait for song to pick up before first activation
if (time > this.initialSilenceSeconds && loudness > this.averageLoudness + 0.1) {
if (time > this.initialSilenceSeconds && state.music.isLoudEnough && loudness > this.averageLoudness + 0.1) {
this.activationState = 'WARMUP';
this.stateTimer = 1.0; // Warmup duration

View File

@ -111,7 +111,7 @@ export class StageTorches extends SceneFeature {
if (!enabled) return;
if (!state.partyStarted) {
if (!state.partyStarted && state.music.isLoudEnough) {
this.torches.forEach(torch => {
if (torch.light.visible) torch.light.visible = false;
if (torch.particles.visible) torch.particles.visible = false;

View File

@ -15,7 +15,8 @@ export function initState() {
lightBarColors: ['#ff00ff', '#00ffff', '#ffff00'], // Default neon colors
guestCount: 150,
blackout: false,
djHat: 'None' // 'None', 'Santa', 'Top Hat'
djHat: 'None', // 'None', 'Santa', 'Top Hat'
debugPanelEnabled: false
};
try {
const saved = localStorage.getItem('partyConfig');