blastoise/public/visualizer.js

400 lines
12 KiB
JavaScript

// 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();
});
})();