kv-music/js/visualizer.js
2026-02-21 00:07:00 +01:00

290 lines
9 KiB
JavaScript

// js/visualizer.js
import { visualizerSettings } from './storage.js';
import { LCDPreset } from './visualizers/lcd.js';
import { ParticlesPreset } from './visualizers/particles.js';
import { UnknownPleasuresWebGL } from './visualizers/unknown_pleasures_webgl.js';
import { ButterchurnPreset } from './visualizers/butterchurn.js';
import { audioContextManager } from './audio-context.js';
export class Visualizer {
constructor(canvas, audio) {
this.canvas = canvas;
this.ctx = null;
this.audio = audio;
this.audioContext = null;
this.analyser = null;
this.isActive = false;
this.animationId = null;
this.presets = {
lcd: new LCDPreset(),
particles: new ParticlesPreset(),
'unknown-pleasures': new UnknownPleasuresWebGL(),
butterchurn: new ButterchurnPreset(),
};
this.activePresetKey = visualizerSettings.getPreset();
// ---- AUDIO BUFFERS (REUSED) ----
this.bufferLength = 0;
this.dataArray = null;
// ---- STATS (REUSED OBJECT) ----
this.stats = {
kick: 0,
intensity: 0,
energyAverage: 0.3,
lastBeatTime: 0,
lastIntensity: 0,
upbeatSmoother: 0,
sensitivity: 0.5,
primaryColor: '#ffffff',
mode: '',
};
// ---- CACHED STATE ----
this._lastPrimaryColor = '';
this._resizeBound = () => this.resize();
}
get activePreset() {
return this.presets[this.activePresetKey] || this.presets['lcd'];
}
init() {
// Ensure shared audio context is initialized
if (!audioContextManager.isReady()) {
audioContextManager.init(this.audio);
}
this.audioContext = audioContextManager.getAudioContext();
this.analyser = audioContextManager.getAnalyser();
if (this.analyser) {
this.bufferLength = this.analyser.frequencyBinCount;
this.dataArray = new Uint8Array(this.bufferLength);
}
}
/**
* Get the shared AudioContext for external use
*/
getAudioContext() {
return this.audioContext;
}
/**
* Get the source node
*/
getSourceNode() {
return audioContextManager.getSourceNode();
}
initContext() {
const preset = this.activePreset;
const type = preset.contextType || '2d';
const currentType = this._currentContextType;
// If context type changed, we need to recreate the canvas
// (you can't get a different context type from the same canvas)
if (this.ctx && currentType !== type) {
// Clone and replace canvas to get fresh context
const parent = this.canvas.parentElement;
const newCanvas = this.canvas.cloneNode(true);
parent.replaceChild(newCanvas, this.canvas);
this.canvas = newCanvas;
this.ctx = null;
}
if (this.ctx) return;
if (type === 'webgl') {
this.ctx =
this.canvas.getContext('webgl2', {
alpha: true,
antialias: true,
preserveDrawingBuffer: true,
premultipliedAlpha: false,
}) ||
this.canvas.getContext('webgl', {
alpha: true,
antialias: true,
preserveDrawingBuffer: true,
premultipliedAlpha: false,
});
} else {
this.ctx = this.canvas.getContext('2d');
}
this._currentContextType = type;
}
start() {
if (this.isActive) return;
if (!this.ctx) {
this.initContext();
}
if (!this.audioContext) {
this.init();
}
if (!this.analyser) {
return;
}
this.isActive = true;
if (this.audioContext.state === 'suspended') {
this.audioContext.resume();
}
// Initialize Butterchurn if it's the active preset
if (this.activePresetKey === 'butterchurn' && this.activePreset.lazyInit) {
const sourceNode = audioContextManager.getSourceNode();
this.activePreset.lazyInit(this.canvas, this.audioContext, sourceNode);
}
this.resize();
window.addEventListener('resize', this._resizeBound);
this.canvas.style.display = 'block';
this.animate();
}
stop() {
this.isActive = false;
if (this.animationId) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
window.removeEventListener('resize', this._resizeBound);
if (this.ctx && this.ctx.clearRect) {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
}
this.canvas.style.display = 'none';
}
resize() {
const w = window.innerWidth;
const h = window.innerHeight;
if (this.canvas.width !== w) this.canvas.width = w;
if (this.canvas.height !== h) this.canvas.height = h;
if (this.activePreset?.resize) {
this.activePreset.resize(w, h);
}
}
animate = () => {
if (!this.isActive) return;
this.animationId = requestAnimationFrame(this.animate);
// ===== AUDIO ANALYSIS =====
this.analyser.getByteFrequencyData(this.dataArray);
// Bass (dynamic bins based on sample rate)
const volume = 10 * Math.max(this.audio.volume, 0.1);
// Robust bass detection: sum bins up to ~250Hz
const binSize = this.audioContext.sampleRate / this.analyser.fftSize;
const startBin = 1; // Skip DC offset
// Calculate how many bins cover the bass range (up to 250Hz)
let numBins = Math.floor(250 / binSize);
if (numBins < 1) numBins = 1; // Ensure at least one bin is checked
let maxVal = 0;
for (let i = 0; i < numBins && (startBin + i) < this.dataArray.length; i++) {
const val = this.dataArray[startBin + i];
if (val > maxVal) maxVal = val;
}
// Normalize: (Max / 255) / Volume
let bass = (maxVal) / 255 / volume;
const intensity = bass * bass * 10;
const stats = this.stats;
stats.energyAverage = stats.energyAverage * 0.99 + intensity * 0.01;
stats.upbeatSmoother = stats.upbeatSmoother * 0.92 + intensity * 0.08;
// ===== SENSITIVITY =====
let sensitivity = visualizerSettings.getSensitivity();
if (visualizerSettings.isSmartIntensityEnabled()) {
if (stats.energyAverage > 0.4) {
sensitivity = 0.7;
} else if (stats.energyAverage > 0.2) {
sensitivity = 0.1 + ((stats.energyAverage - 0.2) / 0.2) * 0.6;
} else {
sensitivity = 0.1;
}
}
// ===== KICK DETECTION =====
const now = performance.now();
let threshold = stats.energyAverage < 0.3 ? 0.5 + (0.3 - stats.energyAverage) * 2 : 0.5;
// Lower threshold for more responsive kick
if (intensity > threshold * 0.7) {
if (intensity > stats.lastIntensity + 0.03 && now - stats.lastBeatTime > 50) {
stats.kick = 1.0;
stats.lastBeatTime = now;
} else {
if (stats.upbeatSmoother > 0.6 && stats.energyAverage > 0.4) {
const upbeatLevel = (stats.upbeatSmoother - 0.6) / 0.4;
if (stats.kick < upbeatLevel) {
stats.kick = upbeatLevel;
} else {
stats.kick *= 0.95;
}
} else {
stats.kick *= 0.9;
}
}
} else {
stats.kick *= 0.95;
}
stats.lastIntensity = intensity;
stats.intensity = intensity;
stats.sensitivity = sensitivity;
// ===== COLORS (CACHED) =====
const color = getComputedStyle(document.documentElement).getPropertyValue('--primary').trim() || '#ffffff';
if (color !== this._lastPrimaryColor) {
stats.primaryColor = color;
this._lastPrimaryColor = color;
}
stats.mode = visualizerSettings.getMode();
// ===== DRAW =====
this.activePreset.draw(this.ctx, this.canvas, this.analyser, this.dataArray, stats);
};
setPreset(key) {
if (!this.presets[key]) return;
if (this.activePreset?.destroy) {
this.activePreset.destroy();
}
this.activePresetKey = key;
this.initContext();
this.resize();
// Initialize Butterchurn if switching to it
if (key === 'butterchurn' && this.presets[key].lazyInit && this.audioContext) {
const sourceNode = audioContextManager.getSourceNode();
this.presets[key].lazyInit(this.canvas, this.audioContext, sourceNode);
}
}
}