/* * media-visualiser — a circular spectrum behind a now-playing screen, coloured from the * album art, with lyrics under the cover. * * Vendored, dependency-free, no build step — the same choice as digest-canvas-sdk and * canvas-sdk. Copied into each host that needs it rather than served from one place, so * a kiosk with no route to the container host still renders. * * THE TWO TIERS, WHICH ARE THE WHOLE DESIGN * ------------------------------------------ * CAVA reads an audio stream. Most endpoints do not have one: a kitchen panel showing * what the LIVING ROOM is playing has no audio to analyse, and never will. A design * that assumes real audio works on one screen and shows a dead circle on the rest, * which is worse than not having it — a dead visualiser reads as broken, not as absent. * * reactive — audio is local. Real FFT, via WebAudio's AnalyserNode or a `cava -r` * feed pushed in by the host's agent. Bars are the actual spectrum. * synthetic — everywhere else. The ring breathes from track POSITION and tempo. It is * a mood light, it is honest about being one (see `tier` on the object and * the `data-tier` attribute on the canvas), and nobody watching from * across a kitchen can tell. * * The synthetic tier must never claim to be the reactive one. The moment somebody * believes it is a spectrum, every bass drop it does not match becomes a bug report. */ "use strict"; (function (global) { const TAU = Math.PI * 2; // --- palette --------------------------------------------------------------------- /** * Dominant colours from an image, as [{r,g,b}]. Done on a 64x64 downscale because * this is a palette, not a photograph — full resolution costs time and changes * nothing. */ function paletteFrom(image, count) { const size = 64; const canvas = document.createElement("canvas"); canvas.width = canvas.height = size; const ctx = canvas.getContext("2d", { willReadFrequently: true }); ctx.drawImage(image, 0, 0, size, size); let data; try { data = ctx.getImageData(0, 0, size, size).data; } catch (err) { // A cross-origin cover taints the canvas and getImageData throws. That is a // configuration problem (art served from another origin without CORS), not a // reason to have no visualiser — fall back to the default palette. return null; } // Bucket in a coarse RGB grid. 4 bits per channel is enough to group "the same // colour" without merging colours a person would call different. const buckets = new Map(); for (let i = 0; i < data.length; i += 4) { const r = data[i], g = data[i + 1], b = data[i + 2]; if (data[i + 3] < 128) continue; // REJECT NEAR-GREYS AND NEAR-BLACKS BEFORE RANKING. Album art is full of them, // and a naive palette from a dark cover is four indistinguishable dark greys — // i.e. a visualiser that looks switched off. const max = Math.max(r, g, b), min = Math.min(r, g, b); if (max < 40) continue; // near-black if (max - min < 24) continue; // near-grey: no hue to speak of const key = ((r >> 4) << 8) | ((g >> 4) << 4) | (b >> 4); const entry = buckets.get(key) || { r: 0, g: 0, b: 0, n: 0 }; entry.r += r; entry.g += g; entry.b += b; entry.n += 1; buckets.set(key, entry); } const ranked = [...buckets.values()] .sort((a, b) => b.n - a.n) .slice(0, count || 4) .map((e) => ({ r: Math.round(e.r / e.n), g: Math.round(e.g / e.n), b: Math.round(e.b / e.n) })); return ranked.length ? ranked : null; } function relativeLuminance({ r, g, b }) { const f = (c) => { const v = c / 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }; return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); } /** * Lift a colour until it clears the background it is drawn on. * * This is the step that gets skipped, and skipping it is why so many album-art * visualisers are invisible on dark covers: a visualiser you cannot see is the same * as no visualiser at all. */ function ensureContrast(colour, backgroundLuminance, minRatio) { let { r, g, b } = colour; for (let i = 0; i < 24; i++) { const l = relativeLuminance({ r, g, b }); const ratio = (Math.max(l, backgroundLuminance) + 0.05) / (Math.min(l, backgroundLuminance) + 0.05); if (ratio >= (minRatio || 3)) break; r = Math.min(255, Math.round(r * 1.12 + 8)); g = Math.min(255, Math.round(g * 1.12 + 8)); b = Math.min(255, Math.round(b * 1.12 + 8)); } return { r, g, b }; } const css = ({ r, g, b }, alpha) => alpha === undefined ? `rgb(${r},${g},${b})` : `rgba(${r},${g},${b},${alpha})`; // --- the visualiser ---------------------------------------------------------------- class MediaVisualiser { /** * @param {HTMLCanvasElement} canvas * @param {object} options { bars, background, minContrast } */ constructor(canvas, options) { this.canvas = canvas; this.ctx = canvas.getContext("2d"); this.options = Object.assign({ bars: 96, background: "#0a0510", minContrast: 3 }, options || {}); this.palette = [{ r: 192, g: 132, b: 252 }, { r: 255, g: 62, b: 200 }]; this.levels = new Float32Array(this.options.bars); this.targets = new Float32Array(this.options.bars); this.tier = "synthetic"; this.track = { position_ms: 0, duration_ms: 0, tempo: null, playing: false }; this.analyser = null; this.running = false; this._resize(); window.addEventListener("resize", () => this._resize()); } _resize() { const rect = this.canvas.getBoundingClientRect(); const dpr = Math.min(window.devicePixelRatio || 1, 2); this.canvas.width = Math.max(1, Math.round(rect.width * dpr)); this.canvas.height = Math.max(1, Math.round(rect.height * dpr)); this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); this.width = rect.width; this.height = rect.height; } /** Colour the ring from an already-loaded of the cover. */ setArtwork(image) { const found = paletteFrom(image, 4); if (!found) return false; const bg = relativeLuminance(this._backgroundRgb()); this.palette = found.map((c) => ensureContrast(c, bg, this.options.minContrast)); return true; } _backgroundRgb() { const hex = String(this.options.background).replace("#", ""); return { r: parseInt(hex.slice(0, 2), 16) || 0, g: parseInt(hex.slice(2, 4), 16) || 0, b: parseInt(hex.slice(4, 6), 16) || 0, }; } /** Reactive tier: drive the ring from a real WebAudio graph. */ attachAudio(mediaElementOrStream) { try { const AudioContextCtor = global.AudioContext || global.webkitAudioContext; if (!AudioContextCtor) return false; const audioCtx = new AudioContextCtor(); const source = mediaElementOrStream instanceof MediaStream ? audioCtx.createMediaStreamSource(mediaElementOrStream) : audioCtx.createMediaElementSource(mediaElementOrStream); const analyser = audioCtx.createAnalyser(); analyser.fftSize = 512; analyser.smoothingTimeConstant = 0.7; source.connect(analyser); if (!(mediaElementOrStream instanceof MediaStream)) analyser.connect(audioCtx.destination); this.analyser = analyser; this.bins = new Uint8Array(analyser.frequencyBinCount); this.tier = "reactive"; this.canvas.dataset.tier = "reactive"; return true; } catch (err) { // Autoplay policy, a cross-origin element, no AudioContext — all end here, and // all mean the same thing: fall back to synthetic rather than showing nothing. return false; } } /** * Reactive tier, remote feed: levels pushed in by the host's agent from `cava -r`. * An array of 0..1 values. Same tier as WebAudio because it is the same claim — * these numbers came from the actual audio. */ pushLevels(levels) { if (!levels || !levels.length) return; this.tier = "reactive"; this.canvas.dataset.tier = "reactive"; for (let i = 0; i < this.targets.length; i++) { const source = levels[Math.floor((i / this.targets.length) * levels.length)]; this.targets[i] = Math.max(0, Math.min(1, source || 0)); } } /** Track state, for the synthetic tier and for the progress arc. */ setTrack(track) { this.track = Object.assign(this.track, track || {}); if (!this.analyser && this.tier !== "reactive") { this.canvas.dataset.tier = "synthetic"; } } start() { if (this.running) return; this.running = true; const frame = (now) => { if (!this.running) return; this._step(now); requestAnimationFrame(frame); }; requestAnimationFrame(frame); } stop() { this.running = false; } _step(now) { const n = this.targets.length; if (this.analyser) { this.analyser.getByteFrequencyData(this.bins); for (let i = 0; i < n; i++) { // Log-ish bin mapping: linear FFT bins put almost everything in the first // eighth of the ring, which looks like a fault rather than like music. const t = i / n; const bin = Math.floor(Math.pow(t, 1.7) * (this.bins.length - 1)); this.targets[i] = this.bins[bin] / 255; } } else if (this.tier !== "reactive") { // SYNTHETIC. Derived from position and tempo — a slow travelling wave with a // beat-rate pulse. Deliberately smooth: anything jittery invites the comparison // with real audio that this tier cannot win. const bpm = this.track.tempo || 100; const beat = (now / 1000) * (bpm / 60); const pulse = 0.5 + 0.5 * Math.sin(beat * TAU); const energy = this.track.playing ? 0.45 + 0.35 * pulse : 0.06; for (let i = 0; i < n; i++) { const phase = (i / n) * TAU * 3 + now / 1400; this.targets[i] = Math.max(0, energy * (0.55 + 0.45 * Math.sin(phase))); } } // One smoother for both tiers, so switching between them does not jump. for (let i = 0; i < n; i++) { this.levels[i] += (this.targets[i] - this.levels[i]) * 0.22; } this._draw(); } _draw() { const { ctx, width, height } = this; const cx = width / 2; const cy = height / 2; const inner = Math.min(width, height) * 0.30; const maxBar = Math.min(width, height) * 0.17; ctx.clearRect(0, 0, width, height); const n = this.levels.length; for (let i = 0; i < n; i++) { const angle = (i / n) * TAU - Math.PI / 2; const level = this.levels[i]; const length = 2 + level * maxBar; const colour = this.palette[i % this.palette.length]; ctx.strokeStyle = css(colour, 0.25 + level * 0.75); ctx.lineWidth = Math.max(2, (TAU * inner) / n - 2); ctx.lineCap = "round"; ctx.beginPath(); ctx.moveTo(cx + Math.cos(angle) * inner, cy + Math.sin(angle) * inner); ctx.lineTo(cx + Math.cos(angle) * (inner + length), cy + Math.sin(angle) * (inner + length)); ctx.stroke(); } // Progress as a thin arc just inside the ring — the one piece of information a // now-playing screen is actually asked for from across a room. const { position_ms: pos, duration_ms: dur } = this.track; if (dur > 0) { ctx.strokeStyle = css(this.palette[0], 0.9); ctx.lineWidth = 3; ctx.lineCap = "butt"; ctx.beginPath(); ctx.arc(cx, cy, inner - 10, -Math.PI / 2, -Math.PI / 2 + TAU * Math.min(1, pos / dur)); ctx.stroke(); } } } // --- lyrics -------------------------------------------------------------------------- /** * Parse an LRC document into [{ms, text}]. Returns [] for anything that is not LRC, * which is how the caller tells synced from plain: if this comes back empty and there * was text, the text is plain and must NOT be auto-scrolled to a guessed rate — that * is wrong within ten seconds and stays wrong. */ function parseLrc(text) { const lines = []; const re = /\[(\d+):(\d+)(?:[.:](\d+))?\]/g; for (const raw of String(text || "").split(/\r?\n/)) { let match; const stamps = []; re.lastIndex = 0; while ((match = re.exec(raw)) !== null) { const centis = match[3] ? parseInt(match[3].padEnd(3, "0").slice(0, 3), 10) : 0; stamps.push(parseInt(match[1], 10) * 60000 + parseInt(match[2], 10) * 1000 + centis); } const body = raw.replace(re, "").trim(); if (stamps.length && body) stamps.forEach((ms) => lines.push({ ms, text: body })); } return lines.sort((a, b) => a.ms - b.ms); } /** Index of the line that should be highlighted at `positionMs`, or -1. */ function currentLyricIndex(lines, positionMs) { let index = -1; for (let i = 0; i < lines.length; i++) { if (lines[i].ms <= positionMs) index = i; else break; } return index; } global.MediaVisualiser = MediaVisualiser; global.MediaVisualiser.paletteFrom = paletteFrom; global.MediaVisualiser.ensureContrast = ensureContrast; global.MediaVisualiser.parseLrc = parseLrc; global.MediaVisualiser.currentLyricIndex = currentLyricIndex; })(typeof window !== "undefined" ? window : globalThis);