harness: a resume mode (autoplay blocked); docs for the state machine and hooks
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
This commit is contained in:
1 parent
dddf8f0add
commit
1aeebac6f2
8 files changed
+302
-46
No files matched your search
+72
-23
@@ -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
@@ -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).
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -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
@@ -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");
|
||||
|
||||
+41
-6
@@ -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
|
||||
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
|
||||
"$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 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
|
||||
Reference in new issue
Block a user