SmartestHome/render/media-visualiser/visualiser.js

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);