400 lines
12 KiB
JavaScript
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();
|
|
});
|
|
})();
|