diff --git a/ventures/gandora/index.html b/ventures/gandora/index.html index 761e1a7..fb61d99 100644 --- a/ventures/gandora/index.html +++ b/ventures/gandora/index.html @@ -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 ? '' : ''; } @@ -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; };