Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dhu3iHseRewQ2WpBSVLokG
201 lines
14 KiB
JavaScript
201 lines
14 KiB
JavaScript
export function hash32(str) { let h = 2166136261 >>> 0; for (const c of String(str)) { h ^= c.codePointAt(0); h = Math.imul(h, 16777619) >>> 0; } return h; }
|
|
export function prng(seed) { let a = seed >>> 0; return () => { a = (a + 0x6D2B79F5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; }
|
|
export const KEYS = ["C","G","D","A","E","B","F#","C#","G#","D#","A#","F"]; // circle of fifths → hue wheel
|
|
export function keyHue(k) {
|
|
const m = /^([A-G])(#|b)?/.exec(String(k || "C").trim()); if (!m) return 40;
|
|
let name = m[1] + (m[2] === "#" ? "#" : "");
|
|
if (m[2] === "b") { const flat = { Db:"C#", Eb:"D#", Gb:"F#", Ab:"G#", Bb:"A#", Cb:"B", Fb:"E" }; name = flat[m[1] + "b"] || m[1]; }
|
|
const i = KEYS.indexOf(name); return (i < 0 ? 0 : i) * 30;
|
|
}
|
|
|
|
/* ---------- the cover: a function of the song ---------- */
|
|
// pulse: live signal from the analyser { bass, mid, high, t } in 0..1 (and seconds); without it the cover is still.
|
|
// The ground and grain depend only on the song, so they are drawn once per (song, size) and cached; the rings and
|
|
// the seed mark are redrawn on top every frame when the music is playing.
|
|
const groundCache = new Map();
|
|
/* palette dial: mono (one hue, its shades), duo (the key's hue and a complement), tri (a triad) */
|
|
export const PALETTES = ["mono", "duo", "tri"];
|
|
function palette(spec) {
|
|
const energy = Math.min(1, Math.max(0, +spec.energy || 0.5)), minor = /m(in|inor)?\b/i.test(spec.key || "") && !/maj/i.test(spec.key || "");
|
|
const hue = keyHue(spec.key), mode = spec.palette || "duo";
|
|
const hue2 = mode === "mono" ? hue : mode === "tri" ? (hue + 120) % 360 : (hue + (minor ? 200 : 150)) % 360;
|
|
const hue3 = mode === "tri" ? (hue + 240) % 360 : hue2;
|
|
return { energy, minor, hue, hue2, hue3, mode, sat: 55 + energy * 35, light: minor ? 36 : 52, bpm: +spec.bpm || 100 };
|
|
}
|
|
function drawGround(W, H, spec, lit) {
|
|
const key = `${spec.seed}|${spec.title}|${spec.tags}|${spec.palette || ""}|${W}|${lit}`;
|
|
if (groundCache.has(key)) return groundCache.get(key);
|
|
const off = document.createElement("canvas"); off.width = W; off.height = H;
|
|
const ctx = off.getContext("2d"), cx = W / 2, cy = H / 2, p = palette(spec);
|
|
const rnd = prng(hash32((spec.seed || "") + "|" + (spec.title || "") + "|" + (spec.tags || "")) ^ 0x9e37);
|
|
const g = ctx.createLinearGradient(0, 0, 0, H);
|
|
g.addColorStop(0, `hsl(${p.hue} ${p.sat * .5}% ${p.light * .3}%)`); g.addColorStop(1, `hsl(${p.hue2} ${p.sat * .4}% ${p.light * .18}%)`);
|
|
ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);
|
|
ctx.globalCompositeOperation = "lighter";
|
|
const spread = W * .46, dots = Math.floor(400 + p.energy * 2600);
|
|
for (let i = 0; i < dots; i++) {
|
|
const a = rnd() * Math.PI * 2, d = Math.sqrt(rnd()) * spread * 1.05;
|
|
ctx.fillStyle = `hsl(${i % 3 ? p.hue2 : p.hue3} ${p.sat}% ${p.light + (p.mode === "mono" ? 38 : 30)}% / ${rnd() * 0.5 * lit})`;
|
|
ctx.fillRect(cx + Math.cos(a) * d, cy + Math.sin(a) * d, (W / 380) * (0.8 + rnd() * 1.6), (W / 380) * (0.8 + rnd() * 1.6));
|
|
}
|
|
if (groundCache.size > 24) groundCache.delete(groundCache.keys().next().value);
|
|
groundCache.set(key, off);
|
|
return off;
|
|
}
|
|
/* looks: every station has one; the seed mark in the centre is the same whichever look draws around it */
|
|
export const LOOKS = ["rings", "chord", "attractor"];
|
|
export function drawCover(canvas, spec, opts = {}) {
|
|
const look = spec.look || opts.look || "rings";
|
|
if (look === "chord") drawChord(canvas, spec, opts);
|
|
else if (look === "attractor") drawAttractor(canvas, spec, opts);
|
|
else drawRings(canvas, spec, opts);
|
|
if (spec.break) drawOnAir(canvas, spec, opts);
|
|
}
|
|
/* an intermission wears the station's cover in monochrome, with the asterisk as the mark and an ON AIR ring */
|
|
function drawOnAir(canvas, spec, { pulse = null } = {}) {
|
|
const ctx = canvas.getContext("2d"), W = canvas.width, H = canvas.height, cx = W / 2, cy = H / 2;
|
|
const bass = pulse ? pulse.bass : 0, tm = pulse ? pulse.t : 0;
|
|
const ink = cssVar("--ink"), ground = cssVar("--bg");
|
|
ctx.save();
|
|
ctx.globalCompositeOperation = "saturation"; ctx.fillStyle = "#808080"; ctx.fillRect(0, 0, W, H); // the colour drains out
|
|
ctx.globalCompositeOperation = "source-over";
|
|
ctx.fillStyle = ground; ctx.globalAlpha = 0.22; ctx.fillRect(0, 0, W, H); ctx.globalAlpha = 1;
|
|
// the asterisk, square ends, over the petal mark
|
|
ctx.translate(cx, cy); ctx.scale(1 + 0.12 * bass, 1 + 0.12 * bass);
|
|
ctx.fillStyle = ground; ctx.beginPath(); ctx.arc(0, 0, W * 0.125, 0, Math.PI * 2); ctx.fill();
|
|
ctx.strokeStyle = ink; ctx.lineWidth = W * 0.026; ctx.lineCap = "butt";
|
|
for (let k = 0; k < 3; k++) { const a = Math.PI / 2 + k * Math.PI / 3; ctx.beginPath(); ctx.moveTo(-Math.cos(a) * W * 0.07, -Math.sin(a) * W * 0.07); ctx.lineTo(Math.cos(a) * W * 0.07, Math.sin(a) * W * 0.07); ctx.stroke(); }
|
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
// the ring: dashed, turning slowly, lettered ON AIR · kind
|
|
ctx.save(); ctx.translate(cx, cy); ctx.rotate(tm * 0.15);
|
|
ctx.strokeStyle = ink; ctx.globalAlpha = 0.7; ctx.lineWidth = W * 0.006; ctx.setLineDash([W * 0.02, W * 0.014]);
|
|
ctx.beginPath(); ctx.arc(0, 0, W * 0.43, 0, Math.PI * 2); ctx.stroke(); ctx.restore();
|
|
const label = `ON AIR · ${String(spec.break).toUpperCase()}${spec.title && spec.break === "welcome" ? " · " + spec.title.toUpperCase() : ""}`;
|
|
ctx.font = `600 ${W * 0.028}px ui-monospace, Menlo, monospace`; ctx.textAlign = "center"; ctx.textBaseline = "middle";
|
|
const tw = ctx.measureText(label).width + W * 0.05;
|
|
ctx.fillStyle = ground; ctx.globalAlpha = 0.85; ctx.fillRect(cx - tw / 2, H * 0.86 - W * 0.025, tw, W * 0.05); ctx.globalAlpha = 1;
|
|
ctx.fillStyle = ink; ctx.fillText(label, cx, H * 0.86);
|
|
ctx.restore();
|
|
}
|
|
const cssVar = (n) => getComputedStyle(document.documentElement).getPropertyValue(n).trim() || "#ededf0";
|
|
function drawMark(ctx, W, spec, p, lit, bass, tm) {
|
|
const core = prng(hash32(spec.seed || ""));
|
|
const petals = 3 + Math.floor(core() * 5);
|
|
ctx.save(); ctx.translate(W / 2, W / 2); ctx.rotate(core() * Math.PI + tm * 0.05); ctx.scale(1 + 0.3 * bass, 1 + 0.3 * bass);
|
|
ctx.fillStyle = `hsl(${p.hue} ${p.sat}% ${Math.min(92, p.light + 45)}% / ${0.85 * lit})`;
|
|
ctx.beginPath();
|
|
for (let a = 0; a <= Math.PI * 2 + 0.01; a += Math.PI / 120) {
|
|
const rr = (W * .06) * (1 + 0.45 * Math.cos(a * petals));
|
|
const x = Math.cos(a) * rr, y = Math.sin(a) * rr;
|
|
a === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
|
}
|
|
ctx.closePath(); ctx.fill(); ctx.restore();
|
|
}
|
|
/* chord: a harmonograph whose frequency ratio is the key's interval (major third 5:4, minor 6:5), damped by tempo,
|
|
phased by the seed; the bass swells the whole figure */
|
|
function drawChord(canvas, spec, { lit = 1, pulse = null } = {}) {
|
|
const ctx = canvas.getContext("2d"), W = canvas.width, H = canvas.height, cx = W / 2, cy = H / 2;
|
|
const p = palette(spec), rnd = prng(hash32((spec.seed || "") + "|" + (spec.title || "") + "|" + (spec.tags || "")));
|
|
const bass = pulse ? pulse.bass : 0, high = pulse ? pulse.high : 0, tm = pulse ? pulse.t : 0;
|
|
ctx.globalCompositeOperation = "source-over";
|
|
const g = ctx.createRadialGradient(cx, cy, 0, cx, cy, W * .7);
|
|
g.addColorStop(0, `hsl(${p.hue} ${p.sat * .5}% ${p.light * .32}%)`); g.addColorStop(1, `hsl(${p.hue2} ${p.sat * .4}% ${p.light * .14}%)`);
|
|
ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);
|
|
const [fa, fb] = p.minor ? [6, 5] : [5, 4];
|
|
const fc = 1 + Math.round(p.bpm / 40); // a third pendulum: tempo adds a faster harmonic
|
|
const damp = 0.00045 + (p.bpm - 60) / 120 * 0.0006, ph1 = rnd() * Math.PI * 2, ph2 = rnd() * Math.PI * 2, ph3 = rnd() * Math.PI * 2;
|
|
const amp = W * 0.36 * (1 + 0.1 * bass), mix = 0.2 + 0.5 * p.energy, detune = 0.004 + 0.01 * rnd();
|
|
ctx.globalCompositeOperation = "lighter";
|
|
// three traces: a soft wide one, then two fine ones a hair apart in frequency, so the figure fills with moiré
|
|
for (let pass = 0; pass < 3; pass++) {
|
|
const dt = pass === 2 ? detune : 0;
|
|
ctx.beginPath();
|
|
for (let t = 0; t < 2600; t += 0.6) {
|
|
const e = Math.exp(-damp * t);
|
|
const x = cx + amp * e * (Math.sin((fa + dt) * t * 0.02 + ph1 + tm * 0.02) + mix * 0.3 * Math.sin(fc * t * 0.02 + ph3));
|
|
const y = cy + amp * e * (Math.sin((fb - dt) * t * 0.02 + ph2 - tm * 0.02) + mix * 0.3 * Math.cos(fc * t * 0.02 + ph3));
|
|
t === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
|
}
|
|
ctx.lineWidth = pass ? (0.55 + 0.6 * high) * (W / 380) : (3 + 2 * bass) * (W / 380);
|
|
ctx.strokeStyle = pass ? `hsl(${pass === 2 ? p.hue2 : p.hue} ${p.sat}% ${p.light + 36}% / ${0.42 * lit})` : `hsl(${p.hue2} ${p.sat}% ${p.light + 12}% / ${0.12 * lit})`;
|
|
ctx.stroke();
|
|
}
|
|
ctx.globalCompositeOperation = "source-over";
|
|
drawMark(ctx, W, spec, p, lit, bass, tm);
|
|
}
|
|
/* attractor: a Clifford attractor whose four parameters come from key, tempo, energy and the seed; the point cloud is
|
|
drawn once and cached, the seed mark beats on top */
|
|
const attractorCache = new Map();
|
|
function drawAttractor(canvas, spec, { lit = 1, pulse = null } = {}) {
|
|
const ctx = canvas.getContext("2d"), W = canvas.width, H = canvas.height;
|
|
const p = palette(spec);
|
|
const bass = pulse ? pulse.bass : 0, tm = pulse ? pulse.t : 0;
|
|
const key = `${spec.seed}|${spec.title}|${spec.tags}|${spec.palette || ""}|${W}|${lit}`;
|
|
let off = attractorCache.get(key);
|
|
if (!off) {
|
|
off = document.createElement("canvas"); off.width = W; off.height = H;
|
|
const o = off.getContext("2d"), rnd = prng(hash32(key));
|
|
const g = o.createLinearGradient(0, 0, W, H);
|
|
g.addColorStop(0, `hsl(${p.hue} ${p.sat * .45}% ${p.light * .3}%)`); g.addColorStop(1, `hsl(${p.hue2} ${p.sat * .4}% ${p.light * .15}%)`);
|
|
o.fillStyle = g; o.fillRect(0, 0, W, H);
|
|
// parameters in the ranges that keep Clifford figures open and filamentary
|
|
const a = -1.4 - 0.6 * ((p.hue % 120) / 120), b = 1.2 + 0.8 * ((p.bpm - 60) / 120), c = 0.6 + 1.2 * p.energy, d = (p.minor ? -1 : 1) * (0.6 + 1.0 * rnd());
|
|
let x = 0.1, y = 0.1; const n = 36000, pts = [];
|
|
let minX = 9, maxX = -9, minY = 9, maxY = -9;
|
|
for (let i = 0; i < n; i++) { const nx = Math.sin(a * y) + c * Math.cos(a * x), ny = Math.sin(b * x) + d * Math.cos(b * y); x = nx; y = ny; if (i > 200) { pts.push(x, y); if (x < minX) minX = x; if (x > maxX) maxX = x; if (y < minY) minY = y; if (y > maxY) maxY = y; } }
|
|
const sx = (W * 0.78) / Math.max(1e-6, maxX - minX), sy = (H * 0.78) / Math.max(1e-6, maxY - minY), s = Math.min(sx, sy);
|
|
const ox = W / 2 - (minX + maxX) / 2 * s, oy = H / 2 - (minY + maxY) / 2 * s;
|
|
o.globalCompositeOperation = "lighter";
|
|
o.fillStyle = `hsl(${p.hue} ${p.sat}% ${p.light + 28}% / ${0.16 * lit})`;
|
|
const dot = Math.max(1, W / 380);
|
|
for (let i = 0; i < pts.length; i += 2) o.fillRect(ox + pts[i] * s, oy + pts[i + 1] * s, dot, dot);
|
|
o.fillStyle = `hsl(${p.hue2} ${p.sat}% ${p.light + 40}% / ${0.08 * lit})`;
|
|
for (let i = 0; i < pts.length; i += 6) o.fillRect(ox + pts[i] * s, oy + pts[i + 1] * s, dot * 2, dot * 2);
|
|
if (attractorCache.size > 16) attractorCache.delete(attractorCache.keys().next().value);
|
|
attractorCache.set(key, off);
|
|
}
|
|
ctx.globalCompositeOperation = "source-over";
|
|
ctx.drawImage(off, 0, 0);
|
|
if (bass > 0.02) { ctx.globalAlpha = 0.5 * bass; ctx.drawImage(off, W / 2 - W * (0.5 + 0.03 * bass), H / 2 - H * (0.5 + 0.03 * bass), W * (1 + 0.06 * bass), H * (1 + 0.06 * bass)); ctx.globalAlpha = 1; }
|
|
drawMark(ctx, W, spec, p, lit, bass, tm);
|
|
}
|
|
function drawRings(canvas, spec, { lit = 1, pulse = null } = {}) {
|
|
const ctx = canvas.getContext("2d"), W = canvas.width, H = canvas.height, cx = W / 2, cy = H / 2;
|
|
const p = palette(spec), { energy, minor, hue, sat, light, bpm } = p;
|
|
const rnd = prng(hash32((spec.seed || "") + "|" + (spec.title || "") + "|" + (spec.tags || "")));
|
|
const bass = pulse ? pulse.bass : 0, high = pulse ? pulse.high : 0, tm = pulse ? pulse.t : 0;
|
|
ctx.globalCompositeOperation = "source-over";
|
|
ctx.drawImage(drawGround(W, H, spec, lit), 0, 0);
|
|
// rings: one per 10 bpm; the bass breathes them outward, the highs sharpen the line
|
|
const rings = Math.max(4, Math.round(bpm / 10));
|
|
const spread = W * .46, sym = 3 + Math.floor(rnd() * 6); // symmetry order from the seed
|
|
ctx.globalCompositeOperation = "lighter";
|
|
for (let r = 0; r < rings; r++) {
|
|
const t = (r + 1) / rings, rad = spread * Math.pow(t, .8) * (1 + 0.07 * bass * (1 - t));
|
|
ctx.beginPath();
|
|
const wob = 0.02 + energy * 0.08 + 0.03 * high, ph = rnd() * Math.PI * 2 + tm * (0.15 + 0.3 * t) * (r % 2 ? 1 : -1);
|
|
for (let a = 0; a <= Math.PI * 2 + 0.01; a += Math.PI / 90) {
|
|
const rr = rad * (1 + wob * Math.sin(a * sym + ph) + wob * 0.5 * Math.sin(a * sym * 2 + ph * 3));
|
|
const x = cx + Math.cos(a) * rr, y = cy + Math.sin(a) * rr;
|
|
a === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
|
}
|
|
ctx.closePath();
|
|
ctx.lineWidth = (0.6 + energy * 2.2 + high * 1.5) * (W / 380) * (1 - t * 0.5);
|
|
ctx.strokeStyle = `hsl(${(hue + t * (minor ? -40 : 40)) % 360} ${sat}% ${light + t * 25}% / ${(0.12 + 0.6 * (1 - t)) * lit * (1 + 0.5 * bass)})`;
|
|
ctx.stroke();
|
|
}
|
|
ctx.globalCompositeOperation = "source-over";
|
|
// a core: the station's seed mark, same shape for every track of one seed; it beats with the bass
|
|
const core = prng(hash32(spec.seed || ""));
|
|
const petals = 3 + Math.floor(core() * 5);
|
|
ctx.save(); ctx.translate(cx, cy); ctx.rotate(core() * Math.PI + tm * 0.05); ctx.scale(1 + 0.3 * bass, 1 + 0.3 * bass);
|
|
ctx.fillStyle = `hsl(${hue} ${sat}% ${Math.min(92, light + 45)}% / ${0.85 * lit})`;
|
|
ctx.beginPath();
|
|
for (let a = 0; a <= Math.PI * 2 + 0.01; a += Math.PI / 120) {
|
|
const rr = (W * .06) * (1 + 0.45 * Math.cos(a * petals));
|
|
const x = Math.cos(a) * rr, y = Math.sin(a) * rr;
|
|
a === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
|
}
|
|
ctx.closePath(); ctx.fill(); ctx.restore();
|
|
}
|