Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dhu3iHseRewQ2WpBSVLokG
14 KiB
How g*ndora fits together
One page, a handful of ES modules, one worker. This is the map: what each part owns, how a track moves through the system, and the invariants that the playback code relies on. Keep it current when you change any of them.
File map
Plain <script type="module">, static files served as-is, no build step. index.html is markup + CSS + two script
tags (app.js as a module, brain.js as a classic script with defer, so it runs after the module).
| File | Owns |
|---|---|
core.js |
state (incl. the one <audio>), hooks, $, LS, toast, sleep, asDataUrl, mmss, the job counter ids |
covers.js |
pure: hash32, prng, keyHue/KEYS, palette/PALETTES, drawCover and the looks, drawMark, drawOnAir |
player.js |
the state machine and everything that touches state.audio: setSource, toCooking … toIdle, playJob, advance, step, prime, the crackle/bed, SILENCE, scrubbing, Media Session, the analyser, the transport buttons |
render.js |
venice() behind an injectable fetcher (setFetcher), renderTrack (Runware/Venice, hedge/queue/poll), makeNoise/noiseSpec, makeTone/toneSpec, wavBlob |
dj.js |
djPrompt/askDJ, askBreak, askMash, DJ_SCHEMA, modes, breaks, voices, personas, speak |
store.js |
db (STORAGE.md), saveStation, remember, logEvent (the ledger), tapes incl. the virtual KEPT tape, tapeSpec |
mashup.js |
mashReference (OfflineAudioContext) and mashup |
ui/player.js |
cover/title/tags/lyrics, keep/share, the trail, ticks, reactions, emoji tray + picker, sheets scrim |
ui/library.js |
the stations row, the library view, now-playing bar, tapes |
ui/sheet.js |
the station sheet |
ui/taste.js |
the taste view (reactionWeight, drawTaste, openTaste) |
app.js |
boot: openStation, makeJob/cookNext, links and share, SW + update pill, theme, the key box; sets window.g and the globals brain.js uses |
brain.js |
unchanged: a classic script reading hooks, state, db, $, the cover/taste drawers and seq as globals |
window.g = { state, openStation, cookNext, crackle, hooks, db, wavBlob, $, hash32, render: { setFetcher } } is for
tooling (the harness fragments). The modules import each other in a cycle (player ↔ ui/* ↔ app), which is fine
because only function declarations cross it at load time; core.js and covers.js import nothing.
The parts
| Part | Owns | Where |
|---|---|---|
| Seed screen | the seed box, the stations row, LIBRARY / TASTE / LIGHT / RELOAD | #seed, listStations, openStation |
| Station | seed, name, taste {like, avoid, notes}, history (last 8 sheets), look, palette, DJ name + voice, welcomed |
openStation, saveStation, stationSheet; IndexedDB stations |
| DJ | writes a sheet (JSON, DJ_SCHEMA) from seed + taste + recent tracks + the brain's taste text |
djPrompt (base) + hooks.djPrompt (brain.js), askDJ; breaks: askBreak; mashups: askMash |
| Renderer | audio from a sheet | renderTrack → worker /v1/render (Runware; seed, bpm, key, meter, reference audio) or Venice queue/poll with a personal key; spoken breaks → speak → /v1/speech |
| Player | one <audio> (state.audio) and one phase (state.phase): play/pause/skip/back/replay/loop/shuffle/remix, scrubbing, Media Session |
the state machine: toCooking, toHolding, toPlaying, startNow, handOver, toEnded, toIdle; playJob, advance, step, the ended handler |
| Room tone | the vinyl crackle: a second <audio> looping a generated 4 s wav whenever music isn't playing; also used inside the player as the hold between tracks |
makeCrackle, crackleOn/Off, bed, toHolding |
| Covers | drawn from the sheet: hue from key, saturation from energy, shape from seed; looks rings / chord / attractor; palette mono / duo / tri; breaks get the ON AIR treatment | drawCover, palette, drawMark, drawOnAir; visualizer vizStart (analyser) / vizBeat (iOS) |
| Reactions | emoji / text / tag taps → taste edits on the station + spec.reaction string + timed spec.hits; brain retrains |
react, tagTap, markReaction, hooks.reacted (brain.js) |
| Breaks | every BREAK_EVERY slots once the station has 3 real tracks: welcome (once), ident, recap, shout-out, tease, sponsor |
askBreak, BREAKS, cookNext |
| Library | every rendered track (unkept ones roll off after KEEP_UNKEPT), KEPT, grouped by station, tapes strip, now-playing bar |
db, remember, the library render, libNow |
| Tapes | named ordered lists of track ids; KEPT is a virtual tape; cover from tapeSpec; MASHUP |
openTape, playTape, pickTape, tapeById, tapeSpec, mashup |
| Taste | per-station + overall nets over (tempo, energy, key x/y, minor, length, 48 hashed word slots); THE FIVE; map; brain drawing; % YOU |
brain.js, through hooks |
| Hooks | the seams something loaded after the page plugs into: djPrompt(prompt), jobMade(spec), stationOpened(seed, opts), reacted(emoji, text); each null or a function, called by the page at that moment |
hooks (top of the script); brain.js assigns them |
| Links | #seed follows the station; ?t= carries a sheet (+ render seed, length, meter, look, palette); ?tape= a tape |
setUrl, shareLink, the boot block at the end |
| PWA | manifest, icons, sw.js (network-first, shell cached), update pill, RELOAD |
head, sw.js, the updates block |
A track's life
cookNext()asks the DJ (askDJ, oraskBreakwhen a break is due) → aspec.makeJob(spec)callshooks.jobMade(the brain addsspec.guess), starts the render immediately and returns{ spec, blob: null, done: Promise }.advance()picks what plays next: tape → queued shared tape → forward in session (step(+1)) → the remix/after slot → shuffle (maybe) →state.nextJob→ or cooks one now.playJob(job)runs it through the machine:toCooking(job)puts the sheet on screen, then it waits forjob.done, sets the source and plays (toPlaying), startscookNext()for the one after, then does the bookkeeping (remember→ IndexedDB,saveStation, trail). If the player is holding when the render lands, the job queues behind the crackle instead (queueBehindHold) andhandOverstarts it.- On
ended:toEnded(). If the next job is cooked and nothing is queued ahead of it, its source starts inside the event, synchronously (startNow; iOS only keeps background playback alive that way) andplayJobfinds itpreplayed. If nothing is cooked, the player itself loops the crackle (toHolding); the landing track queues behind it (state.pendingJob) and takes over at the end of the crackle's pass, again insideended(handOver). Thenadvance()runs the bookkeeping.
The player's state machine
state.phase is the one word that says what the player is doing; state.holding is a read-only view of it
(phase === "holding"), kept because the bed and the harness read it.
idle ──openStation──▶ cooking ──render landed──▶ playing ──track ran out──▶ ended
▲ ▲ ▲ │
│ │ │ startNow (next cooked, nothing queued ahead)
│ │ └──────────────────────┤
│ │ handOver (crackle's pass ended, a track waiting)
│ holding ◀── toHolding (nothing cooked) ─┘
└── advance() → playJob (tape / step / shuffle / nextJob / cookNext) ──┘
| Phase | The player | Who sets it |
|---|---|---|
idle |
nothing on air: no station, or the station stopped | toIdle (change of station, station deleted) |
cooking |
state.current is on screen, its render hasn't landed; the player is silent and the room tone plays beside it |
toCooking (from playJob) |
holding |
the player loops the crackle in its own element so a locked phone keeps the session; a track that lands meanwhile queues as state.pendingJob |
toHolding (inside ended) |
playing |
a track's audio is in the player; pause is not a phase (state.playing says whether it runs; state.needTap that a tap is owed) |
toPlaying (the ordinary way), startNow and handOver (inside ended) |
ended |
the track ran out; transient, inside the event, before the next source starts or the hold begins | toEnded |
Only these functions set state.phase or the player's src (through setSource, which also drops the old object URL,
never the crackle's or the unlock clip's). The one exception is prime(), which loads the unlock clip; it is not a
track. advance() never touches the player directly: it picks a job and hands it to playJob.
Playback invariants (the things that have broken before)
- Unlock. The first pointer/key event plays a 50 ms silent wav (
SILENCE, built in code) instate.audioand starts the crackle element;state.primedis set when that play resolves. Every laterplay()rides on it. Theendedhandler must ignore the unlock clip. - Nothing awaits before
play()except the render. No database reads between settingsrcandplay()(toPlayingis the only place that does both the ordinary way;cookNext()is started between them but not awaited).db.get(id)for one row;db.all()loads every track with its audio — never use it to find one row. - Resume without a gesture waits for a tap. A station link opening with no activation loads the track and asks
play(); when the browser refuses,state.needTapis set and the cover says TAP TO PLAY (#cover.tap). The next tap goes throughprime(), which starts that track instead of the unlock clip. The harness's resume mode checks the wait; the tap half needs a real activation and a phone. - Crackle never routes through the player on desktop except as the hold. On iOS the analyser is never attached
(
IOS→vizBeat), so the music stays a plain element (background playback, mute switch). - The bed is a pure function of state (
bed(): not playing, not holding, player paused → on), debounced 300 ms, fades in over 600 ms. Callbed()on state changes; never toggle the crackle directly from UI code. - Source swaps that must survive a locked screen happen synchronously inside
ended(startNow,toHolding,handOver). Everything async goes after. - Breaks never train taste (
spec.break), never shuffle, never seed THE FIVE. Spoken breaks are TTS (spec.spoken).
The worker (teahubxyz/time › .github/time/cloudflare/inference.js)
x-app: gandora from an allowed origin. Routes: /v1/chat/completions (Venice, CHAT_MODELS), /v1/render
(Runware ACE-Step: prompt, lyrics, duration ≤ AUDIO_SECONDS=300, bpm, key, timeSignature as beats 2/3/4/6, seed,
quality, reference data-URL ≤ 8 MB which also fixes the length; Venice fallback; headers x-cost / x-seed /
x-renderer / x-fallback), /v1/speech (Venice TTS), /v1/audio/* and /v1/image/generate (Venice pass-through).
Quota: one KV write per call, per person per day; fails open. Deployed by Actions › inference on push.
Data
See STORAGE.md. In short: tracks {id, spec, blob, made, kept, plays}; stations {id, seed, name, taste, history,
look, palette, djName, djVoice, welcomed, played, last}; tapes {id, name, ids, made, last}. The sheet (spec) is
the unit of everything: title, tags, lyrics, bpm, key, energy, seconds, meter, line, seed, stationId, made, id,
rseed (render seed), cost, renderer, reaction, hits, guess, break/sponsored/spoken, remixOf, mashOf, look, palette.
Testing
tools/harness.sh loads the live page with the backend stubbed (DJ returns a canned sheet, render returns a generated
wav after a configurable delay) and prints one state line per step. Two modes:
sh tools/harness.sh # tools/harness.html: the station loop, autoplay allowed
sh tools/harness.sh resume # tools/harness-resume.html: resume a stored station, autoplay blocked
sh tools/harness.sh safari # the loop in WebKit (safaridriver), with a real click as the activation
The loop drives the player through: first play, pre-cook, handoff on end, the crackle hold and its handover,
pause/resume, skip. All modes serve the page and the modules from a temp folder on 127.0.0.1 (modules and IndexedDB
both need http) and run in real time: Chrome over the DevTools protocol (tools/harness-cdp.py, python3 stdlib
only), WebKit over safaridriver (tools/harness-safari.py). The fragments are inline modules, so they run after
app.js, reach the page through window.g and stub the backend with render.setFetcher.
The resume mode seeds IndexedDB with a station and one rendered track, calls openStation with no user gesture and
expects the TAP TO PLAY wait (state.needTap, #cover.tap) with the next track cooked behind it.
node tools/check-pure.mjs loads covers.js and render.js's generators in node (a canvas stub stands in for the
DOM) and draws every look and generates a wav, a noise and a tone once.
Every line should show the expected phase/cur/next/paused/holding. Run both before and after any change to
playback, the crackle, the hold, the state machine or playJob/advance. Neither can test real audio output, the
autoplay policy under a real user activation (the tap that ends the TAP TO PLAY wait), iOS background behaviour, or the
OfflineAudioContext used by mashups — those need a phone. CHROME= and CHROME_FLAGS=--no-sandbox when Chrome isn't
found or the run is root.