2026-10-20 22:29:58 +00:00
<!doctype html>
< html lang = "en" >
< head >
< meta charset = "utf-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1, viewport-fit=cover" >
< meta http-equiv = "Content-Security-Policy" content = "default-src 'self'; connect-src https://api.venice.ai https://inference.yolopreneur.lol; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src blob:; font-src 'self'" >
< title > gandora</ title >
< meta name = "description" content = "Type a seed. Get a station. Every song is made for you as it plays, and every cover is drawn from the song's own settings." >
< link rel = "icon" href = "favicon.svg" >
2026-10-20 23:40:58 +00:00
< 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" >
2026-10-20 22:29:58 +00:00
< style >
: root {
2026-10-20 23:53:19 +00:00
--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 ;
2026-10-20 22:29:58 +00:00
}
2026-10-20 23:53:19 +00:00
: 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 ;
}}
2026-10-20 22:29:58 +00:00
* { box-sizing : border-box } html , body { height : 100 % }
body { margin : 0 ; background : var ( -- bg ); color : var ( -- ink ); font : 15 px / 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 : 100 dvh ; display : flex ; flex-direction : column ; align-items : center ; justify-content : center ; padding : 24 px 16 px ; position : relative }
2026-10-20 23:40:58 +00:00
# seed . wordmark { position : absolute ; top : 18 px ; left : 0 ; right : 0 ; text-align : center ; font : 600 15 px var ( -- font ); letter-spacing : .18 em ; color : var ( -- ink )}
# seed . wordmark . ast { color : var ( -- ink ); font-weight : 700 }
2026-10-20 22:29:58 +00:00
# seed form { width : min ( 680 px , 100 % ); text-align : center }
# seedIn { width : 100 % ; background : transparent ; border : 0 ; border-bottom : 1 px solid var ( -- line ); padding : 14 px 4 px ; text-align : center ; font : 400 clamp ( 24 px , 5 vw , 38 px ) / 1.2 var ( -- sans ); outline : none ; caret-color : var ( -- acc ); transition : border-color .3 s }
# seedIn : focus { border-color : var ( -- acc )}
# seedIn :: placeholder { color : #3a3a44 }
# seed . hint { margin-top : 18 px ; color : var ( -- dim ); font-size : 13 px ; min-height : 1.5 em }
# seedCover { width : 120 px ; height : 120 px ; margin : 0 auto 28 px ; border-radius : 14 px ; overflow : hidden ; background : var ( -- card ); opacity : 0 ; transform : scale ( .92 ); transition : opacity .5 s , transform .5 s }
# seedCover . on { opacity : 1 ; transform : none }
# seedCover canvas { width : 100 % ; height : 100 % ; display : block }
# nowRow { position : absolute ; top : 44 px ; left : 0 ; right : 0 ; display : flex ; justify-content : center }
# now { display : flex ; align-items : center ; gap : 10 px ; background : var ( -- card ); border : 1 px solid var ( -- line ); border-radius : 999 px ; padding : 6 px 14 px 6 px 6 px ; font-size : 13 px ; cursor : pointer ; max-width : calc ( 100 % - 32 px )}
# now canvas { width : 28 px ; height : 28 px ; border-radius : 50 % ; display : block }
# now span { white-space : nowrap ; overflow : hidden ; text-overflow : ellipsis }
# now b { font : 600 11 px var ( -- font ); letter-spacing : .14 em ; color : var ( -- dim ); margin-left : 4 px }
# stations { width : min ( 680 px , 100 % ); margin-top : 40 px }
# stations h3 { font : 600 11 px var ( -- font ); letter-spacing : .2 em ; color : var ( -- dim ); margin : 0 0 12 px ; text-align : center }
# stations . row { display : flex ; gap : 14 px ; overflow-x : auto ; padding : 4 px 4 px 10 px ; scrollbar-width : none ; justify-content : safe center }
# stations . s { flex : 0 0 96 px ; cursor : pointer ; text-align : center }
# stations . s canvas { width : 72 px ; height : 72 px ; border-radius : 50 % ; display : block ; margin : 0 auto 6 px ; background : var ( -- card )}
# stations . s . on canvas { outline : 2 px solid var ( -- acc ); outline-offset : 3 px }
# stations . s b { display : block ; font-size : 12 px ; font-weight : 600 ; white-space : nowrap ; overflow : hidden ; text-overflow : ellipsis }
# stations . s small { color : var ( -- dim ); font-size : 11 px }
# keyRow { position : absolute ; bottom : 18 px ; left : 0 ; right : 0 ; text-align : center ; font-size : 12 px ; color : var ( -- dim ); display : flex ; gap : 18 px ; justify-content : center ; flex-wrap : wrap }
# keyRow button { text-decoration : underline ; text-underline-offset : 3 px }
# keyRow . nav { font : 600 11 px var ( -- font ); letter-spacing : .18 em ; text-decoration : none }
# keyBox { position : absolute ; bottom : 44 px ; left : 50 % ; transform : translateX ( -50 % ); width : min ( 420 px , calc ( 100 % - 32 px )); background : var ( -- card ); border : 1 px solid var ( -- line ); border-radius : 12 px ; padding : 14 px ; font-size : 13 px }
2026-10-20 23:53:19 +00:00
# keyBox input { width : 100 % ; margin : 8 px 0 ; background : var ( -- field ); border : 1 px solid var ( -- line ); border-radius : 8 px ; padding : 9 px 10 px ; font-family : var ( -- font ); font-size : 12 px ; outline : none }
2026-10-20 22:29:58 +00:00
# keyBox . row { display : flex ; gap : 8 px ; justify-content : flex-end }
# keyBox . row button { padding : 6 px 12 px ; border-radius : 8 px ; border : 1 px solid var ( -- line )}
2026-10-20 23:53:19 +00:00
# keyBox . row button . go { background : var ( -- acc ); color : var ( -- onacc ); border-color : var ( -- acc )}
2026-10-20 22:29:58 +00:00
# keyBox p { margin : 0 0 4 px ; color : var ( -- dim )}
# keyBox label { display : block ; margin-top : 6 px ; color : var ( -- dim )}
/* ---------- player ---------- */
# player { min-height : 100 dvh ; display : grid ; grid-template-rows : auto 1 fr auto ; padding : 14 px 16 px calc ( 16 px + env ( safe - area - inset - bottom ))}
# top { display : flex ; align-items : center ; justify-content : space-between ; font : 600 12 px var ( -- font ); letter-spacing : .2 em ; color : var ( -- dim )}
# stage . station { font : 600 11 px var ( -- font ); letter-spacing : .22 em ; color : var ( -- dim ); max-width : min ( 78 vw , 380 px ); white-space : nowrap ; overflow : hidden ; text-overflow : ellipsis ; cursor : pointer ; text-transform : uppercase ; margin-bottom : -4 px }
# stage . station : hover { color : var ( -- acc )}
# stations . s { position : relative }
# stations . s . dots { position : absolute ; top : -4 px ; right : 2 px ; width : 22 px ; height : 22 px ; border-radius : 50 % ; background : var ( -- card ); border : 1 px solid var ( -- line ); color : var ( -- dim ); font-size : 12 px ; 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 ( 480 px , 100 % ); background : var ( -- card ); border : 1 px solid var ( -- line ); border-bottom : 0 ; border-radius : 18 px 18 px 0 0 ; padding : 14 px 14 px calc ( 14 px + env ( safe - area - inset - bottom )); z-index : 6 ; box-shadow : 0 -20 px 60 px rgba ( 0 , 0 , 0 , .6 )}
# stationSheet h4 { font : 600 10 px var ( -- font ); letter-spacing : .2 em ; color : var ( -- dim ); margin : 0 0 10 px }
# stationSheet label { display : block ; color : var ( -- dim ); font-size : 12 px ; margin : 8 px 0 4 px }
2026-10-20 23:53:19 +00:00
# stationSheet input { width : 100 % ; background : var ( -- field ); border : 1 px solid var ( -- line ); border-radius : 10 px ; padding : 10 px 12 px ; outline : none }
2026-10-20 22:29:58 +00:00
# stationSheet . row { display : flex ; gap : 8 px ; flex-wrap : wrap ; margin-top : 12 px }
# stationSheet . row button { padding : 9 px 14 px ; border-radius : 999 px ; border : 1 px solid var ( -- line ); font : 600 11 px var ( -- font ); letter-spacing : .14 em }
2026-10-20 23:53:19 +00:00
# stationSheet . row button . go { background : var ( -- acc ); color : var ( -- onacc ); border-color : var ( -- acc )}
2026-10-20 22:29:58 +00:00
# stationSheet . row button . bad { color : hsl ( 0 60 % 70 % ); border-color : hsl ( 0 30 % 35 % )}
# top button { color : var ( -- dim ); letter-spacing : .08 em }
# stage { display : flex ; flex-direction : column ; align-items : center ; justify-content : center ; gap : 18 px ; padding : 14 px 0 }
2026-10-20 22:45:46 +00:00
/* 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 }
2026-10-20 22:40:04 +00:00
# stage # controls { margin-top : 2 px }
2026-10-20 22:45:46 +00:00
# meta2 # pills { margin : 10 px 0 8 px }
2026-10-20 22:29:58 +00:00
# cover { width : min ( 78 vw , 380 px ); aspect-ratio : 1 ; position : relative ; perspective : 1200 px }
# cover . card { position : absolute ; inset : 0 ; transform-style : preserve-3d ; transition : transform .7 s cubic-bezier ( .4 , .1 , .2 , 1 )}
# cover . flipped . card { transform : rotateY ( 180 deg )}
# cover . face { position : absolute ; inset : 0 ; border-radius : 18 px ; overflow : hidden ; background : var ( -- card ); box-shadow : 0 30 px 80 px -30 px rgba ( 0 , 0 , 0 , .9 ); backface-visibility : hidden ; -webkit- backface-visibility : hidden }
2026-10-20 23:53:19 +00:00
# cover . back { transform : rotateY ( 180 deg ); background : var ( -- card ); border : 1 px solid var ( -- line ); display : flex ; flex-direction : column }
2026-10-20 22:29:58 +00:00
# cover canvas { width : 100 % ; height : 100 % ; display : block }
# cover . back . backTitle { font : 600 10 px var ( -- font ); letter-spacing : .22 em ; color : var ( -- dim ); padding : 14 px 16 px 6 px ; text-align : center }
2026-10-20 23:53:19 +00:00
# cover . veil { position : absolute ; inset : 0 ; display : flex ; align-items : center ; justify-content : center ; background : var ( -- veil ); backdrop-filter : blur ( 2 px ); font : 12 px var ( -- font ); letter-spacing : .2 em ; color : var ( -- ink ); opacity : 0 ; transition : opacity .3 s ; pointer-events : none }
2026-10-20 22:29:58 +00:00
# cover . cooking . veil { opacity : 1 }
# meta { text-align : center ; max-width : 520 px }
# title { font-size : 20 px ; font-weight : 600 ; letter-spacing : -.01 em ; margin : 0 }
# tags { color : var ( -- dim ); font : 11 px var ( -- font ); margin : 4 px 0 0 ; letter-spacing : .06 em }
# lyric { color : var ( -- dim ); font-size : 13 px ; margin : 8 px 0 0 ; min-height : 1.5 em ; font-style : italic ; cursor : text }
# lyric : hover { color : var ( -- ink )}
# meta2 { text-align : center ; max-width : 560 px ; width : 100 % }
# meta2 # lyric { margin-top : 0 }
# tagRow { display : flex ; flex-wrap : wrap ; gap : 6 px ; justify-content : center ; margin : 10 px auto 0 ; max-width : 560 px }
# tagList { display : flex ; flex-wrap : wrap ; gap : 6 px ; justify-content : center ; margin : 8 px auto 0 ; max-width : 560 px }
# tagList : empty { display : none }
# tagList button { font : 12 px var ( -- font ); letter-spacing : .02 em ; padding : 5 px 10 px ; border-radius : 999 px ; border : 1 px solid var ( -- line ); color : var ( -- dim ); background : transparent ; transition : all .15 s }
2026-10-20 23:53:19 +00:00
# tagList button . like { color : var ( -- onacc ); background : var ( -- acc ); border-color : var ( -- acc )}
2026-10-20 22:29:58 +00:00
# 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 : 12 px var ( -- font ); width : 110 px ; padding : 5 px 10 px ; border-radius : 999 px ; border : 1 px solid var ( -- acc ); background : var ( -- card ); outline : none }
# meta2 # next { margin : 8 px 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 : .12 em }
# stage { gap : 14 px }
# tagRow button { font : 12 px var ( -- font ); letter-spacing : .02 em ; padding : 5 px 10 px ; border-radius : 999 px ; border : 1 px solid var ( -- line ); color : var ( -- dim ); background : transparent ; transition : all .15 s }
2026-10-20 23:53:19 +00:00
# tagRow button . like { color : var ( -- onacc ); background : var ( -- acc ); border-color : var ( -- acc )}
2026-10-20 22:29:58 +00:00
# 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 : 12 px var ( -- font ); width : 110 px ; padding : 5 px 10 px ; border-radius : 999 px ; border : 1 px solid var ( -- acc ); background : var ( -- card ); outline : none }
# lyrics { white-space : pre-wrap ; text-align : center ; font-size : 13 px ; line-height : 1.65 ; margin : 0 ; color : var ( -- ink ); flex : 1 ; overflow : auto ; padding : 4 px 16 px 18 px ; 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 10 px var ( -- font ); letter-spacing : .2 em ; margin : 12 px 0 2 px }
# lyrics . sec : first-child { margin-top : 0 }
# cover { cursor : pointer }
# bar { width : min ( 78 vw , 380 px ); height : 2 px ; background : var ( -- line ); border-radius : 2 px ; overflow : hidden }
# bar i { display : block ; height : 100 % ; width : 0 ; background : var ( -- acc )}
# controls { display : flex ; align-items : center ; gap : 18 px }
# controls button { width : 44 px ; height : 44 px ; border-radius : 50 % ; display : grid ; place-items : center ; color : var ( -- ink ); opacity : .85 }
# controls # backTrack svg , # controls # skip svg { width : 24 px ; height : 24 px }
# controls # prev { opacity : .6 }
# pills { display : flex ; gap : 10 px ; justify-content : center ; flex-wrap : wrap }
# pills button { display : inline-flex ; align-items : center ; gap : 8 px ; padding : 9 px 18 px ; border-radius : 999 px ; border : 1 px solid var ( -- line ); font : 600 12 px var ( -- font ); letter-spacing : .14 em ; color : var ( -- ink ); background : var ( -- card ); transition : background .2 s , color .2 s , border-color .2 s }
2026-10-20 23:53:19 +00:00
# pills button . on { background : var ( -- acc ); color : var ( -- onacc ); border-color : var ( -- acc )}
2026-10-20 22:29:58 +00:00
# pills button : disabled { opacity : .4 ; cursor : default }
# controls button : hover { opacity : 1 }
2026-10-20 23:53:19 +00:00
# controls # play { width : 62 px ; height : 62 px ; background : var ( -- ink ); color : var ( -- bg )}
2026-10-20 22:45:46 +00:00
/* 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 : 8 px ; align-items : flex-end ; overflow-x : auto ; padding : 26 px 12 px 12 px ; border-radius : 0 0 18 px 18 px ; scrollbar-width : none ; -webkit- overflow-scrolling : touch ;
2026-10-20 23:53:19 +00:00
background : linear-gradient ( to top , rgba ( var ( -- shade ), .92 ), rgba ( var ( -- shade ), .55 ) 60 % , transparent ); opacity : 0 ; pointer-events : none ; transition : opacity .25 s }
2026-10-20 22:45:46 +00:00
# cover : hover # trail , # cover . flipped : hover # trail { opacity : 1 ; pointer-events : auto }
@ media ( hover : none ) {# trail { opacity : 1 ; pointer-events : auto ; padding-top : 18 px }}
2026-10-20 22:29:58 +00:00
# trail :: -webkit-scrollbar { display : none }
# trail : empty { display : none }
2026-10-20 22:45:46 +00:00
# trail canvas { width : 34 px ; height : 34 px ; border-radius : 8 px ; flex : none ; opacity : .55 ; cursor : pointer ; transition : opacity .2 s , transform .2 s }
# trail canvas : hover { opacity : .9 ; transform : translateY ( -2 px )}
# trail canvas . on { opacity : 1 ; transform : scale ( 1.18 ); box-shadow : 0 0 0 2 px var ( -- ink )}
2026-10-20 22:29:58 +00:00
# trail canvas . kept { box-shadow : 0 0 0 2 px var ( -- acc )}
# trail canvas . on . kept { box-shadow : 0 0 0 2 px var ( -- acc ), 0 0 0 4 px var ( -- ink )}
# controls svg { width : 22 px ; height : 22 px ; 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 : 18 px ; height : 18 px }
# controls # loop . on { opacity : 1 ; color : var ( -- acc ); background : rgba ( 233 , 216 , 166 , .12 )}
# next { font : 11 px var ( -- font ); letter-spacing : .14 em ; color : var ( -- dim ); min-height : 1.4 em ; text-align : center }
# react { display : flex ; flex-direction : column ; gap : 10 px ; align-items : center }
# emojis { display : flex ; gap : 6 px ; justify-content : center ; flex-wrap : wrap }
# emojis button { font-size : 22 px ; width : 44 px ; height : 44 px ; border-radius : 12 px ; border : 1 px solid transparent ; transition : transform .15 s , border-color .15 s , background .15 s }
# 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 : 20 px ; color : var ( -- dim ); background : var ( -- card ); border : 1 px solid var ( -- line )}
# picker { position : fixed ; left : 50 % ; bottom : 0 ; transform : translateX ( -50 % ); width : min ( 560 px , 100 % ); max-height : 55 dvh ; background : var ( -- card ); border : 1 px solid var ( -- line ); border-bottom : 0 ; border-radius : 18 px 18 px 0 0 ; padding : 12 px 12 px calc ( 12 px + env ( safe - area - inset - bottom )); overflow : auto ; z-index : 5 ; box-shadow : 0 -20 px 60 px rgba ( 0 , 0 , 0 , .6 )}
# picker h4 { font : 600 10 px var ( -- font ); letter-spacing : .2 em ; color : var ( -- dim ); margin : 10 px 4 px 6 px }
# picker . g { display : grid ; grid-template-columns : repeat ( auto - fill , minmax ( 42 px , 1 fr )); gap : 2 px }
# picker . g button { font-size : 24 px ; height : 42 px ; border-radius : 10 px }
# picker . g button : hover { background : #1a1a20 }
# picker . top { display : flex ; gap : 8 px ; align-items : center ; margin-bottom : 4 px }
# picker . top input { flex : 1 ; background : #0a0a0d ; border : 1 px solid var ( -- line ); border-radius : 10 px ; padding : 8 px 12 px ; font-size : 16 px ; outline : none }
# picker . top button { padding : 8 px 12 px ; border-radius : 10 px ; border : 1 px solid var ( -- line ); color : var ( -- dim ); font : 600 11 px var ( -- font ); letter-spacing : .14 em }
# sayRow { display : flex ; gap : 8 px ; width : min ( 520 px , 100 % )}
# say { flex : 1 ; background : var ( -- card ); border : 1 px solid var ( -- line ); border-radius : 12 px ; padding : 10 px 14 px ; outline : none ; font-size : 14 px }
# say : focus { border-color : var ( -- acc )}
# sayRow button { padding : 0 14 px ; border : 1 px solid var ( -- line ); border-radius : 12 px ; color : var ( -- dim )}
# toast { position : fixed ; left : 50 % ; bottom : 110 px ; transform : translateX ( -50 % ); background : var ( -- card ); border : 1 px solid var ( -- line ); border-radius : 10 px ; padding : 8 px 14 px ; font-size : 13 px ; opacity : 0 ; transition : opacity .25 s ; pointer-events : none ; max-width : calc ( 100 % - 32 px ); text-align : center }
# toast . on { opacity : 1 }
/* ---------- taste ---------- */
# taste { position : fixed ; inset : 0 ; background : rgba ( 11 , 11 , 14 , .97 ); padding : 18 px 16 px calc ( 18 px + env ( safe - area - inset - bottom )); overflow : auto }
# taste header { display : flex ; justify-content : space-between ; align-items : center ; font : 600 12 px var ( -- font ); letter-spacing : .2 em ; color : var ( -- dim ); margin-bottom : 12 px }
# tasteTabs { display : flex ; gap : 8 px ; overflow-x : auto ; padding : 2 px 0 12 px ; scrollbar-width : none }
# tasteTabs button { display : flex ; align-items : center ; gap : 8 px ; padding : 6 px 12 px 6 px 6 px ; border-radius : 999 px ; border : 1 px solid var ( -- line ); font-size : 12 px ; white-space : nowrap ; color : var ( -- dim )}
# tasteTabs button . on { color : var ( -- ink ); border-color : var ( -- acc )}
# tasteTabs canvas { width : 22 px ; height : 22 px ; border-radius : 50 % }
# tasteMap { width : 100 % ; max-width : 640 px ; aspect-ratio : 1 ; display : block ; margin : 0 auto ; border-radius : 18 px ; background : var ( -- card )}
# brainMap { width : 100 % ; max-width : 640 px ; aspect-ratio : 2.2 ; display : block ; margin : 0 auto 14 px ; border-radius : 18 px ; background : var ( -- card )}
2026-10-21 00:27:27 +00:00
# tastePalette { max-width : 640 px ; margin : 6 px auto 18 px }
2026-10-20 22:29:58 +00:00
# tastePalette h3 { font : 600 11 px var ( -- font ); letter-spacing : .2 em ; color : var ( -- dim ); margin : 0 0 10 px ; text-align : center }
# tastePalette . row { display : flex ; gap : 14 px ; overflow-x : auto ; padding : 4 px ; scrollbar-width : none ; justify-content : safe center }
# tastePalette . s { flex : 0 0 96 px ; cursor : pointer ; text-align : center }
# tastePalette . s canvas { width : 80 px ; height : 80 px ; border-radius : 14 px ; display : block ; margin : 0 auto 6 px }
# tastePalette . s b { display : block ; font-size : 11 px ; font-weight : 600 ; white-space : nowrap ; overflow : hidden ; text-overflow : ellipsis }
# tasteNote { color : var ( -- dim ); font-size : 12 px ; text-align : center ; margin : 12 px auto 0 ; max-width : 520 px }
# tasteLegend { display : flex ; justify-content : center ; gap : 16 px ; font : 11 px var ( -- font ); letter-spacing : .08 em ; color : var ( -- dim ); margin-top : 10 px ; flex-wrap : wrap }
/* ---------- tapes ---------- */
# lib . t . addTape { position : absolute ; right : 6 px ; bottom : 6 px ; width : 26 px ; height : 26 px ; border-radius : 50 % ; background : rgba ( 11 , 11 , 14 , .75 ); border : 1 px solid var ( -- line ); color : var ( -- ink ); font-size : 16 px ; line-height : 1 ; display : grid ; place-items : center }
2026-10-21 00:30:28 +00:00
# lib . t . addTape : hover { background : var ( -- acc ); color : var ( -- onacc )}
# lib . t . fromT { position : absolute ; left : 6 px ; top : 6 px ; width : 26 px ; height : 26 px ; border-radius : 50 % ; background : var ( -- veil2 ); border : 1 px solid var ( -- line ); color : var ( -- ink ); font : 700 16 px var ( -- font ); line-height : 1 ; display : grid ; place-items : center ; opacity : 0 ; transition : opacity .2 s }
# lib . t : hover . fromT { opacity : 1 }
@ media ( hover : none ) {# lib . t . fromT { opacity : .8 }}
# lib . t . fromT : hover { background : var ( -- acc ); color : var ( -- onacc )}
2026-10-20 22:29:58 +00:00
# tapeRow { display : flex ; gap : 14 px ; overflow-x : auto ; padding : 4 px 4 px 10 px ; scrollbar-width : none ; grid-column : 1 /- 1 }
# tapeRow . s { flex : 0 0 110 px ; cursor : pointer ; text-align : center }
# tapeRow . s . tc { width : 88 px ; height : 88 px ; border-radius : 14 px ; margin : 0 auto 6 px ; position : relative ; overflow : hidden ; background : var ( -- card ); display : grid ; grid-template-columns : 1 fr 1 fr ; grid-template-rows : 1 fr 1 fr ; gap : 2 px }
# tapeRow . s . tc canvas { width : 100 % ; height : 100 % ; display : block }
# tapeRow . s . tc . one { grid-template-columns : 1 fr ; grid-template-rows : 1 fr }
# tapeRow . s . new . tc { display : grid ; place-items : center ; border : 1 px dashed var ( -- line ); font-size : 26 px ; color : var ( -- dim )}
# tapeRow . s b { display : block ; font-size : 12 px ; font-weight : 600 ; white-space : nowrap ; overflow : hidden ; text-overflow : ellipsis }
# tapeRow . s small { color : var ( -- dim ); font-size : 11 px }
# tapePick { position : fixed ; left : 50 % ; bottom : 0 ; transform : translateX ( -50 % ); width : min ( 480 px , 100 % ); background : var ( -- card ); border : 1 px solid var ( -- line ); border-bottom : 0 ; border-radius : 18 px 18 px 0 0 ; padding : 14 px 14 px calc ( 14 px + env ( safe - area - inset - bottom )); z-index : 6 ; box-shadow : 0 -20 px 60 px rgba ( 0 , 0 , 0 , .6 )}
# tapePick h4 { font : 600 10 px var ( -- font ); letter-spacing : .2 em ; color : var ( -- dim ); margin : 0 0 10 px }
# tapePick button { display : block ; width : 100 % ; text-align : left ; padding : 10 px 12 px ; border-radius : 10 px ; border : 1 px solid var ( -- line ); margin-bottom : 6 px }
# tapePick button : hover { background : #1a1a20 }
# tapePick input { width : 100 % ; background : #0a0a0d ; border : 1 px solid var ( -- line ); border-radius : 10 px ; padding : 10 px 12 px ; outline : none ; margin-bottom : 6 px }
# tape { position : fixed ; inset : 0 ; background : rgba ( 11 , 11 , 14 , .97 ); padding : 18 px 16 px calc ( 18 px + env ( safe - area - inset - bottom )); overflow : auto ; z-index : 4 }
# tape header { display : flex ; justify-content : space-between ; align-items : center ; font : 600 12 px var ( -- font ); letter-spacing : .2 em ; color : var ( -- dim ); margin-bottom : 12 px ; gap : 10 px }
# tape header input { flex : 1 ; background : transparent ; border : 0 ; border-bottom : 1 px solid var ( -- line ); font : 600 18 px var ( -- sans ); letter-spacing : 0 ; color : var ( -- ink ); outline : none ; padding : 4 px 0 ; min-width : 0 }
# tape . acts { display : flex ; gap : 8 px ; flex-wrap : wrap ; margin-bottom : 16 px }
# tape . acts button { padding : 9 px 16 px ; border-radius : 999 px ; border : 1 px solid var ( -- line ); font : 600 12 px var ( -- font ); letter-spacing : .14 em ; background : var ( -- card )}
2026-10-20 23:53:19 +00:00
# tape . acts button . go { background : var ( -- acc ); color : var ( -- onacc ); border-color : var ( -- acc )}
2026-10-20 22:29:58 +00:00
# tape . list { display : flex ; flex-direction : column ; gap : 8 px ; max-width : 640 px ; margin : 0 auto }
# tape . row { display : flex ; align-items : center ; gap : 12 px ; padding : 8 px ; border-radius : 14 px ; background : var ( -- card ); border : 1 px solid var ( -- line )}
# tape . row . on { border-color : var ( -- acc )}
# tape . row canvas { width : 52 px ; height : 52 px ; border-radius : 10 px ; flex : 0 0 52 px ; cursor : pointer }
# tape . row . m { flex : 1 ; min-width : 0 ; cursor : pointer }
# tape . row b { display : block ; font-size : 14 px ; white-space : nowrap ; overflow : hidden ; text-overflow : ellipsis }
# tape . row small { color : var ( -- dim ); font-size : 11 px ; font-family : var ( -- font )}
# tape . row . ctl { display : flex ; gap : 2 px }
# tape . row . ctl button { width : 32 px ; height : 32 px ; border-radius : 8 px ; color : var ( -- dim ); font-size : 14 px }
# tape . row . ctl button : hover { background : #1a1a20 ; color : var ( -- ink )}
# tape . empty { color : var ( -- dim ); text-align : center ; padding : 40 px 0 }
/* ---------- library ---------- */
# lib { position : fixed ; inset : 0 ; background : rgba ( 11 , 11 , 14 , .96 ); padding : 18 px 16 px ; overflow : auto }
# lib header { display : flex ; justify-content : space-between ; align-items : center ; font : 600 12 px var ( -- font ); letter-spacing : .2 em ; color : var ( -- dim ); margin-bottom : 16 px }
# lib . grid { display : grid ; grid-template-columns : repeat ( auto - fill , minmax ( 130 px , 1 fr )); gap : 14 px }
# lib h3 { font : 600 11 px var ( -- font ); letter-spacing : .2 em ; color : var ( -- dim ); margin : 22 px 0 10 px ; display : flex ; align-items : center ; gap : 10 px }
# lib h3 canvas { width : 22 px ; height : 22 px ; border-radius : 50 % }
# lib h3 small { font-weight : 400 ; letter-spacing : .08 em ; opacity : .7 }
# lib . t . on canvas { outline : 2 px solid var ( -- acc ); outline-offset : 2 px }
# lib . t . art { position : relative ; aspect-ratio : 1 ; border-radius : 12 px ; overflow : hidden ; background : var ( -- card )}
# lib . t . art canvas { width : 100 % ; height : 100 % ; display : block }
2026-10-20 23:53:19 +00:00
# lib . t . kept { position : absolute ; top : 6 px ; right : 6 px ; font : 600 9 px var ( -- font ); letter-spacing : .18 em ; color : var ( -- onacc ); background : var ( -- acc ); border-radius : 999 px ; padding : 3 px 7 px }
# lib . t . re { position : absolute ; left : 6 px ; bottom : 6 px ; font-size : 13 px ; letter-spacing : 1 px ; background : var ( -- veil2 ); border-radius : 8 px ; padding : 2 px 6 px ; max-width : calc ( 100 % - 12 px ); white-space : nowrap ; overflow : hidden ; text-overflow : ellipsis }
2026-10-20 22:29:58 +00:00
# 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 : 12 px ; display : block ; background : var ( -- card )}
# lib . t b { display : block ; font-size : 12 px ; margin-top : 6 px ; white-space : nowrap ; overflow : hidden ; text-overflow : ellipsis }
# lib . t small { color : var ( -- dim ); font-size : 11 px }
# lib . empty { color : var ( -- dim ); text-align : center ; padding : 40 px 0 }
# lib . cost { color : var ( -- dim ); font : 11 px var ( -- font ); margin-top : 24 px ; text-align : center }
</ style >
</ head >
< body >
<!-- ============ screen 1: the seed ============ -->
< section id = "seed" >
2026-10-20 23:40:58 +00:00
< div class = "wordmark" > g< span class = "ast" > *</ span > ndora</ div >
2026-10-20 22:29:58 +00:00
< 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 >
2026-10-20 23:40:58 +00:00
< input id = "seedIn" placeholder = "*" maxlength = "200" autofocus >
2026-10-20 22:29:58 +00:00
< 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 >
2026-10-20 23:53:19 +00:00
< div id = "keyRow" >< button class = "nav" id = "libBtn2" type = "button" > LIBRARY</ button >< button class = "nav" id = "tasteBtn2" type = "button" > TASTE</ button >< button class = "nav theme" type = "button" title = "light / dark" > LIGHT</ button >< button id = "keyBtn" type = "button" class = "hidden" > shared budget · add a venice key</ button ></ div >
2026-10-20 22:29:58 +00:00
< 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 >
2026-10-20 23:53:19 +00:00
< div >< button id = "tasteBtn" > TASTE</ button > < button id = "libBtn" > LIBRARY</ button > < button class = "theme" title = "light / dark" > LIGHT</ button ></ div >
2026-10-20 22:29:58 +00:00
</ 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 >
2026-10-20 22:45:46 +00:00
</ div >< div id = "trail" title = "what this station has played · tap to jump" ></ div ></ div >
2026-10-20 22:29:58 +00:00
< div id = "meta" >< h1 id = "title" > …</ h1 ></ div >
< div id = "bar" >< i ></ i ></ div >
2026-10-21 00:30:28 +00:00
< div id = "meta2" >< p id = "lyric" title = "reply to the DJ" ></ p >< div id = "next" ></ div >< div id = "pills" >< button id = "lyricsBtn" > ⇄ LYRICS</ button >< button id = "keep" disabled > ➕ KEEP</ button >< button id = "share" disabled > ↗ SHARE</ button >< button id = "fromTrack" disabled title = "start a new station from this track" > * STATION</ button ></ div >< p id = "tags" class = "hidden" ></ p >< div id = "tagRow" ></ div >< div id = "tagList" ></ div ></ div >
2026-10-20 22:29:58 +00:00
< div id = "controls" >
< button id = "backTrack" title = "previous track" >
< svg viewBox = "0 0 24 24" >< path d = "M18 6l-8.5 6 8.5 6V6zM6 6v12h2V6H6z" /></ svg >
</ button >
< button id = "shuffle" title = "shuffle: mix the station's own library in" >
< svg viewBox = "0 0 24 24" >< path d = "M17 4h4v4l-1.5-1.5L16 10l-1.4-1.4 3.5-3.5L17 4zM3 6h4.5l3 3-1.4 1.4L7 8H3V6zm14.6 10.6 1.5-1.5v4h-4l1.5-1.5-3.1-3.1 1.4-1.4 2.7 2.7zM3 18v-2h4l9.5-9.5L18 8l-10 10H3z" /></ svg >
</ button >
< button id = "prev" title = "replay from the start" >
< svg viewBox = "0 0 24 24" >< path d = "M12 5V2L7 7l5 5V8c3.3 0 6 2.7 6 6s-2.7 6-6 6-6-2.7-6-6H4c0 4.4 3.6 8 8 8s8-3.6 8-8-3.6-8-8-8z" /></ svg >
</ button >
< button id = "play" title = "play / pause" >< svg id = "playIco" viewBox = "0 0 24 24" >< path d = "M8 5v14l11-7z" /></ svg ></ button >
< button id = "loop" title = "repeat this track" >
< svg viewBox = "0 0 24 24" >< path d = "M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z" /></ svg >
</ button >
< button id = "remix" title = "remix this track: same bones, your changes" >
< svg viewBox = "0 0 24 24" >< path d = "M12 3a9 9 0 1 0 9 9h-2a7 7 0 1 1-7-7v3l4-4-4-4v3zm0 5a4 4 0 1 0 0 8 4 4 0 0 0 0-8zm0 2a2 2 0 1 1 0 4 2 2 0 0 1 0-4z" /></ svg >
</ button >
< button id = "skip" title = "next" >
< svg viewBox = "0 0 24 24" >< path d = "M6 18l8.5-6L6 6v12zm10-12v12h2V6h-2z" /></ svg >
</ button >
</ div >
</ div >
< div id = "react" >
< div id = "emojis" >
< button data-e = "🔥" title = "more of this" > 🔥</ button >
< button data-e = "❤️" title = "love it" > ❤️</ button >
< button data-e = "😴" title = "too sleepy" > 😴</ button >
< button data-e = "🌧️" title = "sadder" > 🌧️</ button >
< button data-e = "⚡" title = "faster, louder" > ⚡</ button >
< button data-e = "🎸" title = "more guitar" > 🎸</ button >
< button data-e = "🎹" title = "more keys" > 🎹</ button >
< button data-e = "🚫" title = "not this" > 🚫</ button >
< button id = "more" title = "more emoji" > + </ button >
</ div >
< div id = "picker" class = "hidden" ></ div >
< form id = "sayRow" >< input id = "say" placeholder = "or say it: less snare, sadder but faster…" maxlength = "240" >< button > send</ button ></ form >
</ div >
< div id = "toast" ></ div >
</ section >
< section id = "taste" class = "hidden" >
< header >< span > TASTE</ span >< button id = "tasteClose" > CLOSE</ button ></ header >
< div id = "tasteTabs" ></ div >
2026-10-21 00:27:27 +00:00
< div id = "tastePalette" ></ div >
2026-10-20 22:29:58 +00:00
< canvas id = "brainMap" width = "880" height = "400" ></ canvas >
< canvas id = "tasteMap" width = "900" height = "900" ></ canvas >
< div id = "tasteLegend" >< span > ← slow · tempo · fast →</ span >< span > ↑ energy</ span >< span > colour = key</ span >< span > ring = 🔥❤️ warm · 🚫⏭ cold</ span ></ div >
< div id = "tasteNote" ></ div >
</ section >
< section id = "tape" class = "hidden" >
< header >< span > TAPE</ span >< input id = "tapeName" maxlength = "60" >< button id = "tapeClose" > CLOSE</ button ></ header >
< div class = "acts" >< button class = "go" id = "tapePlay" > ▶ PLAY ALL</ button >< button id = "tapeLoop" > 🔁 REPEAT ALL</ button >< button id = "tapeShare" > ↗ SHARE</ button >< button id = "tapeDelete" > DELETE TAPE</ button ></ div >
< div class = "list" id = "tapeList" ></ div >
< div class = "empty hidden" id = "tapeEmpty" > Empty tape. Add tracks from the library with the + on a tile.</ div >
</ section >
< div id = "tapePick" class = "hidden" ></ div >
< div id = "stationSheet" class = "hidden" ></ div >
< div id = "scrim" class = "hidden" ></ div >
< section id = "lib" class = "hidden" >
< header >< span > LIBRARY</ span >< button id = "libClose" > CLOSE</ button ></ header >
< div class = "grid" id = "libGrid" ></ div >
< div class = "empty hidden" id = "libEmpty" > Nothing played yet. Every track lands here; KEEP one to pin it for good.</ div >
< div class = "cost" id = "libCost" ></ div >
</ section >
< script >
/* ============================================================
gandora — a generative station.
seed → DJ (Venice chat) → ACE-Step (Venice /audio/queue) → player.
Every cover is drawn from the song's own settings: hue from key,
rings from BPM, density from energy, symmetry from the seed.
============================================================ */
const DIRECT = "https://api.venice.ai/api/v1" ; // with a personal key
const SHARED = "https://inference.yolopreneur.lol/v1" ; // without one: the factory's worker, quota'd per person
const $ = ( s ) => document . querySelector ( s );
const LS = { key : "gandora.key" , dj : "gandora.dj" , cost : "gandora.cost" };
const state = {
key : localStorage . getItem ( LS . key ) || "" ,
dj : localStorage . getItem ( LS . dj ) || "claude-haiku-5-5" ,
seed : "" , station : "" , stationId : "" ,
taste : { like : [], avoid : [], notes : [] }, // what the listener has told the station
history : [], // tracks played this session (specs)
current : null , nextJob : null , playing : false ,
recent : [], pos : - 1 , // ids of tracks played this session, and where the player stands in them
audio : new Audio (), cost : parseFloat ( localStorage . getItem ( LS . cost ) || "0" ),
};
state . audio . preload = "auto" ;
/* ---------- small utils ---------- */
const toast = ( m , ms = 2400 ) => { const t = $ ( "#toast" ); t . textContent = m ; t . classList . add ( "on" ); clearTimeout ( toast . h ); toast . h = setTimeout (() => t . classList . remove ( "on" ), ms ); };
const sleep = ( ms ) => new Promise (( r ) => setTimeout ( r , ms ));
function hash32 ( str ) { let h = 2166136261 >>> 0 ; for ( const c of String ( str )) { h ^= c . codePointAt ( 0 ); h = Math . imul ( h , 16777619 ) >>> 0 ; } return h ; }
function prng ( seed ) { let a = seed >>> 0 ; return () => { a = ( a + 0x6D2B79F5 ) >>> 0 ; let t = a ; t = Math . imul ( t ^ ( t >>> 15 ), t | 1 ); t ^= t + Math . imul ( t ^ ( t >>> 7 ), t | 61 ); return (( t ^ ( t >>> 14 )) >>> 0 ) / 4294967296 ; }; }
const KEYS = [ "C" , "G" , "D" , "A" , "E" , "B" , "F#" , "C#" , "G#" , "D#" , "A#" , "F" ]; // circle of fifths → hue wheel
function keyHue ( k ) {
const m = /^([A-G])(#|b)?/ . exec ( String ( k || "C" ). trim ()); if ( ! m ) return 40 ;
let name = m [ 1 ] + ( m [ 2 ] === "#" ? "#" : "" );
if ( m [ 2 ] === "b" ) { const flat = { Db : "C#" , Eb : "D#" , Gb : "F#" , Ab : "G#" , Bb : "A#" , Cb : "B" , Fb : "E" }; name = flat [ m [ 1 ] + "b" ] || m [ 1 ]; }
const i = KEYS . indexOf ( name ); return ( i < 0 ? 0 : i ) * 30 ;
}
/* ---------- the cover: a function of the song ---------- */
// pulse: live signal from the analyser { bass, mid, high, t } in 0..1 (and seconds); without it the cover is still.
// The ground and grain depend only on the song, so they are drawn once per (song, size) and cached; the rings and
// the seed mark are redrawn on top every frame when the music is playing.
const groundCache = new Map ();
/* palette dial: mono (one hue, its shades), duo (the key's hue and a complement), tri (a triad) */
const PALETTES = [ "mono" , "duo" , "tri" ];
function palette ( spec ) {
const energy = Math . min ( 1 , Math . max ( 0 , + spec . energy || 0.5 )), minor = /m(in|inor)?\b/i . test ( spec . key || "" ) && ! /maj/i . test ( spec . key || "" );
const hue = keyHue ( spec . key ), mode = spec . palette || "duo" ;
const hue2 = mode === "mono" ? hue : mode === "tri" ? ( hue + 120 ) % 360 : ( hue + ( minor ? 200 : 150 )) % 360 ;
const hue3 = mode === "tri" ? ( hue + 240 ) % 360 : hue2 ;
return { energy , minor , hue , hue2 , hue3 , mode , sat : 55 + energy * 35 , light : minor ? 36 : 52 , bpm : + spec . bpm || 100 };
}
function drawGround ( W , H , spec , lit ) {
const key = ` ${ spec . seed } | ${ spec . title } | ${ spec . tags } | ${ spec . palette || "" } | ${ W } | ${ lit } ` ;
if ( groundCache . has ( key )) return groundCache . get ( key );
const off = document . createElement ( "canvas" ); off . width = W ; off . height = H ;
const ctx = off . getContext ( "2d" ), cx = W / 2 , cy = H / 2 , p = palette ( spec );
const rnd = prng ( hash32 (( spec . seed || "" ) + "|" + ( spec . title || "" ) + "|" + ( spec . tags || "" )) ^ 0x9e37 );
const g = ctx . createLinearGradient ( 0 , 0 , 0 , H );
g . addColorStop ( 0 , `hsl( ${ p . hue } ${ p . sat * . 5 } % ${ p . light * . 3 } %)` ); g . addColorStop ( 1 , `hsl( ${ p . hue2 } ${ p . sat * . 4 } % ${ p . light * . 18 } %)` );
ctx . fillStyle = g ; ctx . fillRect ( 0 , 0 , W , H );
ctx . globalCompositeOperation = "lighter" ;
const spread = W * . 46 , dots = Math . floor ( 400 + p . energy * 2600 );
for ( let i = 0 ; i < dots ; i ++ ) {
const a = rnd () * Math . PI * 2 , d = Math . sqrt ( rnd ()) * spread * 1.05 ;
ctx . fillStyle = `hsl( ${ i % 3 ? p . hue2 : p . hue3 } ${ p . sat } % ${ p . light + ( p . mode === "mono" ? 38 : 30 ) } % / ${ rnd () * 0.5 * lit } )` ;
ctx . fillRect ( cx + Math . cos ( a ) * d , cy + Math . sin ( a ) * d , ( W / 380 ) * ( 0.8 + rnd () * 1.6 ), ( W / 380 ) * ( 0.8 + rnd () * 1.6 ));
}
if ( groundCache . size > 24 ) groundCache . delete ( groundCache . keys (). next (). value );
groundCache . set ( key , off );
return off ;
}
/* looks: every station has one; the seed mark in the centre is the same whichever look draws around it */
const LOOKS = [ "rings" , "chord" , "attractor" ];
function drawCover ( canvas , spec , opts = {}) {
const look = spec . look || opts . look || "rings" ;
if ( look === "chord" ) return drawChord ( canvas , spec , opts );
if ( look === "attractor" ) return drawAttractor ( canvas , spec , opts );
return drawRings ( canvas , spec , opts );
}
function drawMark ( ctx , W , spec , p , lit , bass , tm ) {
const core = prng ( hash32 ( spec . seed || "" ));
const petals = 3 + Math . floor ( core () * 5 );
ctx . save (); ctx . translate ( W / 2 , W / 2 ); ctx . rotate ( core () * Math . PI + tm * 0.05 ); ctx . scale ( 1 + 0.3 * bass , 1 + 0.3 * bass );
ctx . fillStyle = `hsl( ${ p . hue } ${ p . sat } % ${ Math . min ( 92 , p . light + 45 ) } % / ${ 0.85 * lit } )` ;
ctx . beginPath ();
for ( let a = 0 ; a <= Math . PI * 2 + 0.01 ; a += Math . PI / 120 ) {
const rr = ( W * . 06 ) * ( 1 + 0.45 * Math . cos ( a * petals ));
const x = Math . cos ( a ) * rr , y = Math . sin ( a ) * rr ;
a === 0 ? ctx . moveTo ( x , y ) : ctx . lineTo ( x , y );
}
ctx . closePath (); ctx . fill (); ctx . restore ();
}
/* chord: a harmonograph whose frequency ratio is the key's interval (major third 5:4, minor 6:5), damped by tempo,
phased by the seed; the bass swells the whole figure */
function drawChord ( canvas , spec , { lit = 1 , pulse = null } = {}) {
const ctx = canvas . getContext ( "2d" ), W = canvas . width , H = canvas . height , cx = W / 2 , cy = H / 2 ;
const p = palette ( spec ), rnd = prng ( hash32 (( spec . seed || "" ) + "|" + ( spec . title || "" ) + "|" + ( spec . tags || "" )));
const bass = pulse ? pulse . bass : 0 , high = pulse ? pulse . high : 0 , tm = pulse ? pulse . t : 0 ;
ctx . globalCompositeOperation = "source-over" ;
const g = ctx . createRadialGradient ( cx , cy , 0 , cx , cy , W * . 7 );
g . addColorStop ( 0 , `hsl( ${ p . hue } ${ p . sat * . 5 } % ${ p . light * . 32 } %)` ); g . addColorStop ( 1 , `hsl( ${ p . hue2 } ${ p . sat * . 4 } % ${ p . light * . 14 } %)` );
ctx . fillStyle = g ; ctx . fillRect ( 0 , 0 , W , H );
const [ fa , fb ] = p . minor ? [ 6 , 5 ] : [ 5 , 4 ];
const fc = 1 + Math . round ( p . bpm / 40 ); // a third pendulum: tempo adds a faster harmonic
const damp = 0.00045 + ( p . bpm - 60 ) / 120 * 0.0006 , ph1 = rnd () * Math . PI * 2 , ph2 = rnd () * Math . PI * 2 , ph3 = rnd () * Math . PI * 2 ;
const amp = W * 0.36 * ( 1 + 0.1 * bass ), mix = 0.2 + 0.5 * p . energy , detune = 0.004 + 0.01 * rnd ();
ctx . globalCompositeOperation = "lighter" ;
// three traces: a soft wide one, then two fine ones a hair apart in frequency, so the figure fills with moiré
for ( let pass = 0 ; pass < 3 ; pass ++ ) {
const dt = pass === 2 ? detune : 0 ;
ctx . beginPath ();
for ( let t = 0 ; t < 2600 ; t += 0.6 ) {
const e = Math . exp ( - damp * t );
const x = cx + amp * e * ( Math . sin (( fa + dt ) * t * 0.02 + ph1 + tm * 0.02 ) + mix * 0.3 * Math . sin ( fc * t * 0.02 + ph3 ));
const y = cy + amp * e * ( Math . sin (( fb - dt ) * t * 0.02 + ph2 - tm * 0.02 ) + mix * 0.3 * Math . cos ( fc * t * 0.02 + ph3 ));
t === 0 ? ctx . moveTo ( x , y ) : ctx . lineTo ( x , y );
}
ctx . lineWidth = pass ? ( 0.55 + 0.6 * high ) * ( W / 380 ) : ( 3 + 2 * bass ) * ( W / 380 );
ctx . strokeStyle = pass ? `hsl( ${ pass === 2 ? p . hue2 : p . hue } ${ p . sat } % ${ p . light + 36 } % / ${ 0.42 * lit } )` : `hsl( ${ p . hue2 } ${ p . sat } % ${ p . light + 12 } % / ${ 0.12 * lit } )` ;
ctx . stroke ();
}
ctx . globalCompositeOperation = "source-over" ;
drawMark ( ctx , W , spec , p , lit , bass , tm );
}
/* attractor: a Clifford attractor whose four parameters come from key, tempo, energy and the seed; the point cloud is
drawn once and cached, the seed mark beats on top */
const attractorCache = new Map ();
function drawAttractor ( canvas , spec , { lit = 1 , pulse = null } = {}) {
const ctx = canvas . getContext ( "2d" ), W = canvas . width , H = canvas . height ;
const p = palette ( spec );
const bass = pulse ? pulse . bass : 0 , tm = pulse ? pulse . t : 0 ;
const key = ` ${ spec . seed } | ${ spec . title } | ${ spec . tags } | ${ spec . palette || "" } | ${ W } | ${ lit } ` ;
let off = attractorCache . get ( key );
if ( ! off ) {
off = document . createElement ( "canvas" ); off . width = W ; off . height = H ;
const o = off . getContext ( "2d" ), rnd = prng ( hash32 ( key ));
const g = o . createLinearGradient ( 0 , 0 , W , H );
g . addColorStop ( 0 , `hsl( ${ p . hue } ${ p . sat * . 45 } % ${ p . light * . 3 } %)` ); g . addColorStop ( 1 , `hsl( ${ p . hue2 } ${ p . sat * . 4 } % ${ p . light * . 15 } %)` );
o . fillStyle = g ; o . fillRect ( 0 , 0 , W , H );
// parameters in the ranges that keep Clifford figures open and filamentary
const a = - 1.4 - 0.6 * (( p . hue % 120 ) / 120 ), b = 1.2 + 0.8 * (( p . bpm - 60 ) / 120 ), c = 0.6 + 1.2 * p . energy , d = ( p . minor ? - 1 : 1 ) * ( 0.6 + 1.0 * rnd ());
let x = 0.1 , y = 0.1 ; const n = 36000 , pts = [];
let minX = 9 , maxX = - 9 , minY = 9 , maxY = - 9 ;
for ( let i = 0 ; i < n ; i ++ ) { const nx = Math . sin ( a * y ) + c * Math . cos ( a * x ), ny = Math . sin ( b * x ) + d * Math . cos ( b * y ); x = nx ; y = ny ; if ( i > 200 ) { pts . push ( x , y ); if ( x < minX ) minX = x ; if ( x > maxX ) maxX = x ; if ( y < minY ) minY = y ; if ( y > maxY ) maxY = y ; } }
const sx = ( W * 0.78 ) / Math . max ( 1e-6 , maxX - minX ), sy = ( H * 0.78 ) / Math . max ( 1e-6 , maxY - minY ), s = Math . min ( sx , sy );
const ox = W / 2 - ( minX + maxX ) / 2 * s , oy = H / 2 - ( minY + maxY ) / 2 * s ;
o . globalCompositeOperation = "lighter" ;
o . fillStyle = `hsl( ${ p . hue } ${ p . sat } % ${ p . light + 28 } % / ${ 0.16 * lit } )` ;
const dot = Math . max ( 1 , W / 380 );
for ( let i = 0 ; i < pts . length ; i += 2 ) o . fillRect ( ox + pts [ i ] * s , oy + pts [ i + 1 ] * s , dot , dot );
o . fillStyle = `hsl( ${ p . hue2 } ${ p . sat } % ${ p . light + 40 } % / ${ 0.08 * lit } )` ;
for ( let i = 0 ; i < pts . length ; i += 6 ) o . fillRect ( ox + pts [ i ] * s , oy + pts [ i + 1 ] * s , dot * 2 , dot * 2 );
if ( attractorCache . size > 16 ) attractorCache . delete ( attractorCache . keys (). next (). value );
attractorCache . set ( key , off );
}
ctx . globalCompositeOperation = "source-over" ;
ctx . drawImage ( off , 0 , 0 );
if ( bass > 0.02 ) { ctx . globalAlpha = 0.5 * bass ; ctx . drawImage ( off , W / 2 - W * ( 0.5 + 0.03 * bass ), H / 2 - H * ( 0.5 + 0.03 * bass ), W * ( 1 + 0.06 * bass ), H * ( 1 + 0.06 * bass )); ctx . globalAlpha = 1 ; }
drawMark ( ctx , W , spec , p , lit , bass , tm );
}
function drawRings ( canvas , spec , { lit = 1 , pulse = null } = {}) {
const ctx = canvas . getContext ( "2d" ), W = canvas . width , H = canvas . height , cx = W / 2 , cy = H / 2 ;
const p = palette ( spec ), { energy , minor , hue , sat , light , bpm } = p ;
const rnd = prng ( hash32 (( spec . seed || "" ) + "|" + ( spec . title || "" ) + "|" + ( spec . tags || "" )));
const bass = pulse ? pulse . bass : 0 , high = pulse ? pulse . high : 0 , tm = pulse ? pulse . t : 0 ;
ctx . globalCompositeOperation = "source-over" ;
ctx . drawImage ( drawGround ( W , H , spec , lit ), 0 , 0 );
// rings: one per 10 bpm; the bass breathes them outward, the highs sharpen the line
const rings = Math . max ( 4 , Math . round ( bpm / 10 ));
const spread = W * . 46 , sym = 3 + Math . floor ( rnd () * 6 ); // symmetry order from the seed
ctx . globalCompositeOperation = "lighter" ;
for ( let r = 0 ; r < rings ; r ++ ) {
const t = ( r + 1 ) / rings , rad = spread * Math . pow ( t , . 8 ) * ( 1 + 0.07 * bass * ( 1 - t ));
ctx . beginPath ();
const wob = 0.02 + energy * 0.08 + 0.03 * high , ph = rnd () * Math . PI * 2 + tm * ( 0.15 + 0.3 * t ) * ( r % 2 ? 1 : - 1 );
for ( let a = 0 ; a <= Math . PI * 2 + 0.01 ; a += Math . PI / 90 ) {
const rr = rad * ( 1 + wob * Math . sin ( a * sym + ph ) + wob * 0.5 * Math . sin ( a * sym * 2 + ph * 3 ));
const x = cx + Math . cos ( a ) * rr , y = cy + Math . sin ( a ) * rr ;
a === 0 ? ctx . moveTo ( x , y ) : ctx . lineTo ( x , y );
}
ctx . closePath ();
ctx . lineWidth = ( 0.6 + energy * 2.2 + high * 1.5 ) * ( W / 380 ) * ( 1 - t * 0.5 );
ctx . strokeStyle = `hsl( ${ ( hue + t * ( minor ? - 40 : 40 )) % 360 } ${ sat } % ${ light + t * 25 } % / ${ ( 0.12 + 0.6 * ( 1 - t )) * lit * ( 1 + 0.5 * bass ) } )` ;
ctx . stroke ();
}
ctx . globalCompositeOperation = "source-over" ;
// a core: the station's seed mark, same shape for every track of one seed; it beats with the bass
const core = prng ( hash32 ( spec . seed || "" ));
const petals = 3 + Math . floor ( core () * 5 );
ctx . save (); ctx . translate ( cx , cy ); ctx . rotate ( core () * Math . PI + tm * 0.05 ); ctx . scale ( 1 + 0.3 * bass , 1 + 0.3 * bass );
ctx . fillStyle = `hsl( ${ hue } ${ sat } % ${ Math . min ( 92 , light + 45 ) } % / ${ 0.85 * lit } )` ;
ctx . beginPath ();
for ( let a = 0 ; a <= Math . PI * 2 + 0.01 ; a += Math . PI / 120 ) {
const rr = ( W * . 06 ) * ( 1 + 0.45 * Math . cos ( a * petals ));
const x = Math . cos ( a ) * rr , y = Math . sin ( a ) * rr ;
a === 0 ? ctx . moveTo ( x , y ) : ctx . lineTo ( x , y );
}
ctx . closePath (); ctx . fill (); ctx . restore ();
}
/* ---------- the visualizer: an analyser on the player, driving the cover while music plays ---------- */
const viz = { ctx : null , an : null , data : null , raf : 0 , smooth : { bass : 0 , mid : 0 , high : 0 } };
function vizStart () {
// the player is only wired through the analyser once the context is actually running: a suspended context
// (iOS before a tap) would mute the music, and the cover can wait for the next gesture
try {
viz . ctx ||= new ( window . AudioContext || window . webkitAudioContext )();
if ( viz . ctx . state !== "running" ) { viz . ctx . resume (). then (() => { if ( viz . ctx . state === "running" && state . playing ) vizStart (); }). catch (() => {}); if ( viz . ctx . state !== "running" ) return ; }
if ( ! viz . an ) {
const src = viz . ctx . createMediaElementSource ( state . audio );
viz . an = viz . ctx . createAnalyser (); viz . an . fftSize = 512 ; viz . an . smoothingTimeConstant = 0.7 ;
src . connect ( viz . an ); viz . an . connect ( viz . ctx . destination );
viz . data = new Uint8Array ( viz . an . frequencyBinCount );
}
} catch { return ; }
cancelAnimationFrame ( viz . raf );
const canvas = $ ( "#cover canvas" ), t0 = performance . now ();
const band = ( a , b ) => { let s = 0 ; for ( let i = a ; i < b ; i ++ ) s += viz . data [ i ]; return s / ( b - a ) / 255 ; };
const frame = () => {
if ( ! state . playing || ! state . current || ! state . current . blob ) { viz . raf = 0 ; return ; }
viz . an . getByteFrequencyData ( viz . data );
const raw = { bass : band ( 1 , 8 ), mid : band ( 8 , 40 ), high : band ( 40 , 120 ) };
for ( const k in raw ) viz . smooth [ k ] += ( raw [ k ] - viz . smooth [ k ]) * ( raw [ k ] > viz . smooth [ k ] ? 0.5 : 0.12 ); // fast attack, slow decay
const s = viz . smooth ;
drawCover ( canvas , state . current . spec , { pulse : { bass : Math . min ( 1 , s . bass * 1.4 ), mid : s . mid , high : Math . min ( 1 , s . high * 2.5 ), t : ( performance . now () - t0 ) / 1000 } });
viz . raf = requestAnimationFrame ( frame );
};
viz . raf = requestAnimationFrame ( frame );
}
function vizStop () { cancelAnimationFrame ( viz . raf ); viz . raf = 0 ; if ( state . current && state . current . spec ) drawCover ( $ ( "#cover canvas" ), state . current . spec ); }
/* ---------- Venice ---------- */
async function venice ( path , body , { raw = false } = {}) {
const headers = { "Content-Type" : "application/json" , Accept : raw ? "*/*" : "application/json" };
if ( state . key ) headers . Authorization = "Bearer " + state . key ; else headers [ "x-app" ] = "gandora" ;
const res = await fetch (( state . key ? DIRECT : SHARED ) + path , { method : body ? "POST" : "GET" , headers , body : body ? JSON . stringify ( body ) : undefined });
if ( ! res . ok ) {
let d = null ; try { d = await res . json (); } catch {}
const said = d && ( d . error && ( d . error . message || d . error ) || d . message ) || "" ;
const why = { 401 : "Venice didn't accept that key." , 402 : "Venice says the key is out of credit." , 422 : "Venice turned that down: " + said , 429 : state . key ? "Venice is rate-limiting. Breathe." : ( said || "Over today's shared budget." ), 503 : "That model is busy at Venice." }[ res . status ];
throw new Error ( why || ( "Venice answered " + res . status + ( said ? ": " + said : "" )));
}
return raw ? res : res . json ();
}
const DJ_SCHEMA = {
type : "object" , additionalProperties : false ,
required : [ "title" , "tags" , "lyrics" , "bpm" , "key" , "energy" , "station" , "line" ],
properties : {
title : { type : "string" }, tags : { type : "string" }, lyrics : { type : "string" },
bpm : { type : "integer" }, key : { type : "string" }, energy : { type : "number" },
station : { type : "string" }, line : { type : "string" },
},
};
function 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 } energy ${ t . energy }${ t . reaction ? " ← listener: " + t . reaction : "" } ` ). join ( "\n" );
return `You are the DJ of a one-listener radio station. The listener typed a seed and the station grows from it. ${ remixText () }
Seed: " ${ state . seed } "
${ state . station ? `Station name: ${ state . station } \n` : "" } Listener asked for more of: ${ state . taste . like . join ( "; " ) || "(nothing yet)" }
Listener asked to avoid: ${ state . taste . avoid . join ( "; " ) || "(nothing yet)" }
Listener's words: ${ state . taste . notes . join ( " | " ) || "(none yet)" }
Recent tracks:
${ prev || "- (none yet: this is the first)" }
Write the NEXT track. Keep continuity with the recent tracks unless the listener pushed away; vary something each time so the station moves.
Return JSON:
- title: a short evocative song title (never reuse one)
- tags: the music prompt for a song model, 60-300 characters, comma-separated descriptors: genre, instruments, vocal style or "instrumental", mood, era, production notes. Concrete and musical, no sentences.
2026-10-20 22:40:04 +00:00
- lyrics: full lyrics with [verse] / [chorus] / [bridge] section markers, 16-32 lines for a two-minute track, or an empty string for instrumental.
2026-10-20 22:29:58 +00:00
- bpm: integer 60-180 that fits the tags
- key: like "A minor" or "F# major"
- energy: 0 to 1
- station: a two or three word station name grown from the seed (keep it stable once chosen)
- line: one line, under 90 characters, the DJ says about this track to the listener (no quotes, no emoji)` ;
}
async function askDJ () {
const body = { model : state . dj , messages : [{ role : "user" , content : djPrompt () }], max_tokens : 1200 , temperature : 0.9 , stream : false ,
venice_parameters : { disable_thinking : true , strip_thinking_response : true , include_venice_system_prompt : false },
response_format : { type : "json_schema" , json_schema : { name : "track" , strict : true , schema : DJ_SCHEMA } } };
let d ;
try { d = await venice ( "/chat/completions" , body ); }
catch ( e ) { if ( ! /400/ . test ( e . message )) throw e ; delete body . response_format ; body . messages [ 0 ]. content += "\nAnswer with the JSON object only." ; d = await venice ( "/chat/completions" , body ); }
let text = String ( d . choices ? .[ 0 ] ? . message ? . content || "" );
const m = text . match ( /\{[\s\S]*\}/ ); if ( ! m ) throw new Error ( "The DJ didn't answer in JSON." );
const spec = JSON . parse ( m [ 0 ]);
spec . tags = String ( spec . tags || "" ). slice ( 0 , 500 ); if ( spec . tags . length < 10 ) spec . tags += ", instrumental, warm, lo-fi" ;
spec . lyrics = String ( spec . lyrics || "" ). slice ( 0 , 4000 );
spec . bpm = Math . min ( 180 , Math . max ( 60 , parseInt ( spec . bpm ) || 100 ));
spec . energy = Math . min ( 1 , Math . max ( 0 , + spec . energy || 0.5 ));
spec . seed = state . seed ; spec . made = Date . now (); spec . look = state . look || "rings" ; spec . palette = state . palette || "duo" ;
if ( ! state . station && spec . station ) { state . station = spec . station ; $ ( "#stationName" ). textContent = state . station ; }
return spec ;
}
/* ACE-Step on Venice: queue, poll, download. 60 s costs about $0.03. Venice usually answers in 10-30 s but its queue
sometimes stalls a job for minutes, so after HEDGE_AT a second copy is queued and whichever lands first is taken. */
const HEDGE_AT = 60000 ;
async function renderTrack ( spec , onStatus ) {
if ( ! state . key ) { // shared worker: one call, audio back (Runware when the factory has a key, else Venice)
const started = Date . now (), tick = setInterval (() => onStatus && onStatus ( Math . round (( Date . now () - started ) / 1000 ), 0 , false ), 1000 );
try {
2026-10-20 22:40:04 +00:00
const body = { prompt : spec . tags , lyrics : spec . lyrics || "" , duration : 120 , bpm : spec . bpm , key : spec . key ,
2026-10-20 22:29:58 +00:00
seed : spec . rseed ?? ( hash32 (( spec . seed || "" ) + "|" + ( spec . title || "" ) + "|" + ( spec . tags || "" )) >>> 1 ), quality : spec . quality || "turbo" };
let res ;
try { res = await venice ( "/render" , body , { raw : true }); }
catch ( e ) { if ( ! /answered 404/ . test ( e . message )) throw e ; res = null ; } // older worker: fall through to queue+poll
if ( res ) {
const blob = await res . blob ();
const cost = parseFloat ( res . headers . get ( "x-cost" ) || "" ), seed = parseInt ( res . headers . get ( "x-seed" ) || "" );
spec . renderer = res . headers . get ( "x-renderer" ) || "venice:ace-step-15" ;
if ( ! isNaN ( seed )) spec . rseed = seed ;
2026-10-20 22:40:04 +00:00
spec . cost = isNaN ( cost ) ? 0.06 : cost ;
2026-10-20 22:29:58 +00:00
state . cost += spec . cost ; localStorage . setItem ( LS . cost , String ( state . cost ));
return blob ;
}
} finally { clearInterval ( tick ); }
}
2026-10-20 22:40:04 +00:00
const body = { model : "ace-step-15" , prompt : spec . tags , duration_seconds : 120 , ...( spec . lyrics ? { lyrics_prompt : spec . lyrics } : {}) };
2026-10-20 22:29:58 +00:00
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 ();
2026-10-20 22:40:04 +00:00
spec . cost = 0.06 * jobs . length ; spec . renderer = "venice:ace-step-15" ;
2026-10-20 22:29:58 +00:00
state . cost += spec . cost ; localStorage . setItem ( LS . cost , String ( state . cost ));
return blob ;
}
const d = await res . json ();
const st = String ( d . status || "" ). toUpperCase ();
if ( /FAIL|ERROR|CANCEL|REJECT/ . test ( st )) { if ( jobs . length === 1 ) throw new Error ( "Venice couldn't make the track (" + st . toLowerCase () + ")." ); jobs . splice ( jobs . indexOf ( q ), 1 ); break ; }
expected = Math . max ( expected , d . average_execution_time || 0 );
}
onStatus && onStatus ( Math . round ( elapsed / 1000 ), Math . round ( expected / 1000 ), hedged );
}
throw new Error ( "That render took too long." );
}
/* ---------- the library (IndexedDB) ---------- */
const db = {
open () { return new Promise (( ok , no ) => { const r = indexedDB . open ( "gandora" , 3 ); r . onupgradeneeded = () => { const d = r . result ; for ( const s of [ "tracks" , "stations" , "tapes" ]) if ( ! d . objectStoreNames . contains ( s )) d . createObjectStore ( s , { keyPath : "id" }); }; r . onsuccess = () => ok ( r . result ); r . onerror = () => no ( r . error ); }); },
async put ( t , store = "tracks" ) { const d = await this . open (); return new Promise (( ok , no ) => { const tx = d . transaction ( store , "readwrite" ); tx . objectStore ( store ). put ( t ); tx . oncomplete = ok ; tx . onerror = () => no ( tx . error ); }); },
async all ( store = "tracks" ) { const d = await this . open (); return new Promise (( ok , no ) => { const r = d . transaction ( store ). objectStore ( store ). getAll (); r . onsuccess = () => ok ( r . result . sort (( a , b ) => ( b . last || b . made ) - ( a . last || a . made ))); r . onerror = () => no ( r . error ); }); },
async del ( id , store = "tracks" ) { const d = await this . open (); return new Promise (( ok , no ) => { const tx = d . transaction ( store , "readwrite" ); tx . objectStore ( store ). delete ( id ); tx . oncomplete = ok ; tx . onerror = () => no ( tx . error ); }); },
};
/* ---------- stations: a seed, its taste, and what it has played; resumable from the seed screen ---------- */
async function saveStation () {
if ( ! state . stationId ) return ;
const specs = state . history . slice ( - 8 ). map (({ id , title , tags , bpm , key , energy , reaction , made }) => ({ id , title , tags , bpm , key , energy , reaction , made }));
await db . put ({ id : state . stationId , seed : state . seed , name : state . station , taste : state . taste , history : specs , made : + state . stationId . split ( "-" )[ 1 ] || Date . now (), last : Date . now (), played : ( state . played || 0 ), look : state . look || "rings" , palette : state . palette || "duo" }, "stations" );
}
async function listStations () {
const list = await db . all ( "stations" ); const box = $ ( "#stations" ), row = $ ( "#stationRow" ); row . innerHTML = "" ;
box . classList . toggle ( "hidden" , ! list . length );
for ( const s of list ) {
const d = document . createElement ( "div" ); d . className = "s" + ( s . id === state . stationId ? " on" : "" );
const c = document . createElement ( "canvas" ); c . width = c . height = 144 ;
drawCover ( c , { seed : s . seed , title : "" , tags : "" , bpm : 100 , key : KEYS [ hash32 ( s . seed ) % 12 ], energy : 0.5 }, { lit : 0.9 });
d . append ( c , Object . assign ( document . createElement ( "b" ), { textContent : s . name || s . seed }), Object . assign ( document . createElement ( "small" ), { textContent : ` ${ s . played || 0 } tracks` }));
d . addEventListener ( "click" , () => resumeStation ( s ));
const dots = Object . assign ( document . createElement ( "button" ), { className : "dots" , textContent : "⋯" , title : "rename, reset or delete" });
dots . addEventListener ( "click" , ( e ) => { e . stopPropagation (); stationSheet ( s . id ); });
d . append ( dots );
row . append ( d );
}
}
/* the station sheet: rename, reset its taste, delete it (kept tracks stay) */
async function stationSheet ( id ) {
const s = ( await db . all ( "stations" )). find (( x ) => x . id === id ); if ( ! s ) return ;
const p = $ ( "#stationSheet" ); p . innerHTML = "" ;
p . append ( Object . assign ( document . createElement ( "h4" ), { textContent : "STATION" }));
const name = document . createElement ( "input" ); name . value = s . name || "" ; name . placeholder = s . seed ; name . maxLength = 60 ;
p . append ( Object . assign ( document . createElement ( "label" ), { textContent : "name" }), name , Object . assign ( document . createElement ( "label" ), { textContent : "seed · " + s . seed + " · " + ( s . played || 0 ) + " tracks" }));
const lookRow = document . createElement ( "div" ); lookRow . className = "row" ; lookRow . style . marginTop = "8px" ;
p . append ( Object . assign ( document . createElement ( "label" ), { textContent : "look" }));
for ( const lk of LOOKS ) {
const b = Object . assign ( document . createElement ( "button" ), { textContent : lk . toUpperCase (), className : ( s . look || "rings" ) === lk ? "go" : "" });
b . addEventListener ( "click" , async () => {
s . look = lk ; await db . put ( s , "stations" );
for ( const t of ( await db . all ()). filter (( t ) => t . spec . stationId === id )) { t . spec . look = lk ; await db . put ( t ); }
if ( state . stationId === id ) { state . look = lk ; if ( state . current ) { state . current . spec . look = lk ; drawCover ( $ ( "#cover canvas" ), state . current . spec ); } if ( state . nextJob ) state . nextJob . spec . look = lk ; }
for ( const x of lookRow . children ) x . className = x . textContent === lk . toUpperCase () ? "go" : "" ;
toast ( `Look: ${ lk } .` );
});
lookRow . append ( b );
}
p . append ( lookRow );
const palRow = document . createElement ( "div" ); palRow . className = "row" ; palRow . style . marginTop = "8px" ;
p . append ( Object . assign ( document . createElement ( "label" ), { textContent : "palette" }));
for ( const pm of PALETTES ) {
const b = Object . assign ( document . createElement ( "button" ), { textContent : pm . toUpperCase (), className : ( s . palette || "duo" ) === pm ? "go" : "" });
b . addEventListener ( "click" , async () => {
s . palette = pm ; await db . put ( s , "stations" );
for ( const t of ( await db . all ()). filter (( t ) => t . spec . stationId === id )) { t . spec . palette = pm ; await db . put ( t ); }
if ( state . stationId === id ) { state . palette = pm ; if ( state . current ) { state . current . spec . palette = pm ; drawCover ( $ ( "#cover canvas" ), state . current . spec ); } if ( state . nextJob ) state . nextJob . spec . palette = pm ; }
for ( const x of palRow . children ) x . className = x . textContent === pm . toUpperCase () ? "go" : "" ;
toast ( `Palette: ${ pm } .` );
});
palRow . append ( b );
}
p . append ( palRow );
const row = document . createElement ( "div" ); row . className = "row" ;
const save = Object . assign ( document . createElement ( "button" ), { className : "go" , textContent : "SAVE" });
save . addEventListener ( "click" , async () => { s . name = name . value . trim (). slice ( 0 , 60 ); await db . put ( s , "stations" ); if ( state . stationId === id ) { state . station = s . name ; $ ( "#stationName" ). textContent = s . name || s . seed ; } p . classList . add ( "hidden" ); listStations (). catch (() => {}); toast ( "Renamed." ); });
const reset = Object . assign ( document . createElement ( "button" ), { textContent : "RESET TASTE" });
reset . addEventListener ( "click" , async () => { if ( ! confirm ( "Forget what this station has learned? Its tracks stay." )) return ; s . taste = { like : [], avoid : [], notes : [] }; s . history = []; await db . put ( s , "stations" ); if ( state . stationId === id ) { state . taste = s . taste ; state . history = []; } p . classList . add ( "hidden" ); toast ( "Taste reset." ); });
const del = Object . assign ( document . createElement ( "button" ), { className : "bad" , textContent : "DELETE STATION" });
del . addEventListener ( "click" , async () => {
if ( ! confirm ( `Delete “ ${ s . name || s . seed } ” and its unkept tracks? Kept tracks stay in the library.` )) return ;
for ( const t of ( await db . all ()). filter (( t ) => t . spec . stationId === id && ! t . kept )) await db . del ( t . id );
await db . del ( id , "stations" );
if ( state . stationId === id ) { state . audio . pause (); state . playing = false ; setPlayIcon (); state . current = null ; state . nextJob = null ; state . stationId = "" ; state . seed = "" ; state . station = "" ; $ ( "#player" ). classList . add ( "hidden" ); $ ( "#seed" ). classList . remove ( "hidden" ); $ ( "#nowRow" ). classList . add ( "hidden" ); setUrl (); }
p . classList . add ( "hidden" ); listStations (). catch (() => {}); toast ( "Station deleted." );
});
const close = Object . assign ( document . createElement ( "button" ), { textContent : "CLOSE" }); close . addEventListener ( "click" , () => p . classList . add ( "hidden" ));
row . append ( save , reset , del , close ); p . append ( row ); p . classList . remove ( "hidden" ); name . focus ({ preventScroll : true });
}
$ ( "#stationName" ). addEventListener ( "click" , () => { if ( state . stationId && ! state . tape ) stationSheet ( state . stationId ); });
/* links: the URL follows the station (#seed); a share link carries the current track's sheet music (?t=) so the same
song opens and renders for whoever follows it, even with nothing stored anywhere */
const b64u = ( s ) => btoa ( unescape ( encodeURIComponent ( s ))). replace ( /\+/g , "-" ). replace ( /\//g , "_" ). replace ( /=+$/ , "" );
const unb64u = ( s ) => decodeURIComponent ( escape ( atob ( String ( s ). replace ( /-/g , "+" ). replace ( /_/g , "/" ) + "===" . slice (( s . length + 3 ) % 4 ))));
function shareLink () {
const s = state . current && state . current . spec ; if ( ! s ) return "" ;
const t = { seed : s . seed , station : state . station , title : s . title , tags : s . tags , lyrics : s . lyrics || "" , bpm : s . bpm , key : s . key , energy : s . energy , line : s . line || "" };
return ` ${ location . origin } /?t= ${ b64u ( JSON . stringify ( t )) } ` ;
}
function setUrl () { try { history . replaceState ( null , "" , state . seed ? "/#" + encodeURIComponent ( state . seed ). replace ( /%20/g , "+" ) : "/" ); } catch {} }
async function openStation ( seed , { id , name = "" , taste , history , played , first , look , palette : pal } = {}) {
state . look = look || "rings" ; state . palette = pal || "duo" ;
if ( state . stationId !== id ) { state . audio . pause (); state . playing = false ; setPlayIcon (); state . current = null ; state . nextJob = null ; state . recent = []; state . pos = - 1 ; state . tape = null ; }
state . seed = seed ; state . station = name ; state . stationId = id || `s- ${ Date . now () } - ${ hash32 ( seed ) } ` ;
state . taste = taste || { like : [], avoid : [], notes : [] }; state . history = history || []; state . played = played || 0 ;
$ ( "#stationName" ). textContent = name || seed ;
$ ( "#seed" ). classList . add ( "hidden" ); $ ( "#player" ). classList . remove ( "hidden" );
setUrl ();
if ( ! state . current ) {
if ( first ) { first . seed = seed ; first . made = Date . now (); state . nextJob = makeJob ( first ); drawCover ( $ ( "#cover canvas" ), first , { lit : 0.6 }); $ ( "#title" ). textContent = first . title ; saveStation (). catch (() => {}); advance (); return ; }
drawCover ( $ ( "#cover canvas" ), { seed , title : "" , tags : "" , bpm : 100 , key : KEYS [ hash32 ( seed ) % 12 ], energy : 0.5 }, { lit : 0.6 });
$ ( "#title" ). textContent = "tuning…" ; $ ( "#tags" ). textContent = "" ; $ ( "#lyric" ). textContent = "" ; keepLabel ();
saveStation (). catch (() => {});
// coming back to a station: play its latest track from the library straight away, and cook the next behind it
const have = id ? ( await db . all ()). filter (( t ) => t . spec . stationId === id ) : [];
if ( have . length ) { const t = have [ 0 ]; await playJob ({ id : ++ seq , spec : t . spec , blob : t . blob , err : null , done : Promise . resolve (), stepping : true }); state . recent = [ t . id ]; state . pos = 0 ; return ; }
advance ();
}
}
function resumeStation ( s ) { openStation ( s . seed , s ); }
2026-10-21 00:30:28 +00:00
/* 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 });
toast ( `New station from “ ${ s . title } ”.` , 3000 );
}
$ ( "#fromTrack" ). addEventListener ( "click" , async () => {
const cur = state . current && state . current . spec ; if ( ! cur ) return ;
const row = ( await db . all ()). find (( t ) => t . id === cur . id ) || ( state . current . blob ? { spec : cur , blob : state . current . blob } : null );
if ( ! row ) { toast ( "That one hasn't finished cooking." ); return ; }
stationFrom ( row );
});
2026-10-20 22:29:58 +00:00
const KEEP_UNKEPT = 40 ;
// every rendered track is saved so the station can be scrolled back through; ❤️ pins one, the rest roll off after KEEP_UNKEPT
async function remember ( job , kept ) {
const s = job . spec ; if ( ! job . blob ) return ;
s . id ||= s . made + "-" + hash32 ( s . title );
const old = ( await db . all ()). find (( t ) => t . id === s . id );
await db . put ({ id : s . id , spec : s , blob : job . blob , made : s . made , kept : kept || ( old && old . kept ) || false , plays : ( old && old . plays ) || s . plays || 0 });
const loose = ( await db . all ()). filter (( t ) => ! t . kept ). slice ( KEEP_UNKEPT );
for ( const t of loose ) await db . del ( t . id );
}
/* ---------- the station loop ---------- */
let seq = 0 ;
function makeJob ( spec ) {
const job = { id : ++ seq , spec , blob : null , err : null , done : null };
job . done = ( async () => {
try { job . blob = await renderTrack ( spec , ( el , exp , hedged ) => {
const t = `COOKING ${ el } s ${ exp ? " / ~" + exp + "s" : "" }${ hedged ? " · SECOND OVEN LIT" : "" } ` ;
if ( state . current === job ) $ ( "#cover .veil" ). textContent = t ;
else if ( state . nextJob === job && state . current ) $ ( "#next" ). textContent = "NEXT · " + spec . title . toUpperCase () + " · " + t ;
}); }
catch ( e ) { job . err = e ; }
})();
return job ;
}
let cooking = null ;
function cookNext () {
if ( state . nextJob ) return Promise . resolve ( state . nextJob );
if ( cooking ) return cooking ;
cooking = ( async () => {
try { const spec = await askDJ (); state . nextJob = makeJob ( spec ); if ( state . current ) $ ( "#next" ). textContent = "NEXT · " + spec . title . toUpperCase (); return state . nextJob ; }
catch ( e ) { toast ( e . message , 5000 ); return null ; }
finally { cooking = null ; }
})();
return cooking ;
}
async function playJob ( job ) {
const spec = job . spec ;
state . current = job ;
if ( ! job . stepping ) { state . history . push ( spec ); state . played = ( state . played || 0 ) + 1 ; spec . stationId ||= state . stationId ; }
keepLabel ();
$ ( "#title" ). textContent = spec . title ; $ ( "#tags" ). textContent = ` ${ spec . bpm } bpm · ${ spec . key } ` ;
$ ( "#lyric" ). textContent = spec . line || "" ;
showTags ( spec );
showLyrics ( spec );
drawCover ( $ ( "#cover canvas" ), spec );
if ( ! $ ( "#nowRow" ). classList . contains ( "hidden" )) { drawCover ( $ ( "#now canvas" ), spec ); $ ( "#nowTitle" ). textContent = ` ${ spec . title } · ${ state . station || spec . seed } ` ; }
$ ( "#cover" ). classList . add ( "cooking" ); $ ( "#bar i" ). style . width = "0" ;
await job . done ;
if ( state . current !== job ) return ; // skipped while cooking
$ ( "#cover" ). classList . remove ( "cooking" );
2026-10-21 00:30:28 +00:00
$ ( "#fromTrack" ). disabled = false ;
2026-10-20 22:29:58 +00:00
if ( job . err ) { toast ( job . err . message , 6000 ); $ ( "#lyric" ). textContent = job . err . message + ( /limit|budget/i . test ( job . err . message ) ? "" : " · skip to try the next" ); return ; }
if ( state . audio . src ) URL . revokeObjectURL ( state . audio . src );
state . audio . src = URL . createObjectURL ( job . blob );
await remember ( job , false ). then ( keepLabel ). catch (() => {}); saveStation (). catch (() => {});
if ( job . fileInto ) { const tp = ( await db . all ( "tapes" )). find (( t ) => t . id === job . fileInto ); if ( tp && ! tp . ids . includes ( spec . id )) { tp . ids . push ( spec . id ); await db . put ( tp , "tapes" ); } }
if ( ! job . stepping ) { state . recent = state . recent . slice ( 0 , state . pos + 1 ); state . recent . push ( spec . id ); state . pos = state . recent . length - 1 ; }
drawTrail (). catch (() => {});
try { await state . audio . play (); state . playing = true ; setPlayIcon (); vizStart (); } catch { toast ( "Tap play." ); }
2026-10-20 23:40:58 +00:00
nowPlaying ( spec );
2026-10-20 22:29:58 +00:00
cookNext (); // render ahead while this one plays
}
/* the trail: every track this station has played, oldest left, newest right, the one playing lit. Tapping one plays it and
makes it the point back/forward walk from; the trail is the station's own history, so it survives a refresh. */
let trailIds = [];
async function drawTrail () {
const el = $ ( "#trail" ); if ( ! el ) return ;
const rows = ( await db . all ()). filter (( t ) => t . spec && t . spec . stationId === state . stationId ). sort (( a , b ) => a . made - b . made );
trailIds = rows . map (( t ) => t . id );
const cur = state . current && state . current . spec && state . current . spec . id ;
if ( trailIds . length < 2 ) { el . textContent = "" ; return ; }
el . textContent = "" ;
for ( const t of rows ) {
const c = document . createElement ( "canvas" ); c . width = c . height = 120 ;
drawCover ( c , t . spec ); c . title = t . spec . title ; c . dataset . id = t . id ;
if ( t . kept ) c . classList . add ( "kept" ); if ( t . id === cur ) c . classList . add ( "on" );
el . append ( c );
}
const on = el . querySelector ( ".on" ); if ( on ) on . scrollIntoView ({ inline : "center" , block : "nearest" , behavior : "smooth" });
}
2026-10-20 22:45:46 +00:00
$ ( "#trail" ). addEventListener ( "wheel" , ( e ) => { if ( Math . abs ( e . deltaY ) > Math . abs ( e . deltaX )) { e . currentTarget . scrollLeft += e . deltaY ; e . preventDefault (); } }, { passive : false });
2026-10-20 22:29:58 +00:00
$ ( "#trail" ). addEventListener ( "click" , async ( e ) => {
2026-10-20 22:45:46 +00:00
e . stopPropagation (); // the cover underneath flips to lyrics on click
2026-10-20 22:29:58 +00:00
const c = e . target . closest ( "canvas" ); if ( ! c ) return ;
const id = c . dataset . id ; if ( state . current && state . current . spec && state . current . spec . id === id ) return ;
const row = ( await db . all ()). find (( t ) => t . id === id ); if ( ! row ) return ;
state . recent = trailIds . slice (); state . pos = trailIds . indexOf ( id );
state . audio . pause ();
await playJob ({ id : ++ seq , spec : row . spec , blob : row . blob , err : null , done : Promise . resolve (), stepping : true });
});
// step through what has already played this session: -1 back, +1 forward; false when there's nothing there
async function step ( dir ) {
const i = state . pos + dir ; if ( i < 0 || i >= state . recent . length ) return false ;
const row = ( await db . all ()). find (( t ) => t . id === state . recent [ i ]); if ( ! row ) return false ;
state . pos = i ; state . audio . pause ();
await playJob ({ id : ++ seq , spec : row . spec , blob : row . blob , err : null , done : Promise . resolve (), stepping : true });
return true ;
}
/* shuffle: a track the station already made, weighted by how much the net thinks you'd like it and whether you kept it,
never one of the last few heard; null when the library is too thin */
async function shuffled () {
const rows = ( await db . all ()). filter (( t ) => t . spec . stationId === state . stationId && t . blob );
const avoid = new Set ( state . recent . slice ( - 4 ));
const pool = rows . filter (( t ) => ! avoid . has ( t . id )); if ( pool . length < 3 ) return null ;
const net = typeof brainNow === "function" ? brainNow () : null ;
const w = pool . map (( t ) => Math . exp ( 3 * ( net ? net . predict ( t . spec ) : 0.5 )) * ( t . kept ? 2 : 1 ) * ( 1 + 0.3 * Math . log1p ( t . plays || 0 )));
let r = Math . random () * w . reduce (( a , b ) => a + b , 0 );
for ( let i = 0 ; i < pool . length ; i ++ ) { r -= w [ i ]; if ( r <= 0 ) return pool [ i ]; }
return pool [ pool . length - 1 ];
}
async function advance () {
// a tape playing: the next track on it, around again if it repeats, otherwise back to the station
if ( state . tape ) {
const tp = ( await db . all ( "tapes" )). find (( t ) => t . id === state . tape . id );
if ( tp ) { const n = state . tape . i + 1 ; if ( n < tp . ids . length || state . tape . loop ) return playTape ( tp , n ); }
state . tape = null ; $ ( "#stationName" ). textContent = state . station || state . seed ; toast ( "End of the tape — back to the station." );
}
// a shared tape still rendering: its next track comes before anything fresh
if ( state . tapeQueue && state . tapeQueue . length && ! state . nextJob ) {
const t = state . tapeQueue . shift (); const spec = { ... t , seed : t . seed || state . seed , made : Date . now () };
state . nextJob = makeJob ( spec ); state . nextJob . fileInto = state . sharedTapeId ;
}
if ( await step ( + 1 )) return ; // forward through the session first, then cook new
if ( ! state . nextJob && state . after ) { state . nextJob = state . after ; state . after = null ; }
if ( state . shuffle && Math . random () < 0.5 ) {
const t = await shuffled ();
if ( t ) { state . audio . pause (); await playJob ({ id : ++ seq , spec : t . spec , blob : t . blob , err : null , done : Promise . resolve () }); return ; }
}
const job = state . nextJob || await cookNext ();
state . nextJob = null ; $ ( "#next" ). textContent = "" ;
if ( ! job ) return ;
await playJob ( job );
cookNext ();
}
state . audio . addEventListener ( "ended" , () => { state . playing = false ; setPlayIcon (); vizStop (); advance (); });
state . audio . addEventListener ( "pause" , () => { if ( ! state . audio . ended ) { state . playing = false ; setPlayIcon (); vizStop (); } });
state . audio . addEventListener ( "play" , () => { state . playing = true ; setPlayIcon (); vizStart (); });
state . audio . addEventListener ( "timeupdate" , () => { if ( state . audio . duration ) $ ( "#bar i" ). style . width = ( state . audio . currentTime / state . audio . duration * 100 ) + "%" ; });
function setPlayIcon () { $ ( "#playIco" ). innerHTML = state . playing ? '<path d="M6 5h4v14H6zm8 0h4v14h-4z"/>' : '<path d="M8 5v14l11-7z"/>' ; }
/* ---------- reactions: each one edits the taste, and the next cover shows it ---------- */
const EMOJI_MEANING = {
"🔥" : { like : "more exactly like the current track" }, "❤️" : { like : "the current track's sound; the listener loves this one" },
"😴" : { avoid : "this sleepy energy; wake it up" , energy : + 0.25 }, "🌧️" : { like : "sadder, more melancholy, minor keys" },
"⚡" : { like : "faster and louder" , energy : + 0.3 }, "🎸" : { like : "more guitar" }, "🎹" : { like : "more piano and keys" },
"🚫" : { avoid : "anything like the current track" },
};
async function react ( emoji , text ) {
const cur = state . current && state . current . spec ; if ( ! cur ) return ;
const m = EMOJI_MEANING [ emoji ] || ( emoji ? { notes : emoji } : null );
const who = ` ${ cur . title } ( ${ cur . tags . split ( "," ). slice ( 0 , 3 ). join ( "," ) } )` ;
if ( m ) {
if ( m . like ) state . taste . like . push ( ` ${ m . like } — re ${ who } ` );
if ( m . avoid ) state . taste . avoid . push ( ` ${ m . avoid } — re ${ who } ` );
if ( m . notes ) state . taste . notes . push ( ` ${ m . notes } (an emoji, re ${ who } )` );
cur . reaction = ( cur . reaction ? cur . reaction + " " : "" ) + emoji ;
}
if ( text ) { state . taste . notes . push ( ` ${ text } (re ${ who } )` ); cur . reaction = ( cur . reaction ? cur . reaction + " " : "" ) + `" ${ text } "` ; }
for ( const k of [ "like" , "avoid" , "notes" ]) state . taste [ k ] = state . taste [ k ]. slice ( - 8 );
// a next track already in the oven was planned before this was said: it still plays (it's paid for), the one after hears it
const later = !! state . nextJob ;
if ( ! later ) cookNext ();
saveStation (). catch (() => {});
remember ( state . current , false ). catch (() => {}); // the reaction rides with the track in the library
toast ( ` ${ emoji || "Noted" } — the ${ later ? "track after next" : "next track" } hears it.` );
}
/* ---------- UI wiring ---------- */
$ ( "#seedIn" ). addEventListener ( "input" , ( e ) => {
const v = e . target . value . trim ();
const c = $ ( "#seedCover" ); if ( ! v ) { c . classList . remove ( "on" ); return ; }
c . classList . add ( "on" ); drawCover ( c . querySelector ( "canvas" ), { seed : v , title : "" , tags : "" , bpm : 100 , key : KEYS [ hash32 ( v ) % 12 ], energy : 0.5 });
$ ( "#seedHint" ). textContent = "↵ to start the station" ;
});
$ ( "#seedForm" ). addEventListener ( "submit" , ( e ) => {
e . preventDefault ();
const v = $ ( "#seedIn" ). value . trim (); if ( ! v ) return ;
openStation ( v ); // a new seed is a new station
});
/* tags as controls: tap a tag for more of it, again for less, again to let it go; + adds one of your own.
Each tap is a reaction (+word / -word) the DJ and the taste net both see. */
const tagState = ( spec , tag ) => { const r = " " + String ( spec . reaction || "" ) + " " ; const t = tag . replace ( /\s+/g , "_" ); const L = r . lastIndexOf ( " +" + t + " " ), N = r . lastIndexOf ( " -" + t + " " ), Z = r . lastIndexOf ( " ~" + t + " " ); const m = Math . max ( L , N , Z ); return m < 0 || m === Z ? "" : m === L ? "like" : "avoid" ; };
const reactionEmoji = ( r ) => String ( r || "" ). replace ( /(?:^|\s)[+\-~][^\s]+/g , "" ). replace ( /"[^"]*"/g , "💬" ). replace ( /\[skipped\]/g , "⏭" ). replace ( /\s+/g , "" );
state . tagsOpen = false ; // the tag row shows a few, and folds the rest behind "… more"
function showTags ( spec ) {
const row = $ ( "#tagRow" ); row . innerHTML = "" ; const list = $ ( "#tagList" ); list . innerHTML = "" ;
const tags = String ( spec . tags || "" ). split ( "," ). map (( t ) => t . trim ()). filter ( Boolean ). slice ( 0 , 12 );
const extra = [... String ( spec . reaction || "" ). matchAll ( /(?:^|\s)\+([^\s]+)/g )]. map (( m ) => m [ 1 ]. replace ( /_/g , " " )). filter (( t ) => ! tags . includes ( t ));
const total = tags . length + extra . length , liked = [... tags , ... extra ]. filter (( t ) => tagState ( spec , t )). length ;
// one line closed: TAGS ▾ · bpm · key; open: every tag as a chip, and + tag
for ( const m of [ ` ${ spec . bpm } bpm` , spec . key ]) { const b = document . createElement ( "button" ); b . className = "meta" ; b . textContent = m ; b . disabled = true ; row . append ( b ); }
const f = document . createElement ( "button" ); f . className = "fold" ; f . textContent = ( state . tagsOpen ? "TAGS ▴" : "TAGS ▾" ) + ( liked && ! state . tagsOpen ? ` · ${ liked } ` : "" );
f . addEventListener ( "click" , () => { state . tagsOpen = ! state . tagsOpen ; showTags ( spec ); }); row . append ( f );
if ( ! state . tagsOpen ) return ;
for ( const tag of [... tags , ... extra ]) {
const b = document . createElement ( "button" ); b . textContent = tag ; b . className = tagState ( spec , tag );
b . addEventListener ( "click" , () => tagTap ( spec , tag , b ));
list . append ( b );
}
const add = document . createElement ( "button" ); add . className = "add" ; add . textContent = "+ tag" ;
add . addEventListener ( "click" , () => {
const inp = document . createElement ( "input" ); inp . placeholder = "more …" ; inp . maxLength = 30 ; list . replaceChild ( inp , add ); inp . focus ();
const done = () => { const v = inp . value . trim (). toLowerCase (); if ( v ) { tagTap ( spec , v , null , "like" ); } else showTags ( spec ); };
inp . addEventListener ( "keydown" , ( e ) => { if ( e . key === "Enter" ) { e . preventDefault (); done (); } if ( e . key === "Escape" ) showTags ( spec ); });
inp . addEventListener ( "blur" , done );
});
list . append ( add );
}
function tagTap ( spec , tag , btn , force ) {
const cur = force ? "" : tagState ( spec , tag ), next = force || ( cur === "" ? "like" : cur === "like" ? "avoid" : "" );
const t = tag . replace ( /\s+/g , "_" );
if ( next === "" ) { spec . reaction = ( spec . reaction || "" ) + " ~" + t ; state . taste . like = state . taste . like . filter (( s ) => ! s . startsWith ( "the tag " + JSON . stringify ( tag ))); state . taste . avoid = state . taste . avoid . filter (( s ) => ! s . startsWith ( "the tag " + JSON . stringify ( tag ))); toast ( ` ${ tag } : let go.` ); }
else {
spec . reaction = ( spec . reaction || "" ) + " " + ( next === "like" ? "+" : "-" ) + t ;
const note = `the tag ${ JSON . stringify ( tag ) } ` ;
state . taste . like = state . taste . like . filter (( s ) => ! s . startsWith ( note )); state . taste . avoid = state . taste . avoid . filter (( s ) => ! s . startsWith ( note ));
( next === "like" ? state . taste . like : state . taste . avoid ). push ( note + ( next === "like" ? " — more of this" : " — less of this" ));
toast ( next === "like" ? `More ${ tag } .` : `Less ${ tag } .` );
}
for ( const k of [ "like" , "avoid" ]) state . taste [ k ] = state . taste [ k ]. slice ( - 10 );
showTags ( spec );
if ( state . current && state . current . spec === spec ) remember ( state . current , false ). catch (() => {});
saveStation (). catch (() => {});
if ( ! state . nextJob ) cookNext ();
if ( typeof rebuildBrains === "function" ) rebuildBrains (). catch (() => {});
}
$ ( "#lyric" ). addEventListener ( "click" , () => { const s = $ ( "#say" ); s . focus (); s . placeholder = "reply to the DJ…" ; });
/* lyrics: the DJ's words for the track, under the title; tap LYRICS or the cover */
function showLyrics ( spec ) {
const has = !! ( spec . lyrics && spec . lyrics . trim ()), p = $ ( "#lyrics" );
p . innerHTML = "" ; p . classList . toggle ( "none" , ! has );
if ( ! has ) { p . textContent = "instrumental" ; return ; }
for ( const line of spec . lyrics . split ( /\r?\n/ )) {
const m = line . match ( /^\s*\[([^\]]+)\]\s*$/ );
if ( m ) p . append ( Object . assign ( document . createElement ( "span" ), { className : "sec" , textContent : m [ 1 ]. toUpperCase () }));
else p . append ( document . createTextNode ( line + "\n" ));
}
}
function toggleLyrics () {
state . lyricsOpen = ! state . lyricsOpen ;
$ ( "#cover" ). classList . toggle ( "flipped" , state . lyricsOpen ); $ ( "#lyricsBtn" ). classList . toggle ( "on" , state . lyricsOpen );
$ ( "#lyricsBtn" ). textContent = state . lyricsOpen ? "⇄ COVER" : "⇄ LYRICS" ;
}
$ ( "#lyricsBtn" ). addEventListener ( "click" , toggleLyrics );
$ ( "#cover" ). addEventListener ( "click" , toggleLyrics );
/* keep: the one and only pin, under the controls; emoji are reactions, not saves */
async function keepLabel () {
const b = $ ( "#keep" ), cur = state . current && state . current . spec ;
if ( ! cur || ! cur . id ) { b . disabled = true ; b . classList . remove ( "on" ); b . textContent = "➕ KEEP" ; return ; }
const row = ( await db . all ()). find (( t ) => t . id === cur . id );
b . disabled = false ; b . classList . toggle ( "on" , !! ( row && row . kept )); b . textContent = row && row . kept ? "✓ KEPT" : "➕ KEEP" ;
$ ( "#share" ). disabled = false ;
}
$ ( "#share" ). addEventListener ( "click" , async () => {
const url = shareLink (); if ( ! url ) return ;
const s = state . current . spec , text = ` ${ s . title } · a gandora track from the ${ state . station || s . seed } station` ;
if ( navigator . share ) { try { await navigator . share ({ title : s . title , text , url }); return ; } catch {} }
try { await navigator . clipboard . writeText ( url ); toast ( "Link copied. Whoever opens it gets this song, freshly rendered." ); } catch { prompt ( "Copy this link:" , url ); }
});
$ ( "#keep" ). addEventListener ( "click" , async () => {
const cur = state . current && state . current . spec ; if ( ! cur || ! cur . id || ! state . current . blob ) return ;
const row = ( await db . all ()). find (( t ) => t . id === cur . id );
await db . put ({ ...( row || { id : cur . id , spec : cur , blob : state . current . blob , made : cur . made }), kept : ! ( row && row . kept ) });
await keepLabel (); toast ( row && row . kept ? "Unkept." : "Kept — it stays in your library." );
});
// back to the seed screen keeps the station playing: a small now-playing strip leads back to it
$ ( "#back" ). addEventListener ( "click" , () => {
$ ( "#player" ). classList . add ( "hidden" ); $ ( "#seed" ). classList . remove ( "hidden" );
const cur = state . current && state . current . spec ;
$ ( "#nowRow" ). classList . toggle ( "hidden" , ! cur );
if ( cur ) { drawCover ( $ ( "#now canvas" ), cur ); $ ( "#nowTitle" ). textContent = ` ${ cur . title } · ${ state . station || cur . seed } ` ; }
$ ( "#seedIn" ). value = "" ; $ ( "#seedCover" ). classList . remove ( "on" ); $ ( "#seedHint" ). textContent = cur ? "type a new seed for a new station, or pick one below" : "a mood, a line, a place, a word" ;
listStations (). catch (() => {});
});
$ ( "#now" ). addEventListener ( "click" , () => { $ ( "#seed" ). classList . add ( "hidden" ); $ ( "#player" ). classList . remove ( "hidden" ); });
2026-10-20 23:40:58 +00:00
/* lock screen / media keys: title, station, the cover, and back · pause · next */
function nowPlaying ( spec ) {
if ( ! ( "mediaSession" in navigator )) return ;
try {
const c = document . createElement ( "canvas" ); c . width = c . height = 512 ; drawCover ( c , spec );
navigator . mediaSession . metadata = new MediaMetadata ({ title : spec . title , artist : state . station || spec . seed , album : "gandora" ,
artwork : [{ src : c . toDataURL ( "image/png" ), sizes : "512x512" , type : "image/png" }] });
} catch {}
}
if ( "mediaSession" in navigator ) {
const ms = navigator . mediaSession ;
try {
ms . setActionHandler ( "play" , () => $ ( "#play" ). click ());
ms . setActionHandler ( "pause" , () => $ ( "#play" ). click ());
ms . setActionHandler ( "previoustrack" , () => $ ( "#backTrack" ). click ());
ms . setActionHandler ( "nexttrack" , () => $ ( "#skip" ). click ());
} catch {}
state . audio . addEventListener ( "play" , () => { ms . playbackState = "playing" ; });
state . audio . addEventListener ( "pause" , () => { ms . playbackState = "paused" ; });
}
if ( "serviceWorker" in navigator && location . protocol === "https:" ) navigator . serviceWorker . register ( "sw.js" ). catch (() => {});
2026-10-20 23:53:19 +00:00
/* 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 ();
2026-10-20 22:29:58 +00:00
$ ( "#play" ). addEventListener ( "click" , async () => {
if ( ! state . audio . src ) return ;
if ( state . playing ) { state . audio . pause (); state . playing = false ; vizStop (); } else { try { await state . audio . play (); state . playing = true ; vizStart (); } catch {} }
setPlayIcon ();
});
$ ( "#skip" ). addEventListener ( "click" , () => { state . audio . pause (); state . playing = false ; setPlayIcon (); if ( state . current ) state . current . spec . reaction = ( state . current . spec . reaction || "" ) + " [skipped]" ; advance (); });
$ ( "#remix" ). addEventListener ( "click" , async () => {
const cur = state . current && state . current . spec ; if ( ! cur ) return ;
state . remixOf = cur ; toast ( "Remixing — it goes next." );
try {
const spec = await askDJ (); spec . remixOf = cur . id ;
const job = makeJob ( spec );
if ( state . nextJob && ! state . nextJob . blob ) state . after = state . nextJob ; // the one already cooking plays after the remix
state . nextJob = job ; $ ( "#next" ). textContent = "NEXT · " + spec . title . toUpperCase () + " · REMIX" ;
} catch ( e ) { toast ( e . message , 5000 ); }
finally { state . remixOf = null ; }
});
$ ( "#shuffle" ). addEventListener ( "click" , () => { state . shuffle = ! state . shuffle ; $ ( "#shuffle" ). classList . toggle ( "on" , state . shuffle ); toast ( state . shuffle ? "Shuffle: mixing the station's library in." : "Shuffle off: all fresh." ); });
$ ( "#loop" ). addEventListener ( "click" , () => { state . audio . loop = ! state . audio . loop ; $ ( "#loop" ). classList . toggle ( "on" , state . audio . loop ); toast ( state . audio . loop ? "Repeating this track." : "Back to the station." ); });
$ ( "#prev" ). addEventListener ( "click" , () => { if ( state . audio . src ) { state . audio . currentTime = 0 ; state . audio . play (). catch (() => {}); } });
$ ( "#backTrack" ). addEventListener ( "click" , async () => { if ( ! ( await step ( - 1 ))) toast ( "That's the first track of this session." ); });
/* the emoji row is the listener's eight most used, seeded with the defaults; + opens the picker */
const EMOJI_SETS = {
"REACT" : "🔥 ❤️ ⚡ 😴 🌧️ 🚫 🎸 🎹 🥁 🎤 🎺 🎻 🎷 🪗 🎧 🔊 🔉 🎶 🎵 💃 🕺 🤘 👏 🙌 👍 👎 🤌 💯 🤩 🤯 🤔 😎 😍 🥰 🥺 😭 😢 😤 😱 🤮 💤 🥱 😐 🙄 😬 🤪 🤬 👻" ,
"MOOD" : "🌙 🌞 🌅 🌆 🌃 🌌 ⭐ ✨ 🌟 💥 ❄️ 🌊 🌋 🌪️ ☔ 🌈 🍂 🌸 🌹 🌵 🍄 🌍 🏙️ 🏜️ 🏖️ 🏔️ 🚇 🚗 🛸 🚀 🕯️ 💡 🔮 🪄 💎 🔗 ⏳ 🧠 🫀 💀 👽 🤖 🐉 🦋 🐝 🦊 🐱 🐶" ,
"TASTE" : "☕ 🍵 🍷 🍺 🥃 🍫 🍭 🍓 🍋 🌶️ 🧈 🍰 🍕 🍜 🥟 🍱 🍼 🧊 🔥 💨 🫧 🌫️ 🪨 🪶 🧵 🧶 🎨 🖼️ 📼 📻 📺 💿 📀 🎛️ 🎚️ 🎙️ 📼 🕳️" ,
};
const EMOJI_KEY = "gandora.emoji" ;
function emojiCounts () { try { return JSON . parse ( localStorage . getItem ( EMOJI_KEY ) || "{}" ); } catch { return {}; } }
function bumpEmoji ( e ) { const c = emojiCounts (); c [ e ] = ( c [ e ] || 0 ) + 1 ; localStorage . setItem ( EMOJI_KEY , JSON . stringify ( c )); }
const EMOJI_DEFAULT = [ "🔥" , "❤️" , "😴" , "🌧️" , "⚡" , "🎸" , "🎹" , "🚫" ];
function showEmojiRow () {
const c = emojiCounts (), row = $ ( "#emojis" ), more = $ ( "#more" );
const seeded = EMOJI_DEFAULT . map (( e , i ) => [ e , ( c [ e ] || 0 ) + ( 8 - i ) * 0.01 ]); // defaults keep their order until use says otherwise
for ( const e of Object . keys ( c )) if ( ! EMOJI_DEFAULT . includes ( e )) seeded . push ([ e , c [ e ]]);
const top = seeded . sort (( a , b ) => b [ 1 ] - a [ 1 ]). slice ( 0 , 8 ). map (( x ) => x [ 0 ]);
for ( const b of [... row . querySelectorAll ( "button[data-e]" )]) b . remove ();
for ( const e of top ) { const b = document . createElement ( "button" ); b . dataset . e = e ; b . textContent = e ; b . title = ( EMOJI_MEANING [ e ] && ( EMOJI_MEANING [ e ]. like || EMOJI_MEANING [ e ]. avoid )) || "" ; row . insertBefore ( b , more ); }
}
$ ( "#emojis" ). addEventListener ( "click" , ( e ) => { const b = e . target . closest ( "button[data-e]" ); if ( ! b ) return ; b . classList . add ( "hit" ); setTimeout (() => b . classList . remove ( "hit" ), 300 ); bumpEmoji ( b . dataset . e ); react ( b . dataset . e ); });
function openPicker () {
const p = $ ( "#picker" ); p . innerHTML = "" ;
const top = document . createElement ( "div" ); top . className = "top" ;
const inp = document . createElement ( "input" ); inp . placeholder = "or type any emoji…" ; inp . maxLength = 8 ;
const go = document . createElement ( "button" ); go . textContent = "SEND" ; const close = document . createElement ( "button" ); close . textContent = "CLOSE" ;
const send = () => { const v = inp . value . trim (); if ( ! v ) return ; pick ( v ); };
go . addEventListener ( "click" , send ); inp . addEventListener ( "keydown" , ( e ) => { if ( e . key === "Enter" ) { e . preventDefault (); send (); } });
close . addEventListener ( "click" , () => p . classList . add ( "hidden" ));
top . append ( inp , go , close ); p . append ( top );
const pick = ( e ) => { bumpEmoji ( e ); react ( e ); showEmojiRow (); p . classList . add ( "hidden" ); };
const c = emojiCounts (), used = Object . entries ( c ). sort (( a , b ) => b [ 1 ] - a [ 1 ]). slice ( 0 , 16 ). map (( x ) => x [ 0 ]);
const sets = used . length ? { "MOST USED" : used . join ( " " ), ... EMOJI_SETS } : EMOJI_SETS ;
for ( const [ name , list ] of Object . entries ( sets )) {
p . append ( Object . assign ( document . createElement ( "h4" ), { textContent : name }));
const g = document . createElement ( "div" ); g . className = "g" ;
for ( const e of [... new Set ( list . split ( /\s+/ ). filter ( Boolean ))]) { const b = document . createElement ( "button" ); b . textContent = e ; b . addEventListener ( "click" , () => pick ( e )); g . append ( b ); }
p . append ( g );
}
p . classList . remove ( "hidden" ); inp . focus ({ preventScroll : true });
}
$ ( "#more" ). addEventListener ( "click" , () => { const p = $ ( "#picker" ); if ( p . classList . contains ( "hidden" )) openPicker (); else p . classList . add ( "hidden" ); });
showEmojiRow ();
$ ( "#sayRow" ). addEventListener ( "submit" , ( e ) => { e . preventDefault (); const v = $ ( "#say" ). value . trim (); if ( ! v ) return ; $ ( "#say" ). value = "" ; react ( "" , v ); });
/* key box */
function openKey () { $ ( "#keyIn" ). value = state . key ; $ ( "#djIn" ). value = state . dj ; $ ( "#keyBox" ). classList . remove ( "hidden" ); $ ( "#keyIn" ). focus (); }
$ ( "#keyBtn" ). addEventListener ( "click" , openKey );
if ( new URLSearchParams ( location . search ). has ( "key" )) $ ( "#keyBtn" ). classList . remove ( "hidden" ); // key box is parked for now
$ ( "#keyCancel" ). addEventListener ( "click" , () => $ ( "#keyBox" ). classList . add ( "hidden" ));
$ ( "#keySave" ). addEventListener ( "click" , () => {
state . key = $ ( "#keyIn" ). value . trim (); state . dj = $ ( "#djIn" ). value . trim () || "claude-haiku-5-5" ;
localStorage . setItem ( LS . key , state . key ); localStorage . setItem ( LS . dj , state . dj );
$ ( "#keyBox" ). classList . add ( "hidden" ); keyLabel (); $ ( "#seedIn" ). focus ();
});
/* sheets close on a tap outside them */
const SHEETS = [ "#stationSheet" , "#picker" , "#tapePick" ];
function closeSheets () { for ( const s of SHEETS ) $ ( s ). classList . add ( "hidden" ); $ ( "#scrim" ). classList . add ( "hidden" ); }
$ ( "#scrim" ). addEventListener ( "click" , closeSheets );
new MutationObserver (() => { const open = SHEETS . some (( s ) => ! $ ( s ). classList . contains ( "hidden" )); $ ( "#scrim" ). classList . toggle ( "hidden" , ! open ); })
. observe ( document . body , { attributes : true , attributeFilter : [ "class" ], subtree : true });
function keyLabel () { $ ( "#keyBtn" ). textContent = state . key ? "your venice key ✓" : "shared budget · add a venice key" ; }
keyLabel ();
/* library */
$ ( "#libBtn" ). addEventListener ( "click" , async () => {
const list = await db . all (); const g = $ ( "#libGrid" ); g . innerHTML = "" ;
$ ( "#libEmpty" ). classList . toggle ( "hidden" , list . length > 0 );
const cur = state . current && state . current . spec ;
const tile = ( t , inKept = false ) => {
const d = document . createElement ( "div" ); d . className = "t" + ( cur && t . id === cur . id ? " on" : "" );
const art = document . createElement ( "div" ); art . className = "art" ;
const c = document . createElement ( "canvas" ); c . width = c . height = 260 ; drawCover ( c , t . spec ); art . append ( c );
if ( t . kept && ! inKept ) art . append ( Object . assign ( document . createElement ( "span" ), { className : "kept" , textContent : "KEPT" }));
const re = reactionEmoji ( t . spec . reaction ). trim ();
if ( re ) art . append ( Object . assign ( document . createElement ( "span" ), { className : "re" , textContent : re , title : t . spec . reaction }));
const addT = Object . assign ( document . createElement ( "button" ), { className : "addTape" , textContent : "+ " , title : "add to a tape" });
addT . addEventListener ( "click" , ( e ) => { e . stopPropagation (); pickTape ( t ); });
2026-10-21 00:30:28 +00:00
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 );
2026-10-20 22:29:58 +00:00
d . append ( art , Object . assign ( document . createElement ( "b" ), { textContent : t . spec . title }), Object . assign ( document . createElement ( "small" ), { textContent : ` ${ t . spec . bpm } bpm · ${ t . spec . key }${ ( t . plays || 0 ) > 1 ? " · × " + t . plays : "" } ` , title : t . spec . seed }));
d . addEventListener ( "click" , () => { $ ( "#lib" ). classList . add ( "hidden" ); const job = { id : ++ seq , spec : { ... t . spec }, blob : t . blob , err : null , done : Promise . resolve () }; state . seed = t . spec . seed ; $ ( "#player" ). classList . remove ( "hidden" ); $ ( "#seed" ). classList . add ( "hidden" ); state . audio . pause (); playJob ( job ); });
return d ;
};
// kept first, then everything played, grouped by station (channel), newest station first
const stations = await db . all ( "stations" ); const byId = Object . fromEntries ( stations . map (( s ) => [ s . id , s ]));
const head = ( text , seed , count ) => {
const h = document . createElement ( "h3" ); h . style . gridColumn = "1/-1" ;
if ( seed ) { const c = document . createElement ( "canvas" ); c . width = c . height = 44 ; drawCover ( c , { seed , title : "" , tags : "" , bpm : 100 , key : KEYS [ hash32 ( seed ) % 12 ], energy : 0.5 }); h . append ( c ); }
h . append ( Object . assign ( document . createElement ( "span" ), { textContent : text }));
if ( count != null ) h . append ( Object . assign ( document . createElement ( "small" ), { textContent : `· ${ count } ` }));
return h ;
};
// tapes first: a strip of mixtapes, and a way to start one
const tapes = await db . all ( "tapes" );
g . append ( head ( "TAPES" , "" , tapes . length ));
const strip = document . createElement ( "div" ); strip . id = "tapeRow" ;
for ( const tp of tapes ) {
const s = document . createElement ( "div" ); s . className = "s" ;
const tc = document . createElement ( "div" ); tc . className = "tc" + ( tp . ids . length < 4 ? " one" : "" );
const covers = tp . ids . map (( id ) => list . find (( t ) => t . id === id )). filter ( Boolean ). slice ( 0 , tp . ids . length < 4 ? 1 : 4 );
for ( const t of covers ) { const c = document . createElement ( "canvas" ); c . width = c . height = 120 ; drawCover ( c , t . spec ); tc . append ( c ); }
s . append ( tc , Object . assign ( document . createElement ( "b" ), { textContent : tp . name }), Object . assign ( document . createElement ( "small" ), { textContent : ` ${ tp . ids . length } tracks` }));
s . addEventListener ( "click" , () => openTape ( tp . id ));
strip . append ( s );
}
const nw = document . createElement ( "div" ); nw . className = "s new" ; nw . innerHTML = '<div class="tc">+ </div><b>new tape</b>' ;
nw . addEventListener ( "click" , async () => { const name = prompt ( "Name the tape:" , "" ); if ( ! name ) return ; const tp = { id : "tape-" + Date . now (), name : name . trim (). slice ( 0 , 60 ), ids : [], made : Date . now (), last : Date . now () }; await db . put ( tp , "tapes" ); openTape ( tp . id ); });
strip . append ( nw ); g . append ( strip );
const kept = list . filter (( t ) => t . kept );
if ( kept . length ) { g . append ( head ( "KEPT" , "" , kept . length )); for ( const t of kept ) g . append ( tile ( t , true )); }
const groups = new Map ();
for ( const t of list ) { const k = t . spec . stationId || "seed:" + t . spec . seed ; if ( ! groups . has ( k )) groups . set ( k , []); groups . get ( k ). push ( t ); }
for ( const [ k , rows ] of groups ) {
const s = byId [ k ]; const name = ( s && s . name ) || rows [ 0 ]. spec . seed , seed = ( s && s . seed ) || rows [ 0 ]. spec . seed ;
g . append ( head ( name . toUpperCase (), seed , rows . length ));
for ( const t of rows ) g . append ( tile ( t ));
}
$ ( "#libCost" ). textContent = `~$ ${ state . cost . toFixed ( 2 ) } of music rendered from this browser · replays from the library are free` ;
$ ( "#lib" ). classList . remove ( "hidden" );
});
$ ( "#libClose" ). addEventListener ( "click" , () => $ ( "#lib" ). classList . add ( "hidden" ));
/* ---------- mixtapes: a named, ordered list of tracks; play through, repeat all, share as a link ---------- */
async function pickTape ( track ) {
const p = $ ( "#tapePick" ); p . innerHTML = "" ;
const tapes = await db . all ( "tapes" );
p . append ( Object . assign ( document . createElement ( "h4" ), { textContent : "ADD “" + track . spec . title . toUpperCase () + "” TO" }));
const add = async ( tp ) => { if ( ! tp . ids . includes ( track . id )) { tp . ids . push ( track . id ); tp . last = Date . now (); await db . put ( tp , "tapes" ); } p . classList . add ( "hidden" ); toast ( `On “ ${ tp . name } ”.` ); };
for ( const tp of tapes ) { const b = document . createElement ( "button" ); b . textContent = ` ${ tp . name } · ${ tp . ids . length } ` ; b . addEventListener ( "click" , () => add ( tp )); p . append ( b ); }
const inp = document . createElement ( "input" ); inp . placeholder = "new tape…" ; inp . maxLength = 60 ;
inp . addEventListener ( "keydown" , async ( e ) => { if ( e . key !== "Enter" ) return ; e . preventDefault (); const name = inp . value . trim (); if ( ! name ) return ; const tp = { id : "tape-" + Date . now (), name , ids : [], made : Date . now (), last : Date . now () }; await add ( tp ); });
const close = document . createElement ( "button" ); close . textContent = "cancel" ; close . addEventListener ( "click" , () => p . classList . add ( "hidden" ));
p . append ( inp , close ); p . classList . remove ( "hidden" ); inp . focus ({ preventScroll : true });
}
state . tape = null ; // { id, ids, i, loop } while a tape is playing
async function tapeRows ( tp ) { const all = await db . all (); return tp . ids . map (( id ) => all . find (( t ) => t . id === id )). filter ( Boolean ); }
async function openTape ( id ) {
const tp = ( await db . all ( "tapes" )). find (( t ) => t . id === id ); if ( ! tp ) return ;
$ ( "#lib" ). classList . add ( "hidden" ); $ ( "#tape" ). classList . remove ( "hidden" ); $ ( "#tape" ). dataset . id = id ;
$ ( "#tapeName" ). value = tp . name ;
const rows = await tapeRows ( tp ), list = $ ( "#tapeList" ); list . innerHTML = "" ;
$ ( "#tapeEmpty" ). classList . toggle ( "hidden" , rows . length > 0 );
$ ( "#tapeLoop" ). classList . toggle ( "go" , !! ( state . tape && state . tape . id === id && state . tape . loop ));
rows . forEach (( t , i ) => {
const r = document . createElement ( "div" ); r . className = "row" + ( state . current && state . current . spec . id === t . id ? " on" : "" );
const c = document . createElement ( "canvas" ); c . width = c . height = 120 ; drawCover ( c , t . spec );
const m = document . createElement ( "div" ); m . className = "m" ; m . append ( Object . assign ( document . createElement ( "b" ), { textContent : ` ${ i + 1 } . ${ t . spec . title } ` }), Object . assign ( document . createElement ( "small" ), { textContent : ` ${ t . spec . bpm } bpm · ${ t . spec . key } · ${ t . spec . seed } ` }));
const play = () => playTape ( tp , i );
c . addEventListener ( "click" , play ); m . addEventListener ( "click" , play );
const ctl = document . createElement ( "div" ); ctl . className = "ctl" ;
const mv = async ( d ) => { const j = i + d ; if ( j < 0 || j >= tp . ids . length ) return ; [ tp . ids [ i ], tp . ids [ j ]] = [ tp . ids [ j ], tp . ids [ i ]]; await db . put ( tp , "tapes" ); openTape ( id ); };
const up = Object . assign ( document . createElement ( "button" ), { textContent : "▲" , title : "earlier" }), dn = Object . assign ( document . createElement ( "button" ), { textContent : "▼" , title : "later" }), rm = Object . assign ( document . createElement ( "button" ), { textContent : "✕" , title : "remove from tape" });
up . addEventListener ( "click" , () => mv ( - 1 )); dn . addEventListener ( "click" , () => mv ( 1 ));
rm . addEventListener ( "click" , async () => { tp . ids . splice ( i , 1 ); await db . put ( tp , "tapes" ); openTape ( id ); });
ctl . append ( up , dn , rm ); r . append ( c , m , ctl ); list . append ( r );
});
}
async function playTape ( tp , i , loop ) {
const rows = await tapeRows ( tp ); if ( ! rows . length ) return ;
i = (( i % rows . length ) + rows . length ) % rows . length ;
state . tape = { id : tp . id , ids : rows . map (( t ) => t . id ), i , loop : loop ?? ( state . tape && state . tape . id === tp . id ? state . tape . loop : false ) };
$ ( "#tape" ). classList . add ( "hidden" ); $ ( "#seed" ). classList . add ( "hidden" ); $ ( "#player" ). classList . remove ( "hidden" );
$ ( "#stationName" ). textContent = "📼 " + tp . name ;
const t = rows [ i ]; state . audio . pause ();
await playJob ({ id : ++ seq , spec : t . spec , blob : t . blob , err : null , done : Promise . resolve (), stepping : true });
}
$ ( "#tapePlay" ). addEventListener ( "click" , async () => { const tp = ( await db . all ( "tapes" )). find (( t ) => t . id === $ ( "#tape" ). dataset . id ); if ( tp ) playTape ( tp , 0 ); });
$ ( "#tapeLoop" ). addEventListener ( "click" , async () => { const tp = ( await db . all ( "tapes" )). find (( t ) => t . id === $ ( "#tape" ). dataset . id ); if ( ! tp ) return ; const on = ! ( state . tape && state . tape . id === tp . id && state . tape . loop ); if ( state . tape && state . tape . id === tp . id ) state . tape . loop = on ; else await playTape ( tp , 0 , on ); $ ( "#tapeLoop" ). classList . toggle ( "go" , on ); toast ( on ? "Repeating the whole tape." : "Tape plays through once." ); });
$ ( "#tapeName" ). addEventListener ( "change" , async () => { const tp = ( await db . all ( "tapes" )). find (( t ) => t . id === $ ( "#tape" ). dataset . id ); if ( ! tp ) return ; tp . name = $ ( "#tapeName" ). value . trim (). slice ( 0 , 60 ) || tp . name ; await db . put ( tp , "tapes" ); });
$ ( "#tapeDelete" ). addEventListener ( "click" , async () => { const id = $ ( "#tape" ). dataset . id ; if ( ! confirm ( "Delete this tape? The tracks stay in the library." )) return ; await db . del ( id , "tapes" ); if ( state . tape && state . tape . id === id ) state . tape = null ; $ ( "#tape" ). classList . add ( "hidden" ); $ ( "#libBtn" ). click (); });
$ ( "#tapeShare" ). addEventListener ( "click" , async () => {
const tp = ( await db . all ( "tapes" )). find (( t ) => t . id === $ ( "#tape" ). dataset . id ); if ( ! tp ) return ;
const rows = await tapeRows ( tp );
const payload = { name : tp . name , tracks : rows . map (({ spec : s }) => ({ seed : s . seed , station : s . station || "" , title : s . title , tags : s . tags , lyrics : s . lyrics || "" , bpm : s . bpm , key : s . key , energy : s . energy , line : s . line || "" })) };
const url = ` ${ location . origin } /?tape= ${ b64u ( JSON . stringify ( payload )) } ` ;
if ( navigator . share ) { try { await navigator . share ({ title : tp . name , text : ` ${ tp . name } · a gandora mixtape, ${ rows . length } tracks` , url }); return ; } catch {} }
try { await navigator . clipboard . writeText ( url ); toast ( "Tape link copied. It renders each track for whoever opens it." ); } catch { prompt ( "Copy this link:" , url ); }
});
$ ( "#tapeClose" ). addEventListener ( "click" , () => $ ( "#tape" ). classList . add ( "hidden" ));
// a shared tape (?tape=): make it a tape of this browser's own, rendering each track as it comes up
const sharedTape = new URLSearchParams ( location . search ). get ( "tape" );
if ( sharedTape ) {
( async () => {
try {
const t = JSON . parse ( unb64u ( sharedTape )); if ( ! t || ! Array . isArray ( t . tracks ) || ! t . tracks . length ) throw 0 ;
const tp = { id : "tape-" + Date . now (), name : String ( t . name || "shared tape" ). slice ( 0 , 60 ), ids : [], pending : t . tracks , made : Date . now (), last : Date . now () };
await db . put ( tp , "tapes" );
// render the tracks in order as the station's next ones, and file each into the tape as it lands
state . sharedTapeId = tp . id ; state . tapeQueue = t . tracks . slice ();
openStation ( t . tracks [ 0 ]. seed , { name : t . tracks [ 0 ]. station || "" });
toast ( `Tape “ ${ tp . name } ”: ${ t . tracks . length } tracks to render, one by one.` );
} catch { toast ( "That link didn't carry a tape." ); }
})();
}
$ ( "#libBtn2" ). addEventListener ( "click" , () => $ ( "#libBtn" ). click ());
$ ( "#tasteBtn2" ). addEventListener ( "click" , () => $ ( "#tasteBtn" ). click ());
/* ---------- taste: every track as a point (tempo, energy; colour = key), every reaction a signed weight on it.
The shading is a kernel estimate of "would like" from the reactions, huebrain-style: lit where the warm ones
outweigh the cold ones, dim where the cold ones win, dark where nothing has been heard yet. ---------- */
function reactionWeight ( spec ) {
const r = String ( spec . reaction || "" );
let w = 0 ;
for ( const ch of r . replace ( /"[^"]*"/g , "" )) { if ( "🔥❤️⚡🎸🎹" . includes ( ch ) && ch !== "️ " ) w += 1 ; if ( "🚫😴" . includes ( ch )) w -= 1 ; }
if ( /\[skipped\]/ . test ( r )) w -= 1.5 ;
if ( /"[^"]*"/ . test ( r )) w += 0.3 ; // words mean engagement, mildly warm
return w ;
}
function drawTaste ( rows , title ) {
const c = $ ( "#tasteMap" ), ctx = c . getContext ( "2d" ), W = c . width , H = c . height , pad = 70 ;
const X = ( bpm ) => pad + ( Math . min ( 180 , Math . max ( 60 , bpm )) - 60 ) / 120 * ( W - 2 * pad );
const Y = ( en ) => H - pad - Math . min ( 1 , Math . max ( 0 , en )) * ( H - 2 * pad );
const pts = rows . map (( t ) => ({ x : X ( + t . spec . bpm || 100 ), y : Y ( + t . spec . energy || 0.5 ), w : reactionWeight ( t . spec ) + ( t . kept ? 1.5 : 0 ), hue : keyHue ( t . spec . key ), minor : /m(in|inor)?\b/i . test ( t . spec . key || "" ) && ! /maj/i . test ( t . spec . key || "" ), t }));
ctx . fillStyle = "#121216" ; ctx . fillRect ( 0 , 0 , W , H );
// the field: kernel-weighted reactions, with every played track counting a little as "heard"
const cell = 18 , sig = 90 ;
for ( let gy = pad ; gy < H - pad ; gy += cell ) for ( let gx = pad ; gx < W - pad ; gx += cell ) {
let like = 0 , heard = 0 ;
for ( const p of pts ) { const d2 = ( p . x - gx ) ** 2 + ( p . y - gy ) ** 2 , k = Math . exp ( - d2 / ( 2 * sig * sig )); like += k * p . w ; heard += k ; }
if ( heard < 0.02 ) continue ;
const v = Math . tanh ( like / 1.5 ); // -1..1; 0 means heard but nothing said
const a = Math . min ( 0.5 , heard * 0.3 );
if ( Math . abs ( v ) < 0.05 ) ctx . fillStyle = `hsl(240 6% 30% / ${ a * 0.5 } )` ; // heard, neutral
else ctx . fillStyle = v > 0 ? `hsl(44 60% 60% / ${ a * Math . abs ( v ) } )` : `hsl(220 30% 45% / ${ a * Math . abs ( v ) } )` ;
ctx . fillRect ( gx , gy , cell , cell );
}
// axes
ctx . strokeStyle = "#232329" ; ctx . lineWidth = 2 ; ctx . strokeRect ( pad , pad , W - 2 * pad , H - 2 * pad );
ctx . fillStyle = "#8a8a96" ; ctx . font = `600 ${ W * 0.018 } px ui-monospace, Menlo, monospace` ; ctx . textAlign = "center" ;
for ( const b of [ 60 , 90 , 120 , 150 , 180 ]) ctx . fillText ( b + "" , X ( b ), H - pad + 34 );
ctx . textAlign = "right" ; for ( const e of [ 0 , 0.5 , 1 ]) ctx . fillText ( e === 0 ? "calm" : e === 1 ? "wild" : "·" , pad - 14 , Y ( e ) + 6 );
// the points: colour is the key, size is how much was said about it, ring warm or cold
for ( const p of pts ) {
const r = ( W * 0.012 ) * ( 1 + Math . min ( 3 , Math . abs ( p . w )) * 0.35 );
ctx . beginPath (); ctx . arc ( p . x , p . y , r , 0 , Math . PI * 2 );
ctx . fillStyle = `hsl( ${ p . hue } ${ p . minor ? 55 : 80 } % ${ p . minor ? 48 : 62 } % / .9)` ; ctx . fill ();
if ( p . w !== 0 ) { ctx . lineWidth = 3 ; ctx . strokeStyle = p . w > 0 ? "hsl(44 80% 70%)" : "hsl(220 40% 60%)" ; ctx . stroke (); }
if ( p . t . kept ) { ctx . beginPath (); ctx . arc ( p . x , p . y , r + 7 , 0 , Math . PI * 2 ); ctx . lineWidth = 1.5 ; ctx . strokeStyle = "hsl(44 80% 70% / .7)" ; ctx . stroke (); }
}
const warm = pts . filter (( p ) => p . w > 0 ). length , cold = pts . filter (( p ) => p . w < 0 ). length ;
const mean = ( f ) => pts . length ? pts . reduce (( s , p ) => s + f ( p ), 0 ) / pts . length : 0 ;
const liked = pts . filter (( p ) => p . w > 0 );
const lb = liked . length ? Math . round ( liked . reduce (( s , p ) => s + ( + p . t . spec . bpm || 100 ), 0 ) / liked . length ) : null ;
const le = liked . length ? ( liked . reduce (( s , p ) => s + ( + p . t . spec . energy || 0.5 ), 0 ) / liked . length ) : null ;
const minors = liked . filter (( p ) => p . minor ). length ;
$ ( "#tasteNote" ). textContent = ! pts . length ? "Nothing heard here yet." :
` ${ title } : ${ pts . length } tracks · ${ warm } warm, ${ cold } cold · ` + ( liked . length ? `what you warmed to sits around ${ lb } bpm, ${ le < 0.4 ? "calm" : le < 0.7 ? "mid" : "high" } energy, ${ minors > liked . length / 2 ? "mostly minor keys" : "mostly major keys" } .` : "react to a few tracks and the lit region appears." );
}
async function openTaste () {
const rows = await db . all (), stations = await db . all ( "stations" ), byId = Object . fromEntries ( stations . map (( s ) => [ s . id , s ]));
const tabs = $ ( "#tasteTabs" ); tabs . innerHTML = "" ;
const groups = [[ "all" , "ALL" , "" , rows ]];
const seen = new Map ();
for ( const t of rows ) { const k = t . spec . stationId || "seed:" + t . spec . seed ; if ( ! seen . has ( k )) seen . set ( k , []); seen . get ( k ). push ( t ); }
for ( const [ k , list ] of seen ) { const s = byId [ k ]; groups . push ([ k , (( s && s . name ) || list [ 0 ]. spec . seed ). toUpperCase (), ( s && s . seed ) || list [ 0 ]. spec . seed , list ]); }
let pick = groups . find (( g ) => g [ 0 ] === state . stationId ) || groups [ 0 ];
const show = () => { for ( const b of tabs . children ) b . classList . toggle ( "on" , b . dataset . k === pick [ 0 ]); drawTaste ( pick [ 3 ], pick [ 1 ]); };
for ( const g of groups ) {
const b = document . createElement ( "button" ); b . dataset . k = g [ 0 ];
if ( g [ 2 ]) { const c = document . createElement ( "canvas" ); c . width = c . height = 44 ; drawCover ( c , { seed : g [ 2 ], title : "" , tags : "" , bpm : 100 , key : KEYS [ hash32 ( g [ 2 ]) % 12 ], energy : 0.5 }); b . append ( c ); }
b . append ( document . createTextNode ( g [ 1 ] + " · " + g [ 3 ]. length ));
b . addEventListener ( "click" , () => { pick = g ; show (); });
tabs . append ( b );
}
show (); $ ( "#taste" ). classList . remove ( "hidden" );
}
$ ( "#tasteBtn" ). addEventListener ( "click" , () => openTaste (). catch (( e ) => toast ( e . message )));
$ ( "#tasteClose" ). addEventListener ( "click" , () => $ ( "#taste" ). classList . add ( "hidden" ));
listStations (). catch (() => {});
// a shared track (?t=): open its station and render that song first; a hash (#rainy+trains) just fills the seed
const sharedT = new URLSearchParams ( location . search ). get ( "t" );
if ( sharedT ) {
try { const t = JSON . parse ( unb64u ( sharedT )); if ( t && t . seed && t . title ) { openStation ( t . seed , { name : t . station || "" , first : { title : t . title , tags : t . tags , lyrics : t . lyrics || "" , bpm : t . bpm , key : t . key , energy : t . energy , line : t . line || "" , station : t . station || "" } }); } }
catch { toast ( "That link didn't carry a track." ); }
} else if ( location . hash . length > 1 && ! sharedTape ) {
// a station link: resume that station if this browser has it (its latest track plays, free); otherwise fill the seed
const seed = decodeURIComponent ( location . hash . slice ( 1 )). replace ( /\+/g , " " );
db . all ( "stations" ). then (( list ) => {
const s = list . find (( x ) => x . seed === seed );
if ( s ) resumeStation ( s );
else { $ ( "#seedIn" ). value = seed ; $ ( "#seedIn" ). dispatchEvent ( new Event ( "input" )); }
}). catch (() => { $ ( "#seedIn" ). value = seed ; $ ( "#seedIn" ). dispatchEvent ( new Event ( "input" )); });
}
</ script >
< script src = "brain.js" ></ script >
</ body >
</ html >