// 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 { KawarpPreset } from './visualizers/kawarp.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(), kawarp: new KawarpPreset(), }; 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; // Clone the canvas to get a fresh context when switching context types, // or when the previous preset grabbed its own context (managesOwnContext) const needsClone = (this.ctx && currentType !== type) || (!this.ctx && currentType && currentType !== type); if (needsClone) { const parent = this.canvas.parentElement; const newCanvas = this.canvas.cloneNode(true); parent.replaceChild(newCanvas, this.canvas); this.canvas = newCanvas; this.ctx = null; } // Kawarp grabs its own WebGL context, so we skip this if (preset.managesOwnContext) { this._currentContextType = type; return; } 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(); } // Set canvas dimensions before preset init so WebGL framebuffers are created at correct size this.resize(); window.addEventListener('resize', this._resizeBound); this.canvas.style.display = 'block'; // Initialize presets that need lazy init (Butterchurn, Kawarp) if (this.activePreset.lazyInit) { const sourceNode = audioContextManager.getSourceNode(); this.activePreset.lazyInit(this.canvas, this.audioContext, sourceNode).then(() => { this.resize(); }); } 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; const webglPresets = ['butterchurn', 'kawarp']; const fromPreset = this.activePresetKey; const toPreset = key; if (webglPresets.includes(fromPreset) && webglPresets.includes(toPreset) && fromPreset !== toPreset) { visualizerSettings.setPreset(key); window.location.reload(); return; } if (this.activePreset?.destroy) { this.activePreset.destroy(); } this._currentContextType = undefined; this.ctx = null; this.activePresetKey = key; this.initContext(); this.resize(); if (this.presets[key].lazyInit && this.audioContext) { const sourceNode = audioContextManager.getSourceNode(); this.presets[key].lazyInit(this.canvas, this.audioContext, sourceNode).then(() => { this.resize(); }); } } }