Files
site/ARCHITECTURE.md
T

126 lines
11 KiB
Markdown
Raw Normal View History

# 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) + `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
1. `cookNext()` asks the DJ (`askDJ`, or `askBreak` when a break is due) → a `spec`. `makeJob(spec)` calls
`hooks.jobMade` (the brain adds `spec.guess`), starts the render immediately and returns
`{ spec, blob: null, done: Promise }`.
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)` runs it through the machine: `toCooking(job)` puts the sheet on screen, then it waits for `job.done`,
sets the source and plays (`toPlaying`), starts `cookNext()` 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`) and `handOver` starts it.
4. 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) and `playJob` finds it
`preplayed`. 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 inside `ended` (`handOver`). Then
`advance()` 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) 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()`
(`toPlaying` is 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.needTap` is set and the cover says TAP TO PLAY (`#cover.tap`). The next
tap goes through `prime()`, 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. 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`** (`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, from file://
sh tools/harness.sh resume # tools/harness-resume.html: resume a stored station, autoplay blocked
The loop drives the player through: first play, pre-cook, handoff on end, the crackle hold and its handover,
pause/resume, skip. It runs under headless Chrome's virtual time (`--dump-dom --virtual-time-budget`), so it is quick
and deterministic.
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. IndexedDB needs
http, so the page is served from a temp folder on 127.0.0.1; and virtual time does not wait for IndexedDB, so this
mode is driven in real time over the DevTools protocol by `tools/harness-cdp.py` (python3 stdlib only).
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.