Merge pull request 'playback: one explicit state machine (idle → cooking → holding → playing → ended)' (#1) from playback-state-machine into main
shots / shots (push) Successful in 2m24s

playback as one state machine; brain.js via hooks; db.get; harness resume mode; docs (#1)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
This commit was merged in pull request #1.
This commit is contained in:
teahubxyzandClaude Fable 5.1 committed 2026-10-21 06:14:04 +00:00
commit fc0bfe05a6
10 files changed
+425 -113

No files matched your search

+72 -23
View File
@@ -9,44 +9,82 @@ the invariants that the playback code relies on. Keep it current when you change
|---|---|---|
| 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` |
| 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`), 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` |
| 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`, brain.js `react` override |
| 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`, `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 |
| 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)` starts the render
immediately and returns `{ spec, blob: null, done: Promise }`; the brain adds `spec.guess`.
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)` 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.
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()`.
- **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`.** Everything async goes after.
- **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)
@@ -66,12 +104,23 @@ rseed (render seed), cost, renderer, reaction, hits, guess, break/sponsored/spok
## 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:
`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 # builds the page from index.html, runs Chrome, prints the state lines
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
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.
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.
+31 -7
View File
@@ -7,16 +7,38 @@ do. Read ARCHITECTURE.md first.
- `index.html` is ~1,900 lines in one script. It grew feature by feature in one long session. The sections are
commented and ordered sensibly, but state is one global `state` object and functions reach into each other freely.
- brain.js **overrides** page functions by assignment (`djPrompt = …`, `openStation = …`, `makeJob = …`,
`react = …`) so it can be loaded after the page. A refactor should turn these into explicit hooks.
- Playback has three overlapping mechanisms layered over time: the ordinary `playJob/advance` path, the synchronous
`ended` swap (`swapNow`) for locked screens, and the crackle hold with `pendingJob`. They work together (the
harness proves the paths) but they should be one small state machine: `idle → cooking → holding → playing → ended`.
- brain.js plugs into the page through `hooks` (`djPrompt`, `jobMade`, `stationOpened`, `reacted`); it no longer
overrides page functions by assignment. Anything else loaded after the page should do the same.
- Playback is one small state machine (`state.phase`: idle → cooking → holding → playing → ended) with named
transitions; see ARCHITECTURE.md › The player's state machine. `state.holding` is a read-only view of the phase.
- The crackle exists twice: as the side element (room tone) and as the player's own source (the hold). That is
deliberate (iOS needs the hold in the same element) but easy to misread.
- `db.all()` is used in many places where `db.get` or a filtered index would do; it loads every blob.
- `db.all()` is still used where a filter is needed (a station's tracks, the prune in `remember`, the trail, shuffle,
the library, the brain). Single rows go through `db.get`; tapes through `tapeById` (KEPT is virtual). An index on
`spec.stationId` would remove most of the remaining `db.all()` calls.
- Several things are computed from the sheet in more than one place (section-at-time, mmss, kind chips).
## Found while refactoring playback, and deliberately left alone
- `playJob` on an errored render (`job.err`) calls `crackleOff()` and leaves the phase where it was; if the player was
holding, the crackle keeps looping in the player until skip. Pre-existing; the machine reproduces it.
- `advance()` with nothing to play (`!job`) calls `crackleOff()` and returns without `bed()`; the room tone comes back
on the next state change. Pre-existing.
- `#skip` during a hold pauses the crackle-in-player; the phase stays `holding` (paused) until the landing track takes
over through `toPlaying`. That is how it behaved before; `bed()` stays off meanwhile, as before.
- `setSource` now refuses to revoke the crackle element's object URL (the old `swapNow` could, on a rarely reachable
branch). The only intended behaviour change; it can only prevent a dead crackle.
- `toIdle` resolves a parked `queueBehindHold` promise when the station changes, so that `playJob` returns instead of
leaking; before, it stayed pending forever. Harmless either way.
- `prime()` still sets `state.audio.src` itself for the unlock clip and for the TAP TO PLAY start; it is the one source
write outside the machine, by design.
- brain.js's `drawPalette` and the library tiles call `playJob` directly with a resolved job; fine, but they bypass
`advance()`, so the session's back/forward list does not learn about them (as before).
- The `#play` button's `tapStarted` guard (ignore the click that `prime()` already used to start a resume) is timing
based (800 ms). Untouched.
- STORAGE.md listed four `db` calls and two stores; the code has `db.get` and a `tapes` store, so the doc was brought up
to the code (the contract itself is unchanged).
## Known rough edges
- iOS volume sliders are inert (platform); the welcome/ident may fall back to Venice if Runware times out on a
@@ -26,10 +48,12 @@ do. Read ARCHITECTURE.md first.
- Share links made before the render seed was included re-roll the song.
- `KEEP_UNKEPT = 40` unkept tracks roll off; nothing warns.
- Light theme: a few hard-coded colours may remain in rarely-seen sheets.
- The harness's resume mode checks the TAP TO PLAY wait but cannot press the tap: headless Chrome has no real user
activation. The tap half is a phone test.
## What a refactor should keep
- Every invariant in ARCHITECTURE.md › Playback invariants, and the harness green before and after.
- Every invariant in ARCHITECTURE.md › Playback invariants, and the harness green (both modes) before and after.
- The sheet as the unit (everything derives from `spec`), covers as a pure function of it.
- One static page, no build step, no framework, no keys in the page.
- STORAGE.md's contract (chai's DB adapter targets it).
+4 -2
View File
@@ -19,12 +19,14 @@ browser (IndexedDB) — see STORAGE.md.
## Files
- `index.html` — the whole app: markup, styles, and the player, station, library, tapes, breaks, covers, sheets.
- `index.html` — the whole app: markup, styles, and the player (a small state machine), station, library, tapes,
breaks, covers, sheets, and the `hooks` brain.js plugs into.
- `brain.js` — the taste model (a small net per station and overall), the TASTE screen, the DJ's taste text.
- `sw.js` — service worker: caches the shell; stamped with the commit by the shots workflow.
- `manifest.json`, `icons/`, `favicon.svg` — PWA. Icons are drawn by `tools/icons.py` (stdlib only).
- `tools/shots.sh` — the card image and the icons, run by `.gitea/workflows/shots.yml` on every push.
- `tools/harness.sh` + `tools/harness.html` — the playback regression harness (ARCHITECTURE.md › Testing).
- `tools/harness.sh` + `tools/harness.html` (the station loop) + `tools/harness-resume.html` and `tools/harness-cdp.py`
(resume with autoplay blocked) — the playback regression harness (ARCHITECTURE.md › Testing).
- `STORAGE.md` — the IndexedDB contract. `ARCHITECTURE.md` — how it all fits. `HANDOFF.md` — read before changing things.
## Backend
+20 -4
View File
@@ -1,17 +1,20 @@
# gandora storage contract
The page keeps everything through one object, `db`, in `index.html`. Today it is IndexedDB in the browser; a backend
replaces it by implementing the same four calls and pointing `db` at the new implementation. Nothing else in the page
touches storage (`brain.js` reads through `db.all()` too).
replaces it by implementing the same calls and pointing `db` at the new implementation. Nothing else in the page
touches storage (`brain.js` reads through `db.all()` and `db.get()` too).
```
db.put(row, store = "tracks") // create or replace, keyed by row.id
db.get(id, store = "tracks") // one row, or null
db.all(store = "tracks") // every row, newest first (tracks by `made`, stations by `last`)
db.del(id, store = "tracks")
db.open() // internal; a backend adapter can drop it
```
Two stores.
Three stores: `tracks`, `stations`, `tapes`. `db.all("tapes")` puts a virtual tape first, `{ id: "kept", name: "KEPT",
ids: [every kept track, oldest first], virtual: true }`, built from the tracks; `db.put` ignores it and `db.get("kept",
"tapes")` is null (the page's `tapeById` knows).
## tracks
@@ -47,11 +50,24 @@ Two stores.
}
```
## tapes
```
{
id: "tape-1761002400000",
name: "late trains",
ids: [ "1760998800000-211979153", ... ], // track ids, in order
made: 1761002400000,
last: 1761002400000
}
```
## what a backend adds
- identity: rows are per listener; `/user/station/track` links need the listener in the key
- `blob` as a URL the `<audio>` element and the Web Audio analyser can load (same-origin or CORS-enabled)
- sync: the page calls `db.all()` freely (library, taste, play counts), so cache on the client and keep `all()` cheap
- sync: the page calls `db.all()` freely (library, taste, a station's tracks, play counts), so cache on the client and
keep `all()` cheap; `db.get()` is on the playback path (stepping back and forward, tapes), so keep it fast too
## what stays on the page
+6 -14
View File
@@ -124,19 +124,17 @@ function tasteText() {
(likes ? ` Words it warms to: ${likes}.` : "") + (dislikes ? ` Words it cools on: ${dislikes}.` : "") +
` Aim near the likely region and lean on the warm words unless the listener's own words say otherwise; keep some variety.`;
}
const _djPromptBase = djPrompt; // assignment, not a declaration: a declaration would hoist over this
djPrompt = function () {
const base = _djPromptBase();
/* the page's hooks (index.html › hooks): the taste text rides into the DJ's prompt, and a station opening retrains */
hooks.djPrompt = (base) => {
const tt = tasteText();
return tt ? base.replace("\nWrite the NEXT track.", `\n${tt}\n\nWrite the NEXT track.`) : base;
};
const _openStation = openStation;
openStation = async function (seed, s) { await _openStation(seed, s); rebuildBrains().catch(() => {}); };
hooks.stationOpened = () => { rebuildBrains().catch(() => {}); };
/* ---------- plays: every start counts, and so does every pass of the loop ---------- */
async function countPlay(spec) {
if (!spec || !spec.id) return;
const row = (await db.all()).find((t) => t.id === spec.id); if (!row) return;
const row = await db.get(spec.id); if (!row) return;
row.plays = (row.plays || 0) + 1; row.spec.plays = row.plays; spec.plays = row.plays;
await db.put(row);
if (row.plays === 2 || row.plays === 4 || row.plays % 8 === 0) rebuildBrains().catch(() => {});
@@ -150,11 +148,7 @@ state.audio.addEventListener("timeupdate", () => { // a loop wra
});
/* ---------- the NEXT line carries the net's guess for the track in the oven ---------- */
const _makeJob = makeJob;
makeJob = function (spec) {
const net = brainNow(); if (net) spec.guess = net.predict(spec);
return _makeJob(spec);
};
hooks.jobMade = (spec) => { const net = brainNow(); if (net) spec.guess = net.predict(spec); };
const guessText = (spec) => spec && spec.guess != null ? ` · ${Math.round(spec.guess * 100)}% YOU` : "";
(function () {
const next = $("#next");
@@ -283,9 +277,7 @@ async function openTaste() {
openTaste.redraw = show;
}
/* a reaction while TASTE is open: retrain, and let the neurons flare */
const _react = react;
react = async function (emoji, text) {
await _react(emoji, text);
hooks.reacted = () => {
rebuildBrains().then(() => {
if ($("#taste").classList.contains("hidden") || !openTaste.redraw) return;
let t0 = performance.now();
+115 -51
View File
@@ -416,6 +416,14 @@ const state = {
audio: Object.assign(new Audio(), { volume: Math.min(1, Math.max(0, parseFloat(localStorage.getItem("gandora.vol") || "1"))) }), cost: parseFloat(localStorage.getItem(LS.cost) || "0"),
};
state.audio.preload = "auto";
/* hooks: the seams something loaded after the page (brain.js) plugs into. Each is null or a function; the page calls
them at the named moments. Assign, don't override the page's functions. */
const hooks = {
djPrompt: null, // (prompt) => prompt the DJ's prompt, to add to (brain.js adds the taste text)
jobMade: null, // (spec) a sheet about to be rendered (brain.js writes spec.guess)
stationOpened: null, // (seed, opts) after openStation finished
reacted: null, // (emoji, text) after a reaction was taken in
};
/* ---------- small utils ---------- */
const toast = (m, ms = 2400) => { const t = $("#toast"); t.textContent = m; t.classList.add("on"); clearTimeout(toast.h); toast.h = setTimeout(() => t.classList.remove("on"), ms); };
@@ -787,8 +795,9 @@ Return JSON:
- station: a two or three word station name grown from the seed (keep it stable once chosen)
- line: one line, under 90 characters, the DJ says about this track to the listener (no quotes, no emoji)`;
}
const djText = () => { const p = djPrompt(); return hooks.djPrompt ? hooks.djPrompt(p) : p; };
async function askDJ() {
const body = { model: state.dj, messages: [{ role: "user", content: djPrompt() }], max_tokens: 1200, temperature: 0.9, stream: false,
const body = { model: state.dj, messages: [{ role: "user", content: djText() }], max_tokens: 1200, temperature: 0.9, stream: false,
venice_parameters: { disable_thinking: true, strip_thinking_response: true, include_venice_system_prompt: false },
response_format: { type: "json_schema", json_schema: { name: "track", strict: true, schema: DJ_SCHEMA } } };
let d;
@@ -912,7 +921,7 @@ async function listStations() {
}
/* the station sheet: rename, reset its taste, delete it (kept tracks stay) */
async function stationSheet(id) {
const s = (await db.all("stations")).find((x) => x.id === id); if (!s) return;
const s = await db.get(id, "stations"); if (!s) return;
const p = $("#stationSheet"); p.innerHTML = "";
p.append(Object.assign(document.createElement("h4"), { textContent: "STATION" }));
const x = Object.assign(document.createElement("button"), { className: "x", textContent: "×", title: "close" }); x.setAttribute("aria-label", "close");
@@ -977,7 +986,7 @@ async function stationSheet(id) {
if (!confirm(`Delete “${s.name || s.seed}” and its unkept tracks? Kept tracks stay in the library.`)) return;
for (const t of (await db.all()).filter((t) => t.spec.stationId === id && !t.kept)) await db.del(t.id);
await db.del(id, "stations");
if (state.stationId === id) { state.audio.pause(); state.playing = false; setPlayIcon(); state.current = null; state.nextJob = null; state.stationId = ""; state.seed = ""; state.station = ""; $("#player").classList.add("hidden"); $("#seed").classList.remove("hidden"); $("#nowRow").classList.add("hidden"); setUrl(); }
if (state.stationId === id) { toIdle(); state.stationId = ""; state.seed = ""; state.station = ""; $("#player").classList.add("hidden"); $("#seed").classList.remove("hidden"); $("#nowRow").classList.add("hidden"); setUrl(); }
p.classList.add("hidden"); listStations().catch(() => {}); toast("Station deleted.");
});
row.append(save, reset, del); p.append(row); p.classList.remove("hidden"); name.focus({ preventScroll: true });
@@ -994,9 +1003,10 @@ function shareLink() {
return `${location.origin}/?t=${b64u(JSON.stringify(t))}`;
}
function setUrl() { try { history.replaceState(null, "", state.seed ? "/#" + encodeURIComponent(state.seed).replace(/%20/g, "+") : "/"); } catch {} }
async function openStation(seed, { id, name = "", taste, history, played, first, look, palette: pal, djName = "", djVoice = "", welcomed = false } = {}) {
async function openStation(seed, opts = {}) { await openStationNow(seed, opts); if (hooks.stationOpened) hooks.stationOpened(seed, opts); }
async function openStationNow(seed, { id, name = "", taste, history, played, first, look, palette: pal, djName = "", djVoice = "", welcomed = false } = {}) {
state.look = look || "rings"; state.palette = pal || "duo"; state.djName = djName; state.djVoice = djVoice; state.welcomed = welcomed; state.sinceAd = 0; state.breaks = 0;
if (state.stationId !== id) { state.audio.pause(); state.playing = false; setPlayIcon(); state.current = null; state.nextJob = null; state.recent = []; state.pos = -1; state.tape = null; }
if (state.stationId !== id) { toIdle(); state.recent = []; state.pos = -1; state.tape = null; }
state.seed = seed; state.station = name; state.stationId = id || `s-${Date.now()}-${hash32(seed)}`;
state.taste = taste || { like: [], avoid: [], notes: [] }; state.history = history || []; state.played = played || 0;
$("#stationName").textContent = name || seed;
@@ -1050,6 +1060,7 @@ async function remember(job, kept) {
/* ---------- the station loop ---------- */
let seq = 0;
function makeJob(spec) {
if (hooks.jobMade) hooks.jobMade(spec);
const job = { id: ++seq, spec, blob: null, err: null, done: null };
job.done = (async () => {
try { job.blob = await renderTrack(spec, (el, exp, hedged) => {
@@ -1117,9 +1128,35 @@ function cookNext() {
})();
return cooking;
}
async function playJob(job) {
/* ---------- the player: one small state machine ----------
state.phase is one of
idle nothing on air: no station, or the station stopped
cooking state.current is on screen and its render hasn't landed; the player is silent, the room tone plays beside it
holding the player itself loops the crackle (a locked phone keeps the session only while the element stays busy);
a track that lands meanwhile queues behind it (state.pendingJob) and takes over when the crackle's pass ends
playing a track's audio is in the player (pause is not a phase: state.playing says whether it runs)
ended the track ran out; inside the event the next source starts or the hold begins, then advance() picks on
The transitions are the functions below (toCooking, toHolding, toPlaying, startNow, handOver, toEnded, toIdle) and
only they set state.phase or the player's src. The one exception is prime(): the unlock clip, which is not a track.
The order inside `ended` matters: a source that must survive a locked screen starts synchronously there; the
bookkeeping comes after. And nothing awaits between setting src and play() except the render itself (a database read
there has lost the user activation before). */
state.phase = "idle";
Object.defineProperty(state, "holding", { get: () => state.phase === "holding" }); // the old flag, now read-only
const srcOf = (job) => job.url || (job.url = URL.createObjectURL(job.blob));
/* every source change goes through here; it drops the old object URL, never the crackle's or the unlock clip's */
function setSource(src, loop) {
const old = state.audio.src;
if (old && old !== SILENCE && !(crackle.el && old === crackle.el.src)) URL.revokeObjectURL(old);
state.audio.src = src; state.audio.loop = loop;
}
/* → cooking: the sheet goes on screen and the player waits for the render (resolved already for a library track).
A hold in progress stays a hold: the track will queue behind the crackle. A track started inside `ended`
(job.preplayed) is already playing and keeps that phase. */
function toCooking(job) {
const spec = job.spec;
state.current = job;
if (!job.preplayed && state.phase !== "holding") state.phase = "cooking";
if (!job.stepping) { state.history.push(spec); state.played = (state.played || 0) + 1; spec.stationId ||= state.stationId; }
keepLabel();
for (const t of $("#bar").querySelectorAll("em")) t.remove();
@@ -1132,27 +1169,76 @@ async function playJob(job) {
if (!$("#nowRow").classList.contains("hidden")) { drawCover($("#now canvas"), spec); $("#nowTitle").textContent = `${spec.title} · ${state.station || spec.seed}`; }
$("#cover").classList.add("cooking"); $("#bar i").style.width = "0";
bed(); // the needle is down while it cooks
}
/* → holding, inside `ended`: nothing is cooked, so the player loops the crackle in its own element to stay alive */
function toHolding() {
try { crackle.el ||= makeCrackle(); setSource(crackle.el.src, true); state.phase = "holding"; const p = state.audio.play(); if (p && p.catch) p.catch(() => {}); } catch {}
}
/* a landed track while the player holds: it queues behind the crackle, the loop is switched off so the pass ends, and
handOver starts it inside that `ended`. Resolves once the track has been handed over (or dropped). */
function queueBehindHold(job) {
return new Promise((r) => { srcOf(job); state.pendingJob = job; state.onHoldEnd = r; state.audio.loop = false; });
}
/* → playing, inside `ended`: the crackle's pass is over and the queued track takes its place, synchronously */
function handOver(job) {
state.pendingJob = null;
const go = state.onHoldEnd; state.onHoldEnd = null;
if (state.current !== job) { if (go) go(); toEnded(); return; } // dropped meanwhile: treat it as a track ending
try { setSource(srcOf(job), !!state.loop); state.phase = "playing"; const p = state.audio.play(); if (p && p.catch) p.catch(() => {}); job.preplayed = true; } catch {}
if (go) go();
}
/* → playing, inside `ended`: the next track is cooked and nothing is queued ahead of it, so its source starts right
here; playJob then finds it preplayed and only does the bookkeeping */
function startNow(job) {
try { setSource(srcOf(job), !!state.loop); state.phase = "playing"; const p = state.audio.play(); if (p && p.catch) p.catch(() => {}); job.preplayed = true; return true; }
catch { return false; }
}
/* → playing, the ordinary way: the track's audio goes into the player and play() is asked for. Refused (a resume with
no gesture yet) means TAP TO PLAY: the source stays loaded and prime() starts it on the next tap. */
async function toPlaying(job) {
if (!job.preplayed) { setSource(srcOf(job), !!state.loop); state.pendingJob = null; }
state.phase = "playing";
try { if (!job.preplayed || state.audio.paused) await state.audio.play(); state.playing = true; state.primed = true; state.needTap = false; $("#cover").classList.remove("tap"); crackleOff(); setPlayIcon(); vizStart(); }
catch { state.needTap = true; $("#cover .veil").textContent = "TAP TO PLAY"; $("#cover").classList.add("tap"); }
}
/* → ended: the track ran out. Synchronously: start the cooked next track, or begin the hold; then advance() picks what
follows and does the bookkeeping. A tape, a queued shared tape, a remix/after slot, forward history or shuffle count
as "queued ahead": advance() decides those, so nothing is started here. */
function toEnded() {
state.phase = "ended"; state.playing = false; setPlayIcon(); vizStop();
const job = state.nextJob;
const queued = state.tape || (state.tapeQueue && state.tapeQueue.length) || state.after || state.pos < state.recent.length - 1 || state.shuffle;
if (!queued) { if (job && job.blob) startNow(job); else toHolding(); }
advance();
}
/* → idle: the station stops (a change of station, a deleted station) */
function toIdle() {
state.audio.pause(); state.playing = false; setPlayIcon();
state.current = null; state.nextJob = null; state.pendingJob = null; state.phase = "idle";
const go = state.onHoldEnd; state.onHoldEnd = null; if (go) go();
}
/* a job through the machine: show it, wait for its render, start it, then the bookkeeping */
async function playJob(job) {
const spec = job.spec;
toCooking(job);
await job.done;
if (state.current !== job) return; // skipped while cooking
$("#cover").classList.remove("cooking");
if (job.err) { crackleOff(); toast(job.err.message, 6000); $("#lyric").textContent = job.err.message + (/limit|budget/i.test(job.err.message) ? "" : " · skip to try the next"); return; }
if (state.holding && !state.audio.paused) { // the crackle is on: queue behind it, it hands over at the end of its loop
job.url = job.url || URL.createObjectURL(job.blob);
await new Promise((r) => { state.pendingJob = job; state.onHoldEnd = r; state.audio.loop = false; });
if (state.phase === "holding" && !state.audio.paused) { // the crackle is on: queue behind it, it hands over at the end of its loop
await queueBehindHold(job);
if (state.current !== job) return;
}
if (!job.preplayed) { if (state.audio.src && !state.holding && state.audio.src !== SILENCE) URL.revokeObjectURL(state.audio.src); state.audio.loop = !!state.loop; state.holding = false; state.pendingJob = null; state.audio.src = job.url || URL.createObjectURL(job.blob); }
spec.id ||= spec.made + "-" + hash32(spec.title);
if (!job.stepping) cookNext(); // render ahead: starts now, not after play() comes back
try { if (!job.preplayed || state.audio.paused) await state.audio.play(); state.playing = true; state.primed = true; state.needTap = false; $("#cover").classList.remove("tap"); crackleOff(); setPlayIcon(); vizStart(); }
catch { state.needTap = true; $("#cover .veil").textContent = "TAP TO PLAY"; $("#cover").classList.add("tap"); }
await toPlaying(job);
nowPlaying(spec); document.title = `${spec.title} · g*ndora`;
cookNext(); // (no-op if already cooking)
// the bookkeeping, after the music is already going
if (!job.stepping) { state.recent = state.recent.slice(0, state.pos + 1); state.recent.push(spec.id); state.pos = state.recent.length - 1; }
(async () => {
await remember(job, false).then(keepLabel).catch(() => {}); saveStation().catch(() => {});
if (job.fileInto) { const tp = (await db.all("tapes")).find((t) => t.id === job.fileInto); if (tp && !tp.ids.includes(spec.id)) { tp.ids.push(spec.id); await db.put(tp, "tapes"); } }
if (job.fileInto) { const tp = await tapeById(job.fileInto); if (tp && !tp.ids.includes(spec.id)) { tp.ids.push(spec.id); await db.put(tp, "tapes"); } }
drawTrail().catch(() => {});
})().catch(() => {});
}
@@ -1204,10 +1290,12 @@ async function shuffled() {
for (let i = 0; i < pool.length; i++) { r -= w[i]; if (r <= 0) return pool[i]; }
return pool[pool.length - 1];
}
/* what plays next, in order: the tape → a shared tape still rendering → forward in the session → the remix/after
slot → shuffle (maybe) → the cooked next → or cook one now. Called after toEnded() and on skip. */
async function advance() {
// a tape playing: the next track on it, around again if it repeats, otherwise back to the station
if (state.tape) {
const tp = (await db.all("tapes")).find((t) => t.id === state.tape.id);
const tp = await tapeById(state.tape.id);
if (tp) { const n = state.tape.i + 1; if (n < tp.ids.length || state.tape.loop) return playTape(tp, n); }
state.tape = null; $("#stationName").textContent = state.station || state.seed; toast("End of the tape — back to the station.");
}
@@ -1229,38 +1317,11 @@ async function advance() {
await playJob(job);
cookNext();
}
/* the screen-locked case: iOS keeps background playback alive only if the next source starts inside the `ended`
event itself, synchronously. So when the next track is already cooked and nothing else is queued ahead of it,
the swap happens right here, and playJob does the rest with the audio already running. */
function swapNow() {
const job = state.nextJob;
const queued = state.tape || (state.tapeQueue && state.tapeQueue.length) || state.after || state.pos < state.recent.length - 1 || state.shuffle;
if (!queued && !(job && job.blob)) {
// nothing cooked yet: hold the session open with the crackle, in this same element, until the track lands
try { crackle.el ||= makeCrackle(); if (state.audio.src) URL.revokeObjectURL(state.audio.src); state.audio.src = crackle.el.src; state.audio.loop = true; state.holding = true; const p = state.audio.play(); if (p && p.catch) p.catch(() => {}); } catch {}
return false;
}
if (!job || !job.blob || queued) return false;
try {
if (state.audio.src) URL.revokeObjectURL(state.audio.src);
state.audio.src = job.url || (job.url = URL.createObjectURL(job.blob));
const p = state.audio.play(); if (p && p.catch) p.catch(() => {});
job.preplayed = true;
return true;
} catch { return false; }
}
/* the player's events. `ended` is the only one that moves the machine; the rest keep state.playing honest. */
state.audio.addEventListener("ended", () => {
if (state.audio.src === SILENCE || !state.stationId || !state.current) return; // the unlock clip, or nothing on air
// the crackle finishing its loop with a track waiting: hand over right here, synchronously
if (state.holding && state.pendingJob) {
const job = state.pendingJob; state.pendingJob = null; state.holding = false;
if (state.current !== job) { const go = state.onHoldEnd; state.onHoldEnd = null; if (go) go(); state.playing = false; setPlayIcon(); swapNow(); advance(); return; }
try { state.audio.loop = !!state.loop; state.audio.src = job.url || (job.url = URL.createObjectURL(job.blob)); const p = state.audio.play(); if (p && p.catch) p.catch(() => {}); job.preplayed = true; } catch {}
const go = state.onHoldEnd; state.onHoldEnd = null; if (go) go();
return;
}
if (state.holding) return; // crackle loop ran out with nothing waiting (loop is on, so this is rare)
state.playing = false; setPlayIcon(); vizStop(); swapNow(); advance();
if (state.phase === "holding") { if (state.pendingJob) handOver(state.pendingJob); return; } // the crackle's pass ran out: hand over, or (loop is on, so rarely) nothing waits
toEnded();
});
state.audio.addEventListener("pause", () => { if (!state.audio.ended) { state.playing = false; setPlayIcon(); vizStop(); } bed(); });
state.audio.addEventListener("playing", () => { if (state.audio.src !== SILENCE && !state.holding) { state.playing = true; crackleOff(); } });
@@ -1336,6 +1397,7 @@ async function react(emoji, text) {
saveStation().catch(() => {});
remember(state.current, false).catch(() => {}); // the reaction rides with the track in the library
toast(`${emoji || "Noted"} — the ${later ? "track after next" : "next track"} hears it.`);
if (hooks.reacted) hooks.reacted(emoji, text);
}
/* ---------- UI wiring ---------- */
@@ -1663,7 +1725,9 @@ async function pickTape(track) {
p.append(inp, close); p.classList.remove("hidden"); inp.focus({ preventScroll: true });
}
state.tape = null; // { id, ids, i, loop } while a tape is playing
async function tapeRows(tp) { const all = await db.all(); return tp.ids.map((id) => all.find((t) => t.id === id)).filter(Boolean); }
/* one tape by id. KEPT is virtual (db.all("tapes") writes it from the kept tracks), so it is the one that needs the list */
const tapeById = async (id) => id === "kept" ? (await db.all("tapes"))[0] : db.get(id, "tapes");
async function tapeRows(tp) { return (await Promise.all(tp.ids.map((id) => db.get(id)))).filter(Boolean); }
/* a tape's cover: a sheet blended from its tracks. The key is the one most of them share (the cover's hue), tempo and
energy are averages, the tags are everyone's, the seed is the tape's name so each tape keeps its own shape. */
function tapeSpec(tp, rows) {
@@ -1675,7 +1739,7 @@ function tapeSpec(tp, rows) {
look: specs.length ? (specs[specs.length - 1].look || state.look) : "rings", palette: specs.length > 1 ? "tri" : "duo" };
}
async function openTape(id) {
const tp = (await db.all("tapes")).find((t) => t.id === id); if (!tp) return;
const tp = await tapeById(id); if (!tp) return;
$("#lib").classList.add("hidden"); $("#tape").classList.remove("hidden"); $("#tape").dataset.id = id;
$("#tapeName").value = tp.name; $("#tapeName").disabled = !!tp.virtual; $("#tapeDelete").classList.toggle("hidden", !!tp.virtual);
const rows = await tapeRows(tp), list = $("#tapeList"); list.innerHTML = "";
@@ -1705,9 +1769,9 @@ async function playTape(tp, i, loop) {
const t = rows[i]; state.audio.pause();
await playJob({ id: ++seq, spec: t.spec, blob: t.blob, err: null, done: Promise.resolve(), stepping: true });
}
$("#tapePlay").addEventListener("click", async () => { const tp = (await db.all("tapes")).find((t) => t.id === $("#tape").dataset.id); if (tp) playTape(tp, 0); });
$("#tapeLoop").addEventListener("click", async () => { const tp = (await db.all("tapes")).find((t) => t.id === $("#tape").dataset.id); if (!tp) return; const on = !(state.tape && state.tape.id === tp.id && state.tape.loop); if (state.tape && state.tape.id === tp.id) state.tape.loop = on; else await playTape(tp, 0, on); $("#tapeLoop").classList.toggle("go", on); toast(on ? "Repeating the whole tape." : "Tape plays through once."); });
$("#tapeName").addEventListener("change", async () => { const tp = (await db.all("tapes")).find((t) => t.id === $("#tape").dataset.id); if (!tp) return; tp.name = $("#tapeName").value.trim().slice(0, 60) || tp.name; await db.put(tp, "tapes"); });
$("#tapePlay").addEventListener("click", async () => { const tp = await tapeById($("#tape").dataset.id); if (tp) playTape(tp, 0); });
$("#tapeLoop").addEventListener("click", async () => { const tp = await tapeById($("#tape").dataset.id); if (!tp) return; const on = !(state.tape && state.tape.id === tp.id && state.tape.loop); if (state.tape && state.tape.id === tp.id) state.tape.loop = on; else await playTape(tp, 0, on); $("#tapeLoop").classList.toggle("go", on); toast(on ? "Repeating the whole tape." : "Tape plays through once."); });
$("#tapeName").addEventListener("change", async () => { const tp = await tapeById($("#tape").dataset.id); if (!tp) return; tp.name = $("#tapeName").value.trim().slice(0, 60) || tp.name; await db.put(tp, "tapes"); });
$("#tapeDelete").addEventListener("click", async () => { const id = $("#tape").dataset.id; if (!confirm("Delete this tape? The tracks stay in the library.")) return; await db.del(id, "tapes"); if (state.tape && state.tape.id === id) state.tape = null; $("#tape").classList.add("hidden"); $("#libBtn").click(); });
/* mashups: up to three tracks in, one out. The DJ writes a sheet that fuses theirs; the renderer is handed the
tracks themselves, cut and crossfaded into one reference (OfflineAudioContext, off the playback path, so iOS is
@@ -1769,13 +1833,13 @@ async function mashup(rows) {
} catch (e) { toast(e.message, 5000); }
}
$("#tapeMash").addEventListener("click", async () => {
const tp = (await db.all("tapes")).find((t) => t.id === $("#tape").dataset.id); if (!tp) return;
const tp = await tapeById($("#tape").dataset.id); if (!tp) return;
const rows = await tapeRows(tp);
$("#tape").classList.add("hidden"); $("#player").classList.remove("hidden"); $("#seed").classList.add("hidden");
mashup(rows);
});
$("#tapeShare").addEventListener("click", async () => {
const tp = (await db.all("tapes")).find((t) => t.id === $("#tape").dataset.id); if (!tp) return;
const tp = await tapeById($("#tape").dataset.id); if (!tp) return;
const rows = await tapeRows(tp);
const payload = { name: tp.name, tracks: rows.map(({ spec: s }) => ({ seed: s.seed, station: s.station || "", title: s.title, tags: s.tags, lyrics: s.lyrics || "", bpm: s.bpm, key: s.key, energy: s.energy, line: s.line || "" })) };
const url = `${location.origin}/?tape=${b64u(JSON.stringify(payload))}`;
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""tools/harness-cdp.py CHROME URL [TIMEOUT_S] [CHROME_FLAG ...]
Runs a harness page in headless Chrome in real time and prints its #diag once it says END (or the timeout passes).
--dump-dom with --virtual-time-budget (the loop harness) is deterministic but IndexedDB answers in real time and
virtual time does not wait for it, so the resume harness is driven over the DevTools protocol instead: start Chrome
with a debugging port, attach to the page, poll Runtime.evaluate. Stdlib only (a 40-line websocket client)."""
import base64, json, os, re, socket, struct, subprocess, sys, tempfile, time, urllib.request
class WS:
def __init__(self, url):
m = re.match(r"ws://([^:/]+):(\d+)(/.*)", url)
self.s = socket.create_connection((m.group(1), int(m.group(2))), timeout=10)
key = base64.b64encode(os.urandom(16)).decode()
self.s.sendall((f"GET {m.group(3)} HTTP/1.1\r\nHost: {m.group(1)}:{m.group(2)}\r\nUpgrade: websocket\r\n"
f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n").encode())
buf = b""
while b"\r\n\r\n" not in buf: buf += self.s.recv(4096)
assert b" 101 " in buf.split(b"\r\n")[0], buf[:200]
self.buf = buf.split(b"\r\n\r\n", 1)[1]
self.n = 0
def _read(self, n):
while len(self.buf) < n: self.buf += self.s.recv(65536)
out, self.buf = self.buf[:n], self.buf[n:]
return out
def send(self, obj):
data = json.dumps(obj).encode()
head = bytes([0x81])
if len(data) < 126: head += bytes([0x80 | len(data)])
elif len(data) < 65536: head += bytes([0x80 | 126]) + struct.pack(">H", len(data))
else: head += bytes([0x80 | 127]) + struct.pack(">Q", len(data))
mask = os.urandom(4)
self.s.sendall(head + mask + bytes(b ^ mask[i % 4] for i, b in enumerate(data)))
def recv(self):
while True:
b0, b1 = self._read(2)
n = b1 & 0x7F
if n == 126: n = struct.unpack(">H", self._read(2))[0]
elif n == 127: n = struct.unpack(">Q", self._read(8))[0]
if b1 & 0x80: self._read(4)
data = self._read(n)
op = b0 & 0x0F
if op == 1: return json.loads(data)
if op == 8: raise EOFError("closed")
def call(self, method, **params):
self.n += 1
self.send({"id": self.n, "method": method, "params": params})
while True:
m = self.recv()
if m.get("id") == self.n: return m.get("result", {})
def main():
chrome, url = sys.argv[1], sys.argv[2]
timeout = float(sys.argv[3]) if len(sys.argv) > 3 else 30
flags = sys.argv[4:]
prof = tempfile.mkdtemp()
p = subprocess.Popen([chrome, "--headless=new", "--disable-gpu", "--remote-debugging-port=0", f"--user-data-dir={prof}", *flags, "about:blank"],
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
port = None
for line in p.stderr:
m = re.search(r"DevTools listening on ws://127\.0\.0\.1:(\d+)/", line)
if m: port = m.group(1); break
if not port: print("no diag: Chrome gave no DevTools port"); p.kill(); return 1
try:
targets = json.load(urllib.request.urlopen(f"http://127.0.0.1:{port}/json"))
page = next(t for t in targets if t["type"] == "page")
ws = WS(page["webSocketDebuggerUrl"])
ws.call("Page.enable"); ws.call("Runtime.enable")
ws.call("Page.navigate", url=url)
t0 = time.time(); text = ""
while time.time() - t0 < timeout:
time.sleep(0.25)
r = ws.call("Runtime.evaluate", expression="(document.getElementById('diag')||{}).textContent||''", returnByValue=True)
text = r.get("result", {}).get("value", "") or ""
if "\nEND" in text or text.startswith("END") or text.startswith("TOPERR"): break
print(text if text else "no diag: the harness did not run")
finally:
p.kill()
subprocess.run(["rm", "-rf", prof])
return 0
if __name__ == "__main__":
sys.exit(main())
+40
View File
@@ -0,0 +1,40 @@
<!doctype html>
<meta charset="utf-8">
<title>harness: resume</title>
<p>This file is a fragment: <code>tools/harness.sh resume</code> appends everything from the marker below to index.html, serves
the folder over http (IndexedDB does not work from file:// in headless Chrome), drives it in real time over the
DevTools protocol (tools/harness-cdp.py) and runs it WITHOUT autoplay allowed. It seeds the library with one station
and one rendered track and resumes the station with no user gesture, the way an installed app comes back to a station
link. Expected: the track is loaded but autoplay is refused, so the player waits with the cover saying TAP TO PLAY,
and the next track cooks behind it. The other half — the tap starting it — needs a real user activation, which
headless Chrome cannot fake; it is documented in ARCHITECTURE.md, not asserted here.</p>
<!-- harness -->
<script>
try {
let n = 0;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
venice = async function (path, body, { raw = false } = {}) {
if (path.includes("chat")) return { choices: [{ message: { content: JSON.stringify({ title: "T" + (++n), tags: "lo-fi, piano, instrumental", lyrics: "[verse]\nla\n[chorus]\nlo", bpm: 80, key: "C major", energy: 0.4, seconds: 30, meter: "4/4", station: "Resume FM", line: "hi" }) } }] };
if (path === "/render") { await sleep(50); const b = wavBlob(new Int16Array(8000 * 2), 8000); return new Response(b, { headers: { "content-type": "audio/wav", "x-cost": "0.001", "x-renderer": "test" } }); }
throw new Error("unexpected " + path);
};
const log = []; const dd = document.createElement("pre"); dd.id = "diag"; document.body.append(dd);
const L = (m) => { log.push(m); dd.textContent = log.join("\n"); };
const S = (tag) => L(tag + "\n phase=" + state.phase + " needTap=" + !!state.needTap + " tap=" + document.querySelector("#cover").classList.contains("tap") + " veil=" + JSON.stringify(document.querySelector("#cover .veil").textContent) + " paused=" + state.audio.paused + " playing=" + state.playing + " holding=" + !!state.holding + " cur=" + (state.current && state.current.spec.title) + " curblob=" + !!(state.current && state.current.blob) + " next=" + (state.nextJob && state.nextJob.spec.title) + " nextblob=" + !!(state.nextJob && state.nextJob.blob) + " crackle=" + crackle.on);
window.addEventListener("error", (e) => L("ERR " + e.message + " @" + e.lineno));
window.addEventListener("unhandledrejection", (e) => L("REJ " + (e.reason && (e.reason.stack || e.reason)).toString().slice(0, 200)));
(async () => {
const sid = "s-1700000000000-1", made = 1700000000000, seed = "resume seed";
const spec = { title: "R1", tags: "lo-fi, piano, instrumental", lyrics: "", bpm: 80, key: "C major", energy: 0.4, seconds: 30, meter: "4/4", line: "hi", seed, stationId: sid, made, id: made + "-" + hash32("R1") };
await db.put({ id: spec.id, spec, blob: wavBlob(new Int16Array(8000 * 2), 8000), made, kept: false, plays: 0 });
await db.put({ id: sid, seed, name: "Resume FM", taste: { like: [], avoid: [], notes: [] }, history: [spec], made, last: made, played: 1 }, "stations");
await sleep(600); // past the 400 ms room-tone attempt, which autoplay refuses too
S("before resume expect phase=idle cur=null paused=true needTap=false");
await openStation(seed, { id: sid, name: "Resume FM", history: [spec], played: 1 }); // no gesture: a station link opening
await sleep(1500);
S("resume, no gesture expect phase=playing needTap=true tap=true veil=\"TAP TO PLAY\" paused=true cur=R1 curblob=true next=T1 nextblob=true");
L("(the tap that starts it needs a real user activation; not asserted here)");
L("END");
})();
} catch (e) { const p = document.createElement("pre"); p.id = "diag"; p.textContent = "TOPERR " + e.message; document.body.append(p); }
</script>
+4 -4
View File
@@ -13,19 +13,19 @@ venice = async function (path, body, { raw = false } = {}) {
};
const log = []; const dd = document.createElement("pre"); dd.id = "diag"; document.body.append(dd);
const L = (m) => { log.push(m); dd.textContent = log.join("\n"); };
const S = (tag) => L(tag + "\n src=" + String(state.audio.src).slice(5, 14) + " paused=" + state.audio.paused + " playing=" + state.playing + " holding=" + !!state.holding + " cur=" + (state.current && state.current.spec.title) + " next=" + (state.nextJob && state.nextJob.spec.title) + " nextblob=" + !!(state.nextJob && state.nextJob.blob) + " crackle=" + crackle.on + " loop=" + state.audio.loop);
const S = (tag) => L(tag + "\n phase=" + state.phase + " src=" + String(state.audio.src).slice(5, 14) + " paused=" + state.audio.paused + " playing=" + state.playing + " holding=" + !!state.holding + " cur=" + (state.current && state.current.spec.title) + " next=" + (state.nextJob && state.nextJob.spec.title) + " nextblob=" + !!(state.nextJob && state.nextJob.blob) + " crackle=" + crackle.on + " loop=" + state.audio.loop);
window.addEventListener("error", (e) => L("ERR " + e.message + " @" + e.lineno));
window.addEventListener("unhandledrejection", (e) => L("REJ " + (e.reason && (e.reason.stack || e.reason)).toString().slice(0, 200)));
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const ended = () => { state.audio.dispatchEvent(new Event("ended")); };
(async () => {
document.dispatchEvent(new Event("keydown")); await sleep(300);
openStation("test seed"); await sleep(1500); S("after first expect cur=T1 playing, next=T2 nextblob=true");
openStation("test seed"); await sleep(1500); S("after first expect phase=playing cur=T1 playing, next=T2 nextblob=true");
ended(); await sleep(600); S("after ended expect cur=T2 playing, holding=false");
renderDelay = 3000; state.nextJob = null; cookNext(); await sleep(300); S("slow cook started expect next cooking, nextblob=false");
ended(); await sleep(600); S("ended, nothing ready expect holding=true loop=true");
ended(); await sleep(600); S("ended, nothing ready expect phase=holding holding=true loop=true");
await sleep(3500); S("track landed expect holding=true loop=false (queued behind the crackle)");
ended(); await sleep(600); S("crackle pass ended expect cur=that track playing, holding=false");
ended(); await sleep(600); S("crackle pass ended expect phase=playing cur=that track playing, holding=false");
renderDelay = 50; document.querySelector("#play").click(); await sleep(600); S("after pause expect paused=true crackle=true");
document.querySelector("#play").click(); await sleep(300); S("after resume expect paused=false crackle=false");
document.querySelector("#skip").click(); await sleep(1500); S("after skip expect a new cur");
+43 -8
View File
@@ -1,28 +1,63 @@
#!/bin/sh
# tools/harness.sh: the playback regression run. Appends tools/harness.html's script to index.html, runs it in headless
# Chrome with autoplay allowed, prints one state line per step. Needs Chrome/Chromium and python3.
# tools/harness.sh: the playback regression run. Appends a harness fragment's script to index.html, runs it in headless
# Chrome, prints one state line per step. Needs Chrome/Chromium and python3.
#
# sh tools/harness.sh # tools/harness.html: the station loop with autoplay allowed, from file://
# sh tools/harness.sh resume # tools/harness-resume.html: resume a stored station with autoplay BLOCKED, served
# # over http (IndexedDB needs it) and driven in real time over the DevTools protocol
# # (tools/harness-cdp.py); expects the TAP TO PLAY wait
#
# CHROME=/path/to/chrome and CHROME_FLAGS="--no-sandbox --disable-dev-shm-usage" when it isn't found or runs as root.
set -eu
cd "$(dirname "$0")/.."
MODE=${1:-loop}
case "$MODE" in
loop) FRAG=tools/harness.html; AUTOPLAY=--autoplay-policy=no-user-gesture-required ;;
resume) FRAG=tools/harness-resume.html; AUTOPLAY= ;;
*) echo "usage: sh tools/harness.sh [resume]" >&2; exit 2 ;;
esac
CHROME=${CHROME:-}
for c in "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" "$(command -v chromium || true)" "$(command -v google-chrome || true)"; do
if [ -z "$CHROME" ] && [ -n "$c" ] && [ -x "$c" ]; then CHROME=$c; fi
done
[ -n "$CHROME" ] || { echo "no Chrome found; set CHROME" >&2; exit 1; }
out=$(mktemp -d)
python3 - "$out" <<'EOF'
prof=$(mktemp -d)
python3 - "$out" "$FRAG" <<'EOF'
import sys, pathlib
out = pathlib.Path(sys.argv[1])
page = pathlib.Path("index.html").read_text()
h = pathlib.Path("tools/harness.html").read_text()
h = pathlib.Path(sys.argv[2]).read_text()
script = h[h.index("<!-- harness -->"):]
(out / "run.html").write_text(page.replace("</body>", script + "</body>"))
EOF
# shellcheck disable=SC2086
"$CHROME" --headless=new --disable-gpu --autoplay-policy=no-user-gesture-required --virtual-time-budget=25000 ${CHROME_FLAGS:-} --dump-dom "file://$out/run.html" 2>/dev/null > "$out/dom.html" || true
python3 - "$out/dom.html" <<'EOF'
cp brain.js "$out/brain.js"
if [ "$MODE" = resume ]; then
# a fresh profile and a port picked by the OS, so no run inherits another's IndexedDB
PORT=$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1])')
python3 -m http.server "$PORT" --bind 127.0.0.1 --directory "$out" >/dev/null 2>&1 </dev/null &
SRV=$!
trap 'kill $SRV 2>/dev/null || true; rm -rf "$out" "$prof"' EXIT INT TERM
n=0
until curl -fs -o /dev/null --max-time 2 "http://127.0.0.1:$PORT/run.html"; do
n=$((n + 1)); [ "$n" -lt 40 ] || { echo "the local server on port $PORT never answered" >&2; exit 1; }; sleep 0.5
done
URL="http://127.0.0.1:$PORT/run.html"
else
trap 'rm -rf "$out" "$prof"' EXIT INT TERM
URL="file://$out/run.html"
fi
if [ "$MODE" = resume ]; then
# real time, over the DevTools protocol: --dump-dom's virtual time doesn't wait for IndexedDB (see harness-cdp.py)
# shellcheck disable=SC2086
python3 tools/harness-cdp.py "$CHROME" "$URL" 30 ${CHROME_FLAGS:-}
else
# shellcheck disable=SC2086
"$CHROME" --headless=new --disable-gpu --user-data-dir="$prof" $AUTOPLAY --virtual-time-budget=25000 ${CHROME_FLAGS:-} --dump-dom "$URL" 2>/dev/null > "$out/dom.html" || true
python3 - "$out/dom.html" <<'EOF'
import sys, re, html
s = open(sys.argv[1]).read()
m = re.search(r'<pre id="diag">(.*?)</pre>', s, re.S)
print(html.unescape(m.group(1)) if m else "no diag: the harness did not run")
EOF
rm -rf "$out"
fi