gandora: the cover is a visualizer while music plays; reactions show on library tiles
Analyser on the player: bass breathes the rings and the seed mark, highs sharpen the lines, slow drift on the phases; ground and grain are cached per song so the frame loop only redraws rings and core. Reactions are saved with the track on each tap (they were only in memory before), and the tiles show them (💬 for words, ⏭ for a skip). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
This commit is contained in:
1 parent
af7fe043bd
commit
a8e8d0efbd
1 file changed
+78
-26
+78
-26
@@ -114,6 +114,7 @@ input,textarea{font:inherit;color:inherit}
|
||||
#lib h3{font:600 11px var(--font);letter-spacing:.2em;color:var(--dim);margin:22px 0 10px}
|
||||
#lib .t.on canvas{outline:2px solid var(--acc);outline-offset:2px}
|
||||
#lib .t .heart{position:absolute;top:6px;right:6px;font-size:14px;opacity:.9}
|
||||
#lib .t .re{position:absolute;left:6px;bottom:40px;font-size:13px;letter-spacing:1px;background:rgba(11,11,14,.6);border-radius:8px;padding:2px 6px;max-width:calc(100% - 12px);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
#lib .t{position:relative}
|
||||
#lib .t{cursor:pointer}
|
||||
#lib .t canvas{width:100%;aspect-ratio:1;border-radius:12px;display:block;background:var(--card)}
|
||||
@@ -233,48 +234,65 @@ function keyHue(k) {
|
||||
}
|
||||
|
||||
/* ---------- the cover: a function of the song ---------- */
|
||||
function drawCover(canvas, spec, { lit = 1 } = {}) {
|
||||
const ctx = canvas.getContext("2d"), W = canvas.width, H = canvas.height, cx = W / 2, cy = H / 2;
|
||||
const seedHash = hash32((spec.seed || "") + "|" + (spec.title || "") + "|" + (spec.tags || ""));
|
||||
const rnd = prng(seedHash);
|
||||
const bpm = +spec.bpm || 100, 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 || "");
|
||||
// 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();
|
||||
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), hue2 = (hue + (minor ? 200 : 150)) % 360;
|
||||
const sat = 55 + energy * 35, light = minor ? 36 : 52;
|
||||
// ground: a soft vertical field in the song's key
|
||||
return { energy, minor, hue, hue2, sat: 55 + energy * 35, light: minor ? 36 : 52, bpm: +spec.bpm || 100 };
|
||||
}
|
||||
function drawGround(W, H, spec, lit) {
|
||||
const key = `${spec.id || ""}|${spec.seed}|${spec.title}|${spec.tags}|${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(${hue} ${sat * .5}% ${light * .3}%)`); g.addColorStop(1, `hsl(${hue2} ${sat * .4}% ${light * .18}%)`);
|
||||
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);
|
||||
// rings: one per 10 bpm, spaced by tempo; thicker when energetic
|
||||
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(${p.hue2} ${p.sat}% ${p.light + 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;
|
||||
}
|
||||
function drawCover(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);
|
||||
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, ph = rnd() * Math.PI * 2;
|
||||
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) * (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})`;
|
||||
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();
|
||||
}
|
||||
// grain: density follows energy
|
||||
const dots = Math.floor(400 + energy * 2600);
|
||||
for (let i = 0; i < dots; i++) {
|
||||
const a = rnd() * Math.PI * 2, d = Math.sqrt(rnd()) * spread * 1.05;
|
||||
const x = cx + Math.cos(a) * d, y = cy + Math.sin(a) * d;
|
||||
ctx.fillStyle = `hsl(${hue2} ${sat}% ${light + 30}% / ${rnd() * 0.5 * lit})`;
|
||||
ctx.fillRect(x, y, (W / 380) * (0.8 + rnd() * 1.6), (W / 380) * (0.8 + rnd() * 1.6));
|
||||
}
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
// a core: the station's seed mark, same shape for every track of one seed
|
||||
// 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);
|
||||
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) {
|
||||
@@ -285,6 +303,35 @@ function drawCover(canvas, spec, { lit = 1 } = {}) {
|
||||
ctx.closePath(); ctx.fill(); ctx.restore();
|
||||
}
|
||||
|
||||
/* ---------- the visualizer: an analyser on the player, driving the cover while music plays ---------- */
|
||||
const viz = { ctx: null, an: null, data: null, raf: 0, smooth: { bass: 0, mid: 0, high: 0 } };
|
||||
function vizStart() {
|
||||
try {
|
||||
if (!viz.ctx) {
|
||||
viz.ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const src = viz.ctx.createMediaElementSource(state.audio);
|
||||
viz.an = viz.ctx.createAnalyser(); viz.an.fftSize = 512; viz.an.smoothingTimeConstant = 0.7;
|
||||
src.connect(viz.an); viz.an.connect(viz.ctx.destination);
|
||||
viz.data = new Uint8Array(viz.an.frequencyBinCount);
|
||||
}
|
||||
if (viz.ctx.state === "suspended") viz.ctx.resume();
|
||||
} catch { return; }
|
||||
cancelAnimationFrame(viz.raf);
|
||||
const canvas = $("#cover canvas"), t0 = performance.now();
|
||||
const band = (a, b) => { let s = 0; for (let i = a; i < b; i++) s += viz.data[i]; return s / (b - a) / 255; };
|
||||
const frame = () => {
|
||||
if (!state.playing || !state.current || !state.current.blob) { viz.raf = 0; return; }
|
||||
viz.an.getByteFrequencyData(viz.data);
|
||||
const raw = { bass: band(1, 8), mid: band(8, 40), high: band(40, 120) };
|
||||
for (const k in raw) viz.smooth[k] += (raw[k] - viz.smooth[k]) * (raw[k] > viz.smooth[k] ? 0.5 : 0.12); // fast attack, slow decay
|
||||
const s = viz.smooth;
|
||||
drawCover(canvas, state.current.spec, { pulse: { bass: Math.min(1, s.bass * 1.4), mid: s.mid, high: Math.min(1, s.high * 2.5), t: (performance.now() - t0) / 1000 } });
|
||||
viz.raf = requestAnimationFrame(frame);
|
||||
};
|
||||
viz.raf = requestAnimationFrame(frame);
|
||||
}
|
||||
function vizStop() { cancelAnimationFrame(viz.raf); viz.raf = 0; if (state.current && state.current.spec) drawCover($("#cover canvas"), state.current.spec); }
|
||||
|
||||
/* ---------- Venice ---------- */
|
||||
async function venice(path, body, { raw = false } = {}) {
|
||||
const headers = { "Content-Type": "application/json", Accept: raw ? "*/*" : "application/json" };
|
||||
@@ -472,7 +519,7 @@ async function playJob(job) {
|
||||
if (state.audio.src) URL.revokeObjectURL(state.audio.src);
|
||||
state.audio.src = URL.createObjectURL(job.blob);
|
||||
remember(job, false).then(keepLabel).catch(() => {}); saveStation().catch(() => {});
|
||||
try { await state.audio.play(); state.playing = true; setPlayIcon(); } catch { toast("Tap play."); }
|
||||
try { await state.audio.play(); state.playing = true; setPlayIcon(); vizStart(); } catch { toast("Tap play."); }
|
||||
cookNext(); // render ahead while this one plays
|
||||
}
|
||||
async function advance() {
|
||||
@@ -482,7 +529,9 @@ async function advance() {
|
||||
await playJob(job);
|
||||
cookNext();
|
||||
}
|
||||
state.audio.addEventListener("ended", () => { state.playing = false; setPlayIcon(); advance(); });
|
||||
state.audio.addEventListener("ended", () => { state.playing = false; setPlayIcon(); vizStop(); advance(); });
|
||||
state.audio.addEventListener("pause", () => { if (!state.audio.ended) { state.playing = false; setPlayIcon(); vizStop(); } });
|
||||
state.audio.addEventListener("play", () => { state.playing = true; setPlayIcon(); vizStart(); });
|
||||
state.audio.addEventListener("timeupdate", () => { if (state.audio.duration) $("#bar i").style.width = (state.audio.currentTime / state.audio.duration * 100) + "%"; });
|
||||
function setPlayIcon() { $("#playIco").innerHTML = state.playing ? '<path d="M6 5h4v14H6zm8 0h4v14h-4z"/>' : '<path d="M8 5v14l11-7z"/>'; }
|
||||
|
||||
@@ -510,6 +559,7 @@ async function react(emoji, text) {
|
||||
const later = !!state.nextJob;
|
||||
if (!later) cookNext();
|
||||
saveStation().catch(() => {});
|
||||
remember(state.current, false).catch(() => {}); // the reaction rides with the track in the library
|
||||
toast(`${emoji || "Noted"} — the ${later ? "track after next" : "next track"} hears it.`);
|
||||
}
|
||||
|
||||
@@ -568,7 +618,7 @@ $("#back").addEventListener("click", () => {
|
||||
$("#now").addEventListener("click", () => { $("#seed").classList.add("hidden"); $("#player").classList.remove("hidden"); });
|
||||
$("#play").addEventListener("click", async () => {
|
||||
if (!state.audio.src) return;
|
||||
if (state.playing) { state.audio.pause(); state.playing = false; } else { try { await state.audio.play(); state.playing = true; } catch {} }
|
||||
if (state.playing) { state.audio.pause(); state.playing = false; vizStop(); } else { try { await state.audio.play(); state.playing = true; vizStart(); } catch {} }
|
||||
setPlayIcon();
|
||||
});
|
||||
$("#skip").addEventListener("click", () => { state.audio.pause(); state.playing = false; setPlayIcon(); if (state.current) state.current.spec.reaction = (state.current.spec.reaction || "") + " [skipped]"; advance(); });
|
||||
@@ -600,6 +650,8 @@ $("#libBtn").addEventListener("click", async () => {
|
||||
const c = document.createElement("canvas"); c.width = c.height = 260; drawCover(c, t.spec);
|
||||
d.append(c, Object.assign(document.createElement("b"), { textContent: t.spec.title }), Object.assign(document.createElement("small"), { textContent: `${t.spec.bpm} bpm · ${t.spec.key} · ${t.spec.seed}` }));
|
||||
if (t.kept) d.append(Object.assign(document.createElement("span"), { className: "heart", textContent: "❤️" }));
|
||||
const re = String(t.spec.reaction || "").replace(/"[^"]*"/g, "💬").replace(/\[skipped\]/g, "⏭").replace(/\s+/g, "").trim();
|
||||
if (re) d.append(Object.assign(document.createElement("span"), { className: "re", textContent: re, title: t.spec.reaction }));
|
||||
d.addEventListener("click", () => { $("#lib").classList.add("hidden"); const job = { id: ++seq, spec: { ...t.spec }, blob: t.blob, err: null, done: Promise.resolve() }; state.seed = t.spec.seed; $("#player").classList.remove("hidden"); $("#seed").classList.add("hidden"); state.audio.pause(); playJob(job); });
|
||||
return d;
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user