Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
78 lines
6.8 KiB
Markdown
78 lines
6.8 KiB
Markdown
# 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
|
||
|
||
1. `cookNext()` asks the DJ (`askDJ`, or `askBreak` when a break is due) → a `spec`. `makeJob(spec)` starts the render
|
||
immediately and returns `{ spec, blob: null, done: Promise }`; the brain adds `spec.guess`.
|
||
2. `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.
|
||
3. `playJob(job)` sets the UI from the sheet, waits for `job.done`, sets `state.audio.src`, starts `cookNext()` for the
|
||
one after, calls `play()`, then does the bookkeeping (`remember` → IndexedDB, `saveStation`, trail).
|
||
4. 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 inside `ended`. Then `advance()` 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) in `state.audio` and starts
|
||
the crackle element; `state.primed` is set when that play resolves. Every later `play()` rides on it. The `ended`
|
||
handler must ignore the unlock clip.
|
||
- **Nothing awaits before `play()` except the render.** No database reads between setting `src` and `play()`.
|
||
`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. Call `bed()` 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.
|