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
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:
commit
fc0bfe05a6
10 files changed
+425
-113
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
|
||||
|
||||
|
||||
@@ -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
@@ -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))}`;
|
||||
|
||||
@@ -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");
|
||||
|
||||
+43
-8
@@ -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
|
||||
Reference in new issue
Block a user