Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
706 lines
48 KiB
HTML
706 lines
48 KiB
HTML
<!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>
|