// MusicRoom - Audio visualizer // Pure frontend Web Audio + canvas display for the shared audio element. (function() { const M = window.MusicRoom; const MODES = new Set(["off", "bars", "wave", "radial", "pulse"]); const COOKIE_MAX_AGE = 60 * 60 * 24 * 400; let mode = "off"; let shell = null; let canvas = null; let ctx = null; let selector = null; let audioContext = null; let source = null; let analyser = null; let frequencyData = null; let waveformData = null; let animationId = 0; let graphUnavailable = false; function normalizeMode(value) { return MODES.has(value) ? value : "off"; } function decodeStoredValue(value) { if (!value) return null; try { return decodeURIComponent(value); } catch { M.clearCookieValue(M.VISUALIZER_STORAGE_KEY); return null; } } function getSavedMode() { const cookieMode = normalizeMode(decodeStoredValue(M.getCookieValue(M.VISUALIZER_STORAGE_KEY))); if (cookieMode !== "off") return cookieMode; try { const storedMode = normalizeMode(localStorage.getItem(M.VISUALIZER_STORAGE_KEY)); if (storedMode !== "off") { M.setCookieValue(M.VISUALIZER_STORAGE_KEY, storedMode, COOKIE_MAX_AGE); return storedMode; } } catch {} return "off"; } function rememberMode(nextMode) { const normalized = normalizeMode(nextMode); if (normalized === "off") { M.clearCookieValue(M.VISUALIZER_STORAGE_KEY); try { localStorage.removeItem(M.VISUALIZER_STORAGE_KEY); } catch {} return; } M.setCookieValue(M.VISUALIZER_STORAGE_KEY, normalized, COOKIE_MAX_AGE); try { localStorage.setItem(M.VISUALIZER_STORAGE_KEY, normalized); } catch {} } function getColors() { const styles = getComputedStyle(shell || document.body); const value = (name) => { const raw = styles.getPropertyValue(name).trim(); return raw && !raw.includes("var(") ? raw : ""; }; const accent = value("--visualizer-accent") || value("--accent") || "#44ee88"; const secondary = value("--visualizer-secondary") || value("--warning") || "#66aaff"; const background = value("--visualizer-bg") || "rgba(10, 10, 10, 0.82)"; const muted = value("--visualizer-muted") || "rgba(255, 255, 255, 0.16)"; return { accent, secondary, background, muted }; } function resizeCanvas() { if (!canvas || !ctx) return; const rect = canvas.getBoundingClientRect(); const dpr = Math.min(window.devicePixelRatio || 1, 2); const width = Math.max(1, Math.round(rect.width * dpr)); const height = Math.max(1, Math.round(rect.height * dpr)); if (canvas.width !== width || canvas.height !== height) { canvas.width = width; canvas.height = height; } ctx.setTransform(dpr, 0, 0, dpr, 0, 0); } function clearCanvas() { if (!canvas || !ctx) return; resizeCanvas(); ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight); } function initAudioGraph() { if (source) return true; if (graphUnavailable) return false; const AudioContextCtor = window.AudioContext || window.webkitAudioContext; if (!AudioContextCtor) { graphUnavailable = true; return false; } try { audioContext = audioContext || new AudioContextCtor(); analyser = audioContext.createAnalyser(); analyser.fftSize = 1024; analyser.minDecibels = -92; analyser.maxDecibels = -12; analyser.smoothingTimeConstant = 0.82; source = audioContext.createMediaElementSource(M.audio); source.connect(analyser); analyser.connect(audioContext.destination); frequencyData = new Uint8Array(analyser.frequencyBinCount); waveformData = new Uint8Array(analyser.fftSize); return true; } catch (error) { graphUnavailable = true; console.warn("[Visualizer] Unable to attach audio graph", error); return false; } } function startVisualizer() { if (animationId || mode === "off") return; animationId = requestAnimationFrame(draw); } function stopVisualizer() { if (animationId) { cancelAnimationFrame(animationId); animationId = 0; } clearCanvas(); } function average(data, start, end) { let total = 0; const last = Math.min(end, data.length); for (let i = start; i < last; i++) total += data[i]; return total / Math.max(1, last - start); } function drawIdle(width, height, colors, timestamp) { const bars = Math.max(12, Math.floor(width / 18)); const gap = 3; const barWidth = Math.max(3, (width - gap * (bars - 1)) / bars); ctx.globalAlpha = 0.7; ctx.fillStyle = colors.muted; for (let i = 0; i < bars; i++) { const phase = timestamp / 600 + i * 0.55; const value = 0.16 + (Math.sin(phase) + 1) * 0.08; const barHeight = Math.max(2, height * value); const x = i * (barWidth + gap); ctx.fillRect(x, height - barHeight, barWidth, barHeight); } ctx.globalAlpha = 1; } function drawBars(width, height, colors) { analyser.getByteFrequencyData(frequencyData); const barCount = Math.max(20, Math.min(72, Math.floor(width / 8))); const gap = 2; const barWidth = Math.max(2, (width - gap * (barCount - 1)) / barCount); const sampleSize = Math.max(1, Math.floor(frequencyData.length / barCount)); const gradient = ctx.createLinearGradient(0, height, 0, 0); gradient.addColorStop(0, colors.accent); gradient.addColorStop(1, colors.secondary); ctx.fillStyle = gradient; for (let i = 0; i < barCount; i++) { const value = average(frequencyData, i * sampleSize, (i + 1) * sampleSize) / 255; const barHeight = Math.max(2, value * height * 0.9); const x = i * (barWidth + gap); ctx.fillRect(x, height - barHeight, barWidth, barHeight); } } function drawWave(width, height, colors) { analyser.getByteTimeDomainData(waveformData); ctx.lineWidth = 2; ctx.strokeStyle = colors.accent; ctx.beginPath(); for (let i = 0; i < waveformData.length; i++) { const x = i / (waveformData.length - 1) * width; const y = waveformData[i] / 255 * height; if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.stroke(); ctx.globalAlpha = 0.35; ctx.strokeStyle = colors.secondary; ctx.beginPath(); ctx.moveTo(0, height / 2); ctx.lineTo(width, height / 2); ctx.stroke(); ctx.globalAlpha = 1; } function drawRadial(width, height, colors) { analyser.getByteFrequencyData(frequencyData); const cx = width / 2; const cy = height / 2; const radius = Math.max(12, Math.min(width, height) * 0.22); const spokes = 96; const sampleSize = Math.max(1, Math.floor(frequencyData.length / spokes)); ctx.save(); ctx.translate(cx, cy); ctx.lineCap = "round"; for (let i = 0; i < spokes; i++) { const value = average(frequencyData, i * sampleSize, (i + 1) * sampleSize) / 255; const length = radius + value * Math.min(width, height) * 0.32; const angle = i / spokes * Math.PI * 2; const hueColor = i % 2 === 0 ? colors.accent : colors.secondary; ctx.rotate(angle - (i === 0 ? 0 : (i - 1) / spokes * Math.PI * 2)); ctx.strokeStyle = hueColor; ctx.lineWidth = Math.max(1, 1 + value * 3); ctx.beginPath(); ctx.moveTo(radius, 0); ctx.lineTo(length, 0); ctx.stroke(); } ctx.globalAlpha = 0.35; ctx.strokeStyle = colors.muted; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(0, 0, radius, 0, Math.PI * 2); ctx.stroke(); ctx.restore(); ctx.globalAlpha = 1; } function drawPulse(width, height, colors) { analyser.getByteFrequencyData(frequencyData); analyser.getByteTimeDomainData(waveformData); const bass = average(frequencyData, 1, 18) / 255; const mids = average(frequencyData, 18, 96) / 255; const cx = width / 2; const cy = height / 2; const radius = Math.max(8, Math.min(width, height) * (0.16 + bass * 0.26)); ctx.globalAlpha = 0.28; ctx.fillStyle = colors.secondary; ctx.beginPath(); ctx.arc(cx, cy, radius * 1.75, 0, Math.PI * 2); ctx.fill(); ctx.globalAlpha = 0.9; ctx.fillStyle = colors.accent; ctx.beginPath(); ctx.arc(cx, cy, radius, 0, Math.PI * 2); ctx.fill(); ctx.globalAlpha = 0.75; ctx.strokeStyle = colors.secondary; ctx.lineWidth = 1 + mids * 4; ctx.beginPath(); for (let i = 0; i < waveformData.length; i += 4) { const x = i / (waveformData.length - 1) * width; const y = height * 0.5 + (waveformData[i] - 128) / 128 * height * 0.32; if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.stroke(); ctx.globalAlpha = 1; } function draw(timestamp) { animationId = requestAnimationFrame(draw); if (!canvas || !ctx || mode === "off") return; resizeCanvas(); const width = canvas.clientWidth; const height = canvas.clientHeight; const colors = getColors(); const hasLiveGraph = analyser && frequencyData && waveformData && audioContext?.state === "running" && !M.audio.paused && !!M.audio.src; ctx.clearRect(0, 0, width, height); ctx.fillStyle = colors.background; ctx.fillRect(0, 0, width, height); if (!hasLiveGraph) { drawIdle(width, height, colors, timestamp); return; } if (mode === "wave") drawWave(width, height, colors); else if (mode === "radial") drawRadial(width, height, colors); else if (mode === "pulse") drawPulse(width, height, colors); else drawBars(width, height, colors); } M.applyVisualizer = function(nextMode, remember = true) { mode = normalizeMode(nextMode); if (selector) selector.value = mode; if (shell) shell.classList.toggle("hidden", mode === "off"); if (remember) rememberMode(mode); if (mode === "off") { stopVisualizer(); } else { resizeCanvas(); startVisualizer(); } }; M.resumeVisualizer = async function() { if (mode === "off") return false; if (!initAudioGraph()) return false; try { if (audioContext.state === "suspended") { await audioContext.resume(); } startVisualizer(); return audioContext.state === "running"; } catch (error) { console.warn("[Visualizer] Unable to resume audio context", error); return false; } }; M.visualizer = { getMode: () => mode, hasGraph: () => !!source, isRunning: () => audioContext?.state === "running" }; document.addEventListener("DOMContentLoaded", () => { shell = document.getElementById("visualizer-shell"); canvas = document.getElementById("visualizer-canvas"); selector = document.getElementById("visualizer-select"); ctx = canvas?.getContext("2d") || null; M.applyVisualizer(getSavedMode(), false); if (selector) { selector.onchange = () => { M.applyVisualizer(selector.value); M.resumeVisualizer(); }; } if (window.ResizeObserver && shell) { const observer = new ResizeObserver(resizeCanvas); observer.observe(shell); } window.addEventListener("resize", resizeCanvas); }); document.addEventListener("pointerdown", () => { if (mode !== "off") M.resumeVisualizer(); }, { passive: true }); document.addEventListener("keydown", () => { if (mode !== "off") M.resumeVisualizer(); }); M.audio.addEventListener("play", () => { if (mode !== "off") { if (audioContext && audioContext.state === "suspended") { audioContext.resume().catch(() => {}); } startVisualizer(); } }); M.audio.addEventListener("pause", () => { if (mode !== "off") startVisualizer(); }); })();