Files
site/ARCHITECTURE.md
T

78 lines
6.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.