| 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) |
| 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 |
| `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`.