Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
6.8 KiB
How g*ndora fits together
One page, one script, 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.
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) + brain.js override, 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), play/pause/skip/back/replay/loop/shuffle/remix, scrubbing, Media Session |
playJob, advance, step, swapNow, 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, swapNow |
| 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, brain.js react override |
| 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, 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 |
| 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)starts the render immediately and returns{ spec, blob: null, done: Promise }; the brain addsspec.guess.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)sets the UI from the sheet, waits forjob.done, setsstate.audio.src, startscookNext()for the one after, callsplay(), then does the bookkeeping (remember→ IndexedDB,saveStation, trail).- On
ended:swapNow()— if the next job is cooked and nothing is queued ahead of it, its source starts inside the event, synchronously (iOS only keeps background playback alive that way). If nothing is cooked, the player itself loops the crackle (state.holding); the landing track is queued (state.pendingJob) and takes over at the end of the crackle's pass, again insideended. Thenadvance()runs the bookkeeping.
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().db.get(id)for one row;db.all()loads every track with its audio — never use it to find one row. - 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. 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.html loads the live page with the backend stubbed (DJ returns a canned sheet, render returns a bundled
mp3 after a configurable delay) and drives the player through: first play, pre-cook, handoff on end, the crackle
hold and its handover, pause/resume, skip. It prints one state line per step. Run it in headless Chrome:
sh tools/harness.sh # builds the page from index.html, runs Chrome, prints the state lines
Every line should show the expected cur/next/paused/holding. Run it before and after any change to playback,
the crackle, the hold, or playJob/advance. It cannot test real audio output, autoplay policy, iOS, or the
OfflineAudioContext used by mashups — those need a phone.