339 lines
13 KiB
JavaScript
339 lines
13 KiB
JavaScript
/*
|
|
* 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 <img> 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);
|