playback: one explicit state machine (idle → cooking → holding → playing → ended) #1

Merged
teahubxyz merged 3 commits from playback-state-machine into main 2026-10-21 06:14:05 +00:00
Owner

Makes playback boring. The three overlapping mechanisms (playJob/advance, the synchronous ended swap swapNow, and the crackle hold with state.holding/state.pendingJob) become one small state machine with named transitions. No behaviour change intended; the harness prints the same state lines before and after, in both modes.

What changed

index.html

  • state.phase ∈ idle | cooking | holding | playing | ended. Transitions are functions: toCooking (sheet on screen, wait for the render), toHolding (crackle into the player, inside ended), toPlaying (set src, play(), TAP TO PLAY on refusal), startNow (cooked next starts synchronously inside ended), handOver (crackle's pass ended → queued track, inside ended), toEnded, toIdle. Only they set state.phase or the player's src (via setSource, which drops the old object URL but never the crackle's or the unlock clip's). prime() remains the one exception (the unlock clip; not a track).
  • state.holding is now a read-only getter (phase === "holding") so bed() and the harness read as before.
  • playJob = toCooking → await render → (queue behind the hold if holding) → cookNext → toPlaying → bookkeeping. advance() only picks; it never touches the player. The ended handler is three lines.
  • hooks object (djPrompt, jobMade, stationOpened, reacted), called by the page at those moments; brain.js assigns them instead of reassigning djPrompt/openStation/makeJob/react.
  • db.all() used to find one row → db.get(id): the station sheet, brain.js countPlay, tapeRows (one db.get per id). Tapes go through tapeById (KEPT is virtual, so that one still needs db.all("tapes")). db.all() stays where a filter is needed (a station's tracks, the prune, trail, shuffle, library, brain); HANDOFF.md notes an index on spec.stationId as the next step.

brain.js: hooks instead of overrides; countPlay uses db.get. Same behaviour.

Harness: sh tools/harness.sh resume is the new scenario (resume on load with autoplay blocked). It seeds IndexedDB with a station + one rendered track, calls openStation with no gesture, runs Chrome without --autoplay-policy=no-user-gesture-required, and expects state.needTap === true, #cover.tap, veil "TAP TO PLAY", with the next track cooked behind it. IndexedDB needs http, so the page is served from a temp folder on 127.0.0.1. Finding on the way: headless --dump-dom --virtual-time-budget does not wait for IndexedDB (virtual time runs past the real IO and the dump fires with nothing logged; it passed once by luck, then never), so resume mode is driven in real time over the DevTools protocol by tools/harness-cdp.py (python3 stdlib, ~40-line websocket client). The loop mode is unchanged apart from printing phase=. The "tap starts it" half cannot be asserted headlessly (no real user activation); it is documented in ARCHITECTURE.md › Playback invariants.

Docs: ARCHITECTURE.md describes the machine as implemented (phase table, diagram, who sets what) and the two harness modes; HANDOFF.md has a "found and deliberately left alone" list; STORAGE.md was stale against the code (db.get and the tapes store existed but weren't listed) and was brought up to the code without changing the contract; README points at the new files.

Deliberate, tiny deviations from byte-for-byte behaviour (see HANDOFF.md)

  • setSource never revokes the crackle element's object URL (the old swapNow could, on a rarely reachable branch after a skipped hold).
  • toIdle resolves a parked queueBehindHold promise on a station change, so that playJob returns instead of staying pending forever.

Harness before (main, with this PR's harness files)

## before: sh tools/harness.sh
after first          expect cur=T1 playing, next=T2 nextblob=true
    src=null/5702 paused=false playing=true holding=false cur=T1 next=T2 nextblob=true crackle=false loop=false
after ended          expect cur=T2 playing, holding=false
    src=null/075b paused=false playing=true holding=false cur=T2 next=T3 nextblob=true crackle=false loop=false
slow cook started    expect next cooking, nextblob=false
    src=null/075b paused=false playing=true holding=false cur=T2 next=T4 nextblob=false crackle=false loop=false
ended, nothing ready expect holding=true loop=true
    src=null/f6a7 paused=false playing=true holding=true cur=T4 next=null nextblob=false crackle=false loop=true
track landed         expect holding=true loop=false (queued behind the crackle)
    src=null/f6a7 paused=false playing=true holding=true cur=T4 next=null nextblob=false crackle=false loop=false
crackle pass ended   expect cur=that track playing, holding=false
    src=null/a162 paused=false playing=true holding=false cur=T4 next=T5 nextblob=false crackle=false loop=false
after pause          expect paused=true crackle=true
    src=null/a162 paused=true playing=false holding=false cur=T4 next=T5 nextblob=false crackle=true loop=false
after resume         expect paused=false crackle=false
    src=null/a162 paused=false playing=true holding=false cur=T4 next=T5 nextblob=false crackle=false loop=false
after skip           expect a new cur
    src=null/a162 paused=true playing=false holding=false cur=T5 next=T6 nextblob=true crackle=true loop=false
END

## before: sh tools/harness.sh resume
before resume        expect phase=idle cur=null paused=true needTap=false
    phase=undefined needTap=false tap=false veil="COOKING" paused=true playing=false holding=false cur=null curblob=false next=null nextblob=false crackle=false
resume, no gesture   expect phase=playing needTap=true tap=true veil="TAP TO PLAY" paused=true cur=R1 curblob=true next=T1 nextblob=true
    phase=undefined needTap=true tap=true veil="TAP TO PLAY" paused=true playing=false holding=false cur=R1 curblob=true next=T1 nextblob=true crackle=false
(the tap that starts it needs a real user activation; not asserted here)
END

Harness after (this branch)

## after: sh tools/harness.sh
after first          expect phase=playing cur=T1 playing, next=T2 nextblob=true
    phase=playing src=null/b9c3 paused=false playing=true holding=false cur=T1 next=T2 nextblob=true crackle=false loop=false
after ended          expect cur=T2 playing, holding=false
    phase=playing src=null/10a1 paused=false playing=true holding=false cur=T2 next=T3 nextblob=true crackle=false loop=false
slow cook started    expect next cooking, nextblob=false
    phase=playing src=null/10a1 paused=false playing=true holding=false cur=T2 next=T4 nextblob=false crackle=false loop=false
ended, nothing ready expect phase=holding holding=true loop=true
    phase=holding src=null/315a paused=false playing=true holding=true cur=T4 next=null nextblob=false crackle=false loop=true
track landed         expect holding=true loop=false (queued behind the crackle)
    phase=holding src=null/315a paused=false playing=true holding=true cur=T4 next=null nextblob=false crackle=false loop=false
crackle pass ended   expect phase=playing cur=that track playing, holding=false
    phase=playing src=null/9371 paused=false playing=true holding=false cur=T4 next=T5 nextblob=false crackle=false loop=false
after pause          expect paused=true crackle=true
    phase=playing src=null/9371 paused=true playing=false holding=false cur=T4 next=T5 nextblob=false crackle=true loop=false
after resume         expect paused=false crackle=false
    phase=playing src=null/9371 paused=false playing=true holding=false cur=T4 next=T5 nextblob=false crackle=false loop=false
after skip           expect a new cur
    phase=cooking src=null/9371 paused=true playing=false holding=false cur=T5 next=T6 nextblob=true crackle=true loop=false
END

## after: sh tools/harness.sh resume
before resume        expect phase=idle cur=null paused=true needTap=false
    phase=idle needTap=false tap=false veil="COOKING" paused=true playing=false holding=false cur=null curblob=false next=null nextblob=false crackle=false
resume, no gesture   expect phase=playing needTap=true tap=true veil="TAP TO PLAY" paused=true cur=R1 curblob=true next=T1 nextblob=true
    phase=playing needTap=true tap=true veil="TAP TO PLAY" paused=true playing=false holding=false cur=R1 curblob=true next=T1 nextblob=true crackle=false
(the tap that starts it needs a real user activation; not asserted here)
END

Run on Linux with CHROME=/opt/pw-browsers/chromium-1194/chrome-linux/chrome CHROME_FLAGS="--no-sandbox --disable-dev-shm-usage"; resume mode was run three times in a row and is stable. Both files syntax-check (new Function on the page script and on brain.js).

Not verifiable headlessly — please check on a phone

  • Real audio output and the autoplay policy under a real user activation (the tap that ends TAP TO PLAY; prime() is untouched).
  • iOS background playback through the synchronous swaps in ended (startNow, toHolding, handOver keep the same order of operations as before: src → play() inside the event, bookkeeping after).
  • The OfflineAudioContext path used by mashups (untouched).
  • The worker (teahubxyz/time) is untouched.

🤖 Generated with Claude Code

https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6

Makes playback boring. The three overlapping mechanisms (`playJob`/`advance`, the synchronous `ended` swap `swapNow`, and the crackle hold with `state.holding`/`state.pendingJob`) become one small state machine with named transitions. No behaviour change intended; the harness prints the same state lines before and after, in both modes. ## What changed **index.html** - `state.phase` ∈ `idle | cooking | holding | playing | ended`. Transitions are functions: `toCooking` (sheet on screen, wait for the render), `toHolding` (crackle into the player, inside `ended`), `toPlaying` (set src, `play()`, TAP TO PLAY on refusal), `startNow` (cooked next starts synchronously inside `ended`), `handOver` (crackle's pass ended → queued track, inside `ended`), `toEnded`, `toIdle`. Only they set `state.phase` or the player's `src` (via `setSource`, which drops the old object URL but never the crackle's or the unlock clip's). `prime()` remains the one exception (the unlock clip; not a track). - `state.holding` is now a read-only getter (`phase === "holding"`) so `bed()` and the harness read as before. - `playJob` = `toCooking` → await render → (queue behind the hold if holding) → `cookNext` → `toPlaying` → bookkeeping. `advance()` only picks; it never touches the player. The `ended` handler is three lines. - `hooks` object (`djPrompt`, `jobMade`, `stationOpened`, `reacted`), called by the page at those moments; brain.js assigns them instead of reassigning `djPrompt`/`openStation`/`makeJob`/`react`. - `db.all()` used to find one row → `db.get(id)`: the station sheet, brain.js `countPlay`, `tapeRows` (one `db.get` per id). Tapes go through `tapeById` (KEPT is virtual, so that one still needs `db.all("tapes")`). `db.all()` stays where a filter is needed (a station's tracks, the prune, trail, shuffle, library, brain); HANDOFF.md notes an index on `spec.stationId` as the next step. **brain.js**: hooks instead of overrides; `countPlay` uses `db.get`. Same behaviour. **Harness**: `sh tools/harness.sh resume` is the new scenario (resume on load with autoplay blocked). It seeds IndexedDB with a station + one rendered track, calls `openStation` with no gesture, runs Chrome without `--autoplay-policy=no-user-gesture-required`, and expects `state.needTap === true`, `#cover.tap`, veil `"TAP TO PLAY"`, with the next track cooked behind it. IndexedDB needs http, so the page is served from a temp folder on 127.0.0.1. Finding on the way: headless `--dump-dom --virtual-time-budget` does not wait for IndexedDB (virtual time runs past the real IO and the dump fires with nothing logged; it passed once by luck, then never), so resume mode is driven in real time over the DevTools protocol by `tools/harness-cdp.py` (python3 stdlib, ~40-line websocket client). The loop mode is unchanged apart from printing `phase=`. The "tap starts it" half cannot be asserted headlessly (no real user activation); it is documented in ARCHITECTURE.md › Playback invariants. **Docs**: ARCHITECTURE.md describes the machine as implemented (phase table, diagram, who sets what) and the two harness modes; HANDOFF.md has a "found and deliberately left alone" list; STORAGE.md was stale against the code (`db.get` and the `tapes` store existed but weren't listed) and was brought up to the code without changing the contract; README points at the new files. ## Deliberate, tiny deviations from byte-for-byte behaviour (see HANDOFF.md) - `setSource` never revokes the crackle element's object URL (the old `swapNow` could, on a rarely reachable branch after a skipped hold). - `toIdle` resolves a parked `queueBehindHold` promise on a station change, so that `playJob` returns instead of staying pending forever. ## Harness before (main, with this PR's harness files) ``` ## before: sh tools/harness.sh after first expect cur=T1 playing, next=T2 nextblob=true src=null/5702 paused=false playing=true holding=false cur=T1 next=T2 nextblob=true crackle=false loop=false after ended expect cur=T2 playing, holding=false src=null/075b paused=false playing=true holding=false cur=T2 next=T3 nextblob=true crackle=false loop=false slow cook started expect next cooking, nextblob=false src=null/075b paused=false playing=true holding=false cur=T2 next=T4 nextblob=false crackle=false loop=false ended, nothing ready expect holding=true loop=true src=null/f6a7 paused=false playing=true holding=true cur=T4 next=null nextblob=false crackle=false loop=true track landed expect holding=true loop=false (queued behind the crackle) src=null/f6a7 paused=false playing=true holding=true cur=T4 next=null nextblob=false crackle=false loop=false crackle pass ended expect cur=that track playing, holding=false src=null/a162 paused=false playing=true holding=false cur=T4 next=T5 nextblob=false crackle=false loop=false after pause expect paused=true crackle=true src=null/a162 paused=true playing=false holding=false cur=T4 next=T5 nextblob=false crackle=true loop=false after resume expect paused=false crackle=false src=null/a162 paused=false playing=true holding=false cur=T4 next=T5 nextblob=false crackle=false loop=false after skip expect a new cur src=null/a162 paused=true playing=false holding=false cur=T5 next=T6 nextblob=true crackle=true loop=false END ## before: sh tools/harness.sh resume before resume expect phase=idle cur=null paused=true needTap=false phase=undefined needTap=false tap=false veil="COOKING" paused=true playing=false holding=false cur=null curblob=false next=null nextblob=false crackle=false resume, no gesture expect phase=playing needTap=true tap=true veil="TAP TO PLAY" paused=true cur=R1 curblob=true next=T1 nextblob=true phase=undefined needTap=true tap=true veil="TAP TO PLAY" paused=true playing=false holding=false cur=R1 curblob=true next=T1 nextblob=true crackle=false (the tap that starts it needs a real user activation; not asserted here) END ``` ## Harness after (this branch) ``` ## after: sh tools/harness.sh after first expect phase=playing cur=T1 playing, next=T2 nextblob=true phase=playing src=null/b9c3 paused=false playing=true holding=false cur=T1 next=T2 nextblob=true crackle=false loop=false after ended expect cur=T2 playing, holding=false phase=playing src=null/10a1 paused=false playing=true holding=false cur=T2 next=T3 nextblob=true crackle=false loop=false slow cook started expect next cooking, nextblob=false phase=playing src=null/10a1 paused=false playing=true holding=false cur=T2 next=T4 nextblob=false crackle=false loop=false ended, nothing ready expect phase=holding holding=true loop=true phase=holding src=null/315a paused=false playing=true holding=true cur=T4 next=null nextblob=false crackle=false loop=true track landed expect holding=true loop=false (queued behind the crackle) phase=holding src=null/315a paused=false playing=true holding=true cur=T4 next=null nextblob=false crackle=false loop=false crackle pass ended expect phase=playing cur=that track playing, holding=false phase=playing src=null/9371 paused=false playing=true holding=false cur=T4 next=T5 nextblob=false crackle=false loop=false after pause expect paused=true crackle=true phase=playing src=null/9371 paused=true playing=false holding=false cur=T4 next=T5 nextblob=false crackle=true loop=false after resume expect paused=false crackle=false phase=playing src=null/9371 paused=false playing=true holding=false cur=T4 next=T5 nextblob=false crackle=false loop=false after skip expect a new cur phase=cooking src=null/9371 paused=true playing=false holding=false cur=T5 next=T6 nextblob=true crackle=true loop=false END ## after: sh tools/harness.sh resume before resume expect phase=idle cur=null paused=true needTap=false phase=idle needTap=false tap=false veil="COOKING" paused=true playing=false holding=false cur=null curblob=false next=null nextblob=false crackle=false resume, no gesture expect phase=playing needTap=true tap=true veil="TAP TO PLAY" paused=true cur=R1 curblob=true next=T1 nextblob=true phase=playing needTap=true tap=true veil="TAP TO PLAY" paused=true playing=false holding=false cur=R1 curblob=true next=T1 nextblob=true crackle=false (the tap that starts it needs a real user activation; not asserted here) END ``` Run on Linux with `CHROME=/opt/pw-browsers/chromium-1194/chrome-linux/chrome CHROME_FLAGS="--no-sandbox --disable-dev-shm-usage"`; resume mode was run three times in a row and is stable. Both files syntax-check (`new Function` on the page script and on brain.js). ## Not verifiable headlessly — please check on a phone - Real audio output and the autoplay policy under a real user activation (the tap that ends TAP TO PLAY; `prime()` is untouched). - iOS background playback through the synchronous swaps in `ended` (`startNow`, `toHolding`, `handOver` keep the same order of operations as before: src → `play()` inside the event, bookkeeping after). - The OfflineAudioContext path used by mashups (untouched). - The worker (`teahubxyz/time`) is untouched. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
teahubxyz added 3 commits 2026-10-21 06:02:40 +00:00
state.phase (idle → cooking → holding → playing → ended) with named transitions
(toCooking, toHolding, toPlaying, startNow, handOver, toEnded, toIdle) replaces
the three overlapping mechanisms (playJob/advance, swapNow, the crackle hold with
state.holding/pendingJob). state.holding is now a read-only view of the phase.
Behaviour unchanged; the harness prints the same state lines before and after.

A `hooks` object (djPrompt, jobMade, stationOpened, reacted) is what brain.js
plugs into instead of overriding page functions by assignment.

db.all() used to find one row becomes db.get(id); tapes go through tapeById
(KEPT is virtual). setSource drops old object URLs but never the crackle's.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
hooks.djPrompt, hooks.stationOpened, hooks.jobMade and hooks.reacted carry what
the djPrompt / openStation / makeJob / react reassignments did; same behaviour.
countPlay reads its one row with db.get instead of db.all().

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
tools/harness.sh resume seeds IndexedDB with a station and one rendered
track, resumes it with no user gesture and expects the TAP TO PLAY wait.
IndexedDB needs http, so the page is served from a temp folder; headless
Chrome's virtual time doesn't wait for IndexedDB, so this mode is driven in
real time over the DevTools protocol (tools/harness-cdp.py, stdlib only).
The loop harness now prints state.phase too.

ARCHITECTURE.md describes the machine as implemented; HANDOFF.md lists what
was found and left alone; STORAGE.md gains db.get and the tapes store (the
code already had both); README points at the new files.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
teahubxyz merged commit fc0bfe05a6 into main 2026-10-21 06:14:05 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: gandora/site#1