Files
time/ventures/gandora/index.html
T

706 lines
48 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; connect-src https://api.venice.ai https://inference.yolopreneur.lol; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src blob:; font-src 'self'">
<title>gandora</title>
<meta name="description" content="Type a seed. Get a station. Every song is made for you as it plays, and every cover is drawn from the song's own settings.">
<link rel="icon" href="favicon.svg">
<style>
:root{
--bg:#0b0b0e; --ink:#ededf0; --dim:#8a8a96; --line:#232329; --card:#121216;
--acc:#e9d8a6; --font:ui-monospace,"SF Mono",Menlo,Consolas,monospace; --sans:-apple-system,"Inter",system-ui,sans-serif;
}
*{box-sizing:border-box} html,body{height:100%}
body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.5 var(--sans);-webkit-font-smoothing:antialiased;overflow-x:hidden}
a{color:inherit}
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
input,textarea{font:inherit;color:inherit}
.hidden{display:none!important}
/* ---------- seed screen ---------- */
#seed{min-height:100dvh;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:24px 16px;position:relative}
#seed .wordmark{position:absolute;top:18px;left:0;right:0;text-align:center;font:600 13px var(--font);letter-spacing:.28em;color:var(--dim)}
#seed form{width:min(680px,100%);text-align:center}
#seedIn{width:100%;background:transparent;border:0;border-bottom:1px solid var(--line);padding:14px 4px;text-align:center;font:400 clamp(24px,5vw,38px)/1.2 var(--sans);outline:none;caret-color:var(--acc);transition:border-color .3s}
#seedIn:focus{border-color:var(--acc)}
#seedIn::placeholder{color:#3a3a44}
#seed .hint{margin-top:18px;color:var(--dim);font-size:13px;min-height:1.5em}
#seedCover{width:120px;height:120px;margin:0 auto 28px;border-radius:14px;overflow:hidden;background:var(--card);opacity:0;transform:scale(.92);transition:opacity .5s,transform .5s}
#seedCover.on{opacity:1;transform:none}
#seedCover canvas{width:100%;height:100%;display:block}
#nowRow{position:absolute;top:44px;left:0;right:0;display:flex;justify-content:center}
#now{display:flex;align-items:center;gap:10px;background:var(--card);border:1px solid var(--line);border-radius:999px;padding:6px 14px 6px 6px;font-size:13px;cursor:pointer;max-width:calc(100% - 32px)}
#now canvas{width:28px;height:28px;border-radius:50%;display:block}
#now span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
#now b{font:600 11px var(--font);letter-spacing:.14em;color:var(--dim);margin-left:4px}
#stations{width:min(680px,100%);margin-top:40px}
#stations h3{font:600 11px var(--font);letter-spacing:.2em;color:var(--dim);margin:0 0 12px;text-align:center}
#stations .row{display:flex;gap:14px;overflow-x:auto;padding:4px 4px 10px;scrollbar-width:none;justify-content:safe center}
#stations .s{flex:0 0 96px;cursor:pointer;text-align:center}
#stations .s canvas{width:72px;height:72px;border-radius:50%;display:block;margin:0 auto 6px;background:var(--card)}
#stations .s.on canvas{outline:2px solid var(--acc);outline-offset:3px}
#stations .s b{display:block;font-size:12px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
#stations .s small{color:var(--dim);font-size:11px}
#keyRow{position:absolute;bottom:18px;left:0;right:0;text-align:center;font-size:12px;color:var(--dim)}
#keyRow button{text-decoration:underline;text-underline-offset:3px}
#keyBox{position:absolute;bottom:44px;left:50%;transform:translateX(-50%);width:min(420px,calc(100% - 32px));background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px;font-size:13px}
#keyBox input{width:100%;margin:8px 0;background:#0a0a0d;border:1px solid var(--line);border-radius:8px;padding:9px 10px;font-family:var(--font);font-size:12px;outline:none}
#keyBox .row{display:flex;gap:8px;justify-content:flex-end}
#keyBox .row button{padding:6px 12px;border-radius:8px;border:1px solid var(--line)}
#keyBox .row button.go{background:var(--acc);color:#111;border-color:var(--acc)}
#keyBox p{margin:0 0 4px;color:var(--dim)}
#keyBox label{display:block;margin-top:6px;color:var(--dim)}
/* ---------- player ---------- */
#player{min-height:100dvh;display:grid;grid-template-rows:auto 1fr auto;padding:14px 16px calc(16px + env(safe-area-inset-bottom))}
#top{display:flex;align-items:center;justify-content:space-between;font:600 12px var(--font);letter-spacing:.2em;color:var(--dim)}
#top .station{letter-spacing:0;font:400 13px var(--sans);color:var(--ink);max-width:60%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
#top button{color:var(--dim);letter-spacing:.08em}
#stage{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:18px;padding:14px 0}
#cover{width:min(78vw,380px);aspect-ratio:1;position:relative;perspective:1200px}
#cover .card{position:absolute;inset:0;transform-style:preserve-3d;transition:transform .7s cubic-bezier(.4,.1,.2,1)}
#cover.flipped .card{transform:rotateY(180deg)}
#cover .face{position:absolute;inset:0;border-radius:18px;overflow:hidden;background:var(--card);box-shadow:0 30px 80px -30px rgba(0,0,0,.9);backface-visibility:hidden;-webkit-backface-visibility:hidden}
#cover .back{transform:rotateY(180deg);background:#121216;border:1px solid var(--line);display:flex;flex-direction:column}
#cover canvas{width:100%;height:100%;display:block}
#cover .back .backTitle{font:600 10px var(--font);letter-spacing:.22em;color:var(--dim);padding:14px 16px 6px;text-align:center}
#cover .veil{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(11,11,14,.55);backdrop-filter:blur(2px);font:12px var(--font);letter-spacing:.2em;color:var(--ink);opacity:0;transition:opacity .3s;pointer-events:none}
#cover.cooking .veil{opacity:1}
#meta{text-align:center;max-width:520px}
#title{font-size:20px;font-weight:600;letter-spacing:-.01em;margin:0}
#tags{color:var(--dim);font:12px var(--font);margin:6px 0 0;letter-spacing:.02em}
#lyric{color:var(--dim);font-size:13px;margin:10px 0 0;min-height:1.5em;font-style:italic}
#lyricsBtn{display:block;margin:8px auto 0;font:600 11px var(--font);letter-spacing:.18em;color:var(--dim);text-decoration:underline;text-underline-offset:3px}
#lyricsBtn.on{color:var(--acc)}
#lyrics{white-space:pre-wrap;text-align:center;font-size:13px;line-height:1.65;margin:0;color:var(--ink);flex:1;overflow:auto;padding:4px 16px 18px;font-family:var(--sans);-webkit-mask-image:linear-gradient(#000 85%,transparent);mask-image:linear-gradient(#000 85%,transparent)}
#lyrics.none{color:var(--dim);font-style:italic;display:grid;place-items:center}
#lyrics .sec{display:block;color:var(--dim);font:600 10px var(--font);letter-spacing:.2em;margin:12px 0 2px}
#lyrics .sec:first-child{margin-top:0}
#cover{cursor:pointer}
#bar{width:min(78vw,380px);height:2px;background:var(--line);border-radius:2px;overflow:hidden}
#bar i{display:block;height:100%;width:0;background:var(--acc)}
#controls{display:flex;align-items:center;gap:26px}
#controls button{width:48px;height:48px;border-radius:50%;display:grid;place-items:center;color:var(--ink);opacity:.85}
#controls button:hover{opacity:1}
#controls #play{width:62px;height:62px;background:var(--ink);color:#0b0b0e}
#controls svg{width:22px;height:22px;fill:currentColor}
#controls #loop{width:40px;height:40px;opacity:.5}
#controls #loop svg{width:18px;height:18px}
#controls #loop.on{opacity:1;color:var(--acc);background:rgba(233,216,166,.12)}
#next{font:11px var(--font);letter-spacing:.14em;color:var(--dim);min-height:1.4em;text-align:center}
#keep{display:inline-flex;align-items:center;gap:8px;padding:9px 18px;border-radius:999px;border:1px solid var(--line);font:600 12px var(--font);letter-spacing:.14em;color:var(--ink);background:var(--card);transition:background .2s,color .2s,border-color .2s}
#keep.on{background:var(--acc);color:#111;border-color:var(--acc)}
#keep:disabled{opacity:.4;cursor:default}
#react{display:flex;flex-direction:column;gap:10px;align-items:center}
#emojis{display:flex;gap:6px;justify-content:center;flex-wrap:wrap}
#emojis button{font-size:22px;width:44px;height:44px;border-radius:12px;border:1px solid transparent;transition:transform .15s,border-color .15s,background .15s}
#emojis button:hover{background:var(--card);border-color:var(--line)}
#emojis button.hit{transform:scale(1.25);border-color:var(--acc)}
#emojis input{width:44px;height:44px;text-align:center;font-size:20px;background:var(--card);border:1px solid var(--line);border-radius:12px;outline:none}
#sayRow{display:flex;gap:8px;width:min(520px,100%)}
#say{flex:1;background:var(--card);border:1px solid var(--line);border-radius:12px;padding:10px 14px;outline:none;font-size:14px}
#say:focus{border-color:var(--acc)}
#sayRow button{padding:0 14px;border:1px solid var(--line);border-radius:12px;color:var(--dim)}
#toast{position:fixed;left:50%;bottom:110px;transform:translateX(-50%);background:var(--card);border:1px solid var(--line);border-radius:10px;padding:8px 14px;font-size:13px;opacity:0;transition:opacity .25s;pointer-events:none;max-width:calc(100% - 32px);text-align:center}
#toast.on{opacity:1}
/* ---------- library ---------- */
#lib{position:fixed;inset:0;background:rgba(11,11,14,.96);padding:18px 16px;overflow:auto}
#lib header{display:flex;justify-content:space-between;align-items:center;font:600 12px var(--font);letter-spacing:.2em;color:var(--dim);margin-bottom:16px}
#lib .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:14px}
#lib h3{font:600 11px var(--font);letter-spacing:.2em;color:var(--dim);margin:22px 0 10px;display:flex;align-items:center;gap:10px}
#lib h3 canvas{width:22px;height:22px;border-radius:50%}
#lib h3 small{font-weight:400;letter-spacing:.08em;opacity:.7}
#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)}
#lib .t b{display:block;font-size:12px;margin-top:6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
#lib .t small{color:var(--dim);font-size:11px}
#lib .empty{color:var(--dim);text-align:center;padding:40px 0}
#lib .cost{color:var(--dim);font:11px var(--font);margin-top:24px;text-align:center}
</style>
</head>
<body>
<!-- ============ screen 1: the seed ============ -->
<section id="seed">
<div class="wordmark">GANDORA</div>
<div id="nowRow" class="hidden"><div id="now" title="back to the station"><canvas width="56" height="56"></canvas><span id="nowTitle"></span><b>▶ PLAYING</b></div></div>
<form id="seedForm" autocomplete="off">
<div id="seedCover"><canvas width="240" height="240"></canvas></div>
<input id="seedIn" placeholder="type a seed" maxlength="200" autofocus>
<div class="hint" id="seedHint">a mood, a line, a place, a word</div>
</form>
<div id="stations" class="hidden"><h3>YOUR STATIONS</h3><div class="row" id="stationRow"></div></div>
<div id="keyRow"><button id="keyBtn" type="button">shared budget · add a venice key</button></div>
<div id="keyBox" class="hidden">
<p>Optional. Without a key, gandora runs on a shared budget through inference.yolopreneur.lol (a few tracks a day per person). With your own Venice key it goes straight to api.venice.ai from this browser and the key goes nowhere else.</p>
<input id="keyIn" placeholder="sk-…" spellcheck="false">
<label>DJ model <input id="djIn" placeholder="claude-haiku-5-5" spellcheck="false"></label>
<div class="row"><button id="keyCancel" type="button">cancel</button><button id="keySave" class="go" type="button">save</button></div>
</div>
</section>
<!-- ============ screen 2: the station ============ -->
<section id="player" class="hidden">
<div id="top">
<button id="back" title="new seed">◂ SEED</button>
<div class="station" id="stationName"></div>
<button id="libBtn">LIBRARY</button>
</div>
<div id="stage">
<div id="cover"><div class="card">
<div class="face"><canvas width="760" height="760"></canvas><div class="veil">COOKING</div></div>
<div class="face back"><div class="backTitle">LYRICS</div><pre id="lyrics"></pre></div>
</div></div>
<div id="meta"><h1 id="title">…</h1><p id="tags"></p><p id="lyric"></p><button id="lyricsBtn">LYRICS · FLIP</button></div>
<div id="bar"><i></i></div>
<div id="controls">
<button id="prev" title="previous track (tap twice within 3 s of a start to go back further)">
<svg viewBox="0 0 24 24"><path d="M18 6l-8.5 6 8.5 6V6zM6 6v12h2V6H6z"/></svg>
</button>
<button id="play" title="play / pause"><svg id="playIco" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg></button>
<button id="skip" title="skip">
<svg viewBox="0 0 24 24"><path d="M6 18l8.5-6L6 6v12zm10-12v12h2V6h-2z"/></svg>
</button>
<button id="loop" title="repeat this track">
<svg viewBox="0 0 24 24"><path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z"/></svg>
</button>
</div>
<button id="keep" disabled>➕ KEEP THIS TRACK</button>
<div id="next"></div>
</div>
<div id="react">
<div id="emojis">
<button data-e="🔥" title="more of this">🔥</button>
<button data-e="❤️" title="love it (keeps it too)">❤️</button>
<button data-e="😴" title="too sleepy">😴</button>
<button data-e="🌧️" title="sadder">🌧️</button>
<button data-e="⚡" title="faster, louder">⚡</button>
<button data-e="🎸" title="more guitar">🎸</button>
<button data-e="🎹" title="more keys">🎹</button>
<button data-e="🚫" title="not this">🚫</button>
<input id="anyEmoji" placeholder="+" maxlength="4" title="any emoji">
</div>
<form id="sayRow"><input id="say" placeholder="or say it: less snare, sadder but faster…" maxlength="240"><button>send</button></form>
</div>
<div id="toast"></div>
</section>
<section id="lib" class="hidden">
<header><span>LIBRARY</span><button id="libClose">CLOSE</button></header>
<div class="grid" id="libGrid"></div>
<div class="empty hidden" id="libEmpty">Nothing played yet. Every track lands here; ❤️ one to keep it for good.</div>
<div class="cost" id="libCost"></div>
</section>
<script>
/* ============================================================
gandora — a generative station.
seed → DJ (Venice chat) → ACE-Step (Venice /audio/queue) → player.
Every cover is drawn from the song's own settings: hue from key,
rings from BPM, density from energy, symmetry from the seed.
============================================================ */
const DIRECT = "https://api.venice.ai/api/v1"; // with a personal key
const SHARED = "https://inference.yolopreneur.lol/v1"; // without one: the factory's worker, quota'd per person
const $ = (s) => document.querySelector(s);
const LS = { key: "gandora.key", dj: "gandora.dj", cost: "gandora.cost" };
const state = {
key: localStorage.getItem(LS.key) || "",
dj: localStorage.getItem(LS.dj) || "claude-haiku-5-5",
seed: "", station: "", stationId: "",
taste: { like: [], avoid: [], notes: [] }, // what the listener has told the station
history: [], // tracks played this session (specs)
current: null, nextJob: null, playing: false,
recent: [], pos: -1, // ids of tracks played this session, and where the player stands in them
audio: new Audio(), cost: parseFloat(localStorage.getItem(LS.cost) || "0"),
};
state.audio.preload = "auto";
/* ---------- small utils ---------- */
const toast = (m, ms = 2400) => { const t = $("#toast"); t.textContent = m; t.classList.add("on"); clearTimeout(toast.h); toast.h = setTimeout(() => t.classList.remove("on"), ms); };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
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; }
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; }; }
const KEYS = ["C","G","D","A","E","B","F#","C#","G#","D#","A#","F"]; // circle of fifths → hue wheel
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();
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;
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(${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(${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) * (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();
}
/* ---------- 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() {
// the player is only wired through the analyser once the context is actually running: a suspended context
// (iOS before a tap) would mute the music, and the cover can wait for the next gesture
try {
viz.ctx ||= new (window.AudioContext || window.webkitAudioContext)();
if (viz.ctx.state !== "running") { viz.ctx.resume().then(() => { if (viz.ctx.state === "running" && state.playing) vizStart(); }).catch(() => {}); if (viz.ctx.state !== "running") return; }
if (!viz.an) {
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);
}
} 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" };
if (state.key) headers.Authorization = "Bearer " + state.key; else headers["x-app"] = "gandora";
const res = await fetch((state.key ? DIRECT : SHARED) + path, { method: body ? "POST" : "GET", headers, body: body ? JSON.stringify(body) : undefined });
if (!res.ok) {
let d = null; try { d = await res.json(); } catch {}
const said = d && (d.error && (d.error.message || d.error) || d.message) || "";
const why = { 401: "Venice didn't accept that key.", 402: "Venice says the key is out of credit.", 422: "Venice turned that down: " + said, 429: state.key ? "Venice is rate-limiting. Breathe." : (said || "Over today's shared budget."), 503: "That model is busy at Venice." }[res.status];
throw new Error(why || ("Venice answered " + res.status + (said ? ": " + said : "")));
}
return raw ? res : res.json();
}
const DJ_SCHEMA = {
type: "object", additionalProperties: false,
required: ["title", "tags", "lyrics", "bpm", "key", "energy", "station", "line"],
properties: {
title: { type: "string" }, tags: { type: "string" }, lyrics: { type: "string" },
bpm: { type: "integer" }, key: { type: "string" }, energy: { type: "number" },
station: { type: "string" }, line: { type: "string" },
},
};
function djPrompt() {
const prev = state.history.slice(-4).map((t, i) => `- ${t.title} [${t.tags}] ${t.bpm}bpm ${t.key} energy ${t.energy}${t.reaction ? " ← listener: " + t.reaction : ""}`).join("\n");
return `You are the DJ of a one-listener radio station. The listener typed a seed and the station grows from it.
Seed: "${state.seed}"
${state.station ? `Station name: ${state.station}\n` : ""}Listener asked for more of: ${state.taste.like.join("; ") || "(nothing yet)"}
Listener asked to avoid: ${state.taste.avoid.join("; ") || "(nothing yet)"}
Listener's words: ${state.taste.notes.join(" | ") || "(none yet)"}
Recent tracks:
${prev || "- (none yet: this is the first)"}
Write the NEXT track. Keep continuity with the recent tracks unless the listener pushed away; vary something each time so the station moves.
Return JSON:
- title: a short evocative song title (never reuse one)
- tags: the music prompt for a song model, 60-300 characters, comma-separated descriptors: genre, instruments, vocal style or "instrumental", mood, era, production notes. Concrete and musical, no sentences.
- lyrics: full lyrics with [verse] / [chorus] / [bridge] section markers, 8-24 lines, or an empty string for instrumental.
- bpm: integer 60-180 that fits the tags
- key: like "A minor" or "F# major"
- energy: 0 to 1
- station: a two or three word station name grown from the seed (keep it stable once chosen)
- line: one line, under 90 characters, the DJ says about this track to the listener (no quotes, no emoji)`;
}
async function askDJ() {
const body = { model: state.dj, messages: [{ role: "user", content: djPrompt() }], max_tokens: 1200, temperature: 0.9, stream: false,
venice_parameters: { disable_thinking: true, strip_thinking_response: true, include_venice_system_prompt: false },
response_format: { type: "json_schema", json_schema: { name: "track", strict: true, schema: DJ_SCHEMA } } };
let d;
try { d = await venice("/chat/completions", body); }
catch (e) { if (!/400/.test(e.message)) throw e; delete body.response_format; body.messages[0].content += "\nAnswer with the JSON object only."; d = await venice("/chat/completions", body); }
let text = String(d.choices?.[0]?.message?.content || "");
const m = text.match(/\{[\s\S]*\}/); if (!m) throw new Error("The DJ didn't answer in JSON.");
const spec = JSON.parse(m[0]);
spec.tags = String(spec.tags || "").slice(0, 500); if (spec.tags.length < 10) spec.tags += ", instrumental, warm, lo-fi";
spec.lyrics = String(spec.lyrics || "").slice(0, 4000);
spec.bpm = Math.min(180, Math.max(60, parseInt(spec.bpm) || 100));
spec.energy = Math.min(1, Math.max(0, +spec.energy || 0.5));
spec.seed = state.seed; spec.made = Date.now();
if (!state.station && spec.station) { state.station = spec.station; $("#stationName").textContent = state.station; }
return spec;
}
/* ACE-Step on Venice: queue, poll, download. 60 s costs about $0.03. Venice usually answers in 10-30 s but its queue
sometimes stalls a job for minutes, so after HEDGE_AT a second copy is queued and whichever lands first is taken. */
const HEDGE_AT = 60000;
async function renderTrack(spec, onStatus) {
const body = { model: "ace-step-15", prompt: spec.tags, duration_seconds: 60, ...(spec.lyrics ? { lyrics_prompt: spec.lyrics } : {}) };
const jobs = [await venice("/audio/queue", body)];
const started = Date.now();
let hedged = false;
for (let i = 0; i < 90; i++) {
await sleep(i < 4 ? 2500 : 4000);
const elapsed = Date.now() - started;
if (!hedged && elapsed > HEDGE_AT) { hedged = true; try { jobs.push(await venice("/audio/queue", body)); } catch {} }
let expected = 0;
for (const q of jobs) {
const res = await venice("/audio/retrieve", { model: q.model || "ace-step-15", queue_id: q.queue_id, delete_media_on_completion: true }, { raw: true });
const type = res.headers.get("content-type") || "";
if (/^audio\//i.test(type) || /octet-stream/i.test(type)) {
const blob = await res.blob();
state.cost += 0.03 * jobs.length; localStorage.setItem(LS.cost, String(state.cost));
return blob;
}
const d = await res.json();
const st = String(d.status || "").toUpperCase();
if (/FAIL|ERROR|CANCEL|REJECT/.test(st)) { if (jobs.length === 1) throw new Error("Venice couldn't make the track (" + st.toLowerCase() + ")."); jobs.splice(jobs.indexOf(q), 1); break; }
expected = Math.max(expected, d.average_execution_time || 0);
}
onStatus && onStatus(Math.round(elapsed / 1000), Math.round(expected / 1000), hedged);
}
throw new Error("That render took too long.");
}
/* ---------- the library (IndexedDB) ---------- */
const db = {
open() { return new Promise((ok, no) => { const r = indexedDB.open("gandora", 2); r.onupgradeneeded = () => { const d = r.result; for (const s of ["tracks", "stations"]) if (!d.objectStoreNames.contains(s)) d.createObjectStore(s, { keyPath: "id" }); }; r.onsuccess = () => ok(r.result); r.onerror = () => no(r.error); }); },
async put(t, store = "tracks") { const d = await this.open(); return new Promise((ok, no) => { const tx = d.transaction(store, "readwrite"); tx.objectStore(store).put(t); tx.oncomplete = ok; tx.onerror = () => no(tx.error); }); },
async all(store = "tracks") { const d = await this.open(); return new Promise((ok, no) => { const r = d.transaction(store).objectStore(store).getAll(); r.onsuccess = () => ok(r.result.sort((a, b) => (b.last || b.made) - (a.last || a.made))); r.onerror = () => no(r.error); }); },
async del(id, store = "tracks") { const d = await this.open(); return new Promise((ok, no) => { const tx = d.transaction(store, "readwrite"); tx.objectStore(store).delete(id); tx.oncomplete = ok; tx.onerror = () => no(tx.error); }); },
};
/* ---------- stations: a seed, its taste, and what it has played; resumable from the seed screen ---------- */
async function saveStation() {
if (!state.stationId) return;
const specs = state.history.slice(-8).map(({ id, title, tags, bpm, key, energy, reaction, made }) => ({ id, title, tags, bpm, key, energy, reaction, made }));
await db.put({ id: state.stationId, seed: state.seed, name: state.station, taste: state.taste, history: specs, made: +state.stationId.split("-")[1] || Date.now(), last: Date.now(), played: (state.played || 0) }, "stations");
}
async function listStations() {
const list = await db.all("stations"); const box = $("#stations"), row = $("#stationRow"); row.innerHTML = "";
box.classList.toggle("hidden", !list.length);
for (const s of list) {
const d = document.createElement("div"); d.className = "s" + (s.id === state.stationId ? " on" : "");
const c = document.createElement("canvas"); c.width = c.height = 144;
drawCover(c, { seed: s.seed, title: "", tags: "", bpm: 100, key: KEYS[hash32(s.seed) % 12], energy: 0.5 }, { lit: 0.9 });
d.append(c, Object.assign(document.createElement("b"), { textContent: s.name || s.seed }), Object.assign(document.createElement("small"), { textContent: `${s.played || 0} tracks` }));
d.addEventListener("click", () => resumeStation(s));
row.append(d);
}
}
function openStation(seed, { id, name = "", taste, history, played } = {}) {
if (state.stationId !== id) { state.audio.pause(); state.playing = false; setPlayIcon(); state.current = null; state.nextJob = null; }
state.seed = seed; state.station = name; state.stationId = id || `s-${Date.now()}-${hash32(seed)}`;
state.taste = taste || { like: [], avoid: [], notes: [] }; state.history = history || []; state.played = played || 0;
$("#stationName").textContent = name || seed;
$("#seed").classList.add("hidden"); $("#player").classList.remove("hidden");
if (!state.current) {
drawCover($("#cover canvas"), { seed, title: "", tags: "", bpm: 100, key: KEYS[hash32(seed) % 12], energy: 0.5 }, { lit: 0.6 });
$("#title").textContent = "tuning…"; $("#tags").textContent = ""; $("#lyric").textContent = ""; keepLabel();
saveStation().catch(() => {});
advance();
}
}
function resumeStation(s) { openStation(s.seed, s); }
const KEEP_UNKEPT = 40;
// every rendered track is saved so the station can be scrolled back through; ❤️ pins one, the rest roll off after KEEP_UNKEPT
async function remember(job, kept) {
const s = job.spec; if (!job.blob) return;
s.id ||= s.made + "-" + hash32(s.title);
const old = (await db.all()).find((t) => t.id === s.id);
await db.put({ id: s.id, spec: s, blob: job.blob, made: s.made, kept: kept || (old && old.kept) || false });
const loose = (await db.all()).filter((t) => !t.kept).slice(KEEP_UNKEPT);
for (const t of loose) await db.del(t.id);
}
/* ---------- the station loop ---------- */
let seq = 0;
function makeJob(spec) {
const job = { id: ++seq, spec, blob: null, err: null, done: null };
job.done = (async () => {
try { job.blob = await renderTrack(spec, (el, exp, hedged) => {
const t = `COOKING ${el}s${exp ? " / ~" + exp + "s" : ""}${hedged ? " · SECOND OVEN LIT" : ""}`;
if (state.current === job) $("#cover .veil").textContent = t;
else if (state.nextJob === job && state.current) $("#next").textContent = "NEXT · " + spec.title.toUpperCase() + " · " + t;
}); }
catch (e) { job.err = e; }
})();
return job;
}
let cooking = null;
function cookNext() {
if (state.nextJob) return Promise.resolve(state.nextJob);
if (cooking) return cooking;
cooking = (async () => {
try { const spec = await askDJ(); state.nextJob = makeJob(spec); if (state.current) $("#next").textContent = "NEXT · " + spec.title.toUpperCase(); return state.nextJob; }
catch (e) { toast(e.message, 5000); return null; }
finally { cooking = null; }
})();
return cooking;
}
async function playJob(job) {
const spec = job.spec;
state.current = job; state.history.push(spec); state.played = (state.played || 0) + 1; spec.stationId = state.stationId;
keepLabel();
$("#title").textContent = spec.title; $("#tags").textContent = `${spec.tags.split(",").slice(0, 5).join(",")} · ${spec.bpm} bpm · ${spec.key}`;
$("#lyric").textContent = spec.line || "";
showLyrics(spec);
drawCover($("#cover canvas"), spec);
if (!$("#nowRow").classList.contains("hidden")) { drawCover($("#now canvas"), spec); $("#nowTitle").textContent = `${spec.title} · ${state.station || spec.seed}`; }
$("#cover").classList.add("cooking"); $("#bar i").style.width = "0";
await job.done;
if (state.current !== job) return; // skipped while cooking
$("#cover").classList.remove("cooking");
if (job.err) { toast(job.err.message, 6000); $("#lyric").textContent = job.err.message + (/limit|budget/i.test(job.err.message) ? "" : " · skip to try the next"); return; }
if (state.audio.src) URL.revokeObjectURL(state.audio.src);
state.audio.src = URL.createObjectURL(job.blob);
await remember(job, false).then(keepLabel).catch(() => {}); saveStation().catch(() => {});
if (!job.stepping) { state.recent = state.recent.slice(0, state.pos + 1); state.recent.push(spec.id); state.pos = state.recent.length - 1; }
try { await state.audio.play(); state.playing = true; setPlayIcon(); vizStart(); } catch { toast("Tap play."); }
cookNext(); // render ahead while this one plays
}
// step through what has already played this session: -1 back, +1 forward; false when there's nothing there
async function step(dir) {
const i = state.pos + dir; if (i < 0 || i >= state.recent.length) return false;
const row = (await db.all()).find((t) => t.id === state.recent[i]); if (!row) return false;
state.pos = i; state.audio.pause();
await playJob({ id: ++seq, spec: row.spec, blob: row.blob, err: null, done: Promise.resolve(), stepping: true });
return true;
}
async function advance() {
if (await step(+1)) return; // forward through the session first, then cook new
const job = state.nextJob || await cookNext();
state.nextJob = null; $("#next").textContent = "";
if (!job) return;
await playJob(job);
cookNext();
}
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"/>'; }
/* ---------- reactions: each one edits the taste, and the next cover shows it ---------- */
const EMOJI_MEANING = {
"🔥": { like: "more exactly like the current track" }, "❤️": { like: "the current track's sound", keep: true },
"😴": { avoid: "this sleepy energy; wake it up", energy: +0.25 }, "🌧️": { like: "sadder, more melancholy, minor keys" },
"⚡": { like: "faster and louder", energy: +0.3 }, "🎸": { like: "more guitar" }, "🎹": { like: "more piano and keys" },
"🚫": { avoid: "anything like the current track" },
};
async function react(emoji, text) {
const cur = state.current && state.current.spec; if (!cur) return;
const m = EMOJI_MEANING[emoji] || (emoji ? { notes: emoji } : null);
const who = `${cur.title} (${cur.tags.split(",").slice(0, 3).join(",")})`;
if (m) {
if (m.like) state.taste.like.push(`${m.like} — re ${who}`);
if (m.avoid) state.taste.avoid.push(`${m.avoid} — re ${who}`);
if (m.notes) state.taste.notes.push(`${m.notes} (an emoji, re ${who})`);
cur.reaction = (cur.reaction ? cur.reaction + " " : "") + emoji;
if (m.keep && state.current.blob) { await remember(state.current, true); keepLabel(); toast("Kept in your library."); }
}
if (text) { state.taste.notes.push(`${text} (re ${who})`); cur.reaction = (cur.reaction ? cur.reaction + " " : "") + `"${text}"`; }
for (const k of ["like", "avoid", "notes"]) state.taste[k] = state.taste[k].slice(-8);
// a next track already in the oven was planned before this was said: it still plays (it's paid for), the one after hears it
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.`);
}
/* ---------- UI wiring ---------- */
$("#seedIn").addEventListener("input", (e) => {
const v = e.target.value.trim();
const c = $("#seedCover"); if (!v) { c.classList.remove("on"); return; }
c.classList.add("on"); drawCover(c.querySelector("canvas"), { seed: v, title: "", tags: "", bpm: 100, key: KEYS[hash32(v) % 12], energy: 0.5 });
$("#seedHint").textContent = "↵ to start the station";
});
$("#seedForm").addEventListener("submit", (e) => {
e.preventDefault();
const v = $("#seedIn").value.trim(); if (!v) return;
openStation(v); // a new seed is a new station
});
/* lyrics: the DJ's words for the track, under the title; tap LYRICS or the cover */
function showLyrics(spec) {
const has = !!(spec.lyrics && spec.lyrics.trim()), p = $("#lyrics");
p.innerHTML = ""; p.classList.toggle("none", !has);
if (!has) { p.textContent = "instrumental"; return; }
for (const line of spec.lyrics.split(/\r?\n/)) {
const m = line.match(/^\s*\[([^\]]+)\]\s*$/);
if (m) p.append(Object.assign(document.createElement("span"), { className: "sec", textContent: m[1].toUpperCase() }));
else p.append(document.createTextNode(line + "\n"));
}
}
function toggleLyrics() {
state.lyricsOpen = !state.lyricsOpen;
$("#cover").classList.toggle("flipped", state.lyricsOpen); $("#lyricsBtn").classList.toggle("on", state.lyricsOpen);
$("#lyricsBtn").textContent = state.lyricsOpen ? "COVER · FLIP" : "LYRICS · FLIP";
}
$("#lyricsBtn").addEventListener("click", toggleLyrics);
$("#cover").addEventListener("click", toggleLyrics);
/* keep: an explicit pin, next to the controls; ❤️ does it too */
async function keepLabel() {
const b = $("#keep"), cur = state.current && state.current.spec;
if (!cur || !cur.id) { b.disabled = true; b.classList.remove("on"); b.textContent = "➕ KEEP THIS TRACK"; return; }
const row = (await db.all()).find((t) => t.id === cur.id);
b.disabled = false; b.classList.toggle("on", !!(row && row.kept)); b.textContent = row && row.kept ? "✓ KEPT" : "➕ KEEP THIS TRACK";
}
$("#keep").addEventListener("click", async () => {
const cur = state.current && state.current.spec; if (!cur || !cur.id || !state.current.blob) return;
const row = (await db.all()).find((t) => t.id === cur.id);
await db.put({ ...(row || { id: cur.id, spec: cur, blob: state.current.blob, made: cur.made }), kept: !(row && row.kept) });
await keepLabel(); toast(row && row.kept ? "Unkept." : "Kept — it stays in your library.");
});
// back to the seed screen keeps the station playing: a small now-playing strip leads back to it
$("#back").addEventListener("click", () => {
$("#player").classList.add("hidden"); $("#seed").classList.remove("hidden");
const cur = state.current && state.current.spec;
$("#nowRow").classList.toggle("hidden", !cur);
if (cur) { drawCover($("#now canvas"), cur); $("#nowTitle").textContent = `${cur.title} · ${state.station || cur.seed}`; }
$("#seedIn").value = ""; $("#seedCover").classList.remove("on"); $("#seedHint").textContent = cur ? "type a new seed for a new station, or pick one below" : "a mood, a line, a place, a word";
listStations().catch(() => {});
});
$("#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; 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(); });
$("#loop").addEventListener("click", () => { state.audio.loop = !state.audio.loop; $("#loop").classList.toggle("on", state.audio.loop); toast(state.audio.loop ? "Repeating this track." : "Back to the station."); });
$("#prev").addEventListener("click", async () => {
// within 3 s of a start: the track before; otherwise back to the start of this one (the usual convention)
if (state.audio.currentTime > 3 && state.audio.src) { state.audio.currentTime = 0; state.audio.play().catch(() => {}); return; }
if (!(await step(-1))) { if (state.audio.src) { state.audio.currentTime = 0; state.audio.play().catch(() => {}); } toast("That's the first track of this session."); }
});
$("#emojis").addEventListener("click", (e) => { const b = e.target.closest("button[data-e]"); if (!b) return; b.classList.add("hit"); setTimeout(() => b.classList.remove("hit"), 300); react(b.dataset.e); });
$("#anyEmoji").addEventListener("change", (e) => { const v = e.target.value.trim(); e.target.value = ""; if (v) react(v); });
$("#sayRow").addEventListener("submit", (e) => { e.preventDefault(); const v = $("#say").value.trim(); if (!v) return; $("#say").value = ""; react("", v); });
/* key box */
function openKey() { $("#keyIn").value = state.key; $("#djIn").value = state.dj; $("#keyBox").classList.remove("hidden"); $("#keyIn").focus(); }
$("#keyBtn").addEventListener("click", openKey);
$("#keyCancel").addEventListener("click", () => $("#keyBox").classList.add("hidden"));
$("#keySave").addEventListener("click", () => {
state.key = $("#keyIn").value.trim(); state.dj = $("#djIn").value.trim() || "claude-haiku-5-5";
localStorage.setItem(LS.key, state.key); localStorage.setItem(LS.dj, state.dj);
$("#keyBox").classList.add("hidden"); keyLabel(); $("#seedIn").focus();
});
function keyLabel() { $("#keyBtn").textContent = state.key ? "your venice key ✓" : "shared budget · add a venice key"; }
keyLabel();
/* library */
$("#libBtn").addEventListener("click", async () => {
const list = await db.all(); const g = $("#libGrid"); g.innerHTML = "";
$("#libEmpty").classList.toggle("hidden", list.length > 0);
const cur = state.current && state.current.spec;
const tile = (t) => {
const d = document.createElement("div"); d.className = "t" + (cur && t.id === cur.id ? " on" : "");
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;
};
// kept first, then everything played, grouped by station (channel), newest station first
const stations = await db.all("stations"); const byId = Object.fromEntries(stations.map((s) => [s.id, s]));
const head = (text, seed, count) => {
const h = document.createElement("h3"); h.style.gridColumn = "1/-1";
if (seed) { const c = document.createElement("canvas"); c.width = c.height = 44; drawCover(c, { seed, title: "", tags: "", bpm: 100, key: KEYS[hash32(seed) % 12], energy: 0.5 }); h.append(c); }
h.append(Object.assign(document.createElement("span"), { textContent: text }));
if (count != null) h.append(Object.assign(document.createElement("small"), { textContent: `· ${count}` }));
return h;
};
const kept = list.filter((t) => t.kept);
if (kept.length) { g.append(head("KEPT", "", kept.length)); for (const t of kept) g.append(tile(t)); }
const groups = new Map();
for (const t of list) { const k = t.spec.stationId || "seed:" + t.spec.seed; if (!groups.has(k)) groups.set(k, []); groups.get(k).push(t); }
for (const [k, rows] of groups) {
const s = byId[k]; const name = (s && s.name) || rows[0].spec.seed, seed = (s && s.seed) || rows[0].spec.seed;
g.append(head(name.toUpperCase(), seed, rows.length));
for (const t of rows) g.append(tile(t));
}
$("#libCost").textContent = `~$${state.cost.toFixed(2)} of music rendered from this browser · replays from the library are free`;
$("#lib").classList.remove("hidden");
});
$("#libClose").addEventListener("click", () => $("#lib").classList.add("hidden"));
listStations().catch(() => {});
// first paint of the seed mark, for a visitor who lands with a hash like #rainy+trains
if (location.hash.length > 1) { $("#seedIn").value = decodeURIComponent(location.hash.slice(1)).replace(/\+/g, " "); $("#seedIn").dispatchEvent(new Event("input")); }
</script>
</body>
</html>