Files
site/index.html
T

1513 lines
115 KiB
HTML
Raw Normal View History

<!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">
<link rel="apple-touch-icon" href="icons/apple-touch-icon.png">
<link rel="manifest" href="manifest.json">
<meta name="theme-color" content="#0b0b0e">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="gandora">
<style>
:root{
--bg:#0b0b0e; --ink:#ededf0; --dim:#8a8a96; --line:#232329; --card:#121216; --field:#0a0a0d;
--acc:var(--ink); --onacc:var(--bg); --veil:rgba(11,11,14,.55); --veil2:rgba(11,11,14,.65); --shade:8,8,10;
--font:ui-monospace,"SF Mono",Menlo,Consolas,monospace; --sans:-apple-system,"Inter",system-ui,sans-serif;
color-scheme:dark;
}
:root[data-theme="light"]{
--bg:#f4f4f6; --ink:#17171a; --dim:#6b6b76; --line:#dcdce2; --card:#ffffff; --field:#ffffff;
--veil:rgba(244,244,246,.6); --veil2:rgba(244,244,246,.75); --shade:244,244,246; color-scheme:light;
}
@media (prefers-color-scheme:light){:root:not([data-theme="dark"]){
--bg:#f4f4f6; --ink:#17171a; --dim:#6b6b76; --line:#dcdce2; --card:#ffffff; --field:#ffffff;
--veil:rgba(244,244,246,.6); --veil2:rgba(244,244,246,.75); --shade:244,244,246; color-scheme:light;
}}
*{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 15px var(--font);letter-spacing:.18em;color:var(--ink)}
#seed .wordmark .ast{color:var(--ink);font-weight:700}
#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);display:flex;gap:18px;justify-content:center;flex-wrap:wrap}
#keyRow button{text-decoration:underline;text-underline-offset:3px}
#keyRow .nav{font:600 11px var(--font);letter-spacing:.18em;text-decoration:none}
#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:var(--field);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:var(--onacc);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)}
#stage .station{font:600 11px var(--font);letter-spacing:.22em;color:var(--dim);max-width:min(78vw,380px);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer;text-transform:uppercase;margin-bottom:-4px}
#stage .station:hover{color:var(--acc)}
#stations .s{position:relative}
#stations .s .dots{position:absolute;top:-4px;right:2px;width:22px;height:22px;border-radius:50%;background:var(--card);border:1px solid var(--line);color:var(--dim);font-size:12px;line-height:1}
#scrim{position:fixed;inset:0;z-index:5;background:rgba(0,0,0,.25)}
#stationSheet,#picker,#tapePick{z-index:6}
#stationSheet{position:fixed;left:50%;bottom:0;transform:translateX(-50%);width:min(480px,100%);background:var(--card);border:1px solid var(--line);border-bottom:0;border-radius:18px 18px 0 0;padding:14px 14px calc(14px + env(safe-area-inset-bottom));z-index:6;box-shadow:0 -20px 60px rgba(0,0,0,.6)}
#stationSheet h4{font:600 10px var(--font);letter-spacing:.2em;color:var(--dim);margin:0 0 10px}
#stationSheet label{display:block;color:var(--dim);font-size:12px;margin:8px 0 4px}
#stationSheet input{width:100%;background:var(--field);border:1px solid var(--line);border-radius:10px;padding:10px 12px;outline:none}
#stationSheet .row{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}
#stationSheet .row button{padding:9px 14px;border-radius:999px;border:1px solid var(--line);font:600 11px var(--font);letter-spacing:.14em}
#stationSheet .row button.go{background:var(--acc);color:var(--onacc);border-color:var(--acc)}
#stationSheet .row button.bad{color:hsl(0 60% 70%);border-color:hsl(0 30% 35%)}
#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}
/* the flow: station, cover (trail on hover), transport, title, bar, then the DJ line, NEXT, lyrics/keep/share, tags */
#stationName{order:0}#cover{order:1}#controls{order:2}#meta{order:4}#bar{order:5}#meta2{order:7}
#stage #controls{margin-top:2px}
#meta2 #pills{margin:10px 0 8px}
#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:var(--card);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:var(--veil);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:11px var(--font);margin:4px 0 0;letter-spacing:.06em}
#lyric{color:var(--dim);font-size:13px;margin:8px 0 0;min-height:1.5em;font-style:italic;cursor:text}
#lyric:hover{color:var(--ink)}
#meta2{text-align:center;max-width:560px;width:100%}
#meta2 #lyric{margin-top:0}
#tagRow{display:flex;flex-wrap:wrap;gap:6px;justify-content:center;margin:10px auto 0;max-width:560px}
#tagList{display:flex;flex-wrap:wrap;gap:6px;justify-content:center;margin:8px auto 0;max-width:560px}
#tagList:empty{display:none}
#tagList button{font:12px var(--font);letter-spacing:.02em;padding:5px 10px;border-radius:999px;border:1px solid var(--line);color:var(--dim);background:transparent;transition:all .15s}
#tagList button.like{color:var(--onacc);background:var(--acc);border-color:var(--acc)}
#tagList button.avoid{color:hsl(220 40% 70%);border-color:hsl(220 30% 40%);text-decoration:line-through;opacity:.8}
#tagList button.add{border-style:dashed}
#tagList input{font:12px var(--font);width:110px;padding:5px 10px;border-radius:999px;border:1px solid var(--acc);background:var(--card);outline:none}
#meta2 #next{margin:8px 0 0}
#tagRow button.meta{color:var(--dim);border-style:dotted;cursor:default}
#tagRow button.fold{color:var(--acc);border-color:transparent;font-weight:600;letter-spacing:.12em}
#stage{gap:14px}
#tagRow button{font:12px var(--font);letter-spacing:.02em;padding:5px 10px;border-radius:999px;border:1px solid var(--line);color:var(--dim);background:transparent;transition:all .15s}
#tagRow button.like{color:var(--onacc);background:var(--acc);border-color:var(--acc)}
#tagRow button.avoid{color:hsl(220 40% 70%);border-color:hsl(220 30% 40%);text-decoration:line-through;opacity:.8}
#tagRow button.add{border-style:dashed}
#tagRow input{font:12px var(--font);width:110px;padding:5px 10px;border-radius:999px;border:1px solid var(--acc);background:var(--card);outline:none}
#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:18px}
#controls button{width:44px;height:44px;border-radius:50%;display:grid;place-items:center;color:var(--ink);opacity:.85}
#controls #backTrack svg,#controls #skip svg{width:24px;height:24px}
#controls #prev{opacity:.6}
#pills{display:flex;gap:10px;justify-content:center;flex-wrap:wrap}
#pills button{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}
#pills button.on{background:var(--acc);color:var(--onacc);border-color:var(--acc)}
#pills button:disabled{opacity:.4;cursor:default}
#controls button:hover{opacity:1}
#controls #play{width:62px;height:62px;background:var(--ink);color:var(--bg)}
/* the trail: a strip along the bottom of the cover; shows on hover (always on touch screens) */
2026-10-21 01:31:16 +00:00
#trail{position:absolute;left:0;right:0;bottom:0;z-index:3;display:flex;gap:10px;align-items:flex-end;overflow-x:auto;padding:40px 14px 14px;border-radius:0 0 18px 18px;scrollbar-width:none;-webkit-overflow-scrolling:touch;
background:linear-gradient(to top,rgba(var(--shade),.92),rgba(var(--shade),.55) 60%,transparent);opacity:0;pointer-events:none;transition:opacity .25s}
#cover:hover #trail,#cover.flipped:hover #trail{opacity:1;pointer-events:auto}
@media (hover:none){#trail{opacity:1;pointer-events:auto;padding-top:18px}}
#trail::-webkit-scrollbar{display:none}
#trail:empty{display:none}
2026-10-21 01:31:16 +00:00
#trail canvas{width:64px;height:64px;border-radius:12px;flex:none;opacity:.6;cursor:pointer;transition:opacity .2s,transform .2s}
#trail canvas:hover{opacity:.95;transform:translateY(-3px)}
#trail canvas.on{opacity:1;transform:scale(1.12);box-shadow:0 0 0 2px var(--ink)}
#trail canvas.kept{box-shadow:0 0 0 2px var(--acc)}
#trail canvas.on.kept{box-shadow:0 0 0 2px var(--acc),0 0 0 4px var(--ink)}
#controls svg{width:22px;height:22px;fill:currentColor}
#controls #loop,#controls #shuffle,#controls #remix{opacity:.6}
#controls #remix:hover{opacity:1}
#controls #shuffle.on{opacity:1;color:var(--acc);background:rgba(233,216,166,.12)}
#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}
#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 #more{font-size:20px;color:var(--dim);background:var(--card);border:1px solid var(--line)}
#picker{position:fixed;left:50%;bottom:0;transform:translateX(-50%);width:min(560px,100%);max-height:55dvh;background:var(--card);border:1px solid var(--line);border-bottom:0;border-radius:18px 18px 0 0;padding:12px 12px calc(12px + env(safe-area-inset-bottom));overflow:auto;z-index:5;box-shadow:0 -20px 60px rgba(0,0,0,.6)}
#picker h4{font:600 10px var(--font);letter-spacing:.2em;color:var(--dim);margin:10px 4px 6px}
#picker .g{display:grid;grid-template-columns:repeat(auto-fill,minmax(42px,1fr));gap:2px}
#picker .g button{font-size:24px;height:42px;border-radius:10px}
#picker .g button:hover{background:#1a1a20}
#picker .top{display:flex;gap:8px;align-items:center;margin-bottom:4px}
#picker .top input{flex:1;background:#0a0a0d;border:1px solid var(--line);border-radius:10px;padding:8px 12px;font-size:16px;outline:none}
#picker .top button{padding:8px 12px;border-radius:10px;border:1px solid var(--line);color:var(--dim);font:600 11px var(--font);letter-spacing:.14em}
#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}
/* ---------- taste ---------- */
#taste{position:fixed;inset:0;background:rgba(11,11,14,.97);padding:18px 16px calc(18px + env(safe-area-inset-bottom));overflow:auto}
#taste header{display:flex;justify-content:space-between;align-items:center;font:600 12px var(--font);letter-spacing:.2em;color:var(--dim);margin-bottom:12px}
#tasteTabs{display:flex;gap:8px;overflow-x:auto;padding:2px 0 12px;scrollbar-width:none}
#tasteTabs button{display:flex;align-items:center;gap:8px;padding:6px 12px 6px 6px;border-radius:999px;border:1px solid var(--line);font-size:12px;white-space:nowrap;color:var(--dim)}
#tasteTabs button.on{color:var(--ink);border-color:var(--acc)}
#tasteTabs canvas{width:22px;height:22px;border-radius:50%}
#tasteMap{width:100%;max-width:640px;aspect-ratio:1;display:block;margin:0 auto;border-radius:18px;background:var(--card)}
#brainMap{width:100%;max-width:640px;aspect-ratio:2.2;display:block;margin:0 auto 14px;border-radius:18px;background:var(--card)}
#tastePalette{max-width:640px;margin:6px auto 18px}
#tastePalette h3{font:600 11px var(--font);letter-spacing:.2em;color:var(--dim);margin:0 0 10px;text-align:center}
#tastePalette .row{display:flex;gap:14px;overflow-x:auto;padding:4px;scrollbar-width:none;justify-content:safe center}
#tastePalette .s{flex:0 0 96px;cursor:pointer;text-align:center}
#tastePalette .s canvas{width:80px;height:80px;border-radius:14px;display:block;margin:0 auto 6px}
#tastePalette .s b{display:block;font-size:11px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
#tasteNote{color:var(--dim);font-size:12px;text-align:center;margin:12px auto 0;max-width:520px}
#tasteLegend{display:flex;justify-content:center;gap:16px;font:11px var(--font);letter-spacing:.08em;color:var(--dim);margin-top:10px;flex-wrap:wrap}
/* ---------- tapes ---------- */
#lib .t .addTape{position:absolute;right:6px;bottom:6px;width:26px;height:26px;border-radius:50%;background:rgba(11,11,14,.75);border:1px solid var(--line);color:var(--ink);font-size:16px;line-height:1;display:grid;place-items:center}
#lib .t .addTape:hover{background:var(--acc);color:var(--onacc)}
#lib .t .fromT{position:absolute;left:6px;top:6px;width:26px;height:26px;border-radius:50%;background:var(--veil2);border:1px solid var(--line);color:var(--ink);font:700 16px var(--font);line-height:1;display:grid;place-items:center;opacity:0;transition:opacity .2s}
#lib .t:hover .fromT{opacity:1}
@media (hover:none){#lib .t .fromT{opacity:.8}}
#lib .t .fromT:hover{background:var(--acc);color:var(--onacc)}
#tapeRow{display:flex;gap:14px;overflow-x:auto;padding:4px 4px 10px;scrollbar-width:none;grid-column:1/-1}
#tapeRow .s{flex:0 0 110px;cursor:pointer;text-align:center}
#tapeRow .s .tc{width:88px;height:88px;border-radius:14px;margin:0 auto 6px;position:relative;overflow:hidden;background:var(--card);display:grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;gap:2px}
#tapeRow .s .tc canvas{width:100%;height:100%;display:block}
#tapeRow .s .tc.one{grid-template-columns:1fr;grid-template-rows:1fr}
#tapeRow .s.new .tc{display:grid;place-items:center;border:1px dashed var(--line);font-size:26px;color:var(--dim)}
#tapeRow .s b{display:block;font-size:12px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
#tapeRow .s small{color:var(--dim);font-size:11px}
#tapePick{position:fixed;left:50%;bottom:0;transform:translateX(-50%);width:min(480px,100%);background:var(--card);border:1px solid var(--line);border-bottom:0;border-radius:18px 18px 0 0;padding:14px 14px calc(14px + env(safe-area-inset-bottom));z-index:6;box-shadow:0 -20px 60px rgba(0,0,0,.6)}
#tapePick h4{font:600 10px var(--font);letter-spacing:.2em;color:var(--dim);margin:0 0 10px}
#tapePick button{display:block;width:100%;text-align:left;padding:10px 12px;border-radius:10px;border:1px solid var(--line);margin-bottom:6px}
#tapePick button:hover{background:#1a1a20}
#tapePick input{width:100%;background:#0a0a0d;border:1px solid var(--line);border-radius:10px;padding:10px 12px;outline:none;margin-bottom:6px}
#tape{position:fixed;inset:0;background:rgba(11,11,14,.97);padding:18px 16px calc(18px + env(safe-area-inset-bottom));overflow:auto;z-index:4}
#tape header{display:flex;justify-content:space-between;align-items:center;font:600 12px var(--font);letter-spacing:.2em;color:var(--dim);margin-bottom:12px;gap:10px}
#tape header input{flex:1;background:transparent;border:0;border-bottom:1px solid var(--line);font:600 18px var(--sans);letter-spacing:0;color:var(--ink);outline:none;padding:4px 0;min-width:0}
#tape .acts{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px}
#tape .acts button{padding:9px 16px;border-radius:999px;border:1px solid var(--line);font:600 12px var(--font);letter-spacing:.14em;background:var(--card)}
#tape .acts button.go{background:var(--acc);color:var(--onacc);border-color:var(--acc)}
#tape .list{display:flex;flex-direction:column;gap:8px;max-width:640px;margin:0 auto}
#tape .row{display:flex;align-items:center;gap:12px;padding:8px;border-radius:14px;background:var(--card);border:1px solid var(--line)}
#tape .row.on{border-color:var(--acc)}
#tape .row canvas{width:52px;height:52px;border-radius:10px;flex:0 0 52px;cursor:pointer}
#tape .row .m{flex:1;min-width:0;cursor:pointer}
#tape .row b{display:block;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
#tape .row small{color:var(--dim);font-size:11px;font-family:var(--font)}
#tape .row .ctl{display:flex;gap:2px}
#tape .row .ctl button{width:32px;height:32px;border-radius:8px;color:var(--dim);font-size:14px}
#tape .row .ctl button:hover{background:#1a1a20;color:var(--ink)}
#tape .empty{color:var(--dim);text-align:center;padding:40px 0}
/* ---------- 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 .art{position:relative;aspect-ratio:1;border-radius:12px;overflow:hidden;background:var(--card)}
#lib .t .art canvas{width:100%;height:100%;display:block}
#lib .t .kept{position:absolute;top:6px;right:6px;font:600 9px var(--font);letter-spacing:.18em;color:var(--onacc);background:var(--acc);border-radius:999px;padding:3px 7px}
#lib .t .re{position:absolute;left:6px;bottom:6px;font-size:13px;letter-spacing:1px;background:var(--veil2);border-radius:8px;padding:2px 6px;max-width:calc(100% - 12px);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
#lib .t small{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
#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">g<span class="ast">*</span>ndora</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="*" 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 class="nav" id="libBtn2" type="button">LIBRARY</button><button class="nav" id="tasteBtn2" type="button">TASTE</button><button class="nav theme" type="button" title="light / dark">LIGHT</button><button id="keyBtn" type="button" class="hidden">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><button id="tasteBtn">TASTE</button> &nbsp; <button id="libBtn">LIBRARY</button> &nbsp; <button class="theme" title="light / dark">LIGHT</button></div>
</div>
<div id="stage">
<div class="station" id="stationName" title="rename, look, reset or delete"></div>
<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 id="trail" title="what this station has played · tap to jump"></div></div>
<div id="meta"><h1 id="title">…</h1></div>
<div id="bar"><i></i></div>
<div id="meta2"><p id="lyric" title="reply to the DJ"></p><div id="next"></div><div id="pills"><button id="lyricsBtn">⇄ LYRICS</button><button id="keep" disabled>➕ KEEP</button><button id="share" disabled>↗ SHARE</button><button id="fromTrack" disabled title="start a new station from this track">* STATION</button></div><p id="tags" class="hidden"></p><div id="tagRow"></div><div id="tagList"></div></div>
<div id="controls">
<button id="backTrack" title="previous track">
<svg viewBox="0 0 24 24"><path d="M18 6l-8.5 6 8.5 6V6zM6 6v12h2V6H6z"/></svg>
</button>
<button id="shuffle" title="shuffle: mix the station's own library in">
<svg viewBox="0 0 24 24"><path d="M17 4h4v4l-1.5-1.5L16 10l-1.4-1.4 3.5-3.5L17 4zM3 6h4.5l3 3-1.4 1.4L7 8H3V6zm14.6 10.6 1.5-1.5v4h-4l1.5-1.5-3.1-3.1 1.4-1.4 2.7 2.7zM3 18v-2h4l9.5-9.5L18 8l-10 10H3z"/></svg>
</button>
<button id="prev" title="replay from the start">
<svg viewBox="0 0 24 24"><path d="M12 5V2L7 7l5 5V8c3.3 0 6 2.7 6 6s-2.7 6-6 6-6-2.7-6-6H4c0 4.4 3.6 8 8 8s8-3.6 8-8-3.6-8-8-8z"/></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="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>
<button id="remix" title="remix this track: same bones, your changes">
<svg viewBox="0 0 24 24"><path d="M12 3a9 9 0 1 0 9 9h-2a7 7 0 1 1-7-7v3l4-4-4-4v3zm0 5a4 4 0 1 0 0 8 4 4 0 0 0 0-8zm0 2a2 2 0 1 1 0 4 2 2 0 0 1 0-4z"/></svg>
</button>
<button id="skip" title="next">
<svg viewBox="0 0 24 24"><path d="M6 18l8.5-6L6 6v12zm10-12v12h2V6h-2z"/></svg>
</button>
</div>
</div>
<div id="react">
<div id="emojis">
<button data-e="🔥" title="more of this">🔥</button>
<button data-e="❤️" title="love it">❤️</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>
<button id="more" title="more emoji">+</button>
</div>
<div id="picker" class="hidden"></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="taste" class="hidden">
<header><span>TASTE</span><button id="tasteClose">CLOSE</button></header>
<div id="tasteTabs"></div>
<div id="tastePalette"></div>
<canvas id="brainMap" width="880" height="400"></canvas>
<canvas id="tasteMap" width="900" height="900"></canvas>
<div id="tasteLegend"><span>← slow · tempo · fast →</span><span>↑ energy</span><span>colour = key</span><span>ring = 🔥❤️ warm · 🚫⏭ cold</span></div>
<div id="tasteNote"></div>
</section>
<section id="tape" class="hidden">
<header><span>TAPE</span><input id="tapeName" maxlength="60"><button id="tapeClose">CLOSE</button></header>
<div class="acts"><button class="go" id="tapePlay">▶ PLAY ALL</button><button id="tapeLoop">🔁 REPEAT ALL</button><button id="tapeShare">↗ SHARE</button><button id="tapeDelete">DELETE TAPE</button></div>
<div class="list" id="tapeList"></div>
<div class="empty hidden" id="tapeEmpty">Empty tape. Add tracks from the library with the + on a tile.</div>
</section>
<div id="tapePick" class="hidden"></div>
<div id="stationSheet" class="hidden"></div>
<div id="scrim" class="hidden"></div>
<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; KEEP one to pin 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: "", sinceAd: 0,
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();
/* palette dial: mono (one hue, its shades), duo (the key's hue and a complement), tri (a triad) */
const PALETTES = ["mono", "duo", "tri"];
function palette(spec) {
const energy = Math.min(1, Math.max(0, +spec.energy || 0.5)), minor = /m(in|inor)?\b/i.test(spec.key || "") && !/maj/i.test(spec.key || "");
const hue = keyHue(spec.key), mode = spec.palette || "duo";
const hue2 = mode === "mono" ? hue : mode === "tri" ? (hue + 120) % 360 : (hue + (minor ? 200 : 150)) % 360;
const hue3 = mode === "tri" ? (hue + 240) % 360 : hue2;
return { energy, minor, hue, hue2, hue3, mode, sat: 55 + energy * 35, light: minor ? 36 : 52, bpm: +spec.bpm || 100 };
}
function drawGround(W, H, spec, lit) {
const key = `${spec.seed}|${spec.title}|${spec.tags}|${spec.palette || ""}|${W}|${lit}`;
if (groundCache.has(key)) return groundCache.get(key);
const off = document.createElement("canvas"); off.width = W; off.height = H;
const ctx = off.getContext("2d"), cx = W / 2, cy = H / 2, p = palette(spec);
const rnd = prng(hash32((spec.seed || "") + "|" + (spec.title || "") + "|" + (spec.tags || "")) ^ 0x9e37);
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, `hsl(${p.hue} ${p.sat * .5}% ${p.light * .3}%)`); g.addColorStop(1, `hsl(${p.hue2} ${p.sat * .4}% ${p.light * .18}%)`);
ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);
ctx.globalCompositeOperation = "lighter";
const spread = W * .46, dots = Math.floor(400 + p.energy * 2600);
for (let i = 0; i < dots; i++) {
const a = rnd() * Math.PI * 2, d = Math.sqrt(rnd()) * spread * 1.05;
ctx.fillStyle = `hsl(${i % 3 ? p.hue2 : p.hue3} ${p.sat}% ${p.light + (p.mode === "mono" ? 38 : 30)}% / ${rnd() * 0.5 * lit})`;
ctx.fillRect(cx + Math.cos(a) * d, cy + Math.sin(a) * d, (W / 380) * (0.8 + rnd() * 1.6), (W / 380) * (0.8 + rnd() * 1.6));
}
if (groundCache.size > 24) groundCache.delete(groundCache.keys().next().value);
groundCache.set(key, off);
return off;
}
/* looks: every station has one; the seed mark in the centre is the same whichever look draws around it */
const LOOKS = ["rings", "chord", "attractor"];
function drawCover(canvas, spec, opts = {}) {
const look = spec.look || opts.look || "rings";
if (look === "chord") return drawChord(canvas, spec, opts);
if (look === "attractor") return drawAttractor(canvas, spec, opts);
return drawRings(canvas, spec, opts);
}
function drawMark(ctx, W, spec, p, lit, bass, tm) {
const core = prng(hash32(spec.seed || ""));
const petals = 3 + Math.floor(core() * 5);
ctx.save(); ctx.translate(W / 2, W / 2); ctx.rotate(core() * Math.PI + tm * 0.05); ctx.scale(1 + 0.3 * bass, 1 + 0.3 * bass);
ctx.fillStyle = `hsl(${p.hue} ${p.sat}% ${Math.min(92, p.light + 45)}% / ${0.85 * lit})`;
ctx.beginPath();
for (let a = 0; a <= Math.PI * 2 + 0.01; a += Math.PI / 120) {
const rr = (W * .06) * (1 + 0.45 * Math.cos(a * petals));
const x = Math.cos(a) * rr, y = Math.sin(a) * rr;
a === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.closePath(); ctx.fill(); ctx.restore();
}
/* chord: a harmonograph whose frequency ratio is the key's interval (major third 5:4, minor 6:5), damped by tempo,
phased by the seed; the bass swells the whole figure */
function drawChord(canvas, spec, { lit = 1, pulse = null } = {}) {
const ctx = canvas.getContext("2d"), W = canvas.width, H = canvas.height, cx = W / 2, cy = H / 2;
const p = palette(spec), rnd = prng(hash32((spec.seed || "") + "|" + (spec.title || "") + "|" + (spec.tags || "")));
const bass = pulse ? pulse.bass : 0, high = pulse ? pulse.high : 0, tm = pulse ? pulse.t : 0;
ctx.globalCompositeOperation = "source-over";
const g = ctx.createRadialGradient(cx, cy, 0, cx, cy, W * .7);
g.addColorStop(0, `hsl(${p.hue} ${p.sat * .5}% ${p.light * .32}%)`); g.addColorStop(1, `hsl(${p.hue2} ${p.sat * .4}% ${p.light * .14}%)`);
ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);
const [fa, fb] = p.minor ? [6, 5] : [5, 4];
const fc = 1 + Math.round(p.bpm / 40); // a third pendulum: tempo adds a faster harmonic
const damp = 0.00045 + (p.bpm - 60) / 120 * 0.0006, ph1 = rnd() * Math.PI * 2, ph2 = rnd() * Math.PI * 2, ph3 = rnd() * Math.PI * 2;
const amp = W * 0.36 * (1 + 0.1 * bass), mix = 0.2 + 0.5 * p.energy, detune = 0.004 + 0.01 * rnd();
ctx.globalCompositeOperation = "lighter";
// three traces: a soft wide one, then two fine ones a hair apart in frequency, so the figure fills with moiré
for (let pass = 0; pass < 3; pass++) {
const dt = pass === 2 ? detune : 0;
ctx.beginPath();
for (let t = 0; t < 2600; t += 0.6) {
const e = Math.exp(-damp * t);
const x = cx + amp * e * (Math.sin((fa + dt) * t * 0.02 + ph1 + tm * 0.02) + mix * 0.3 * Math.sin(fc * t * 0.02 + ph3));
const y = cy + amp * e * (Math.sin((fb - dt) * t * 0.02 + ph2 - tm * 0.02) + mix * 0.3 * Math.cos(fc * t * 0.02 + ph3));
t === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.lineWidth = pass ? (0.55 + 0.6 * high) * (W / 380) : (3 + 2 * bass) * (W / 380);
ctx.strokeStyle = pass ? `hsl(${pass === 2 ? p.hue2 : p.hue} ${p.sat}% ${p.light + 36}% / ${0.42 * lit})` : `hsl(${p.hue2} ${p.sat}% ${p.light + 12}% / ${0.12 * lit})`;
ctx.stroke();
}
ctx.globalCompositeOperation = "source-over";
drawMark(ctx, W, spec, p, lit, bass, tm);
}
/* attractor: a Clifford attractor whose four parameters come from key, tempo, energy and the seed; the point cloud is
drawn once and cached, the seed mark beats on top */
const attractorCache = new Map();
function drawAttractor(canvas, spec, { lit = 1, pulse = null } = {}) {
const ctx = canvas.getContext("2d"), W = canvas.width, H = canvas.height;
const p = palette(spec);
const bass = pulse ? pulse.bass : 0, tm = pulse ? pulse.t : 0;
const key = `${spec.seed}|${spec.title}|${spec.tags}|${spec.palette || ""}|${W}|${lit}`;
let off = attractorCache.get(key);
if (!off) {
off = document.createElement("canvas"); off.width = W; off.height = H;
const o = off.getContext("2d"), rnd = prng(hash32(key));
const g = o.createLinearGradient(0, 0, W, H);
g.addColorStop(0, `hsl(${p.hue} ${p.sat * .45}% ${p.light * .3}%)`); g.addColorStop(1, `hsl(${p.hue2} ${p.sat * .4}% ${p.light * .15}%)`);
o.fillStyle = g; o.fillRect(0, 0, W, H);
// parameters in the ranges that keep Clifford figures open and filamentary
const a = -1.4 - 0.6 * ((p.hue % 120) / 120), b = 1.2 + 0.8 * ((p.bpm - 60) / 120), c = 0.6 + 1.2 * p.energy, d = (p.minor ? -1 : 1) * (0.6 + 1.0 * rnd());
let x = 0.1, y = 0.1; const n = 36000, pts = [];
let minX = 9, maxX = -9, minY = 9, maxY = -9;
for (let i = 0; i < n; i++) { const nx = Math.sin(a * y) + c * Math.cos(a * x), ny = Math.sin(b * x) + d * Math.cos(b * y); x = nx; y = ny; if (i > 200) { pts.push(x, y); if (x < minX) minX = x; if (x > maxX) maxX = x; if (y < minY) minY = y; if (y > maxY) maxY = y; } }
const sx = (W * 0.78) / Math.max(1e-6, maxX - minX), sy = (H * 0.78) / Math.max(1e-6, maxY - minY), s = Math.min(sx, sy);
const ox = W / 2 - (minX + maxX) / 2 * s, oy = H / 2 - (minY + maxY) / 2 * s;
o.globalCompositeOperation = "lighter";
o.fillStyle = `hsl(${p.hue} ${p.sat}% ${p.light + 28}% / ${0.16 * lit})`;
const dot = Math.max(1, W / 380);
for (let i = 0; i < pts.length; i += 2) o.fillRect(ox + pts[i] * s, oy + pts[i + 1] * s, dot, dot);
o.fillStyle = `hsl(${p.hue2} ${p.sat}% ${p.light + 40}% / ${0.08 * lit})`;
for (let i = 0; i < pts.length; i += 6) o.fillRect(ox + pts[i] * s, oy + pts[i + 1] * s, dot * 2, dot * 2);
if (attractorCache.size > 16) attractorCache.delete(attractorCache.keys().next().value);
attractorCache.set(key, off);
}
ctx.globalCompositeOperation = "source-over";
ctx.drawImage(off, 0, 0);
if (bass > 0.02) { ctx.globalAlpha = 0.5 * bass; ctx.drawImage(off, W / 2 - W * (0.5 + 0.03 * bass), H / 2 - H * (0.5 + 0.03 * bass), W * (1 + 0.06 * bass), H * (1 + 0.06 * bass)); ctx.globalAlpha = 1; }
drawMark(ctx, W, spec, p, lit, bass, tm);
}
function drawRings(canvas, spec, { lit = 1, pulse = null } = {}) {
const ctx = canvas.getContext("2d"), W = canvas.width, H = canvas.height, cx = W / 2, cy = H / 2;
const p = palette(spec), { energy, minor, hue, sat, light, bpm } = p;
const rnd = prng(hash32((spec.seed || "") + "|" + (spec.title || "") + "|" + (spec.tags || "")));
const bass = pulse ? pulse.bass : 0, high = pulse ? pulse.high : 0, tm = pulse ? pulse.t : 0;
ctx.globalCompositeOperation = "source-over";
ctx.drawImage(drawGround(W, H, spec, lit), 0, 0);
// rings: one per 10 bpm; the bass breathes them outward, the highs sharpen the line
const rings = Math.max(4, Math.round(bpm / 10));
const spread = W * .46, sym = 3 + Math.floor(rnd() * 6); // symmetry order from the seed
ctx.globalCompositeOperation = "lighter";
for (let r = 0; r < rings; r++) {
const t = (r + 1) / rings, rad = spread * Math.pow(t, .8) * (1 + 0.07 * bass * (1 - t));
ctx.beginPath();
const wob = 0.02 + energy * 0.08 + 0.03 * high, ph = rnd() * Math.PI * 2 + tm * (0.15 + 0.3 * t) * (r % 2 ? 1 : -1);
for (let a = 0; a <= Math.PI * 2 + 0.01; a += Math.PI / 90) {
const rr = rad * (1 + wob * Math.sin(a * sym + ph) + wob * 0.5 * Math.sin(a * sym * 2 + ph * 3));
const x = cx + Math.cos(a) * rr, y = cy + Math.sin(a) * rr;
a === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.closePath();
ctx.lineWidth = (0.6 + energy * 2.2 + high * 1.5) * (W / 380) * (1 - t * 0.5);
ctx.strokeStyle = `hsl(${(hue + t * (minor ? -40 : 40)) % 360} ${sat}% ${light + t * 25}% / ${(0.12 + 0.6 * (1 - t)) * lit * (1 + 0.5 * bass)})`;
ctx.stroke();
}
ctx.globalCompositeOperation = "source-over";
// a core: the station's seed mark, same shape for every track of one seed; it beats with the bass
const core = prng(hash32(spec.seed || ""));
const petals = 3 + Math.floor(core() * 5);
ctx.save(); ctx.translate(cx, cy); ctx.rotate(core() * Math.PI + tm * 0.05); ctx.scale(1 + 0.3 * bass, 1 + 0.3 * bass);
ctx.fillStyle = `hsl(${hue} ${sat}% ${Math.min(92, light + 45)}% / ${0.85 * lit})`;
ctx.beginPath();
for (let a = 0; a <= Math.PI * 2 + 0.01; a += Math.PI / 120) {
const rr = (W * .06) * (1 + 0.45 * Math.cos(a * petals));
const x = Math.cos(a) * rr, y = Math.sin(a) * rr;
a === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.closePath(); ctx.fill(); ctx.restore();
}
/* ---------- 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", "seconds", "meter", "station", "line"],
properties: {
title: { type: "string" }, tags: { type: "string" }, lyrics: { type: "string" },
bpm: { type: "integer" }, key: { type: "string" }, energy: { type: "number" },
seconds: { type: "integer" }, meter: { type: "string" },
station: { type: "string" }, line: { type: "string" },
},
};
function remixText() {
const s = state.remixOf; if (!s) return "";
const likes = [], avoids = [];
for (const m of String(s.reaction || "").matchAll(/(?:^|\s)([+\-])([^\s]+)/g)) (m[1] === "+" ? likes : avoids).push(m[2].replace(/_/g, " "));
const words = [...String(s.reaction || "").matchAll(/"([^"]*)"/g)].map((m) => m[1]);
return `\nTHIS IS A REMIX REQUEST. Rework this exact track rather than writing a new one:\n- title: ${s.title}\n- tags: ${s.tags}\n- bpm ${s.bpm}, key ${s.key}, energy ${s.energy}\n- lyrics:\n${s.lyrics || "(instrumental)"}\nKeep its bones (hook, mood, most of the lyrics) and apply the listener's changes: ${likes.length ? "more " + likes.join(", ") + "; " : ""}${avoids.length ? "less or no " + avoids.join(", ") + "; " : ""}${words.length ? "in their words: " + words.join(" / ") : ""}${!likes.length && !avoids.length && !words.length ? "a fresh take on the same song (new arrangement, same heart)" : ""}. Title it as a remix of the original (e.g. "${s.title} (remix)" or a named version).\n`;
}
function djPrompt() {
const prev = state.history.slice(-4).map((t, i) => `- ${t.title} [${t.tags}] ${t.bpm}bpm ${t.key} ${t.meter || "4/4"} energy ${t.energy} ${t.seconds || 120}s${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.${remixText()}
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, 16-32 lines for a two-minute track, 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
- seconds: how long the track runs, an integer 30-300. Default around 120; go shorter for sketches, jingles and interludes, longer for builds and journeys; if the listener said shorter or longer, move by at least 30 and keep moving that way until they say otherwise. Lyrics should fit the length (about 8 lines per 30 s).
- meter: the time signature, one of "4/4", "3/4", "6/8", "5/4", "7/8"; 4/4 unless the music calls for another
- 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]);
if (state.refFor && state.refFor.left > 0 && state.refFor.blob) { if (Math.abs((parseInt(spec.seconds) || 120) - (state.refFor.seconds || 120)) < 30) spec.refAudio = await asDataUrl(state.refFor.blob); if (--state.refFor.left <= 0) state.refFor = null; }
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.seconds = Math.min(300, Math.max(30, Math.round((parseInt(spec.seconds) || 120) / 10) * 10));
spec.meter = /^(2|3|4|5|6|7)\/(4|8)$/.test(String(spec.meter || "")) ? spec.meter : "4/4";
spec.energy = Math.min(1, Math.max(0, +spec.energy || 0.5));
spec.seed = state.seed; spec.made = Date.now(); spec.look = state.look || "rings"; spec.palette = state.palette || "duo";
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) {
if (!state.key) { // shared worker: one call, audio back (Runware when the factory has a key, else Venice)
const started = Date.now(), tick = setInterval(() => onStatus && onStatus(Math.round((Date.now() - started) / 1000), 0, false), 1000);
try {
const body = { prompt: spec.tags, lyrics: spec.lyrics || "", duration: spec.seconds || 120, bpm: spec.bpm, key: spec.key, timeSignature: spec.meter || "4/4",
reference: spec.refAudio || undefined,
seed: spec.rseed ?? (hash32((spec.seed || "") + "|" + (spec.title || "") + "|" + (spec.tags || "")) >>> 1), quality: spec.quality || "turbo" };
let res;
try { res = await venice("/render", body, { raw: true }); }
catch (e) { if (!/answered 404/.test(e.message)) throw e; res = null; } // older worker: fall through to queue+poll
if (res) {
const blob = await res.blob();
const cost = parseFloat(res.headers.get("x-cost") || ""), seed = parseInt(res.headers.get("x-seed") || "");
spec.renderer = res.headers.get("x-renderer") || "venice:ace-step-15";
if (!isNaN(seed)) spec.rseed = seed;
spec.cost = isNaN(cost) ? 0.0005 * (spec.seconds || 120) : cost;
delete spec.refAudio; // the reference did its job; don't keep megabytes on the sheet
state.cost += spec.cost; localStorage.setItem(LS.cost, String(state.cost));
return blob;
}
} finally { clearInterval(tick); }
}
const body = { model: "ace-step-15", prompt: spec.tags, duration_seconds: Math.max(60, Math.min(210, spec.seconds || 120)), ...(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();
spec.cost = 0.06 * jobs.length; spec.renderer = "venice:ace-step-15";
state.cost += spec.cost; 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", 3); r.onupgradeneeded = () => { const d = r.result; for (const s of ["tracks", "stations", "tapes"]) 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), look: state.look || "rings", palette: state.palette || "duo" }, "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));
const dots = Object.assign(document.createElement("button"), { className: "dots", textContent: "⋯", title: "rename, reset or delete" });
dots.addEventListener("click", (e) => { e.stopPropagation(); stationSheet(s.id); });
d.append(dots);
row.append(d);
}
}
/* the station sheet: rename, reset its taste, delete it (kept tracks stay) */
async function stationSheet(id) {
const s = (await db.all("stations")).find((x) => x.id === id); if (!s) return;
const p = $("#stationSheet"); p.innerHTML = "";
p.append(Object.assign(document.createElement("h4"), { textContent: "STATION" }));
const name = document.createElement("input"); name.value = s.name || ""; name.placeholder = s.seed; name.maxLength = 60;
p.append(Object.assign(document.createElement("label"), { textContent: "name" }), name, Object.assign(document.createElement("label"), { textContent: "seed · " + s.seed + " · " + (s.played || 0) + " tracks" }));
const lookRow = document.createElement("div"); lookRow.className = "row"; lookRow.style.marginTop = "8px";
p.append(Object.assign(document.createElement("label"), { textContent: "look" }));
for (const lk of LOOKS) {
const b = Object.assign(document.createElement("button"), { textContent: lk.toUpperCase(), className: (s.look || "rings") === lk ? "go" : "" });
b.addEventListener("click", async () => {
s.look = lk; await db.put(s, "stations");
for (const t of (await db.all()).filter((t) => t.spec.stationId === id)) { t.spec.look = lk; await db.put(t); }
if (state.stationId === id) { state.look = lk; if (state.current) { state.current.spec.look = lk; drawCover($("#cover canvas"), state.current.spec); } if (state.nextJob) state.nextJob.spec.look = lk; }
for (const x of lookRow.children) x.className = x.textContent === lk.toUpperCase() ? "go" : "";
toast(`Look: ${lk}.`);
});
lookRow.append(b);
}
p.append(lookRow);
const palRow = document.createElement("div"); palRow.className = "row"; palRow.style.marginTop = "8px";
p.append(Object.assign(document.createElement("label"), { textContent: "palette" }));
for (const pm of PALETTES) {
const b = Object.assign(document.createElement("button"), { textContent: pm.toUpperCase(), className: (s.palette || "duo") === pm ? "go" : "" });
b.addEventListener("click", async () => {
s.palette = pm; await db.put(s, "stations");
for (const t of (await db.all()).filter((t) => t.spec.stationId === id)) { t.spec.palette = pm; await db.put(t); }
if (state.stationId === id) { state.palette = pm; if (state.current) { state.current.spec.palette = pm; drawCover($("#cover canvas"), state.current.spec); } if (state.nextJob) state.nextJob.spec.palette = pm; }
for (const x of palRow.children) x.className = x.textContent === pm.toUpperCase() ? "go" : "";
toast(`Palette: ${pm}.`);
});
palRow.append(b);
}
p.append(palRow);
const row = document.createElement("div"); row.className = "row";
const save = Object.assign(document.createElement("button"), { className: "go", textContent: "SAVE" });
save.addEventListener("click", async () => { s.name = name.value.trim().slice(0, 60); await db.put(s, "stations"); if (state.stationId === id) { state.station = s.name; $("#stationName").textContent = s.name || s.seed; } p.classList.add("hidden"); listStations().catch(() => {}); toast("Renamed."); });
const reset = Object.assign(document.createElement("button"), { textContent: "RESET TASTE" });
reset.addEventListener("click", async () => { if (!confirm("Forget what this station has learned? Its tracks stay.")) return; s.taste = { like: [], avoid: [], notes: [] }; s.history = []; await db.put(s, "stations"); if (state.stationId === id) { state.taste = s.taste; state.history = []; } p.classList.add("hidden"); toast("Taste reset."); });
const del = Object.assign(document.createElement("button"), { className: "bad", textContent: "DELETE STATION" });
del.addEventListener("click", async () => {
if (!confirm(`Delete “${s.name || s.seed}” and its unkept tracks? Kept tracks stay in the library.`)) return;
for (const t of (await db.all()).filter((t) => t.spec.stationId === id && !t.kept)) await db.del(t.id);
await db.del(id, "stations");
if (state.stationId === id) { state.audio.pause(); state.playing = false; setPlayIcon(); state.current = null; state.nextJob = null; state.stationId = ""; state.seed = ""; state.station = ""; $("#player").classList.add("hidden"); $("#seed").classList.remove("hidden"); $("#nowRow").classList.add("hidden"); setUrl(); }
p.classList.add("hidden"); listStations().catch(() => {}); toast("Station deleted.");
});
const close = Object.assign(document.createElement("button"), { textContent: "CLOSE" }); close.addEventListener("click", () => p.classList.add("hidden"));
row.append(save, reset, del, close); p.append(row); p.classList.remove("hidden"); name.focus({ preventScroll: true });
}
$("#stationName").addEventListener("click", () => { if (state.stationId && !state.tape) stationSheet(state.stationId); });
/* links: the URL follows the station (#seed); a share link carries the current track's sheet music (?t=) so the same
song opens and renders for whoever follows it, even with nothing stored anywhere */
const b64u = (s) => btoa(unescape(encodeURIComponent(s))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const unb64u = (s) => decodeURIComponent(escape(atob(String(s).replace(/-/g, "+").replace(/_/g, "/") + "===".slice((s.length + 3) % 4))));
function shareLink() {
const s = state.current && state.current.spec; if (!s) return "";
const t = { seed: s.seed, station: state.station, title: s.title, tags: s.tags, lyrics: s.lyrics || "", bpm: s.bpm, key: s.key, energy: s.energy, line: s.line || "" };
return `${location.origin}/?t=${b64u(JSON.stringify(t))}`;
}
function setUrl() { try { history.replaceState(null, "", state.seed ? "/#" + encodeURIComponent(state.seed).replace(/%20/g, "+") : "/"); } catch {} }
async function openStation(seed, { id, name = "", taste, history, played, first, look, palette: pal } = {}) {
state.look = look || "rings"; state.palette = pal || "duo";
if (state.stationId !== id) { state.audio.pause(); state.playing = false; setPlayIcon(); state.current = null; state.nextJob = null; state.recent = []; state.pos = -1; state.tape = 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");
setUrl();
if (!state.current) {
if (first) { first.seed = seed; first.made = Date.now(); state.nextJob = makeJob(first); drawCover($("#cover canvas"), first, { lit: 0.6 }); $("#title").textContent = first.title; saveStation().catch(() => {}); advance(); return; }
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(() => {});
// coming back to a station: play its latest track from the library straight away, and cook the next behind it
const have = id ? (await db.all()).filter((t) => t.spec.stationId === id) : [];
if (have.length) { const t = have[0]; await playJob({ id: ++seq, spec: t.spec, blob: t.blob, err: null, done: Promise.resolve(), stepping: true }); state.recent = [t.id]; state.pos = 0; return; }
advance();
}
}
function resumeStation(s) { openStation(s.seed, s); }
const asDataUrl = (blob) => new Promise((ok, no) => { const r = new FileReader(); r.onload = () => ok(r.result); r.onerror = () => no(r.error); r.readAsDataURL(blob); });
/* a station grown from one track: the track plays first (no re-render), its tags are what the station likes, the DJ
continues from it. The track is copied into the new station so the original stays where it was. */
async function stationFrom(t) {
const s = t.spec; if (!t.blob) { toast("That one hasn't finished cooking."); return; }
const id = `s-${Date.now()}-${hash32(s.title + s.tags)}`;
const seed = `like "${s.title}": ${s.tags.split(",").map((x) => x.trim()).filter(Boolean).slice(0, 5).join(", ")}`;
const copy = { ...s, id: s.id + "-" + hash32(id), stationId: id, seed, made: Date.now(), reaction: "", plays: 0 };
delete copy.station;
await db.put({ id: copy.id, spec: copy, blob: t.blob, made: copy.made, kept: false, plays: 0 });
state.stationId = null; // so openStation treats it as a change of station
await openStation(seed, { id, name: `${s.title} radio`, taste: { like: [s.tags], avoid: [], notes: [`this station started from "${s.title}": stay close to it, then grow`] },
history: [copy], played: 0, look: s.look, palette: s.palette });
state.refFor = { blob: t.blob, left: 2, seconds: s.seconds || 120 }; // the next two tracks are rendered with the source as reference
toast(`New station from “${s.title}”.`, 3000);
}
$("#fromTrack").addEventListener("click", async () => {
const cur = state.current && state.current.spec; if (!cur) return;
const row = (await db.all()).find((t) => t.id === cur.id) || (state.current.blob ? { spec: cur, blob: state.current.blob } : null);
if (!row) { toast("That one hasn't finished cooking."); return; }
stationFrom(row);
});
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, plays: (old && old.plays) || s.plays || 0 });
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;
/* sponsored: every AD_EVERY tracks the DJ writes a 30 s station ident in the station's own style. It plays like any
track (keep it if you like it) but it is marked, never trains taste, and never comes up in shuffle or THE FIVE. */
const AD_EVERY = 6;
async function askAd() {
const prev = state.history.slice(-2).map((t) => `${t.tags} (${t.bpm} bpm, ${t.key})`).join("; ") || "the seed's style";
const body = { model: state.dj, max_tokens: 700, 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 } },
messages: [{ role: "user", content: `You are the DJ of g*ndora (say it "gandora"), a radio station that writes its own songs for one listener.
Seed: "${state.seed}"${state.station ? `, station "${state.station}"` : ""}. The station's sound right now: ${prev}.
Write a 30-second STATION IDENT in that same sound: a tiny song that thanks the listener for listening to gandora, with one light,
unpushy line that gandora pro gets them longer tracks, bigger renders and their stations everywhere. Playful, warm, never salesy;
the word "gandora" sung at least twice. 4-8 lines of lyrics.
Return JSON: title (short, may include "gandora"), tags (the music prompt, same style as the station), lyrics, bpm, key, energy,
seconds (30), meter, station ("${state.station || state.seed}"), line (one line the DJ says, under 70 characters, e.g. a word from our sponsor: us)` }] };
const d = await venice("/chat/completions", body);
const m = String(d.choices?.[0]?.message?.content || "").match(/\{[\s\S]*\}/); if (!m) throw new Error("The DJ didn't write the ident.");
const spec = JSON.parse(m[0]);
spec.tags = String(spec.tags || "").slice(0, 500); spec.lyrics = String(spec.lyrics || "").slice(0, 1200);
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.seconds = 30; spec.meter = /^(2|3|4|5|6|7)\/(4|8)$/.test(String(spec.meter || "")) ? spec.meter : "4/4";
spec.sponsored = true; spec.seed = state.seed; spec.made = Date.now(); spec.line = spec.line || "a word from our sponsor: us";
return spec;
}
function cookNext() {
if (state.nextJob) return Promise.resolve(state.nextJob);
if (cooking) return cooking;
cooking = (async () => {
try {
const ad = AD_EVERY > 0 && state.sinceAd >= AD_EVERY - 1;
const spec = ad ? await askAd().catch(() => askDJ()) : await askDJ();
state.sinceAd = spec.sponsored ? 0 : (state.sinceAd || 0) + 1;
state.nextJob = makeJob(spec); if (state.current) $("#next").textContent = (spec.sponsored ? "NEXT · SPONSORED · " : "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;
if (!job.stepping) { state.history.push(spec); state.played = (state.played || 0) + 1; spec.stationId ||= state.stationId; }
keepLabel();
$("#title").textContent = spec.sponsored ? "✱ " + spec.title : spec.title; $("#tags").textContent = `${spec.bpm} bpm · ${spec.key}${spec.sponsored ? " · sponsored" : ""}`;
$("#lyric").textContent = spec.line || "";
showTags(spec);
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");
$("#fromTrack").disabled = false;
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.fileInto) { const tp = (await db.all("tapes")).find((t) => t.id === job.fileInto); if (tp && !tp.ids.includes(spec.id)) { tp.ids.push(spec.id); await db.put(tp, "tapes"); } }
if (!job.stepping) { state.recent = state.recent.slice(0, state.pos + 1); state.recent.push(spec.id); state.pos = state.recent.length - 1; }
drawTrail().catch(() => {});
try { await state.audio.play(); state.playing = true; setPlayIcon(); vizStart(); } catch { toast("Tap play."); }
nowPlaying(spec);
cookNext(); // render ahead while this one plays
}
/* the trail: every track this station has played, oldest left, newest right, the one playing lit. Tapping one plays it and
makes it the point back/forward walk from; the trail is the station's own history, so it survives a refresh. */
let trailIds = [];
async function drawTrail() {
const el = $("#trail"); if (!el) return;
const rows = (await db.all()).filter((t) => t.spec && t.spec.stationId === state.stationId).sort((a, b) => a.made - b.made);
trailIds = rows.map((t) => t.id);
const cur = state.current && state.current.spec && state.current.spec.id;
if (trailIds.length < 2) { el.textContent = ""; return; }
el.textContent = "";
for (const t of rows) {
const c = document.createElement("canvas"); c.width = c.height = 120;
drawCover(c, t.spec); c.title = t.spec.title; c.dataset.id = t.id;
if (t.kept) c.classList.add("kept"); if (t.id === cur) c.classList.add("on");
el.append(c);
}
const on = el.querySelector(".on"); if (on) on.scrollIntoView({ inline: "center", block: "nearest", behavior: "smooth" });
}
$("#trail").addEventListener("wheel", (e) => { if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) { e.currentTarget.scrollLeft += e.deltaY; e.preventDefault(); } }, { passive: false });
$("#trail").addEventListener("click", async (e) => {
e.stopPropagation(); // the cover underneath flips to lyrics on click
const c = e.target.closest("canvas"); if (!c) return;
const id = c.dataset.id; if (state.current && state.current.spec && state.current.spec.id === id) return;
const row = (await db.all()).find((t) => t.id === id); if (!row) return;
state.recent = trailIds.slice(); state.pos = trailIds.indexOf(id);
state.audio.pause();
await playJob({ id: ++seq, spec: row.spec, blob: row.blob, err: null, done: Promise.resolve(), stepping: true });
});
// 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;
}
/* shuffle: a track the station already made, weighted by how much the net thinks you'd like it and whether you kept it,
never one of the last few heard; null when the library is too thin */
async function shuffled() {
const rows = (await db.all()).filter((t) => t.spec.stationId === state.stationId && t.blob && !t.spec.sponsored);
const avoid = new Set(state.recent.slice(-4));
const pool = rows.filter((t) => !avoid.has(t.id)); if (pool.length < 3) return null;
const net = typeof brainNow === "function" ? brainNow() : null;
const w = pool.map((t) => Math.exp(3 * (net ? net.predict(t.spec) : 0.5)) * (t.kept ? 2 : 1) * (1 + 0.3 * Math.log1p(t.plays || 0)));
let r = Math.random() * w.reduce((a, b) => a + b, 0);
for (let i = 0; i < pool.length; i++) { r -= w[i]; if (r <= 0) return pool[i]; }
return pool[pool.length - 1];
}
async function advance() {
// a tape playing: the next track on it, around again if it repeats, otherwise back to the station
if (state.tape) {
const tp = (await db.all("tapes")).find((t) => t.id === state.tape.id);
if (tp) { const n = state.tape.i + 1; if (n < tp.ids.length || state.tape.loop) return playTape(tp, n); }
state.tape = null; $("#stationName").textContent = state.station || state.seed; toast("End of the tape — back to the station.");
}
// a shared tape still rendering: its next track comes before anything fresh
if (state.tapeQueue && state.tapeQueue.length && !state.nextJob) {
const t = state.tapeQueue.shift(); const spec = { ...t, seed: t.seed || state.seed, made: Date.now() };
state.nextJob = makeJob(spec); state.nextJob.fileInto = state.sharedTapeId;
}
if (await step(+1)) return; // forward through the session first, then cook new
if (!state.nextJob && state.after) { state.nextJob = state.after; state.after = null; }
if (state.shuffle && Math.random() < 0.5) {
const t = await shuffled();
if (t) { state.audio.pause(); await playJob({ id: ++seq, spec: t.spec, blob: t.blob, err: null, done: Promise.resolve() }); return; }
}
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; the listener loves this one" },
"😴": { 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 (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
});
/* tags as controls: tap a tag for more of it, again for less, again to let it go; + adds one of your own.
Each tap is a reaction (+word / -word) the DJ and the taste net both see. */
const tagState = (spec, tag) => { const r = " " + String(spec.reaction || "") + " "; const t = tag.replace(/\s+/g, "_"); const L = r.lastIndexOf(" +" + t + " "), N = r.lastIndexOf(" -" + t + " "), Z = r.lastIndexOf(" ~" + t + " "); const m = Math.max(L, N, Z); return m < 0 || m === Z ? "" : m === L ? "like" : "avoid"; };
const reactionEmoji = (r) => String(r || "").replace(/(?:^|\s)[+\-~][^\s]+/g, "").replace(/"[^"]*"/g, "💬").replace(/\[skipped\]/g, "⏭").replace(/\s+/g, "");
state.tagsOpen = false; // the tag row shows a few, and folds the rest behind "… more"
function showTags(spec) {
const row = $("#tagRow"); row.innerHTML = ""; const list = $("#tagList"); list.innerHTML = "";
const tags = String(spec.tags || "").split(",").map((t) => t.trim()).filter(Boolean).slice(0, 12);
const extra = [...String(spec.reaction || "").matchAll(/(?:^|\s)\+([^\s]+)/g)].map((m) => m[1].replace(/_/g, " ")).filter((t) => !tags.includes(t));
const total = tags.length + extra.length, liked = [...tags, ...extra].filter((t) => tagState(spec, t)).length;
// one line closed: TAGS ▾ · bpm · key; open: every tag as a chip, and + tag
for (const m of [`${spec.bpm} bpm`, spec.key]) { const b = document.createElement("button"); b.className = "meta"; b.textContent = m; b.disabled = true; row.append(b); }
const f = document.createElement("button"); f.className = "fold"; f.textContent = (state.tagsOpen ? "TAGS ▴" : "TAGS ▾") + (liked && !state.tagsOpen ? ` ·${liked}` : "");
f.addEventListener("click", () => { state.tagsOpen = !state.tagsOpen; showTags(spec); }); row.append(f);
if (!state.tagsOpen) return;
for (const tag of [...tags, ...extra]) {
const b = document.createElement("button"); b.textContent = tag; b.className = tagState(spec, tag);
b.addEventListener("click", () => tagTap(spec, tag, b));
list.append(b);
}
const add = document.createElement("button"); add.className = "add"; add.textContent = "+ tag";
add.addEventListener("click", () => {
const inp = document.createElement("input"); inp.placeholder = "more …"; inp.maxLength = 30; list.replaceChild(inp, add); inp.focus();
const done = () => { const v = inp.value.trim().toLowerCase(); if (v) { tagTap(spec, v, null, "like"); } else showTags(spec); };
inp.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); done(); } if (e.key === "Escape") showTags(spec); });
inp.addEventListener("blur", done);
});
list.append(add);
}
function tagTap(spec, tag, btn, force) {
const cur = force ? "" : tagState(spec, tag), next = force || (cur === "" ? "like" : cur === "like" ? "avoid" : "");
const t = tag.replace(/\s+/g, "_");
if (next === "") { spec.reaction = (spec.reaction || "") + " ~" + t; state.taste.like = state.taste.like.filter((s) => !s.startsWith("the tag " + JSON.stringify(tag))); state.taste.avoid = state.taste.avoid.filter((s) => !s.startsWith("the tag " + JSON.stringify(tag))); toast(`${tag}: let go.`); }
else {
spec.reaction = (spec.reaction || "") + " " + (next === "like" ? "+" : "-") + t;
const note = `the tag ${JSON.stringify(tag)}`;
state.taste.like = state.taste.like.filter((s) => !s.startsWith(note)); state.taste.avoid = state.taste.avoid.filter((s) => !s.startsWith(note));
(next === "like" ? state.taste.like : state.taste.avoid).push(note + (next === "like" ? " — more of this" : " — less of this"));
toast(next === "like" ? `More ${tag}.` : `Less ${tag}.`);
}
for (const k of ["like", "avoid"]) state.taste[k] = state.taste[k].slice(-10);
showTags(spec);
if (state.current && state.current.spec === spec) remember(state.current, false).catch(() => {});
saveStation().catch(() => {});
if (!state.nextJob) cookNext();
if (typeof rebuildBrains === "function") rebuildBrains().catch(() => {});
}
$("#lyric").addEventListener("click", () => { const s = $("#say"); s.focus(); s.placeholder = "reply to the DJ…"; });
/* 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" : "⇄ LYRICS";
}
$("#lyricsBtn").addEventListener("click", toggleLyrics);
$("#cover").addEventListener("click", toggleLyrics);
/* keep: the one and only pin, under the controls; emoji are reactions, not saves */
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"; 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";
$("#share").disabled = false;
}
$("#share").addEventListener("click", async () => {
const url = shareLink(); if (!url) return;
const s = state.current.spec, text = `${s.title} · a gandora track from the ${state.station || s.seed} station`;
if (navigator.share) { try { await navigator.share({ title: s.title, text, url }); return; } catch {} }
try { await navigator.clipboard.writeText(url); toast("Link copied. Whoever opens it gets this song, freshly rendered."); } catch { prompt("Copy this link:", url); }
});
$("#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"); });
/* lock screen / media keys: title, station, the cover, and back · pause · next */
function nowPlaying(spec) {
if (!("mediaSession" in navigator)) return;
try {
const c = document.createElement("canvas"); c.width = c.height = 512; drawCover(c, spec);
navigator.mediaSession.metadata = new MediaMetadata({ title: spec.title, artist: state.station || spec.seed, album: "gandora",
artwork: [{ src: c.toDataURL("image/png"), sizes: "512x512", type: "image/png" }] });
} catch {}
}
if ("mediaSession" in navigator) {
const ms = navigator.mediaSession;
try {
ms.setActionHandler("play", () => $("#play").click());
ms.setActionHandler("pause", () => $("#play").click());
ms.setActionHandler("previoustrack", () => $("#backTrack").click());
ms.setActionHandler("nexttrack", () => $("#skip").click());
} catch {}
state.audio.addEventListener("play", () => { ms.playbackState = "playing"; });
state.audio.addEventListener("pause", () => { ms.playbackState = "paused"; });
}
if ("serviceWorker" in navigator && location.protocol === "https:") navigator.serviceWorker.register("sw.js").catch(() => {});
/* light / dark: follows the system until the toggle is used, then remembered */
const sysDark = window.matchMedia("(prefers-color-scheme: dark)");
function applyTheme() {
const saved = localStorage.getItem("gandora.theme");
const t = saved || (sysDark.matches ? "dark" : "light");
if (saved) document.documentElement.dataset.theme = saved; else delete document.documentElement.dataset.theme;
const m = document.querySelector('meta[name="theme-color"]'); if (m) m.content = t === "dark" ? "#0b0b0e" : "#f4f4f6";
for (const b of document.querySelectorAll("button.theme")) b.textContent = t === "dark" ? "LIGHT" : "DARK";
if (typeof drawTaste === "function" && $("#taste") && !$("#taste").classList.contains("hidden")) { try { openTaste(); } catch {} }
}
for (const b of document.querySelectorAll("button.theme")) b.addEventListener("click", () => {
const cur = document.documentElement.dataset.theme || (sysDark.matches ? "dark" : "light");
localStorage.setItem("gandora.theme", cur === "dark" ? "light" : "dark"); applyTheme();
});
sysDark.addEventListener("change", applyTheme);
applyTheme();
$("#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(); });
$("#remix").addEventListener("click", async () => {
const cur = state.current && state.current.spec; if (!cur) return;
state.remixOf = cur; toast("Remixing — it goes next.");
try {
const spec = await askDJ(); spec.remixOf = cur.id;
// the renderer hears the original, which also fixes the length: when the listener asked for shorter/longer, let the length win
if (state.current && state.current.blob && Math.abs((spec.seconds || 120) - (cur.seconds || 120)) < 30) spec.refAudio = await asDataUrl(state.current.blob);
const job = makeJob(spec);
if (state.nextJob && !state.nextJob.blob) state.after = state.nextJob; // the one already cooking plays after the remix
state.nextJob = job; $("#next").textContent = "NEXT · " + spec.title.toUpperCase() + " · REMIX";
} catch (e) { toast(e.message, 5000); }
finally { state.remixOf = null; }
});
$("#shuffle").addEventListener("click", () => { state.shuffle = !state.shuffle; $("#shuffle").classList.toggle("on", state.shuffle); toast(state.shuffle ? "Shuffle: mixing the station's library in." : "Shuffle off: all fresh."); });
$("#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", () => { if (state.audio.src) { state.audio.currentTime = 0; state.audio.play().catch(() => {}); } });
$("#backTrack").addEventListener("click", async () => { if (!(await step(-1))) toast("That's the first track of this session."); });
/* the emoji row is the listener's eight most used, seeded with the defaults; + opens the picker */
const EMOJI_SETS = {
"REACT": "🔥 ❤️ ⚡ 😴 🌧️ 🚫 🎸 🎹 🥁 🎤 🎺 🎻 🎷 🪗 🎧 🔊 🔉 🎶 🎵 💃 🕺 🤘 👏 🙌 👍 👎 🤌 💯 🤩 🤯 🤔 😎 😍 🥰 🥺 😭 😢 😤 😱 🤮 💤 🥱 😐 🙄 😬 🤪 🤬 👻",
"MOOD": "🌙 🌞 🌅 🌆 🌃 🌌 ⭐ ✨ 🌟 💥 ❄️ 🌊 🌋 🌪️ ☔ 🌈 🍂 🌸 🌹 🌵 🍄 🌍 🏙️ 🏜️ 🏖️ 🏔️ 🚇 🚗 🛸 🚀 🕯️ 💡 🔮 🪄 💎 🔗 ⏳ 🧠 🫀 💀 👽 🤖 🐉 🦋 🐝 🦊 🐱 🐶",
"TASTE": "☕ 🍵 🍷 🍺 🥃 🍫 🍭 🍓 🍋 🌶️ 🧈 🍰 🍕 🍜 🥟 🍱 🍼 🧊 🔥 💨 🫧 🌫️ 🪨 🪶 🧵 🧶 🎨 🖼️ 📼 📻 📺 💿 📀 🎛️ 🎚️ 🎙️ 📼 🕳️",
};
const EMOJI_KEY = "gandora.emoji";
function emojiCounts() { try { return JSON.parse(localStorage.getItem(EMOJI_KEY) || "{}"); } catch { return {}; } }
function bumpEmoji(e) { const c = emojiCounts(); c[e] = (c[e] || 0) + 1; localStorage.setItem(EMOJI_KEY, JSON.stringify(c)); }
const EMOJI_DEFAULT = ["🔥", "❤️", "😴", "🌧️", "⚡", "🎸", "🎹", "🚫"];
function showEmojiRow() {
const c = emojiCounts(), row = $("#emojis"), more = $("#more");
const seeded = EMOJI_DEFAULT.map((e, i) => [e, (c[e] || 0) + (8 - i) * 0.01]); // defaults keep their order until use says otherwise
for (const e of Object.keys(c)) if (!EMOJI_DEFAULT.includes(e)) seeded.push([e, c[e]]);
const top = seeded.sort((a, b) => b[1] - a[1]).slice(0, 8).map((x) => x[0]);
for (const b of [...row.querySelectorAll("button[data-e]")]) b.remove();
for (const e of top) { const b = document.createElement("button"); b.dataset.e = e; b.textContent = e; b.title = (EMOJI_MEANING[e] && (EMOJI_MEANING[e].like || EMOJI_MEANING[e].avoid)) || ""; row.insertBefore(b, more); }
}
$("#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); bumpEmoji(b.dataset.e); react(b.dataset.e); });
function openPicker() {
const p = $("#picker"); p.innerHTML = "";
const top = document.createElement("div"); top.className = "top";
const inp = document.createElement("input"); inp.placeholder = "or type any emoji…"; inp.maxLength = 8;
const go = document.createElement("button"); go.textContent = "SEND"; const close = document.createElement("button"); close.textContent = "CLOSE";
const send = () => { const v = inp.value.trim(); if (!v) return; pick(v); };
go.addEventListener("click", send); inp.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); send(); } });
close.addEventListener("click", () => p.classList.add("hidden"));
top.append(inp, go, close); p.append(top);
const pick = (e) => { bumpEmoji(e); react(e); showEmojiRow(); p.classList.add("hidden"); };
const c = emojiCounts(), used = Object.entries(c).sort((a, b) => b[1] - a[1]).slice(0, 16).map((x) => x[0]);
const sets = used.length ? { "MOST USED": used.join(" "), ...EMOJI_SETS } : EMOJI_SETS;
for (const [name, list] of Object.entries(sets)) {
p.append(Object.assign(document.createElement("h4"), { textContent: name }));
const g = document.createElement("div"); g.className = "g";
for (const e of [...new Set(list.split(/\s+/).filter(Boolean))]) { const b = document.createElement("button"); b.textContent = e; b.addEventListener("click", () => pick(e)); g.append(b); }
p.append(g);
}
p.classList.remove("hidden"); inp.focus({ preventScroll: true });
}
$("#more").addEventListener("click", () => { const p = $("#picker"); if (p.classList.contains("hidden")) openPicker(); else p.classList.add("hidden"); });
showEmojiRow();
$("#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);
if (new URLSearchParams(location.search).has("key")) $("#keyBtn").classList.remove("hidden"); // key box is parked for now
$("#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();
});
/* sheets close on a tap outside them */
const SHEETS = ["#stationSheet", "#picker", "#tapePick"];
function closeSheets() { for (const s of SHEETS) $(s).classList.add("hidden"); $("#scrim").classList.add("hidden"); }
$("#scrim").addEventListener("click", closeSheets);
new MutationObserver(() => { const open = SHEETS.some((s) => !$(s).classList.contains("hidden")); $("#scrim").classList.toggle("hidden", !open); })
.observe(document.body, { attributes: true, attributeFilter: ["class"], subtree: true });
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, inKept = false) => {
const d = document.createElement("div"); d.className = "t" + (cur && t.id === cur.id ? " on" : "");
const art = document.createElement("div"); art.className = "art";
const c = document.createElement("canvas"); c.width = c.height = 260; drawCover(c, t.spec); art.append(c);
if (t.kept && !inKept) art.append(Object.assign(document.createElement("span"), { className: "kept", textContent: "KEPT" }));
const re = reactionEmoji(t.spec.reaction).trim();
if (re) art.append(Object.assign(document.createElement("span"), { className: "re", textContent: re, title: t.spec.reaction }));
const addT = Object.assign(document.createElement("button"), { className: "addTape", textContent: "+", title: "add to a tape" });
addT.addEventListener("click", (e) => { e.stopPropagation(); pickTape(t); });
const fromT = Object.assign(document.createElement("button"), { className: "fromT", textContent: "*", title: "start a station from this track" });
fromT.addEventListener("click", (e) => { e.stopPropagation(); $("#lib").classList.add("hidden"); stationFrom(t); });
art.append(addT, fromT);
d.append(art, Object.assign(document.createElement("b"), { textContent: t.spec.title }), Object.assign(document.createElement("small"), { textContent: `${t.spec.bpm} bpm · ${t.spec.key}${(t.plays || 0) > 1 ? " · ×" + t.plays : ""}`, title: t.spec.seed }));
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;
};
// tapes first: a strip of mixtapes, and a way to start one
const tapes = await db.all("tapes");
g.append(head("TAPES", "", tapes.length));
const strip = document.createElement("div"); strip.id = "tapeRow";
for (const tp of tapes) {
const s = document.createElement("div"); s.className = "s";
const tc = document.createElement("div"); tc.className = "tc" + (tp.ids.length < 4 ? " one" : "");
const covers = tp.ids.map((id) => list.find((t) => t.id === id)).filter(Boolean).slice(0, tp.ids.length < 4 ? 1 : 4);
for (const t of covers) { const c = document.createElement("canvas"); c.width = c.height = 120; drawCover(c, t.spec); tc.append(c); }
s.append(tc, Object.assign(document.createElement("b"), { textContent: tp.name }), Object.assign(document.createElement("small"), { textContent: `${tp.ids.length} tracks` }));
s.addEventListener("click", () => openTape(tp.id));
strip.append(s);
}
const nw = document.createElement("div"); nw.className = "s new"; nw.innerHTML = '<div class="tc">+</div><b>new tape</b>';
nw.addEventListener("click", async () => { const name = prompt("Name the tape:", ""); if (!name) return; const tp = { id: "tape-" + Date.now(), name: name.trim().slice(0, 60), ids: [], made: Date.now(), last: Date.now() }; await db.put(tp, "tapes"); openTape(tp.id); });
strip.append(nw); g.append(strip);
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, true)); }
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"));
/* ---------- mixtapes: a named, ordered list of tracks; play through, repeat all, share as a link ---------- */
async function pickTape(track) {
const p = $("#tapePick"); p.innerHTML = "";
const tapes = await db.all("tapes");
p.append(Object.assign(document.createElement("h4"), { textContent: "ADD “" + track.spec.title.toUpperCase() + "” TO" }));
const add = async (tp) => { if (!tp.ids.includes(track.id)) { tp.ids.push(track.id); tp.last = Date.now(); await db.put(tp, "tapes"); } p.classList.add("hidden"); toast(`On “${tp.name}”.`); };
for (const tp of tapes) { const b = document.createElement("button"); b.textContent = `${tp.name} · ${tp.ids.length}`; b.addEventListener("click", () => add(tp)); p.append(b); }
const inp = document.createElement("input"); inp.placeholder = "new tape…"; inp.maxLength = 60;
inp.addEventListener("keydown", async (e) => { if (e.key !== "Enter") return; e.preventDefault(); const name = inp.value.trim(); if (!name) return; const tp = { id: "tape-" + Date.now(), name, ids: [], made: Date.now(), last: Date.now() }; await add(tp); });
const close = document.createElement("button"); close.textContent = "cancel"; close.addEventListener("click", () => p.classList.add("hidden"));
p.append(inp, close); p.classList.remove("hidden"); inp.focus({ preventScroll: true });
}
state.tape = null; // { id, ids, i, loop } while a tape is playing
async function tapeRows(tp) { const all = await db.all(); return tp.ids.map((id) => all.find((t) => t.id === id)).filter(Boolean); }
async function openTape(id) {
const tp = (await db.all("tapes")).find((t) => t.id === id); if (!tp) return;
$("#lib").classList.add("hidden"); $("#tape").classList.remove("hidden"); $("#tape").dataset.id = id;
$("#tapeName").value = tp.name;
const rows = await tapeRows(tp), list = $("#tapeList"); list.innerHTML = "";
$("#tapeEmpty").classList.toggle("hidden", rows.length > 0);
$("#tapeLoop").classList.toggle("go", !!(state.tape && state.tape.id === id && state.tape.loop));
rows.forEach((t, i) => {
const r = document.createElement("div"); r.className = "row" + (state.current && state.current.spec.id === t.id ? " on" : "");
const c = document.createElement("canvas"); c.width = c.height = 120; drawCover(c, t.spec);
const m = document.createElement("div"); m.className = "m"; m.append(Object.assign(document.createElement("b"), { textContent: `${i + 1}. ${t.spec.title}` }), Object.assign(document.createElement("small"), { textContent: `${t.spec.bpm} bpm · ${t.spec.key} · ${t.spec.seed}` }));
const play = () => playTape(tp, i);
c.addEventListener("click", play); m.addEventListener("click", play);
const ctl = document.createElement("div"); ctl.className = "ctl";
const mv = async (d) => { const j = i + d; if (j < 0 || j >= tp.ids.length) return; [tp.ids[i], tp.ids[j]] = [tp.ids[j], tp.ids[i]]; await db.put(tp, "tapes"); openTape(id); };
const up = Object.assign(document.createElement("button"), { textContent: "▲", title: "earlier" }), dn = Object.assign(document.createElement("button"), { textContent: "▼", title: "later" }), rm = Object.assign(document.createElement("button"), { textContent: "✕", title: "remove from tape" });
up.addEventListener("click", () => mv(-1)); dn.addEventListener("click", () => mv(1));
rm.addEventListener("click", async () => { tp.ids.splice(i, 1); await db.put(tp, "tapes"); openTape(id); });
ctl.append(up, dn, rm); r.append(c, m, ctl); list.append(r);
});
}
async function playTape(tp, i, loop) {
const rows = await tapeRows(tp); if (!rows.length) return;
i = ((i % rows.length) + rows.length) % rows.length;
state.tape = { id: tp.id, ids: rows.map((t) => t.id), i, loop: loop ?? (state.tape && state.tape.id === tp.id ? state.tape.loop : false) };
$("#tape").classList.add("hidden"); $("#seed").classList.add("hidden"); $("#player").classList.remove("hidden");
$("#stationName").textContent = "📼 " + tp.name;
const t = rows[i]; state.audio.pause();
await playJob({ id: ++seq, spec: t.spec, blob: t.blob, err: null, done: Promise.resolve(), stepping: true });
}
$("#tapePlay").addEventListener("click", async () => { const tp = (await db.all("tapes")).find((t) => t.id === $("#tape").dataset.id); if (tp) playTape(tp, 0); });
$("#tapeLoop").addEventListener("click", async () => { const tp = (await db.all("tapes")).find((t) => t.id === $("#tape").dataset.id); if (!tp) return; const on = !(state.tape && state.tape.id === tp.id && state.tape.loop); if (state.tape && state.tape.id === tp.id) state.tape.loop = on; else await playTape(tp, 0, on); $("#tapeLoop").classList.toggle("go", on); toast(on ? "Repeating the whole tape." : "Tape plays through once."); });
$("#tapeName").addEventListener("change", async () => { const tp = (await db.all("tapes")).find((t) => t.id === $("#tape").dataset.id); if (!tp) return; tp.name = $("#tapeName").value.trim().slice(0, 60) || tp.name; await db.put(tp, "tapes"); });
$("#tapeDelete").addEventListener("click", async () => { const id = $("#tape").dataset.id; if (!confirm("Delete this tape? The tracks stay in the library.")) return; await db.del(id, "tapes"); if (state.tape && state.tape.id === id) state.tape = null; $("#tape").classList.add("hidden"); $("#libBtn").click(); });
$("#tapeShare").addEventListener("click", async () => {
const tp = (await db.all("tapes")).find((t) => t.id === $("#tape").dataset.id); if (!tp) return;
const rows = await tapeRows(tp);
const payload = { name: tp.name, tracks: rows.map(({ spec: s }) => ({ seed: s.seed, station: s.station || "", title: s.title, tags: s.tags, lyrics: s.lyrics || "", bpm: s.bpm, key: s.key, energy: s.energy, line: s.line || "" })) };
const url = `${location.origin}/?tape=${b64u(JSON.stringify(payload))}`;
if (navigator.share) { try { await navigator.share({ title: tp.name, text: `${tp.name} · a gandora mixtape, ${rows.length} tracks`, url }); return; } catch {} }
try { await navigator.clipboard.writeText(url); toast("Tape link copied. It renders each track for whoever opens it."); } catch { prompt("Copy this link:", url); }
});
$("#tapeClose").addEventListener("click", () => $("#tape").classList.add("hidden"));
// a shared tape (?tape=): make it a tape of this browser's own, rendering each track as it comes up
const sharedTape = new URLSearchParams(location.search).get("tape");
if (sharedTape) {
(async () => {
try {
const t = JSON.parse(unb64u(sharedTape)); if (!t || !Array.isArray(t.tracks) || !t.tracks.length) throw 0;
const tp = { id: "tape-" + Date.now(), name: String(t.name || "shared tape").slice(0, 60), ids: [], pending: t.tracks, made: Date.now(), last: Date.now() };
await db.put(tp, "tapes");
// render the tracks in order as the station's next ones, and file each into the tape as it lands
state.sharedTapeId = tp.id; state.tapeQueue = t.tracks.slice();
openStation(t.tracks[0].seed, { name: t.tracks[0].station || "" });
toast(`Tape “${tp.name}”: ${t.tracks.length} tracks to render, one by one.`);
} catch { toast("That link didn't carry a tape."); }
})();
}
$("#libBtn2").addEventListener("click", () => $("#libBtn").click());
$("#tasteBtn2").addEventListener("click", () => $("#tasteBtn").click());
/* ---------- taste: every track as a point (tempo, energy; colour = key), every reaction a signed weight on it.
The shading is a kernel estimate of "would like" from the reactions, huebrain-style: lit where the warm ones
outweigh the cold ones, dim where the cold ones win, dark where nothing has been heard yet. ---------- */
function reactionWeight(spec) {
const r = String(spec.reaction || "");
let w = 0;
for (const ch of r.replace(/"[^"]*"/g, "")) { if ("🔥❤️⚡🎸🎹".includes(ch) && ch !== "️") w += 1; if ("🚫😴".includes(ch)) w -= 1; }
if (/\[skipped\]/.test(r)) w -= 1.5;
if (/"[^"]*"/.test(r)) w += 0.3; // words mean engagement, mildly warm
return w;
}
function drawTaste(rows, title) {
const c = $("#tasteMap"), ctx = c.getContext("2d"), W = c.width, H = c.height, pad = 70;
const X = (bpm) => pad + (Math.min(180, Math.max(60, bpm)) - 60) / 120 * (W - 2 * pad);
const Y = (en) => H - pad - Math.min(1, Math.max(0, en)) * (H - 2 * pad);
const pts = rows.map((t) => ({ x: X(+t.spec.bpm || 100), y: Y(+t.spec.energy || 0.5), w: reactionWeight(t.spec) + (t.kept ? 1.5 : 0), hue: keyHue(t.spec.key), minor: /m(in|inor)?\b/i.test(t.spec.key || "") && !/maj/i.test(t.spec.key || ""), t }));
ctx.fillStyle = "#121216"; ctx.fillRect(0, 0, W, H);
// the field: kernel-weighted reactions, with every played track counting a little as "heard"
const cell = 18, sig = 90;
for (let gy = pad; gy < H - pad; gy += cell) for (let gx = pad; gx < W - pad; gx += cell) {
let like = 0, heard = 0;
for (const p of pts) { const d2 = (p.x - gx) ** 2 + (p.y - gy) ** 2, k = Math.exp(-d2 / (2 * sig * sig)); like += k * p.w; heard += k; }
if (heard < 0.02) continue;
const v = Math.tanh(like / 1.5); // -1..1; 0 means heard but nothing said
const a = Math.min(0.5, heard * 0.3);
if (Math.abs(v) < 0.05) ctx.fillStyle = `hsl(240 6% 30% / ${a * 0.5})`; // heard, neutral
else ctx.fillStyle = v > 0 ? `hsl(44 60% 60% / ${a * Math.abs(v)})` : `hsl(220 30% 45% / ${a * Math.abs(v)})`;
ctx.fillRect(gx, gy, cell, cell);
}
// axes
ctx.strokeStyle = "#232329"; ctx.lineWidth = 2; ctx.strokeRect(pad, pad, W - 2 * pad, H - 2 * pad);
ctx.fillStyle = "#8a8a96"; ctx.font = `600 ${W * 0.018}px ui-monospace, Menlo, monospace`; ctx.textAlign = "center";
for (const b of [60, 90, 120, 150, 180]) ctx.fillText(b + "", X(b), H - pad + 34);
ctx.textAlign = "right"; for (const e of [0, 0.5, 1]) ctx.fillText(e === 0 ? "calm" : e === 1 ? "wild" : "·", pad - 14, Y(e) + 6);
// the points: colour is the key, size is how much was said about it, ring warm or cold
for (const p of pts) {
const r = (W * 0.012) * (1 + Math.min(3, Math.abs(p.w)) * 0.35);
ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
ctx.fillStyle = `hsl(${p.hue} ${p.minor ? 55 : 80}% ${p.minor ? 48 : 62}% / .9)`; ctx.fill();
if (p.w !== 0) { ctx.lineWidth = 3; ctx.strokeStyle = p.w > 0 ? "hsl(44 80% 70%)" : "hsl(220 40% 60%)"; ctx.stroke(); }
if (p.t.kept) { ctx.beginPath(); ctx.arc(p.x, p.y, r + 7, 0, Math.PI * 2); ctx.lineWidth = 1.5; ctx.strokeStyle = "hsl(44 80% 70% / .7)"; ctx.stroke(); }
}
const warm = pts.filter((p) => p.w > 0).length, cold = pts.filter((p) => p.w < 0).length;
const mean = (f) => pts.length ? pts.reduce((s, p) => s + f(p), 0) / pts.length : 0;
const liked = pts.filter((p) => p.w > 0);
const lb = liked.length ? Math.round(liked.reduce((s, p) => s + (+p.t.spec.bpm || 100), 0) / liked.length) : null;
const le = liked.length ? (liked.reduce((s, p) => s + (+p.t.spec.energy || 0.5), 0) / liked.length) : null;
const minors = liked.filter((p) => p.minor).length;
$("#tasteNote").textContent = !pts.length ? "Nothing heard here yet." :
`${title}: ${pts.length} tracks · ${warm} warm, ${cold} cold · ` + (liked.length ? `what you warmed to sits around ${lb} bpm, ${le < 0.4 ? "calm" : le < 0.7 ? "mid" : "high"} energy, ${minors > liked.length / 2 ? "mostly minor keys" : "mostly major keys"}.` : "react to a few tracks and the lit region appears.");
}
async function openTaste() {
const rows = await db.all(), stations = await db.all("stations"), byId = Object.fromEntries(stations.map((s) => [s.id, s]));
const tabs = $("#tasteTabs"); tabs.innerHTML = "";
const groups = [["all", "ALL", "", rows]];
const seen = new Map();
for (const t of rows) { const k = t.spec.stationId || "seed:" + t.spec.seed; if (!seen.has(k)) seen.set(k, []); seen.get(k).push(t); }
for (const [k, list] of seen) { const s = byId[k]; groups.push([k, ((s && s.name) || list[0].spec.seed).toUpperCase(), (s && s.seed) || list[0].spec.seed, list]); }
let pick = groups.find((g) => g[0] === state.stationId) || groups[0];
const show = () => { for (const b of tabs.children) b.classList.toggle("on", b.dataset.k === pick[0]); drawTaste(pick[3], pick[1]); };
for (const g of groups) {
const b = document.createElement("button"); b.dataset.k = g[0];
if (g[2]) { const c = document.createElement("canvas"); c.width = c.height = 44; drawCover(c, { seed: g[2], title: "", tags: "", bpm: 100, key: KEYS[hash32(g[2]) % 12], energy: 0.5 }); b.append(c); }
b.append(document.createTextNode(g[1] + " · " + g[3].length));
b.addEventListener("click", () => { pick = g; show(); });
tabs.append(b);
}
show(); $("#taste").classList.remove("hidden");
}
$("#tasteBtn").addEventListener("click", () => openTaste().catch((e) => toast(e.message)));
$("#tasteClose").addEventListener("click", () => $("#taste").classList.add("hidden"));
listStations().catch(() => {});
// a shared track (?t=): open its station and render that song first; a hash (#rainy+trains) just fills the seed
const sharedT = new URLSearchParams(location.search).get("t");
if (sharedT) {
try { const t = JSON.parse(unb64u(sharedT)); if (t && t.seed && t.title) { openStation(t.seed, { name: t.station || "", first: { title: t.title, tags: t.tags, lyrics: t.lyrics || "", bpm: t.bpm, key: t.key, energy: t.energy, line: t.line || "", station: t.station || "" } }); } }
catch { toast("That link didn't carry a track."); }
} else if (location.hash.length > 1 && !sharedTape) {
// a station link: resume that station if this browser has it (its latest track plays, free); otherwise fill the seed
const seed = decodeURIComponent(location.hash.slice(1)).replace(/\+/g, " ");
db.all("stations").then((list) => {
const s = list.find((x) => x.seed === seed);
if (s) resumeStation(s);
else { $("#seedIn").value = seed; $("#seedIn").dispatchEvent(new Event("input")); }
}).catch(() => { $("#seedIn").value = seed; $("#seedIn").dispatchEvent(new Event("input")); });
}
</script>
<script src="brain.js"></script>
</body>
</html>