2026-10-21 22:31:34 +00:00
import { $ , LS , hooks , ids , state , toast } from "./core.js" ;
import { BREAKS , BREAK_EVERY , MODES , askBreak , askDJ , modeFromSeed } from "./dj.js" ;
import { advance , crackle , playJob , toIdle } from "./player.js" ;
import { KEYS , drawCover , hash32 , keyHue , prng } from "./covers.js" ;
import { db , logEvent , saveStation } from "./store.js" ;
import { keepLabel , reactionEmoji } from "./ui/player.js" ;
import { noiseSpec , renderTrack , setFetcher , toneSpec , wavBlob } from "./render.js" ;
import { drawTaste , openTaste , reactionWeight } from "./ui/taste.js" ;
export 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 ))));
export function shareLink () {
const s = state . current && state . current . spec ; if ( ! s ) return "" ;
const t = { seed : s . seed , station : state . station , title : s . title , tags : s . tags , lyrics : s . lyrics || "" , bpm : s . bpm , key : s . key , energy : s . energy , line : s . line || "" ,
rseed : s . rseed , seconds : s . seconds , meter : s . meter , look : s . look || state . look , palette : s . palette || state . palette };
return ` ${ location . origin } /?t= ${ b64u ( JSON . stringify ( t )) } ` ;
}
export function setUrl () { try { history . replaceState ( null , "" , state . seed ? "/#" + encodeURIComponent ( state . seed ). replace ( /%20/g , "+" ) : "/" ); } catch {} }
async function openStation ( seed , opts = {}) { await openStationNow ( seed , opts ); if ( hooks . stationOpened ) hooks . stationOpened ( seed , opts ); }
async function openStationNow ( seed , { id , name = "" , taste , history , played , first , look , palette : pal , djName = "" , djVoice = "" , djSpeed = 1 , djPersona = "" , mode , welcomed = false } = {}) {
state . mode = MODES . includes ( mode ) ? mode : modeFromSeed ( seed );
state . look = look || "rings" ; state . palette = pal || "duo" ; state . djName = djName ; state . djVoice = djVoice ; state . djSpeed = djSpeed || 1 ; state . djPersona = djPersona ; state . welcomed = welcomed ; state . sinceAd = 0 ; state . breaks = 0 ;
if ( state . stationId !== id ) { toIdle (); state . recent = []; state . pos = - 1 ; state . tape = null ; }
state . seed = seed ; state . station = name ; state . stationId = id || `s- ${ Date . now () } - ${ hash32 ( seed ) } ` ;
state . taste = taste || { like : [], avoid : [], notes : [] }; state . history = history || []; state . played = played || 0 ;
$ ( "#stationName" ). textContent = name || seed ;
$ ( "#seed" ). classList . add ( "hidden" ); $ ( "#player" ). classList . remove ( "hidden" );
setUrl ();
if ( ! state . current ) {
if ( first ) { first . seed = seed ; first . made = Date . now (); state . nextJob = makeJob ( first ); drawCover ( $ ( "#cover canvas" ), first , { lit : 0.6 }); $ ( "#title" ). textContent = first . title ; saveStation (). catch (() => {}); advance (); return ; }
drawCover ( $ ( "#cover canvas" ), { seed , title : "" , tags : "" , bpm : 100 , key : KEYS [ hash32 ( seed ) % 12 ], energy : 0.5 }, { lit : 0.6 });
$ ( "#title" ). textContent = "tuning…" ; $ ( "#tags" ). textContent = "" ; $ ( "#lyric" ). textContent = "" ; keepLabel ();
saveStation (). catch (() => {});
// coming back to a station: play its latest track from the library straight away, and cook the next behind it
const have = id ? ( await db . all ()). filter (( t ) => t . spec . stationId === id ) : [];
if ( have . length ) { const t = have [ 0 ]; await playJob ({ id : ++ ids . seq , spec : t . spec , blob : t . blob , err : null , done : Promise . resolve (), stepping : true }); state . recent = [ t . id ]; state . pos = 0 ; return ; }
advance ();
}
}
export function resumeStation ( s ) { openStation ( s . seed , s ); }
/* 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. */
export async function stationFrom ( t ) {
const s = t . spec ; if ( ! t . blob ) { toast ( "That one hasn't finished cooking." ); return ; }
const id = `s- ${ Date . now () } - ${ hash32 ( s . title + s . tags ) } ` ;
const seed = `like " ${ s . title } ": ${ s . tags . split ( "," ). map (( x ) => x . trim ()). filter ( Boolean ). slice ( 0 , 5 ). join ( ", " ) } ` ;
const copy = { ... s , id : s . id + "-" + hash32 ( id ), stationId : id , seed , made : Date . now (), reaction : "" , plays : 0 };
delete copy . station ;
await db . put ({ id : copy . id , spec : copy , blob : t . blob , made : copy . made , kept : false , plays : 0 });
state . stationId = null ; // so openStation treats it as a change of station
await openStation ( seed , { id , name : ` ${ s . title } radio` , taste : { like : [ s . tags ], avoid : [], notes : [ `this station started from " ${ s . title } ": stay close to it, then grow` ] },
history : [ copy ], played : 0 , look : s . look , palette : s . palette });
state . refFor = { blob : t . blob , left : 2 , seconds : s . seconds || 120 }; // the next two tracks are rendered with the source as reference
toast ( `New station from “ ${ s . title } ”.` , 3000 );
}
export async function stationFromCurrent () {
const cur = state . current && state . current . spec ; if ( ! cur ) return ;
const row = ( await db . get ( cur . id )) || ( state . current . blob ? { spec : cur , blob : state . current . blob } : null );
if ( ! row ) { toast ( "That one hasn't finished cooking." ); return ; }
stationFrom ( row );
}
/* ---------- the station loop ---------- */
export function makeJob ( spec ) {
if ( hooks . jobMade ) hooks . jobMade ( spec );
const job = { id : ++ ids . seq , spec , blob : null , err : null , done : null };
job . done = ( async () => {
const t0 = Date . now ();
try { job . blob = await renderTrack ( spec , ( el , exp , hedged ) => {
const t = `COOKING ${ el } s ${ exp ? " / ~" + exp + "s" : "" }${ hedged ? " · SECOND OVEN LIT" : "" } ` ;
if ( state . current === job ) $ ( "#cover .veil" ). textContent = t ;
else if ( state . nextJob === job && state . current ) $ ( "#next" ). textContent = "NEXT · " + spec . title . toUpperCase () + " · " + t ;
}); }
catch ( e ) { job . err = e ; }
logEvent ( "cook" , { ms : Date . now () - t0 , ok : ! job . err , err : job . err ? String ( job . err . message || job . err ). slice ( 0 , 80 ) : undefined , cost : spec . cost || 0 , r : spec . renderer || ( spec . noise ? "device:noise" : spec . tone ? "device:tone" : "" ), s : spec . seconds || 0 ,
mode : state . mode || "music" , st : state . station || state . seed || "" , brk : spec . break || undefined , fb : spec . fallback || undefined , title : spec . title });
})();
return job ;
}
let cooking = null ;
/* DJ intermissions: every BREAK_EVERY slots the DJ takes a 30 s break in the station's own sound — a station ident, a
recap of what just played, a tease of what's coming, a shout-out to the listener, or (now and then) the pro word.
A break plays like any track (keep it if you like it) but it is marked, never trains taste, never comes up in
shuffle or THE FIVE. Spoken breaks are sung-spoken over a bed: ACE-Step does that well with "spoken word" tags. */
export function cookNext () {
if ( state . nextJob ) return Promise . resolve ( state . nextJob );
if ( cooking ) return cooking ;
cooking = ( async () => {
try {
const real = state . history . filter (( t ) => ! t . break ). length ;
const welcome = ! state . welcomed && real >= 3 && ! state . history . some (( t ) => t . break === "welcome" ); // the DJ pops on once the brain does
const local = state . mode === "noise" || state . mode === "tones" ;
const due = ! local && ( welcome || ( BREAK_EVERY > 0 && state . sinceAd >= BREAK_EVERY - 1 && real >= 3 )); // three real tracks first: that's when the brain wakes up too
const kind = welcome ? "welcome" : BREAKS [( state . breaks || 0 ) % BREAKS . length ];
const spec = state . mode === "noise" ? noiseSpec () : state . mode === "tones" ? toneSpec () : due ? await askBreak ( kind ). catch (() => askDJ ()) : await askDJ ();
if ( spec . break ) { state . sinceAd = 0 ; state . breaks = ( state . breaks || 0 ) + 1 ; } else state . sinceAd = ( state . sinceAd || 0 ) + 1 ;
state . nextJob = makeJob ( spec ); if ( state . current ) $ ( "#next" ). textContent = ( spec . break ? `NEXT · ${ spec . break . toUpperCase () } · ` : "NEXT · " ) + spec . title . toUpperCase (); return state . nextJob ; }
catch ( e ) { toast ( e . message , 5000 ); return null ; }
finally { cooking = null ; }
})();
return cooking ;
}
/* ---------- 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. */
function showUpdate () { const u = $ ( "#upd" ); if ( u ) u . classList . remove ( "hidden" ); }
if ( "serviceWorker" in navigator && location . protocol === "https:" ) {
navigator . serviceWorker . register ( "sw.js" ). then (( reg ) => {
const check = () => reg . update (). catch (() => {});
setInterval ( check , 10 * 60 * 1000 );
document . addEventListener ( "visibilitychange" , () => { if ( document . visibilityState === "visible" ) check (); });
reg . addEventListener ( "updatefound" , () => { const nw = reg . installing ; if ( ! nw ) return ; nw . addEventListener ( "statechange" , () => { if ( nw . state === "activated" && navigator . serviceWorker . controller ) showUpdate (); }); });
}). catch (() => {});
}
$ ( "#upd" ). addEventListener ( "click" , () => location . reload ());
for ( const b of document . querySelectorAll ( "button.reload" )) b . addEventListener ( "click" , () => location . reload ());
/* light / dark: follows the system until the toggle is used, then remembered */
const sysDark = window . matchMedia ( "(prefers-color-scheme: dark)" );
function applyTheme () {
const saved = localStorage . getItem ( "gandora.theme" );
const t = saved || ( sysDark . matches ? "dark" : "light" );
if ( saved ) document . documentElement . dataset . theme = saved ; else delete document . documentElement . dataset . theme ;
const m = document . querySelector ( 'meta[name="theme-color"]' ); if ( m ) m . content = t === "dark" ? "#0b0b0e" : "#f4f4f6" ;
for ( const b of document . querySelectorAll ( "button.theme" )) b . textContent = t === "dark" ? "LIGHT" : "DARK" ;
if ( typeof drawTaste === "function" && $ ( "#taste" ) && ! $ ( "#taste" ). classList . contains ( "hidden" )) { try { openTaste (); } catch {} }
}
for ( const b of document . querySelectorAll ( "button.theme" )) b . addEventListener ( "click" , () => {
const cur = document . documentElement . dataset . theme || ( sysDark . matches ? "dark" : "light" );
localStorage . setItem ( "gandora.theme" , cur === "dark" ? "light" : "dark" ); applyTheme ();
});
sysDark . addEventListener ( "change" , applyTheme );
applyTheme ();
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 */
function keyLabel () { $ ( "#keyBtn" ). textContent = state . key ? "your venice key ✓" : "shared budget · add a venice key" ; }
keyLabel ();
/* library */
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 ());
const sharedT = new URLSearchParams ( location . search ). get ( "t" );
if ( sharedT ) {
try { const t = JSON . parse ( unb64u ( sharedT )); if ( t && t . seed && t . title ) { openStation ( t . seed , { name : t . station || "" , look : t . look , palette : t . palette , first : { title : t . title , tags : t . tags , lyrics : t . lyrics || "" , bpm : t . bpm , key : t . key , energy : t . energy , line : t . line || "" , station : t . station || "" , rseed : t . rseed , seconds : t . seconds , meter : t . meter , look : t . look , palette : t . palette } }); } }
catch { toast ( "That link didn't carry a track." ); }
} else if ( location . hash . length > 1 && ! sharedTape ) {
// a station link: resume that station if this browser has it (its latest track plays, free); otherwise fill the seed
const seed = decodeURIComponent ( location . hash . slice ( 1 )). replace ( /\+/g , " " );
db . all ( "stations" ). then (( list ) => {
const s = list . find (( x ) => x . seed === seed );
if ( s ) resumeStation ( s );
else { $ ( "#seedIn" ). value = seed ; $ ( "#seedIn" ). dispatchEvent ( new Event ( "input" )); }
}). catch (() => { $ ( "#seedIn" ). value = seed ; $ ( "#seedIn" ). dispatchEvent ( new Event ( "input" )); });
}
/* what the page shares with classic scripts loaded after it (brain.js, the harness fragments): window.g for tooling,
and the exact names brain.js uses today, as globals. Everything else stays inside the modules. */
window . g = { state , openStation , cookNext , crackle , hooks , db , wavBlob , $ , hash32 , render : { setFetcher } };
Object . assign ( window , { state , hooks , $ , db , playJob , drawCover , hash32 , prng , keyHue , KEYS , reactionWeight , drawTaste , openTaste , reactionEmoji });
2026-10-21 22:34:19 +00:00
Object . defineProperty ( window , "seq" , { get : () => ids . seq , set : ( v ) => { ids . seq = v ; } }); // brain.js increments the job counter as a global