shots / shots (push) Successful in 2m15s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2203 lines
179 KiB
HTML
2203 lines
179 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; connect-src https://api.venice.ai https://inference.yolopreneur.lol; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src blob: data:; font-src 'self'">
|
||
<title>g*ndora</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:calc(26px + env(safe-area-inset-top));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:calc(22px + env(safe-area-inset-top)) 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 16px var(--font);letter-spacing:.18em;color:var(--ink);max-width:min(86vw,420px);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer;text-transform:uppercase;margin-bottom:-2px}
|
||
#stage .station:hover{color:var(--dim)}
|
||
#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%);max-height:88vh;max-height:88dvh;overflow-y:auto;-webkit-overflow-scrolling:touch;overscroll-behavior:contain;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 details{margin-top:10px}#stationSheet summary{cursor:pointer;color:var(--dim);font-size:12px;list-style:none;display:flex;align-items:center;gap:6px}#stationSheet summary::-webkit-details-marker{display:none}#stationSheet summary::before{content:"▸";font-size:10px}#stationSheet details[open] summary::before{content:"▾"}
|
||
#stationSheet h4{font:600 10px var(--font);letter-spacing:.2em;color:var(--dim);margin:0 0 10px}
|
||
#upd{position:fixed;top:calc(10px + env(safe-area-inset-top));left:50%;transform:translateX(-50%);z-index:9;padding:9px 16px;border-radius:999px;background:var(--ink);color:var(--bg);font:600 11px var(--font);letter-spacing:.16em;box-shadow:0 10px 30px -10px rgba(0,0,0,.6)}
|
||
#stationSheet .x{position:sticky;top:-8px;float:right;margin:-8px -6px 0 0;z-index:1;background:var(--card);width:36px;height:36px;border-radius:50%;display:grid;place-items:center;color:var(--dim);font:300 22px var(--sans);line-height:1}
|
||
#stationSheet .x:hover{color:var(--ink);background:var(--bg)}
|
||
#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 .sl{display:flex;align-items:center;gap:12px;margin-top:6px}
|
||
#stationSheet .sl label{margin:0;width:84px}
|
||
#stationSheet input[type=range]{flex:1;accent-color:var(--ink);margin:0}
|
||
#stationSheet .sl span{font:11px var(--font);color:var(--dim);width:32px;text-align:right}
|
||
#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,#cover.tap .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;position:relative;cursor:pointer;touch-action:none}
|
||
#bar::before{content:"";position:absolute;left:0;right:0;top:-14px;bottom:-14px} /* a fat finger target around a thin line */
|
||
#bar i{display:block;height:100%;width:0;background:var(--acc);border-radius:2px;pointer-events:none}
|
||
#bar:hover,#bar.drag{height:4px;margin:-1px 0}
|
||
#bar em{position:absolute;top:-20px;transform:translateX(-50%);font-size:11px;font-style:normal;line-height:1;cursor:pointer;opacity:.95;white-space:nowrap;background:var(--card);border:1px solid var(--line);border-radius:999px;padding:2px 5px}
|
||
#bar em.below{top:auto;bottom:-20px}
|
||
#bar em:hover{border-color:var(--ink)}
|
||
#bar b{position:absolute;top:-28px;transform:translateX(-50%);font:11px var(--font);letter-spacing:.1em;color:var(--dim);background:var(--card);border:1px solid var(--line);border-radius:6px;padding:2px 6px;pointer-events:none;display:none;white-space:nowrap}
|
||
#bar.drag b{display:block}
|
||
#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) */
|
||
#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}
|
||
#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(--onacc);background:var(--acc);border-radius:999px}
|
||
#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:6;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:var(--bg);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;justify-content:center}
|
||
#tapeCover{display:flex;justify-content:center;margin:0 0 16px}
|
||
#tapeCover canvas{width:min(50vw,220px);height:min(50vw,220px);border-radius:18px;box-shadow:0 30px 60px -30px rgba(0,0,0,.8)}
|
||
#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:var(--bg);padding:calc(18px + env(safe-area-inset-top)) 16px calc(90px + env(safe-area-inset-bottom));overflow:auto}
|
||
#lib .t.now .art{box-shadow:0 0 0 2px var(--ink)}
|
||
#lib .t.now .art::after{content:"▶";position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:34px;height:34px;border-radius:50%;background:var(--ink);color:var(--bg);display:grid;place-items:center;font-size:13px;padding-left:2px}
|
||
#lib .t.now.paused .art::after{content:"‖";padding-left:0}
|
||
#libNow{position:fixed;left:50%;bottom:calc(14px + env(safe-area-inset-bottom));transform:translateX(-50%);width:min(560px,calc(100% - 24px));display:flex;align-items:center;gap:10px;background:var(--card);border:1px solid var(--line);border-radius:16px;padding:8px 8px 8px 8px;z-index:4;box-shadow:0 20px 50px -20px rgba(0,0,0,.6)}
|
||
#libNow canvas{width:44px;height:44px;border-radius:9px;flex:none}
|
||
#libNow .t{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
|
||
#libNow b{font:600 13px var(--sans);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
#libNow small{font:11px var(--font);letter-spacing:.14em;color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
#libNow button{width:40px;height:40px;border-radius:50%;display:grid;place-items:center;color:var(--ink)}
|
||
#libNow button.go{background:var(--ink);color:var(--bg)}
|
||
#libNow button.open{width:auto;border-radius:999px;padding:0 12px;font:600 11px var(--font);letter-spacing:.14em;border:1px solid var(--line)}
|
||
#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 class="nav reload" type="button" title="reload the app">RELOAD</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> <button id="libBtn">LIBRARY</button> <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" title="scrub"><i></i><b></b></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></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="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 id="tapeCover"><canvas width="480" height="480"></canvas></div>
|
||
<div class="acts"><button class="go" id="tapePlay">▶ PLAY ALL</button><button id="tapeLoop">🔁 REPEAT ALL</button><button id="tapeShare">↗ SHARE</button><button id="tapeMash" title="one new track out of up to three of these">⚡ MASHUP</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>
|
||
<button id="upd" class="hidden" type="button">NEW VERSION · RELOAD</button>
|
||
<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>
|
||
<div class="cost"><a href="stats.html" title="your numbers" style="text-decoration:none;font-size:16px;opacity:.6;margin-right:10px">📈</a><a id="llama" href="webamp.html" title="it really whips" style="text-decoration:none;font-size:18px;opacity:.6">🦙</a></div>
|
||
<div id="libNow" class="hidden"><canvas width="120" height="120"></canvas><div class="t"><b></b><small></small></div><button class="go" id="libPlay" title="play / pause"><svg viewBox="0 0 24 24" style="width:20px;height:20px;fill:currentColor"><path d="M8 5v14l11-7z"/></svg></button><button class="open" id="libOpen" title="back to the player">PLAYER ▸</button></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: Object.assign(new Audio(), { volume: Math.min(1, Math.max(0, parseFloat(localStorage.getItem("gandora.vol") || "1"))) }), cost: parseFloat(localStorage.getItem(LS.cost) || "0"),
|
||
};
|
||
state.audio.preload = "auto";
|
||
/* hooks: the seams something loaded after the page (brain.js) plugs into. Each is null or a function; the page calls
|
||
them at the named moments. Assign, don't override the page's functions. */
|
||
const hooks = {
|
||
djPrompt: null, // (prompt) => prompt the DJ's prompt, to add to (brain.js adds the taste text)
|
||
jobMade: null, // (spec) a sheet about to be rendered (brain.js writes spec.guess)
|
||
stationOpened: null, // (seed, opts) after openStation finished
|
||
reacted: null, // (emoji, text) after a reaction was taken in
|
||
};
|
||
|
||
/* ---------- 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") drawChord(canvas, spec, opts);
|
||
else if (look === "attractor") drawAttractor(canvas, spec, opts);
|
||
else drawRings(canvas, spec, opts);
|
||
if (spec.break) drawOnAir(canvas, spec, opts);
|
||
}
|
||
/* an intermission wears the station's cover in monochrome, with the asterisk as the mark and an ON AIR ring */
|
||
function drawOnAir(canvas, spec, { pulse = null } = {}) {
|
||
const ctx = canvas.getContext("2d"), W = canvas.width, H = canvas.height, cx = W / 2, cy = H / 2;
|
||
const bass = pulse ? pulse.bass : 0, tm = pulse ? pulse.t : 0;
|
||
const ink = cssVar("--ink"), ground = cssVar("--bg");
|
||
ctx.save();
|
||
ctx.globalCompositeOperation = "saturation"; ctx.fillStyle = "#808080"; ctx.fillRect(0, 0, W, H); // the colour drains out
|
||
ctx.globalCompositeOperation = "source-over";
|
||
ctx.fillStyle = ground; ctx.globalAlpha = 0.22; ctx.fillRect(0, 0, W, H); ctx.globalAlpha = 1;
|
||
// the asterisk, square ends, over the petal mark
|
||
ctx.translate(cx, cy); ctx.scale(1 + 0.12 * bass, 1 + 0.12 * bass);
|
||
ctx.fillStyle = ground; ctx.beginPath(); ctx.arc(0, 0, W * 0.125, 0, Math.PI * 2); ctx.fill();
|
||
ctx.strokeStyle = ink; ctx.lineWidth = W * 0.026; ctx.lineCap = "butt";
|
||
for (let k = 0; k < 3; k++) { const a = Math.PI / 2 + k * Math.PI / 3; ctx.beginPath(); ctx.moveTo(-Math.cos(a) * W * 0.07, -Math.sin(a) * W * 0.07); ctx.lineTo(Math.cos(a) * W * 0.07, Math.sin(a) * W * 0.07); ctx.stroke(); }
|
||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||
// the ring: dashed, turning slowly, lettered ON AIR · kind
|
||
ctx.save(); ctx.translate(cx, cy); ctx.rotate(tm * 0.15);
|
||
ctx.strokeStyle = ink; ctx.globalAlpha = 0.7; ctx.lineWidth = W * 0.006; ctx.setLineDash([W * 0.02, W * 0.014]);
|
||
ctx.beginPath(); ctx.arc(0, 0, W * 0.43, 0, Math.PI * 2); ctx.stroke(); ctx.restore();
|
||
const label = `ON AIR · ${String(spec.break).toUpperCase()}${spec.title && spec.break === "welcome" ? " · " + spec.title.toUpperCase() : ""}`;
|
||
ctx.font = `600 ${W * 0.028}px ui-monospace, Menlo, monospace`; ctx.textAlign = "center"; ctx.textBaseline = "middle";
|
||
const tw = ctx.measureText(label).width + W * 0.05;
|
||
ctx.fillStyle = ground; ctx.globalAlpha = 0.85; ctx.fillRect(cx - tw / 2, H * 0.86 - W * 0.025, tw, W * 0.05); ctx.globalAlpha = 1;
|
||
ctx.fillStyle = ink; ctx.fillText(label, cx, H * 0.86);
|
||
ctx.restore();
|
||
}
|
||
const cssVar = (n) => getComputedStyle(document.documentElement).getPropertyValue(n).trim() || "#ededf0";
|
||
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 ---------- */
|
||
/* the first tap unlocks the player: a silent clip played inside the gesture makes the <audio> element "user-started",
|
||
so the first real track can start itself when the render lands, however long that takes */
|
||
/* vinyl crackle under the gaps: a few seconds of soft noise and pops, made once, looped quietly in a second <audio>
|
||
while a track cooks or between tracks. Not Web Audio, so it behaves on iOS like the music does. */
|
||
const crackle = { el: null, on: false };
|
||
function makeCrackle(seconds = 12, gain = 1) {
|
||
const sr = 22050, n = sr * seconds, mix = new Float32Array(n), out = new Int16Array(n), rnd = prng(0x5eed);
|
||
let lp = 0;
|
||
for (let i = 0; i < n; i++) {
|
||
lp += ((rnd() * 2 - 1) - lp) * 0.08; // dull hiss, around -16 dBFS
|
||
mix[i] += lp * 1.6;
|
||
if (rnd() < 0.0015) { const len = 20 + Math.floor(rnd() * 80), amp = 0.3 + rnd() * 0.5; for (let k = 0; k < len && i + k < n; k++) mix[i + k] += amp * (1 - k / len) * (rnd() * 2 - 1); } // a pop
|
||
}
|
||
for (let i = 0; i < n; i++) out[i] = Math.max(-1, Math.min(1, mix[i] * gain)) * 32767;
|
||
const buf = new ArrayBuffer(44 + n * 2), dv = new DataView(buf);
|
||
const str = (o, s) => { for (let i = 0; i < s.length; i++) dv.setUint8(o + i, s.charCodeAt(i)); };
|
||
str(0, "RIFF"); dv.setUint32(4, 36 + n * 2, true); str(8, "WAVE"); str(12, "fmt "); dv.setUint32(16, 16, true); dv.setUint16(20, 1, true); dv.setUint16(22, 1, true);
|
||
dv.setUint32(24, sr, true); dv.setUint32(28, sr * 2, true); dv.setUint16(32, 2, true); dv.setUint16(34, 16, true); str(36, "data"); dv.setUint32(40, n * 2, true);
|
||
new Int16Array(buf, 44).set(out);
|
||
const el = new Audio(URL.createObjectURL(new Blob([buf], { type: "audio/wav" }))); el.loop = true; el.volume = crackle.base = Math.min(parseFloat(localStorage.getItem("gandora.bed") || "0.18"), localStorage.getItem("gandora.bed2") ? 1 : 0.18); localStorage.setItem("gandora.bed2", "1"); el.preload = "auto";
|
||
el.mix = mix; el.sr = sr; // kept for the Web Audio bed
|
||
return el;
|
||
}
|
||
/* the bed through Web Audio: a looping buffer has no seam (an <audio> loop pauses at the join, badly on Safari).
|
||
This is not the music's path, so the iOS rules about the player don't apply; if the context can't start, the
|
||
element plays instead. */
|
||
const bedAC = { ctx: null, gain: null, src: null, buf: null };
|
||
function bedStart(base) {
|
||
if (state.phase === "playing" && (state.playing || state.playPending || !state.audio.paused)) return false; // never over the music
|
||
const AC = window.AudioContext || window.webkitAudioContext; if (!AC) return false;
|
||
try {
|
||
bedAC.ctx ||= new AC();
|
||
if (bedAC.ctx.state !== "running") bedAC.ctx.resume().catch(() => {});
|
||
if (bedAC.ctx.state !== "running") return false;
|
||
if (!bedAC.buf) { const el = crackle.el; bedAC.buf = bedAC.ctx.createBuffer(1, el.mix.length, el.sr); bedAC.buf.getChannelData(0).set(el.mix.map((v) => Math.max(-1, Math.min(1, v)))); }
|
||
bedStop(0);
|
||
const src = bedAC.ctx.createBufferSource(); src.buffer = bedAC.buf; src.loop = true;
|
||
const gain = bedAC.ctx.createGain(); const t = bedAC.ctx.currentTime;
|
||
gain.gain.setValueAtTime(0, t); gain.gain.linearRampToValueAtTime(base, t + 0.6);
|
||
src.connect(gain).connect(bedAC.ctx.destination); src.start();
|
||
bedAC.src = src; bedAC.gain = gain;
|
||
try { crackle.el.pause(); } catch {}
|
||
return true;
|
||
} catch { return false; }
|
||
}
|
||
function bedStop(fade = 0.4) {
|
||
const { ctx, gain, src } = bedAC; if (!src) return;
|
||
bedAC.src = null; bedAC.gain = null;
|
||
try { const t = ctx.currentTime; gain.gain.cancelScheduledValues(t); gain.gain.setValueAtTime(gain.gain.value, t); gain.gain.linearRampToValueAtTime(0, t + fade); src.stop(t + fade + 0.05); } catch {}
|
||
}
|
||
function bedLevel(v) { if (bedAC.gain && bedAC.ctx) { const t = bedAC.ctx.currentTime; bedAC.gain.gain.cancelScheduledValues(t); bedAC.gain.gain.setValueAtTime(v, t); } }
|
||
/* the bed: crackle whenever no music is playing. Decided from state, called whenever state changes. */
|
||
function bed() {
|
||
clearTimeout(crackle.timer);
|
||
crackle.timer = setTimeout(() => {
|
||
// on unless music is actually running or the player itself is holding with the crackle; the unlock clip is neither
|
||
const music = state.phase === "playing" && (state.playing || !state.audio.paused || state.playPending) && state.audio.src !== SILENCE;
|
||
const want = !music && state.phase !== "holding";
|
||
if (want) crackleOn(); else crackleOff();
|
||
}, 300);
|
||
}
|
||
const bedOff = () => localStorage.getItem("gandora.bedoff") === "1";
|
||
function crackleOn() {
|
||
if (crackle.on || state.holding || bedOff()) return;
|
||
crackle.el ||= makeCrackle(); crackle.on = true;
|
||
const base = crackle.base ?? crackle.el.volume, t0 = performance.now();
|
||
if (bedStart(base)) { crackle.via = "ac"; return; }
|
||
crackle.via = "el"; crackle.el.volume = 0; crackle.el.currentTime = 0;
|
||
crackle.el.play().catch(() => { crackle.on = false; clearInterval(crackle.ramp); }); // refused without a gesture: the first tap calls again
|
||
clearInterval(crackle.ramp);
|
||
crackle.ramp = setInterval(() => { const k = Math.min(1, (performance.now() - t0) / 600); if (!crackle.on) { clearInterval(crackle.ramp); return; } crackle.el.volume = base * k; if (k >= 1) clearInterval(crackle.ramp); }, 40);
|
||
}
|
||
function crackleOff() { if (!crackle.on) return; crackle.on = false; clearInterval(crackle.ramp); if (crackle.via === "ac") { bedStop(); return; } const el = crackle.el, t0 = crackle.base ?? el.volume; let v = el.volume; const fade = setInterval(() => { v -= 0.04; if (v <= 0 || crackle.on) { clearInterval(fade); if (!crackle.on) el.pause(); el.volume = t0; } else el.volume = v; }, 40); }
|
||
/* 50 ms of silence as a wav, built here rather than pasted */
|
||
function wavBlob(samples, sr) {
|
||
const n = samples.length, buf = new ArrayBuffer(44 + n * 2), dv = new DataView(buf);
|
||
const str = (o, s) => { for (let i = 0; i < s.length; i++) dv.setUint8(o + i, s.charCodeAt(i)); };
|
||
str(0, "RIFF"); dv.setUint32(4, 36 + n * 2, true); str(8, "WAVE"); str(12, "fmt "); dv.setUint32(16, 16, true); dv.setUint16(20, 1, true); dv.setUint16(22, 1, true);
|
||
dv.setUint32(24, sr, true); dv.setUint32(28, sr * 2, true); dv.setUint16(32, 2, true); dv.setUint16(34, 16, true); str(36, "data"); dv.setUint32(40, n * 2, true);
|
||
new Int16Array(buf, 44).set(samples);
|
||
return new Blob([buf], { type: "audio/wav" });
|
||
}
|
||
const SILENCE = URL.createObjectURL(wavBlob(new Int16Array(400), 8000));
|
||
const SILENCE_UNUSED = "data:audio/wav;base64,UklGRkQDAABXQVZFZm10IBAAAAABAAEAQB8AAIA+AAACABAAZGF0YSADAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==";
|
||
function prime() {
|
||
if (state.needTap && state.current && state.current.blob) { // a resume that autoplay refused: this tap starts it
|
||
state.needTap = false; $("#cover").classList.remove("tap"); state.tapStarted = performance.now();
|
||
state.audio.play().then(() => { state.playing = true; state.primed = true; crackleOff(); setPlayIcon(); vizStart(); }).catch(() => { state.needTap = true; $("#cover").classList.add("tap"); });
|
||
return;
|
||
}
|
||
if (!crackle.on) bed();
|
||
if (state.primed || state.current || (state.audio.src && !state.audio.paused)) return;
|
||
try { state.audio.src = SILENCE; const p = state.audio.play(); if (p && p.then) p.then(() => { state.primed = true; bed(); }).catch(() => {}); } catch {}
|
||
try { crackle.el ||= makeCrackle(); if (!crackle.on) crackleOn(); } catch {}
|
||
}
|
||
for (const ev of ["pointerdown", "touchend", "keydown"]) document.addEventListener(ev, prime, { capture: true, passive: true });
|
||
setTimeout(bed, 400); // the room tone, from the start where the browser lets it
|
||
/* iOS routes sound through Web Audio the moment the element is wired to an analyser, and suspends Web Audio when
|
||
the screen locks (and obeys the mute switch). So on iOS the cover pulses to the beat from the sheet instead,
|
||
and the music stays a plain <audio> element that keeps playing in the background. */
|
||
const IOS = /iP(hone|ad|od)/.test(navigator.userAgent) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
|
||
const viz = { ctx: null, an: null, data: null, raf: 0, smooth: { bass: 0, mid: 0, high: 0 } };
|
||
function vizBeat() {
|
||
cancelAnimationFrame(viz.raf);
|
||
const canvas = $("#cover canvas"), t0 = performance.now(), rnd = prng(hash32(state.current.spec.title || ""));
|
||
const frame = () => {
|
||
if (!state.playing || !state.current || !state.current.blob) { viz.raf = 0; return; }
|
||
const spec = state.current.spec, t = (performance.now() - t0) / 1000, bps = (+spec.bpm || 100) / 60, en = +spec.energy || 0.5;
|
||
const beat = Math.pow(Math.max(0, Math.cos(2 * Math.PI * t * bps)), 6); // a kick on every beat
|
||
const bar = Math.pow(Math.max(0, Math.cos(2 * Math.PI * t * bps / 4)), 2); // and a swell on the bar
|
||
const bass = Math.min(1, (0.25 + 0.75 * beat) * (0.5 + 0.5 * en)), mid = 0.3 + 0.3 * bar * en, high = 0.2 + 0.25 * Math.pow(Math.max(0, Math.sin(2 * Math.PI * t * bps * 2)), 4) * en;
|
||
drawCover(canvas, spec, { pulse: { bass, mid, high, t } });
|
||
viz.raf = requestAnimationFrame(frame);
|
||
};
|
||
viz.raf = requestAnimationFrame(frame);
|
||
}
|
||
function vizStart() {
|
||
if (IOS || state.beatViz) return vizBeat();
|
||
// 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 who = state.key ? "Venice" : "The renderer";
|
||
const why = { 401: "Venice didn't accept that key.", 402: "Venice says the key is out of credit.", 422: who + " turned that down: " + said, 429: state.key ? "Venice is rate-limiting. Breathe." : (said || "Over today's shared budget."), 503: "That model is busy.", 504: "The render timed out; the next one will try again." }[res.status];
|
||
throw new Error(why || (who + " 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 : ""}${t.hits && t.hits.length ? " ← when: " + t.hits.map((h) => `${h.e} at ${mmss(h.t)}${h.s ? " (" + h.s + ")" : ""}`).join(", ") : ""}`).join("\n");
|
||
return `You are the DJ of a one-listener radio station${state.djName ? ` (your name: ${state.djName})` : ""}.${state.djPersona ? ` Your persona: ${state.djPersona}. It colours the on-screen line and the lyrics' voice, not the music's genre.` : ""} The listener typed a seed and the station grows from it.${remixText()}${MODE_BRIEF[state.mode] ? "\n" + MODE_BRIEF[state.mode] : ""}
|
||
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.
|
||
Hard rule: nothing from "Listener asked to avoid" appears in the tags, the instruments, the lyrics or the mood — not even a hint, whatever any taste note above suggests. This is a different station from the listener's others: its seed is the law here, not what they liked elsewhere.
|
||
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)`;
|
||
}
|
||
const djText = () => { const p = djPrompt(); return hooks.djPrompt ? hooks.djPrompt(p) : p; };
|
||
async function askDJ() {
|
||
const body = { model: state.dj, messages: [{ role: "user", content: djText() }], 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.mode === "asmr") { spec.spoken = true; spec.seconds = Math.min(180, spec.seconds); }
|
||
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;
|
||
/* the DJ's voice: Venice TTS for the spoken breaks. Each station's DJ keeps one voice, picked from the DJ name. */
|
||
/* station modes. music is the default; freestyle is rap off the dome over a beat (Harry Mack inspo); asmr is spoken
|
||
only, no music; noise is made on the device, no DJ and no cost. */
|
||
const MODES = ["music", "freestyle", "asmr", "noise", "tones"];
|
||
const MODE_BRIEF = {
|
||
freestyle: "FREESTYLE MODE: this station is an MC freestyling off the dome, Harry Mack style. tags: a beat prompt (boom bap, trap, lo-fi or jazzy hip-hop; instrumental backing; vocal style 'rap, freestyle MC, conversational flow'). lyrics: one continuous freestyle in [verse] blocks, no chorus, 24-40 bars, internal rhymes, flipping the seed and weaving in the listener's words and recent reactions as they come, talking straight to the listener by the station name; it should sound improvised and in the room, not written. bpm 80-100.",
|
||
asmr: "ASMR MODE: no music at all. The whole piece is SPOKEN, whispery, slow, close to the mic. lyrics: an ASMR script of 10-20 short lines, gentle personal attention for the listener, triggers described in soft words (tapping, brushing, page turns, rain on the window), pauses written as '...', no rhyming, no section markers, no stage directions. tags: the scene, for the record. seconds 60-120.",
|
||
};
|
||
const modeFromSeed = (seed) => { const m = (String(seed).match(/^(asmr|noise|freestyle|tones?|binaural|solfeggio|isochronic|\d{2,4}\s*hz)\b/i) || [])[1]; return !m ? "music" : /^(tone|binaural|solf|iso|\d)/i.test(m) ? "tones" : m.toLowerCase(); };
|
||
/* tones: binaural beats need stereo (one ear each), solfeggio is a pure tone, isochronic pulses the tone on and off.
|
||
Name one in the seed ("binaural theta", "528 hz", "isochronic alpha") or they cycle. All made here, free. */
|
||
const BANDS = { delta: 2.5, theta: 6, alpha: 10, beta: 18, gamma: 40 };
|
||
const SOLFEGGIO = [174, 285, 396, 417, 528, 639, 741, 852, 963];
|
||
const TONES = [["binaural", "alpha"], ["solfeggio", 528], ["binaural", "theta"], ["isochronic", "beta"], ["solfeggio", 396], ["binaural", "delta"], ["solfeggio", 963], ["isochronic", "gamma"]];
|
||
function toneSpec() {
|
||
const s = String(state.seed).toLowerCase(), n = state.history.filter((t) => t.tone).length;
|
||
const hz = (s.match(/(\d{2,4})\s*hz/) || [])[1], band = (s.match(/\b(delta|theta|alpha|beta|gamma)\b/) || [])[1];
|
||
let [kind, what] = TONES[n % TONES.length];
|
||
if (/isochronic/.test(s)) kind = "isochronic"; else if (/binaural/.test(s)) kind = "binaural"; else if (/solfeggio/.test(s) || hz) kind = "solfeggio";
|
||
if (kind === "solfeggio") what = hz ? +hz : typeof what === "number" ? what : 528; else if (band) what = band; else if (typeof what === "number") what = "alpha";
|
||
const carrier = hz && kind !== "solfeggio" ? +hz : 200, beat = kind === "solfeggio" ? 0 : BANDS[what];
|
||
const title = kind === "solfeggio" ? `${what} Hz` : `${what} ${kind} · ${beat} Hz`;
|
||
return { title, tags: `${kind} ${kind === "solfeggio" ? what + " Hz, pure tone" : what + " " + beat + " Hz on a " + carrier + " Hz carrier"}, made on this device`, lyrics: "", bpm: 60, key: "—", energy: 0.1, seconds: 300, meter: "4/4",
|
||
tone: { kind, hz: kind === "solfeggio" ? what : carrier, beat }, station: state.station || "the tone room", line: kind === "binaural" ? "headphones: one ear each" : "breathe with it", seed: state.seed, made: Date.now(), look: state.look || "rings", palette: state.palette || "duo" };
|
||
}
|
||
async function makeTone({ kind, hz, beat }, seconds) {
|
||
const sr = 16000, n = sr * seconds, L = new Int16Array(n), R = new Int16Array(n), amp = 0.28 * 32767;
|
||
for (let i = 0; i < n; i++) {
|
||
if (i % (sr * 5) === 0 && i) await new Promise((r) => setTimeout(r, 0));
|
||
const t = i / sr, fade = Math.min(1, t / 3, (seconds - t) / 3);
|
||
let l, r;
|
||
if (kind === "binaural") { l = Math.sin(2 * Math.PI * hz * t); r = Math.sin(2 * Math.PI * (hz + beat) * t); }
|
||
else if (kind === "isochronic") { const g = 0.5 - 0.5 * Math.cos(2 * Math.PI * beat * t); l = r = Math.sin(2 * Math.PI * hz * t) * (g > 0.5 ? 1 : g * 2) ; }
|
||
else { l = r = Math.sin(2 * Math.PI * hz * t) * 0.8 + Math.sin(2 * Math.PI * hz * 2 * t) * 0.08; }
|
||
L[i] = l * amp * fade; R[i] = r * amp * fade;
|
||
}
|
||
return wavBlob2(L, R, sr);
|
||
}
|
||
function wavBlob2(L, R, sr) {
|
||
const n = L.length, buf = new ArrayBuffer(44 + n * 4), dv = new DataView(buf), str = (o, s) => { for (let i = 0; i < s.length; i++) dv.setUint8(o + i, s.charCodeAt(i)); };
|
||
str(0, "RIFF"); dv.setUint32(4, 36 + n * 4, true); str(8, "WAVE"); str(12, "fmt "); dv.setUint32(16, 16, true); dv.setUint16(20, 1, true); dv.setUint16(22, 2, true);
|
||
dv.setUint32(24, sr, true); dv.setUint32(28, sr * 4, true); dv.setUint16(32, 4, true); dv.setUint16(34, 16, true); str(36, "data"); dv.setUint32(40, n * 4, true);
|
||
const out = new Int16Array(buf, 44); for (let i = 0; i < n; i++) { out[2 * i] = L[i]; out[2 * i + 1] = R[i]; }
|
||
return new Blob([buf], { type: "audio/wav" });
|
||
}
|
||
const NOISES = ["brown", "pink", "rain", "white", "crackle"];
|
||
function noiseSpec() {
|
||
const n = state.history.filter((t) => t.noise).length, kind = (String(state.seed).match(/\b(brown|pink|rain|white|crackle)\b/i) || [])[1]?.toLowerCase() || NOISES[n % NOISES.length];
|
||
return { title: `${kind} noise`, tags: `${kind} noise, room tone, made on this device`, lyrics: "", bpm: 60, key: "—", energy: 0.1, seconds: 300, meter: "4/4", noise: kind, station: state.station || "the noise floor", line: "nothing to learn here, just the room", seed: state.seed, made: Date.now(), look: state.look || "rings", palette: state.palette || "duo" };
|
||
}
|
||
async function makeNoise(kind, seconds) {
|
||
const sr = 16000, n = sr * seconds, out = new Int16Array(n), rnd = prng(hash32(kind + seconds) || 1);
|
||
let b = 0, b0 = 0, b1 = 0, b2 = 0, lp = 0, drop = 0, dropLen = 0, dropAmp = 0;
|
||
for (let i = 0; i < n; i++) {
|
||
if (i % (sr * 5) === 0 && i) await new Promise((r) => setTimeout(r, 0)); // a slice at a time: the page keeps breathing
|
||
const w = rnd() * 2 - 1; let v;
|
||
if (kind === "white") v = w * 0.25;
|
||
else if (kind === "brown") { b = (b + 0.02 * w) / 1.02; v = b * 2.2; }
|
||
else if (kind === "pink") { b0 = 0.99765 * b0 + w * 0.0990460; b1 = 0.96300 * b1 + w * 0.2965164; b2 = 0.57000 * b2 + w * 1.0526913; v = (b0 + b1 + b2 + w * 0.1848) * 0.12; }
|
||
else if (kind === "rain") { lp += (w - lp) * 0.3; v = lp * 0.5; if (drop <= 0 && rnd() < 0.004) { drop = dropLen = 30 + Math.floor(rnd() * 120); dropAmp = 0.15 + rnd() * 0.35; } if (drop > 0) { v += dropAmp * (drop / dropLen) * w; drop--; } }
|
||
else { lp += (w - lp) * 0.08; v = lp * 1.0; if (rnd() < 0.0015) { drop = dropLen = 20 + Math.floor(rnd() * 80); dropAmp = 0.3 + rnd() * 0.5; } if (drop > 0) { v += dropAmp * (drop / dropLen) * w; drop--; } }
|
||
const fade = Math.min(1, i / (sr * 2), (n - i) / (sr * 2));
|
||
out[i] = Math.max(-1, Math.min(1, v * fade)) * 32767;
|
||
}
|
||
return wavBlob(out, sr);
|
||
}
|
||
/* voices: "model/voice". Kokoro (plain, cheap, no style), Qwen3 (takes a style prompt: whisper, ASMR, excited),
|
||
Orpheus (expressive), ElevenLabs turbo (polished). A bare name is Kokoro. */
|
||
const VOICES = ["af_sky", "af_bella", "af_nova", "af_river", "af_sarah", "am_adam", "am_echo", "am_michael", "am_puck", "bf_emma", "bm_george",
|
||
"tts-qwen3-1-7b/Serena", "tts-qwen3-1-7b/Vivian", "tts-qwen3-1-7b/Dylan", "tts-orpheus/tara", "tts-orpheus/leo", "tts-orpheus/mia", "tts-elevenlabs-turbo-v2-5/Rachel", "tts-elevenlabs-turbo-v2-5/Aria"];
|
||
const voiceParts = (v) => { const i = String(v).indexOf("/"); return i < 0 ? { model: "tts-kokoro", voice: v } : { model: v.slice(0, i), voice: v.slice(i + 1) }; };
|
||
/* DJ personas: a name, a voice, a speaking speed and a one-line brief. The brief goes into the track prompt and the
|
||
break scripts; the voice and speed go to the TTS. Presets to start from; everything is editable per station. */
|
||
const PERSONAS = [
|
||
{ id: "asmr", name: "Whisper", voice: "tts-qwen3-1-7b/Serena", speed: 0.85, persona: "an ASMR host: whispery, close to the mic, slow, soothing, tiny personal asides, long pauses, never loud" },
|
||
{ id: "latenight", name: "Night Owl", voice: "am_echo", speed: 0.95, persona: "late-night pirate radio: dry, deadpan, unhurried, a little mysterious, speaks in short sentences" },
|
||
{ id: "top40", name: "Big Mike", voice: "am_michael", speed: 1.12, persona: "a 90s Top-40 shouter: high energy, catchphrases, station idents, hypes every track like a world premiere" },
|
||
{ id: "bbc", name: "Margaret", voice: "bf_emma", speed: 1, persona: "a BBC-style announcer: crisp, warm, precise, polite, gently witty, never slangy" },
|
||
{ id: "cosmic", name: "Stardust", voice: "af_nova", speed: 0.9, persona: "a cosmic hippie: dreamy, speaks in images about space and light, calls the listener traveller" },
|
||
{ id: "coach", name: "Coach", voice: "am_adam", speed: 1.08, persona: "a motivational coach: punchy, encouraging, counts you in, turns every track into a rep" },
|
||
];
|
||
const voiceLabel = (v) => { const { model, voice } = voiceParts(v); if (model === "tts-kokoro") return ({ af: "♀ US ", am: "♂ US ", bf: "♀ UK ", bm: "♂ UK " }[voice.slice(0, 2)] || "") + voice.slice(3); return ({ "tts-qwen3-1-7b": "q ", "tts-orpheus": "o ", "tts-elevenlabs-turbo-v2-5": "11 " }[model] || "") + voice; };
|
||
async function speak(spec) {
|
||
const text = String(spec.lyrics || "").replace(/\[[^\]]*\]/g, " ").replace(/\s+/g, " ").trim().slice(0, 1800);
|
||
const picked = state.djVoice || VOICES[hash32(state.djName || state.seed || "dj") % 11], { model, voice } = voiceParts(picked);
|
||
const speed = state.mode === "asmr" && (!state.djSpeed || state.djSpeed === 1) ? 0.8 : +state.djSpeed || 1;
|
||
const body = { model, input: text, voice, response_format: "mp3", speed: Math.min(1.5, Math.max(0.6, speed)) };
|
||
if (model === "tts-qwen3-1-7b") body.prompt = (state.mode === "asmr" ? "Whispering ASMR voice, very soft, slow, breathy, close to the microphone. " : "") + (state.djPersona || "").slice(0, 300);
|
||
const res = await venice(state.key ? "/audio/speech" : "/speech", body, { raw: true });
|
||
spec.cost = model === "tts-kokoro" ? 0.001 : 0.004; spec.renderer = "venice:" + model; spec.voice = picked;
|
||
state.cost += spec.cost; localStorage.setItem(LS.cost, String(state.cost));
|
||
return res.blob();
|
||
}
|
||
async function renderTrack(spec, onStatus) {
|
||
if (spec.noise) return makeNoise(spec.noise, spec.seconds || 300);
|
||
if (spec.tone) return makeTone(spec.tone, spec.seconds || 300);
|
||
if (spec.spoken) return speak(spec);
|
||
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 (res.headers.get("x-fallback")) spec.fallback = res.headers.get("x-fallback");
|
||
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") { if (store === "tapes" && t.id === "kept") return; 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();
|
||
const rows = await 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); });
|
||
if (store !== "tapes") return rows;
|
||
// KEPT is a tape that writes itself: every kept track, oldest first, always at the front of the strip
|
||
const kept = await new Promise((ok, no) => { const r = d.transaction("tracks").objectStore("tracks").getAll(); r.onsuccess = () => ok(r.result.filter((t) => t.kept).sort((a, b) => a.made - b.made).map((t) => t.id)); r.onerror = () => no(r.error); });
|
||
return [{ id: "kept", name: "KEPT", ids: kept, made: 0, last: Date.now(), virtual: true }, ...rows];
|
||
},
|
||
async get(id, store = "tracks") { const d = await this.open(); return new Promise((ok, no) => { const r = d.transaction(store).objectStore(store).get(id); r.onsuccess = () => ok(r.result || null); 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, seconds, meter, hits }) => ({ id, title, tags, bpm, key, energy, reaction, made, seconds, meter, hits }));
|
||
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", djName: state.djName || "", djVoice: state.djVoice || "", djSpeed: state.djSpeed || 1, djPersona: state.djPersona || "", mode: state.mode || "music", welcomed: !!state.welcomed }, "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.get(id, "stations"); if (!s) return;
|
||
let p = $("#stationSheet"); p.innerHTML = ""; p.scrollTop = 0;
|
||
p.append(Object.assign(document.createElement("h4"), { textContent: "STATION" }));
|
||
const x = Object.assign(document.createElement("button"), { className: "x", textContent: "×", title: "close" }); x.setAttribute("aria-label", "close");
|
||
x.addEventListener("click", () => p.classList.add("hidden")); p.append(x);
|
||
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 modeRow = document.createElement("div"); modeRow.className = "row"; modeRow.style.marginTop = "8px";
|
||
p.append(Object.assign(document.createElement("label"), { textContent: "mode" }));
|
||
for (const md of MODES) {
|
||
const b = Object.assign(document.createElement("button"), { textContent: md.toUpperCase(), className: (s.mode || "music") === md ? "go" : "", title: { music: "songs", freestyle: "an MC off the dome over a beat", asmr: "spoken only, no music", noise: "brown, pink, rain, white, crackle: made here, free", tones: "binaural beats, solfeggio, isochronic: made here, free" }[md] });
|
||
b.addEventListener("click", async () => {
|
||
s.mode = md; await db.put(s, "stations");
|
||
if (state.stationId === id) { state.mode = md; if (state.nextJob && !state.nextJob.blob) state.nextJob = null; }
|
||
for (const x of modeRow.children) x.className = x.textContent === md.toUpperCase() ? "go" : "";
|
||
toast(`Mode: ${md}. Takes effect from the next track.`);
|
||
});
|
||
modeRow.append(b);
|
||
}
|
||
p.append(modeRow);
|
||
// the DJ: name, voice, speed, persona, folded away unless something is set
|
||
const dj = document.createElement("details"); dj.open = false;
|
||
dj.append(Object.assign(document.createElement("summary"), { textContent: "dj" + (s.djName ? " · " + s.djName : "") + (s.djVoice ? " · " + voiceLabel(s.djVoice) : "") }));
|
||
const pAll = p, pDj = dj; p = dj;
|
||
const preRow = document.createElement("div"); preRow.className = "row"; preRow.style.marginTop = "4px";
|
||
const djName = document.createElement("input"); djName.value = s.djName || ""; djName.placeholder = "name (the DJ picks one at the welcome if blank)"; djName.maxLength = 40;
|
||
const djPersona = document.createElement("input"); djPersona.value = s.djPersona || ""; djPersona.placeholder = "persona: how they talk, e.g. whispery ASMR host"; djPersona.maxLength = 240; djPersona.style.marginTop = "6px";
|
||
const voiceRow = document.createElement("div"); voiceRow.className = "row"; voiceRow.style.marginTop = "6px";
|
||
let voice = s.djVoice || "", speed = s.djSpeed || 1;
|
||
const paintVoices = () => { for (const b of voiceRow.children) b.className = b.dataset.v === voice ? "go" : ""; };
|
||
for (const v of VOICES) {
|
||
const b = Object.assign(document.createElement("button"), { textContent: voiceLabel(v).toUpperCase(), title: "tap to hear " + v }); b.dataset.v = v;
|
||
b.addEventListener("click", async () => {
|
||
voice = v; paintVoices();
|
||
if (crackle.preview) { try { crackle.preview.pause(); } catch {} crackle.preview = null; }
|
||
const mine = crackle.previewSeq = (crackle.previewSeq || 0) + 1;
|
||
try { const was = { v: state.djVoice, sp: state.djSpeed }; state.djVoice = v; state.djSpeed = speed;
|
||
const blob = await speak({ lyrics: `Hi, this is ${djName.value.trim() || "your DJ"}, and you're listening to ${s.name || s.seed}.` });
|
||
state.djVoice = was.v; state.djSpeed = was.sp;
|
||
if (mine !== crackle.previewSeq) return; // a later tap won
|
||
if (crackle.preview) { try { crackle.preview.pause(); } catch {} }
|
||
const a = crackle.preview = new Audio(URL.createObjectURL(blob)); a.volume = state.audio.volume; a.play().catch(() => {}); } catch { toast("Couldn't fetch the voice."); }
|
||
});
|
||
voiceRow.append(b);
|
||
}
|
||
paintVoices();
|
||
for (const pr of PERSONAS) {
|
||
const b = Object.assign(document.createElement("button"), { textContent: pr.id.toUpperCase(), title: pr.persona });
|
||
b.addEventListener("click", () => { djName.value = pr.name; djPersona.value = pr.persona; voice = pr.voice; speed = pr.speed; spd.value = Math.round(speed * 100); spdOut.textContent = spd.value + "%"; paintVoices(); });
|
||
preRow.append(b);
|
||
}
|
||
const spdRow = document.createElement("div"); spdRow.className = "sl";
|
||
const spd = document.createElement("input"); spd.type = "range"; spd.min = 60; spd.max = 150; spd.value = Math.round(speed * 100);
|
||
const spdOut = Object.assign(document.createElement("span"), { textContent: spd.value + "%" });
|
||
spd.addEventListener("input", () => { speed = spd.value / 100; spdOut.textContent = spd.value + "%"; });
|
||
spdRow.append(Object.assign(document.createElement("label"), { textContent: "speed" }), spd, spdOut);
|
||
p.append(preRow, djName, djPersona, voiceRow, spdRow);
|
||
p = pAll; p.append(pDj);
|
||
const applyDj = async () => {
|
||
s.djName = djName.value.trim().slice(0, 40); s.djPersona = djPersona.value.trim().slice(0, 240); s.djVoice = voice; s.djSpeed = speed;
|
||
if (state.stationId === id) { state.djName = s.djName; state.djPersona = s.djPersona; state.djVoice = s.djVoice; state.djSpeed = s.djSpeed; }
|
||
};
|
||
// volume and room tone, for everything (not just this station)
|
||
for (const [label, key, get, set] of [["volume", "gandora.vol", () => state.audio.volume, (v) => { state.audio.volume = v; }], ["room tone", "gandora.bed", () => (crackle.el ||= makeCrackle()).volume, (v) => { (crackle.el ||= makeCrackle()).volume = v; crackle.base = v; bedLevel(v); }]]) {
|
||
const sl = document.createElement("div"); sl.className = "sl";
|
||
const r = document.createElement("input"); r.type = "range"; r.min = 0; r.max = 100; r.value = Math.round(get() * 100);
|
||
const out = Object.assign(document.createElement("span"), { textContent: r.value + "%" });
|
||
r.addEventListener("input", () => { set(r.value / 100); out.textContent = r.value + "%"; localStorage.setItem(key, String(r.value / 100)); });
|
||
sl.append(Object.assign(document.createElement("label"), { textContent: label }), r, out);
|
||
if (key === "gandora.bed") {
|
||
const off = Object.assign(document.createElement("button"), { textContent: bedOff() ? "OFF" : "ON", title: "the crackle between tracks" });
|
||
off.style.cssText = "padding:6px 10px;border-radius:999px;border:1px solid var(--line);font:600 10px var(--font);letter-spacing:.12em";
|
||
off.addEventListener("click", () => { const now = !bedOff(); localStorage.setItem("gandora.bedoff", now ? "1" : "0"); off.textContent = now ? "OFF" : "ON"; if (now) crackleOff(); else bed(); toast(now ? "Room tone off." : "Room tone on."); });
|
||
sl.append(off);
|
||
}
|
||
p.append(sl);
|
||
}
|
||
if (state.stationId === id) {
|
||
const more = document.createElement("div"); more.className = "row"; more.style.marginTop = "10px"; more.style.justifyContent = "flex-start";
|
||
const fromT = Object.assign(document.createElement("button"), { textContent: "* STATION FROM THIS TRACK", title: "a new station grown from the track playing now" });
|
||
fromT.disabled = !(state.current && state.current.blob && !state.current.spec.break);
|
||
fromT.addEventListener("click", () => { p.classList.add("hidden"); stationFromCurrent(); });
|
||
const shareS = Object.assign(document.createElement("button"), { textContent: "↗ SHARE STATION", title: "a link that opens this station's seed" });
|
||
shareS.addEventListener("click", async () => {
|
||
const url = `${location.origin}/#${encodeURIComponent(s.seed).replace(/%20/g, "+")}`;
|
||
try { if (navigator.share) await navigator.share({ title: `${s.name || s.seed} · gandora`, url }); else { await navigator.clipboard.writeText(url); toast("Station link copied."); } } catch {}
|
||
});
|
||
more.append(fromT, shareS); p.append(more);
|
||
}
|
||
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 applyDj(); 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("Saved."); });
|
||
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) { toIdle(); 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.");
|
||
});
|
||
row.append(save, reset, del); 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 || "",
|
||
rseed: s.rseed, seconds: s.seconds, meter: s.meter, look: s.look || state.look, palette: s.palette || state.palette };
|
||
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, opts = {}) { await openStationNow(seed, opts); if (hooks.stationOpened) hooks.stationOpened(seed, opts); }
|
||
async function openStationNow(seed, { id, name = "", taste, history, played, first, look, palette: pal, djName = "", djVoice = "", djSpeed = 1, djPersona = "", mode, welcomed = false } = {}) {
|
||
state.mode = MODES.includes(mode) ? mode : modeFromSeed(seed);
|
||
state.look = look || "rings"; state.palette = pal || "duo"; state.djName = djName; state.djVoice = djVoice; state.djSpeed = djSpeed || 1; state.djPersona = djPersona; state.welcomed = welcomed; state.sinceAd = 0; state.breaks = 0;
|
||
if (state.stationId !== id) { toIdle(); 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);
|
||
}
|
||
async function stationFromCurrent() {
|
||
const cur = state.current && state.current.spec; if (!cur) return;
|
||
const row = (await db.get(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.get(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;
|
||
/* the ledger: one line per cook, play and reaction, in localStorage (the track store prunes, this doesn't; capped at
|
||
3,000 lines). stats.html reads it. Nothing leaves the device. */
|
||
function logEvent(kind, d = {}) {
|
||
try { const log = JSON.parse(localStorage.getItem("gandora.log") || "[]"); log.push({ t: Date.now(), k: kind, ...d }); while (log.length > 3000) log.shift(); localStorage.setItem("gandora.log", JSON.stringify(log)); } catch {}
|
||
}
|
||
function makeJob(spec) {
|
||
if (hooks.jobMade) hooks.jobMade(spec);
|
||
const job = { id: ++seq, spec, blob: null, err: null, done: null };
|
||
job.done = (async () => {
|
||
const t0 = Date.now();
|
||
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; }
|
||
logEvent("cook", { ms: Date.now() - t0, ok: !job.err, err: job.err ? String(job.err.message || job.err).slice(0, 80) : undefined, cost: spec.cost || 0, r: spec.renderer || (spec.noise ? "device:noise" : spec.tone ? "device:tone" : ""), s: spec.seconds || 0,
|
||
mode: state.mode || "music", st: state.station || state.seed || "", brk: spec.break || undefined, fb: spec.fallback || undefined, title: spec.title });
|
||
})();
|
||
return job;
|
||
}
|
||
let cooking = null;
|
||
/* DJ intermissions: every BREAK_EVERY slots the DJ takes a 30 s break in the station's own sound — a station ident, a
|
||
recap of what just played, a tease of what's coming, a shout-out to the listener, or (now and then) the pro word.
|
||
A break plays like any track (keep it if you like it) but it is marked, never trains taste, never comes up in
|
||
shuffle or THE FIVE. Spoken breaks are sung-spoken over a bed: ACE-Step does that well with "spoken word" tags. */
|
||
const BREAK_EVERY = 5;
|
||
const BREAKS = ["ident", "recap", "shoutout", "tease", "sponsor"];
|
||
const BREAK_BRIEF = {
|
||
welcome: "a WELCOME: the DJ pops on for the first time. Pick a DJ name that fits this station (two words at most; you keep it for good) and open with it — \"this is <name>, and this is <station name>\". Say the station's brain just switched on: it has heard three tracks and now learns from every reaction — the tempo, the energy, the key and the mood words are its inputs. Tell the listener to react with the emojis, or drop a line in the box below. Thank them for listening to gandora. Sign off with \"enjoy\".",
|
||
ident: "a STATION IDENT: a tiny song that thanks the listener for listening to gandora; sing the word \"gandora\" at least twice.",
|
||
recap: "a RECAP: the DJ talks over a soft bed about the last two tracks — what they were, one thing to notice in them — like a late-night radio host between records. Name the tracks by title.",
|
||
shoutout: "a SHOUT-OUT: the DJ speaks to this one listener — what the station has learned they like (from the reactions and words below), warmly and specifically, no flattery. Mention the station by name.",
|
||
tease: "a TEASE: the DJ talks over a bed about where the station is heading next — a direction, a mood, a dare — and asks the listener to react to steer it.",
|
||
sponsor: "a WORD FROM THE SPONSOR (us): a tiny song, playful and unpushy, that gandora pro gets them longer tracks, bigger renders and their stations everywhere. Never salesy; sing \"gandora\" at least once.",
|
||
};
|
||
async function askBreak(kind) {
|
||
const prev = state.history.slice(-2).map((t) => `"${t.title}" — ${t.tags} (${t.bpm} bpm, ${t.key})${t.reaction ? "; listener: " + t.reaction : ""}`).join("; ") || "(nothing yet)";
|
||
const spoken = kind === "recap" || kind === "shoutout" || kind === "tease" || kind === "welcome";
|
||
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}"` : ""}.${state.djName ? ` Your DJ name is ${state.djName}.` : ""}${state.djPersona ? ` Your persona, and the way you speak: ${state.djPersona}.` : ""} The last tracks: ${prev}.
|
||
Listener likes: ${state.taste.like.join("; ") || "(unknown)"}. Avoids: ${state.taste.avoid.join("; ") || "(unknown)"}. Their words: ${state.taste.notes.join(" | ") || "(none)"}.
|
||
Write a 30-second INTERMISSION in the station's own sound: ${state.djName ? BREAK_BRIEF[kind].replace(/Pick a DJ name[^)]*\) and open/, "Open") : BREAK_BRIEF[kind]}
|
||
${spoken ? "It is SPOKEN by a voice, not sung: lyrics are exactly what the DJ says, 4-7 short lines of natural speech, no rhyming, no section markers, no stage directions. tags describe the station's sound (for the record), bpm/key/energy match the station." : "4-8 lines of lyrics."}
|
||
Return JSON: title (short${kind === "welcome" ? "; for the welcome, the title is your DJ name" : ""}), tags (the music prompt), lyrics, bpm, key, energy, seconds (30), meter, station ("${state.station || state.seed}"), line (one line the DJ says on screen, under 70 characters)` }] };
|
||
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 break.");
|
||
const spec = JSON.parse(m[0]);
|
||
spec.tags = String(spec.tags || "").slice(0, 500); spec.lyrics = String(spec.lyrics || "").slice(0, 1200);
|
||
if (spoken) { spec.spoken = true; spec.tags = String(spec.tags || "").replace(/^spoken word, radio DJ voice, talking over,?\s*/i, ""); }
|
||
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.break = kind; spec.sponsored = kind === "sponsor"; spec.seed = state.seed; spec.made = Date.now();
|
||
if (kind === "welcome") { if (!state.djName) state.djName = String(spec.title || "").replace(/^(dj|this is)\s+/i, "").slice(0, 40) || "the DJ"; spec.title = state.djName; if (!state.djVoice) state.djVoice = VOICES[hash32(state.djName) % VOICES.length]; state.welcomed = true; saveStation().catch(() => {}); }
|
||
spec.line = spec.line || { ident: "station ident", recap: "that was…", shoutout: "this one's for you", tease: "coming up", sponsor: "a word from our sponsor: us" }[kind];
|
||
return spec;
|
||
}
|
||
function cookNext() {
|
||
if (state.nextJob) return Promise.resolve(state.nextJob);
|
||
if (cooking) return cooking;
|
||
cooking = (async () => {
|
||
try {
|
||
const real = state.history.filter((t) => !t.break).length;
|
||
const welcome = !state.welcomed && real >= 3 && !state.history.some((t) => t.break === "welcome"); // the DJ pops on once the brain does
|
||
const local = state.mode === "noise" || state.mode === "tones";
|
||
const due = !local && (welcome || (BREAK_EVERY > 0 && state.sinceAd >= BREAK_EVERY - 1 && real >= 3)); // three real tracks first: that's when the brain wakes up too
|
||
const kind = welcome ? "welcome" : BREAKS[(state.breaks || 0) % BREAKS.length];
|
||
const spec = state.mode === "noise" ? noiseSpec() : state.mode === "tones" ? toneSpec() : due ? await askBreak(kind).catch(() => askDJ()) : await askDJ();
|
||
if (spec.break) { state.sinceAd = 0; state.breaks = (state.breaks || 0) + 1; } else state.sinceAd = (state.sinceAd || 0) + 1;
|
||
state.nextJob = makeJob(spec); if (state.current) $("#next").textContent = (spec.break ? `NEXT · ${spec.break.toUpperCase()} · ` : "NEXT · ") + spec.title.toUpperCase(); return state.nextJob; }
|
||
catch (e) { toast(e.message, 5000); return null; }
|
||
finally { cooking = null; }
|
||
})();
|
||
return cooking;
|
||
}
|
||
/* ---------- the player: one small state machine ----------
|
||
state.phase is one of
|
||
idle nothing on air: no station, or the station stopped
|
||
cooking state.current is on screen and its render hasn't landed; the player is silent, the room tone plays beside it
|
||
holding the player itself loops the crackle (a locked phone keeps the session only while the element stays busy);
|
||
a track that lands meanwhile queues behind it (state.pendingJob) and takes over when the crackle's pass ends
|
||
playing a track's audio is in the player (pause is not a phase: state.playing says whether it runs)
|
||
ended the track ran out; inside the event the next source starts or the hold begins, then advance() picks on
|
||
The transitions are the functions below (toCooking, toHolding, toPlaying, startNow, handOver, toEnded, toIdle) and
|
||
only they set state.phase or the player's src. The one exception is prime(): the unlock clip, which is not a track.
|
||
The order inside `ended` matters: a source that must survive a locked screen starts synchronously there; the
|
||
bookkeeping comes after. And nothing awaits between setting src and play() except the render itself (a database read
|
||
there has lost the user activation before). */
|
||
state.phase = "idle";
|
||
Object.defineProperty(state, "holding", { get: () => state.phase === "holding" }); // the old flag, now read-only
|
||
const srcOf = (job) => job.url || (job.url = URL.createObjectURL(job.blob));
|
||
/* every source change goes through here; it drops the old object URL, never the crackle's or the unlock clip's */
|
||
function setSource(src, loop) {
|
||
const old = state.audio.src;
|
||
if (old && old !== SILENCE && !(crackle.el && old === crackle.el.src) && !(crackle.hold && old === crackle.hold.src)) URL.revokeObjectURL(old);
|
||
state.audio.src = src; state.audio.loop = loop;
|
||
if (state.holdVol != null && !(crackle.hold && src === crackle.hold.src)) { state.audio.volume = state.holdVol; state.holdVol = null; } // the hold ran at room-tone level
|
||
}
|
||
/* → cooking: the sheet goes on screen and the player waits for the render (resolved already for a library track).
|
||
A hold in progress stays a hold: the track will queue behind the crackle. A track started inside `ended`
|
||
(job.preplayed) is already playing and keeps that phase. */
|
||
function toCooking(job) {
|
||
const spec = job.spec;
|
||
state.current = job;
|
||
if (!job.preplayed && state.phase !== "holding") state.phase = "cooking";
|
||
if (!job.stepping) { state.history.push(spec); state.played = (state.played || 0) + 1; spec.stationId ||= state.stationId; }
|
||
keepLabel();
|
||
for (const t of $("#bar").querySelectorAll("em")) t.remove();
|
||
drawTicks(spec, spec.seconds || 120);
|
||
$("#title").textContent = spec.break ? "✱ " + spec.title : spec.title; $("#tags").textContent = `${spec.bpm} bpm · ${spec.key}${spec.break ? " · " + (spec.sponsored ? "sponsored" : "intermission") : spec.remixOf ? " · remix" : ""}`;
|
||
$("#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";
|
||
bed(); // the needle is down while it cooks
|
||
}
|
||
/* → holding, inside `ended`: nothing is cooked, so the player loops the crackle in its own element to stay alive */
|
||
function toHolding() {
|
||
try {
|
||
// the level is baked into the sample: iOS ignores an element's volume. OFF holds on four seconds of nothing (its own
|
||
// URL, not SILENCE, so `ended` still fires and hands over).
|
||
const lvl = bedOff() ? 0 : Math.round((crackle.base ?? 0.18) * 100) / 100;
|
||
if (!crackle.hold || crackle.hold.lvl !== lvl) { crackle.hold = lvl ? makeCrackle(4, lvl) : Object.assign(new Audio(URL.createObjectURL(wavBlob(new Int16Array(8000 * 4), 8000))), { loop: true }); crackle.hold.lvl = lvl; }
|
||
crackleOff(); setSource(crackle.hold.src, true); state.phase = "holding"; const p = state.audio.play(); if (p && p.catch) p.catch(() => {}); } catch {}
|
||
}
|
||
/* a landed track while the player holds: it queues behind the crackle, the loop is switched off so the pass ends, and
|
||
handOver starts it inside that `ended`. Resolves once the track has been handed over (or dropped). */
|
||
function queueBehindHold(job) {
|
||
return new Promise((r) => { srcOf(job); state.pendingJob = job; state.onHoldEnd = r; state.audio.loop = false; });
|
||
}
|
||
/* → playing, inside `ended`: the crackle's pass is over and the queued track takes its place, synchronously */
|
||
function handOver(job) {
|
||
state.pendingJob = null;
|
||
const go = state.onHoldEnd; state.onHoldEnd = null;
|
||
if (state.current !== job) { if (go) go(); toEnded(); return; } // dropped meanwhile: treat it as a track ending
|
||
try { setSource(srcOf(job), !!state.loop); state.phase = "playing"; crackleOff(); playNow(job); job.preplayed = true; logEvent("play", { s: job.spec.seconds || 0, st: state.station || state.seed || "", title: job.spec.title, brk: job.spec.break || undefined }); } catch {}
|
||
if (go) go();
|
||
}
|
||
/* → playing, inside `ended`: the next track is cooked and nothing is queued ahead of it, so its source starts right
|
||
here; playJob then finds it preplayed and only does the bookkeeping */
|
||
function startNow(job) {
|
||
try { setSource(srcOf(job), !!state.loop); state.phase = "playing"; crackleOff(); playNow(job); job.preplayed = true; logEvent("play", { s: job.spec.seconds || 0, st: state.station || state.seed || "", title: job.spec.title, brk: job.spec.break || undefined }); return true; }
|
||
catch { return false; }
|
||
}
|
||
/* → playing, the ordinary way: the track's audio goes into the player and play() is asked for. Refused (a resume with
|
||
no gesture yet) means TAP TO PLAY: the source stays loaded and prime() starts it on the next tap. */
|
||
async function toPlaying(job) {
|
||
if (!job.preplayed) { setSource(srcOf(job), !!state.loop); state.pendingJob = null; }
|
||
state.phase = "playing";
|
||
try { if (!job.preplayed || state.audio.paused) await state.audio.play(); state.playing = true; state.primed = true; state.needTap = false; $("#cover").classList.remove("tap"); crackleOff(); setPlayIcon(); vizStart(); }
|
||
catch { state.needTap = true; $("#cover .veil").textContent = "TAP TO PLAY"; $("#cover").classList.add("tap"); }
|
||
}
|
||
/* play() inside the swap: the playing flag follows the promise, so a refusal (iOS after a lock, after another element
|
||
played) shows TAP TO PLAY instead of a playing UI with no sound; the bed stays off while the answer is pending */
|
||
function playNow(job) {
|
||
state.playPending = true;
|
||
let p; try { p = state.audio.play(); } catch (e) { p = Promise.reject(e); }
|
||
Promise.resolve(p).then(() => { state.playPending = false; if (state.current === job) { state.playing = true; state.needTap = false; $("#cover").classList.remove("tap"); setPlayIcon(); } })
|
||
.catch(() => { state.playPending = false; if (state.current === job || !state.current) { state.playing = false; state.needTap = true; $("#cover .veil").textContent = "TAP TO PLAY"; $("#cover").classList.add("tap"); setPlayIcon(); bed(); } });
|
||
}
|
||
/* → ended: the track ran out. Synchronously: start the cooked next track, or begin the hold; then advance() picks what
|
||
follows and does the bookkeeping. A tape, a queued shared tape, a remix/after slot, forward history or shuffle count
|
||
as "queued ahead": advance() decides those, so nothing is started here. */
|
||
function toEnded() {
|
||
state.phase = "ended"; state.playing = false; setPlayIcon(); vizStop();
|
||
const job = state.nextJob;
|
||
const queued = state.tape || (state.tapeQueue && state.tapeQueue.length) || state.after || state.pos < state.recent.length - 1 || state.shuffle;
|
||
if (!queued) { if (job && job.blob) startNow(job); else toHolding(); }
|
||
advance();
|
||
}
|
||
/* → idle: the station stops (a change of station, a deleted station) */
|
||
function toIdle() {
|
||
state.audio.pause(); state.playing = false; setPlayIcon();
|
||
state.current = null; state.nextJob = null; state.pendingJob = null; state.phase = "idle";
|
||
const go = state.onHoldEnd; state.onHoldEnd = null; if (go) go();
|
||
}
|
||
/* a job through the machine: show it, wait for its render, start it, then the bookkeeping */
|
||
async function playJob(job) {
|
||
const spec = job.spec;
|
||
toCooking(job);
|
||
await job.done;
|
||
if (state.current !== job) return; // skipped while cooking
|
||
$("#cover").classList.remove("cooking");
|
||
if (job.err) { crackleOff(); 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.phase === "holding" && !state.audio.paused) { // the crackle is on: queue behind it, it hands over at the end of its loop
|
||
await queueBehindHold(job);
|
||
if (state.current !== job) return;
|
||
}
|
||
spec.id ||= spec.made + "-" + hash32(spec.title);
|
||
if (!job.stepping) cookNext(); // render ahead: starts now, not after play() comes back
|
||
await toPlaying(job);
|
||
nowPlaying(spec); document.title = `${spec.title} · g*ndora`;
|
||
cookNext(); // (no-op if already cooking)
|
||
// the bookkeeping, after the music is already going
|
||
if (!job.stepping) { state.recent = state.recent.slice(0, state.pos + 1); state.recent.push(spec.id); state.pos = state.recent.length - 1; }
|
||
(async () => {
|
||
await remember(job, false).then(keepLabel).catch(() => {}); saveStation().catch(() => {});
|
||
if (job.fileInto) { const tp = await tapeById(job.fileInto); if (tp && !tp.ids.includes(spec.id)) { tp.ids.push(spec.id); await db.put(tp, "tapes"); } }
|
||
drawTrail().catch(() => {});
|
||
})().catch(() => {});
|
||
}
|
||
/* 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.get(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.get(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.break);
|
||
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];
|
||
}
|
||
/* what plays next, in order: the tape → a shared tape still rendering → forward in the session → the remix/after
|
||
slot → shuffle (maybe) → the cooked next → or cook one now. Called after toEnded() and on skip. */
|
||
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 tapeById(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; }
|
||
}
|
||
bed(); // nothing ready: the room tone carries while the DJ writes and the oven runs
|
||
const job = state.nextJob || await cookNext();
|
||
state.nextJob = null; $("#next").textContent = "";
|
||
if (!job) { crackleOff(); return; }
|
||
await playJob(job);
|
||
cookNext();
|
||
}
|
||
/* the player's events. `ended` is the only one that moves the machine; the rest keep state.playing honest. */
|
||
state.audio.addEventListener("ended", () => {
|
||
if (state.audio.src === SILENCE || !state.stationId || !state.current) return; // the unlock clip, or nothing on air
|
||
if (state.phase === "holding") { if (state.pendingJob) handOver(state.pendingJob); return; } // the crackle's pass ran out: hand over, or (loop is on, so rarely) nothing waits
|
||
toEnded();
|
||
});
|
||
state.audio.addEventListener("pause", () => { if (!state.audio.ended) { state.playing = false; setPlayIcon(); vizStop(); } bed(); });
|
||
state.audio.addEventListener("playing", () => { if (state.audio.src !== SILENCE && !state.holding) { state.playing = true; crackleOff(); } });
|
||
state.audio.addEventListener("play", () => { state.playing = true; setPlayIcon(); vizStart(); });
|
||
state.audio.addEventListener("timeupdate", () => { if (state.audio.duration && !$("#bar").classList.contains("drag")) $("#bar i").style.width = (state.audio.currentTime / state.audio.duration * 100) + "%"; });
|
||
/* scrubbing: tap or drag along the bar; the readout shows where you'd land */
|
||
(function () {
|
||
const bar = $("#bar"), fill = $("#bar i"), tip = $("#bar b");
|
||
const mmss = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
|
||
const frac = (e) => { const r = bar.getBoundingClientRect(); return Math.min(1, Math.max(0, (e.clientX - r.left) / r.width)); };
|
||
let down = false;
|
||
const show = (f) => { fill.style.width = f * 100 + "%"; tip.style.left = f * 100 + "%"; tip.textContent = state.audio.duration ? `${mmss(f * state.audio.duration)} / ${mmss(state.audio.duration)}` : ""; };
|
||
bar.addEventListener("pointerdown", (e) => { if (!state.audio.duration || !state.current || !state.current.blob) return; down = true; bar.classList.add("drag"); bar.setPointerCapture(e.pointerId); show(frac(e)); e.preventDefault(); });
|
||
bar.addEventListener("pointermove", (e) => { if (down) show(frac(e)); });
|
||
const end = (e) => { if (!down) return; down = false; bar.classList.remove("drag"); const f = frac(e); state.audio.currentTime = f * state.audio.duration; show(f); };
|
||
bar.addEventListener("pointerup", end); bar.addEventListener("pointercancel", end);
|
||
})();
|
||
function setPlayIcon() {
|
||
$("#playIco").innerHTML = state.playing ? '<path d="M6 5h4v14H6zm8 0h4v14h-4z"/>' : '<path d="M8 5v14l11-7z"/>';
|
||
libNow();
|
||
}
|
||
/* the library's own now-playing: ring the tile, keep the bottom bar current */
|
||
function libNow() {
|
||
const bar = $("#libNow"); if (!bar) return;
|
||
const cur = state.current && state.current.spec;
|
||
for (const d of document.querySelectorAll("#libGrid .t")) { const on = !!(cur && d.dataset.id === cur.id); d.classList.toggle("now", on); d.classList.toggle("paused", on && !state.playing); }
|
||
if (!cur) { bar.classList.add("hidden"); return; }
|
||
bar.classList.remove("hidden");
|
||
drawCover(bar.querySelector("canvas"), cur); bar.querySelector("b").textContent = cur.title; bar.querySelector("small").textContent = (state.station || cur.seed || "").toUpperCase();
|
||
bar.querySelector("#libPlay svg").innerHTML = state.playing ? '<path d="M6 5h4v14H6zm8 0h4v14h-4z"/>' : '<path d="M8 5v14l11-7z"/>';
|
||
}
|
||
$("#libPlay").addEventListener("click", () => $("#play").click());
|
||
$("#libOpen").addEventListener("click", () => { $("#lib").classList.add("hidden"); $("#player").classList.remove("hidden"); $("#seed").classList.add("hidden"); });
|
||
|
||
/* ---------- 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" },
|
||
};
|
||
/* where in the track a reaction landed: the second, and the lyric section playing then (sections assumed evenly spread) */
|
||
function sectionAt(spec, t) {
|
||
const marks = [...String(spec.lyrics || "").matchAll(/\[([a-z -]+)\]/gi)].map((m) => m[1].toLowerCase());
|
||
if (!marks.length) return "";
|
||
const dur = state.audio.duration || spec.seconds || 120;
|
||
return marks[Math.min(marks.length - 1, Math.floor(t / dur * marks.length))];
|
||
}
|
||
const mmss = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
|
||
function markReaction(spec, emoji, t) {
|
||
const dur = state.audio.duration || spec.seconds || 120;
|
||
spec.hits = (spec.hits || []).concat([{ e: emoji, t: Math.round(t), s: sectionAt(spec, t) }]).slice(-24);
|
||
if (state.current.spec === spec) drawTicks(spec, dur);
|
||
}
|
||
/* the ticks above the bar: hits within a few percent of each other become one pill reading them in order */
|
||
function drawTicks(spec, dur) {
|
||
const bar = $("#bar"); if (!bar) return;
|
||
for (const t of bar.querySelectorAll("em")) t.remove();
|
||
const hits = (spec.hits || []).slice().sort((a, b) => a.t - b.t), groups = [];
|
||
for (const h of hits) { const g = groups[groups.length - 1]; if (g && h.t - g.at <= dur * 0.04) { g.e.push(h.e); g.last = h.t; } else groups.push({ at: h.t, last: h.t, e: [h.e] }); }
|
||
let below = false, prevAt = -Infinity;
|
||
for (const g of groups) {
|
||
const tick = document.createElement("em"); const shown = g.e.slice(0, 3).join("") + (g.e.length > 3 ? "+" + (g.e.length - 3) : "");
|
||
tick.textContent = shown; tick.title = `${g.e.join(" ")} at ${mmss(g.at)}${g.last !== g.at ? "–" + mmss(g.last) : ""} · tap to jump there`;
|
||
below = g.at - prevAt <= dur * 0.12 ? !below : false; prevAt = g.last; // close neighbours alternate sides
|
||
tick.classList.toggle("below", below);
|
||
tick.style.left = Math.min(100, (g.at + g.last) / 2 / dur * 100) + "%";
|
||
tick.addEventListener("click", (e) => { e.stopPropagation(); if (state.audio.duration) state.audio.currentTime = Math.min(state.audio.duration - 0.5, g.at); });
|
||
bar.append(tick);
|
||
}
|
||
}
|
||
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 at = state.audio.currentTime || 0, sec = sectionAt(cur, at), when = ` at ${mmss(at)}${sec ? " (" + sec + ")" : ""}`;
|
||
const who = `${cur.title} (${cur.tags.split(",").slice(0, 3).join(",")})${when}`;
|
||
if (emoji) markReaction(cur, emoji, at);
|
||
logEvent("react", { e: emoji || undefined, txt: text ? String(text).slice(0, 60) : undefined, at: Math.round(at), st: state.station || state.seed || "", title: cur.title });
|
||
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}"`;
|
||
// "no violin", "less classical, no strings", "more guitar": the named things go on the lists and on the sheet as tag signals
|
||
const parts = text.toLowerCase().split(/[,;.!]|\band\b/).map((s) => s.trim()).filter(Boolean);
|
||
for (const p of parts) {
|
||
const neg = p.match(/^(?:no|not|less|stop|without|drop|enough|never|skip|hate|lose|kill|cut|no more)\s+(?:the\s+|any\s+)?([a-z][a-z0-9 -]{1,30}?)$/);
|
||
const pos = p.match(/^(?:more|love|loving|keep|add|give me|i like|i love)\s+(?:the\s+|this\s+|that\s+)?([a-z][a-z0-9 -]{1,30}?)$/);
|
||
if (neg) { state.taste.avoid.push(`${neg[1]} (listener said: "${p}")`); cur.reaction += " -" + neg[1].replace(/\s+/g, "_"); }
|
||
else if (pos) { state.taste.like.push(`${pos[1]} (listener said: "${p}")`); cur.reaction += " +" + pos[1].replace(/\s+/g, "_"); }
|
||
}
|
||
}
|
||
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.`);
|
||
if (hooks.reacted) hooks.reacted(emoji, text);
|
||
}
|
||
|
||
/* ---------- 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
|
||
const kind = spec.break ? (spec.sponsored ? "sponsored" : "intermission") : spec.mashOf ? "mashup" : spec.remixOf ? "remix" : "";
|
||
for (const m of [`${spec.bpm} bpm`, spec.key, kind].filter(Boolean)) { 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.get(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) { toast("Still cooking."); return; }
|
||
const row = await db.get(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"; });
|
||
}
|
||
/* updates: an installed app has no address bar to refresh from, so the page checks for a new worker whenever it
|
||
comes back to the foreground (and every ten minutes) and offers a reload when one has taken over */
|
||
function showUpdate() { const u = $("#upd"); if (u) u.classList.remove("hidden"); }
|
||
if ("serviceWorker" in navigator && location.protocol === "https:") {
|
||
navigator.serviceWorker.register("sw.js").then((reg) => {
|
||
const check = () => reg.update().catch(() => {});
|
||
setInterval(check, 10 * 60 * 1000);
|
||
document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") check(); });
|
||
reg.addEventListener("updatefound", () => { const nw = reg.installing; if (!nw) return; nw.addEventListener("statechange", () => { if (nw.state === "activated" && navigator.serviceWorker.controller) showUpdate(); }); });
|
||
}).catch(() => {});
|
||
}
|
||
$("#upd").addEventListener("click", () => location.reload());
|
||
for (const b of document.querySelectorAll("button.reload")) b.addEventListener("click", () => location.reload());
|
||
/* 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.tapStarted && performance.now() - state.tapStarted < 800) { state.tapStarted = 0; setPlayIcon(); return; } // this tap already started it
|
||
if (state.needTap) { prime(); 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; state.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)); localStorage.setItem(EMOJI_KEY + ".last", e); }
|
||
const EMOJI_DEFAULT = ["🔥", "❤️", "😴", "🌧️", "⚡", "🎸", "🚫"];
|
||
const EMOJI_TRAY = 7;
|
||
function showEmojiRow() {
|
||
const c = emojiCounts(), row = $("#emojis"), more = $("#more");
|
||
const seeded = EMOJI_DEFAULT.map((e, i) => [e, (c[e] || 0) + (EMOJI_TRAY - 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 ranked = seeded.sort((a, b) => b[1] - a[1]).map((x) => x[0]);
|
||
const last = localStorage.getItem(EMOJI_KEY + ".last");
|
||
const top = ranked.slice(0, EMOJI_TRAY - 1); if (last && !top.includes(last)) top.push(last); else top.push(...ranked.slice(EMOJI_TRAY - 1, EMOJI_TRAY)); // the last one used always has a slot
|
||
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); setTimeout(showEmojiRow, 350); });
|
||
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);
|
||
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.type = "button"; b.textContent = e; b.addEventListener("click", (ev) => { ev.preventDefault(); pick(e); }); g.append(b); }
|
||
p.append(g);
|
||
}
|
||
p.append(top); // the type-box last, and no autofocus: the drawer must not raise the keyboard
|
||
p.classList.remove("hidden");
|
||
}
|
||
$("#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.dataset.id = t.id; if (state.current && state.current.spec && state.current.spec.id === t.id) { d.classList.add("now"); if (!state.playing) d.classList.add("paused"); }
|
||
d.addEventListener("click", () => {
|
||
// the library stays open; the track plays underneath, and the bar at the bottom follows it
|
||
if (state.current && state.current.spec && state.current.spec.id === t.id) { $("#play").click(); return; }
|
||
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 one";
|
||
const rows = tp.ids.map((id) => list.find((t) => t.id === id)).filter(Boolean);
|
||
const c = document.createElement("canvas"); c.width = c.height = 180; drawCover(c, tapeSpec(tp, rows)); 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));
|
||
}
|
||
libNow();
|
||
$("#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.virtual) { const row = await db.get(track.id); if (row && !row.kept) { row.kept = true; await db.put(row); keepLabel().catch(() => {}); } } else 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
|
||
/* one tape by id. KEPT is virtual (db.all("tapes") writes it from the kept tracks), so it is the one that needs the list */
|
||
const tapeById = async (id) => id === "kept" ? (await db.all("tapes"))[0] : db.get(id, "tapes");
|
||
async function tapeRows(tp) { return (await Promise.all(tp.ids.map((id) => db.get(id)))).filter(Boolean); }
|
||
/* a tape's cover: a sheet blended from its tracks. The key is the one most of them share (the cover's hue), tempo and
|
||
energy are averages, the tags are everyone's, the seed is the tape's name so each tape keeps its own shape. */
|
||
function tapeSpec(tp, rows) {
|
||
const specs = rows.map((t) => t.spec);
|
||
const keys = {}; for (const s of specs) keys[s.key] = (keys[s.key] || 0) + 1;
|
||
const key = Object.keys(keys).sort((a, b) => keys[b] - keys[a])[0] || KEYS[hash32(tp.name) % 12];
|
||
const avg = (f, d) => specs.length ? specs.reduce((a, s) => a + (+f(s) || d), 0) / specs.length : d;
|
||
return { seed: "tape:" + (tp.id === "kept" ? "kept" : tp.name), title: tp.name, tags: specs.map((s) => s.tags).join(", ").slice(0, 600), bpm: Math.round(avg((s) => s.bpm, 100)), key, energy: avg((s) => s.energy, 0.5),
|
||
look: specs.length ? (specs[specs.length - 1].look || state.look) : "rings", palette: specs.length > 1 ? "tri" : "duo" };
|
||
}
|
||
async function openTape(id) {
|
||
const tp = await tapeById(id); if (!tp) return;
|
||
$("#lib").classList.add("hidden"); $("#tape").classList.remove("hidden"); $("#tape").dataset.id = id;
|
||
$("#tapeName").value = tp.name; $("#tapeName").disabled = !!tp.virtual; $("#tapeDelete").classList.toggle("hidden", !!tp.virtual);
|
||
const rows = await tapeRows(tp), list = $("#tapeList"); list.innerHTML = "";
|
||
drawCover($("#tapeCover canvas"), tapeSpec(tp, rows));
|
||
$("#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 () => { if (tp.virtual) { const row = await db.get(tp.ids[i]); if (row) { row.kept = false; await db.put(row); } } else { 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 tapeById($("#tape").dataset.id); if (tp) playTape(tp, 0); });
|
||
$("#tapeLoop").addEventListener("click", async () => { const tp = await tapeById($("#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 tapeById($("#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(); });
|
||
/* mashups: up to three tracks in, one out. The DJ writes a sheet that fuses theirs; the renderer is handed the
|
||
tracks themselves, cut and crossfaded into one reference (OfflineAudioContext, off the playback path, so iOS is
|
||
fine), and the mash is the station's next track. */
|
||
const MASH_MAX = 3, MASH_SR = 22050;
|
||
async function mashReference(blobs, seconds) {
|
||
const AC = window.OfflineAudioContext || window.webkitOfflineAudioContext; if (!AC) return null;
|
||
const probe = new AC(1, 1, MASH_SR);
|
||
const bufs = [];
|
||
for (const b of blobs) { try { bufs.push(await probe.decodeAudioData(await b.arrayBuffer())); } catch {} }
|
||
if (bufs.length < 2) return null;
|
||
const per = seconds / bufs.length, fade = 2, total = seconds;
|
||
const ctx = new AC(1, Math.ceil(total * MASH_SR), MASH_SR);
|
||
let at = 0;
|
||
bufs.forEach((buf, i) => {
|
||
const src = ctx.createBufferSource(); src.buffer = buf;
|
||
const g = ctx.createGain(); src.connect(g); g.connect(ctx.destination);
|
||
const start = Math.max(0, at - (i ? fade : 0)), len = Math.min(buf.duration, per + fade);
|
||
const from = Math.max(0, Math.min(buf.duration - len, buf.duration * 0.25)); // skip the intro, take the meat
|
||
g.gain.setValueAtTime(i ? 0 : 1, start); if (i) g.gain.linearRampToValueAtTime(1, start + fade);
|
||
g.gain.setValueAtTime(1, start + len - fade); g.gain.linearRampToValueAtTime(0, start + len);
|
||
src.start(start, from, len); at = start + len;
|
||
});
|
||
const out = await ctx.startRendering();
|
||
const ch = out.getChannelData(0), pcm = new Int16Array(ch.length);
|
||
for (let i = 0; i < ch.length; i++) pcm[i] = Math.max(-1, Math.min(1, ch[i])) * 32767;
|
||
return asDataUrl(wavBlob(pcm, MASH_SR));
|
||
}
|
||
async function askMash(rows) {
|
||
const sheets = rows.map((t, i) => `${i + 1}. "${t.spec.title}" [${t.spec.tags}] ${t.spec.bpm} bpm ${t.spec.key} energy ${t.spec.energy}${t.spec.lyrics ? "\n lyrics: " + t.spec.lyrics.replace(/\s+/g, " ").slice(0, 400) : ""}`).join("\n");
|
||
const body = { model: state.dj, 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 } },
|
||
messages: [{ role: "user", content: `You are the DJ of g*ndora, a station that writes its own songs. Make a MASHUP of these tracks — one new song that is recognisably all of them at once:
|
||
${sheets}
|
||
|
||
Rules: the title fuses their titles; tags blend their genres and instruments into one coherent prompt (the renderer will also hear the tracks themselves, cut together); lyrics weave lines and images from each (keep their best hooks, alternate voices by section); bpm between theirs; the key of whichever track leads; energy the higher one; seconds ${Math.min(120, 30 * rows.length)}; meter 4/4.
|
||
Return JSON: title, tags, lyrics, bpm, key, energy, seconds, meter, station ("${state.station || state.seed}"), line (one line, under 80 characters, the DJ introducing the mash)` }] };
|
||
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 couldn't write the mash.");
|
||
const spec = JSON.parse(m[0]);
|
||
spec.tags = String(spec.tags || "").slice(0, 500); spec.lyrics = String(spec.lyrics || "").slice(0, 4000);
|
||
spec.bpm = Math.min(180, Math.max(60, parseInt(spec.bpm) || 100)); spec.energy = Math.min(1, Math.max(0, +spec.energy || 0.5));
|
||
spec.seconds = Math.min(120, 30 * rows.length); spec.meter = "4/4";
|
||
spec.mashOf = rows.map((t) => t.id); spec.seed = state.seed; spec.made = Date.now();
|
||
return spec;
|
||
}
|
||
async function mashup(rows) {
|
||
rows = rows.filter((t) => t.blob && !t.spec.break).slice(0, MASH_MAX);
|
||
if (rows.length < 2) { toast("A mashup needs at least two tracks."); return; }
|
||
if (!state.stationId) { toast("Open a station first; the mash goes on it."); return; }
|
||
toast(`Mashing ${rows.length} tracks — it goes next.`, 4000);
|
||
try {
|
||
const [spec, ref] = await Promise.all([askMash(rows), mashReference(rows.map((t) => t.blob), Math.min(120, 30 * rows.length))]);
|
||
if (ref) spec.refAudio = ref;
|
||
const job = makeJob(spec);
|
||
if (state.nextJob && !state.nextJob.blob) state.after = state.nextJob;
|
||
state.nextJob = job; $("#next").textContent = "NEXT · " + spec.title.toUpperCase() + " · MASHUP";
|
||
} catch (e) { toast(e.message, 5000); }
|
||
}
|
||
$("#tapeMash").addEventListener("click", async () => {
|
||
const tp = await tapeById($("#tape").dataset.id); if (!tp) return;
|
||
const rows = await tapeRows(tp);
|
||
$("#tape").classList.add("hidden"); $("#player").classList.remove("hidden"); $("#seed").classList.add("hidden");
|
||
mashup(rows);
|
||
});
|
||
$("#tapeShare").addEventListener("click", async () => {
|
||
const tp = await tapeById($("#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 || "", look: t.look, palette: t.palette, 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 || "", rseed: t.rseed, seconds: t.seconds, meter: t.meter, look: t.look, palette: t.palette } }); } }
|
||
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>
|