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