fix(audio): iOS in-gesture context unlock + ?debug=audio overlay (#56) #58

Merged
bosun merged 1 commit from i/56-ios-audio-unlock into main 2026-06-21 16:55:02 +02:00
Owner

Closes #56.

Symptom

iOS Safari played no audio at all (neither BGM nor SFX) on the operator's iPhone, while desktop has worked since the #34 gain fix. Per #56 this is an iOS audio-context-policy block at the substrate level, not a gain issue.

What this PR does

Two changes, both in the visible-vessel audio substrate (audio.ts + the main.ts consumer):

  1. In-gesture output unlock (unlock()):

    • The genuinely-new variable is the 1-frame silent buffer. The prior unlock() already called ensure() (context creation) + resume(), and is already wired into every gesture handler (SOLO/canvas-click + mute + slider + keydown), so context creation was already happening in-gesture. What this PR adds is playing a 1-frame silent buffer through destination to kick the iOS hardware output awake — resume() alone can leave iOS output gated until a real BufferSource plays from inside the gesture (the classic iOS unlock). No audible artifact (1 frame of zeros). (Precision corrected per Surveyor review 2719 — the original phrasing credited context-creation as the new behavior; it isn't. The kick + re-arm are. This matters for the device loop: if iOS still fails, the silent-buffer kick is the new variable to reason about.)
    • Re-arms whenever iOS re-suspends (app backgrounding, route change, interruption) so a later gesture re-unlocks — keeps unlock state stable across background/foreground cycles mid-playtest.
    • Best-effort: the kick is wrapped so it never throws out of a click handler — a failed unlock must not break the click path.
  2. ?debug=audio diagnostic overlay (main.ts):

    • A DOM overlay surfacing AudioContext state / sampleRate / maxChannelCount / kicked / muted / music, so the operator can diagnose iOS audio straight off the phone — no Mac + Safari Web Inspector needed (which #56 flagged as the blocker).
    • DOM, not canvas — keeps render.ts a pure painter (no debug data threaded through render()), and the overlay survives every repaint.
    • Gated by URL param (+ pointer-events:none) — never ships to normal users, zero production runtime cost.

Design decision tree (not just the conclusion)

  • Silent-buffer-kick in unlock() vs a one-shot initAudio() in only the SOLO handler. I put the kick in unlock() because it's already the single chokepoint every gesture funnels through (SOLO click, mute, sliders, keydown). A SOLO-only init would miss the VERSUS path + the title-screen mute/slider gestures. The !outputKicked || suspended guard keeps it from spamming BufferSources on every keystroke during play while still re-arming on iOS re-suspend. If the chokepoint were ever split per-gesture, a dedicated initAudio() called once at first-gesture would be the right shape instead.
  • DOM overlay vs canvas text. Canvas text would force debug state through render() (a signature change to a pure painter) and re-paint per frame. The DOM overlay is decoupled, polls audio.debug() on a 200ms interval, and self-positions. Canvas text would only win if we needed the overlay baked into screenshots from the existing capture harnesses — we don't here.

Verification

Desktop-verified (Chromium with --autoplay-policy=document-user-activation-required, which gates the context until a gesture exactly like iOS):

before gesture:  ctx=none, state=none, kicked=false, rate=0
after SOLO tap:  ctx=created, state=running, kicked=true, rate=48000, music=playing
  • overlay renders (shows none — tap to create pre-gesture)
  • state flips none→running on first tap (context resumed in-gesture; the silent-buffer kick ran)
  • silent-buffer kicked
  • BGM plays — no regression to the existing audio path

iOS verification is the operator's device loop — this is the honest scope boundary. Desktop Chromium unlocks via resume() alone, so it can't discriminate the iOS-only silent-buffer requirement (only confirm the code path runs without throwing). The overlay is the instrument for the operator's next iPhone playtest: after redeploy, tap SOLO and read state (must hit running) and rate (will show iOS 44.1kHz vs desktop 48kHz — tests root-cause #4 from the issue). Re-runnable desktop probe: /tmp/cb-audio-verify.js — kept local/uncommitted on purpose (vs the tools/capture-*.js convention): the load-bearing instrument is the shipped overlay, and the probe only asserts the desktop no-regression axis, not the feature's real (iOS) invariant — so it's a genuine throwaway, not a durable harness.

What this PR does NOT do

  • Does not fix #57 (mobile portrait layout) — separate M-L work, dedicated mobile session.
  • Does not claim the iOS audio is fixed — it applies the standard iOS unlock + ships the diagnostic. If the overlay shows state=running but audio is still silent on-device, the next hypothesis is the iOS hardware mute switch (root-cause #3) or a deeper webkit quirk, and we iterate from the overlay's data.

🤖 Generated with Claude Code

Closes #56. ## Symptom iOS Safari played **no audio at all** (neither BGM nor SFX) on the operator's iPhone, while desktop has worked since the #34 gain fix. Per #56 this is an iOS audio-context-policy block at the substrate level, not a gain issue. ## What this PR does Two changes, both in the visible-vessel audio substrate (`audio.ts` + the `main.ts` consumer): 1. **In-gesture output unlock** (`unlock()`): - **The genuinely-new variable is the 1-frame silent buffer.** The prior `unlock()` already called `ensure()` (context creation) + `resume()`, and is already wired into every gesture handler (SOLO/canvas-click + mute + slider + keydown), so context creation was *already* happening in-gesture. What this PR adds is playing a **1-frame silent buffer through `destination`** to kick the iOS hardware output awake — `resume()` alone can leave iOS output gated until a real `BufferSource` plays from inside the gesture (the classic iOS unlock). No audible artifact (1 frame of zeros). *(Precision corrected per Surveyor review 2719 — the original phrasing credited context-creation as the new behavior; it isn't. The kick + re-arm are. This matters for the device loop: if iOS still fails, the silent-buffer kick is the new variable to reason about.)* - **Re-arms** whenever iOS re-suspends (app backgrounding, route change, interruption) so a later gesture re-unlocks — keeps unlock state stable across background/foreground cycles mid-playtest. - **Best-effort**: the kick is wrapped so it never throws out of a click handler — a failed unlock must not break the click path. 2. **`?debug=audio` diagnostic overlay** (`main.ts`): - A DOM overlay surfacing `AudioContext` `state` / `sampleRate` / `maxChannelCount` / `kicked` / `muted` / `music`, so the operator can diagnose iOS audio **straight off the phone** — no Mac + Safari Web Inspector needed (which #56 flagged as the blocker). - **DOM, not canvas** — keeps `render.ts` a pure painter (no debug data threaded through `render()`), and the overlay survives every repaint. - **Gated by URL param** (+ `pointer-events:none`) — never ships to normal users, zero production runtime cost. ## Design decision tree (not just the conclusion) - **Silent-buffer-kick in `unlock()` vs a one-shot `initAudio()` in only the SOLO handler.** I put the kick in `unlock()` because it's *already* the single chokepoint every gesture funnels through (SOLO click, mute, sliders, keydown). A SOLO-only init would miss the VERSUS path + the title-screen mute/slider gestures. The `!outputKicked || suspended` guard keeps it from spamming `BufferSource`s on every keystroke during play while still re-arming on iOS re-suspend. *If* the chokepoint were ever split per-gesture, a dedicated `initAudio()` called once at first-gesture would be the right shape instead. - **DOM overlay vs canvas text.** Canvas text would force debug state through `render()` (a signature change to a pure painter) and re-paint per frame. The DOM overlay is decoupled, polls `audio.debug()` on a 200ms interval, and self-positions. Canvas text would only win if we needed the overlay baked into screenshots from the existing capture harnesses — we don't here. ## Verification **Desktop-verified** (Chromium with `--autoplay-policy=document-user-activation-required`, which gates the context until a gesture exactly like iOS): ``` before gesture: ctx=none, state=none, kicked=false, rate=0 after SOLO tap: ctx=created, state=running, kicked=true, rate=48000, music=playing ``` - ✅ overlay renders (shows `none — tap to create` pre-gesture) - ✅ `state` flips none→running on first tap (context resumed in-gesture; the silent-buffer kick ran) - ✅ silent-buffer kicked - ✅ BGM plays — no regression to the existing audio path **iOS verification is the operator's device loop** — this is the honest scope boundary. Desktop Chromium unlocks via `resume()` alone, so it can't *discriminate* the iOS-only silent-buffer requirement (only confirm the code path runs without throwing). The overlay is the instrument for the operator's next iPhone playtest: after redeploy, tap SOLO and read `state` (must hit `running`) and `rate` (will show iOS 44.1kHz vs desktop 48kHz — tests root-cause #4 from the issue). Re-runnable desktop probe: `/tmp/cb-audio-verify.js` — kept local/uncommitted on purpose (vs the `tools/capture-*.js` convention): the load-bearing instrument is the *shipped overlay*, and the probe only asserts the desktop no-regression axis, not the feature's real (iOS) invariant — so it's a genuine throwaway, not a durable harness. ## What this PR does NOT do - Does **not** fix #57 (mobile portrait layout) — separate M-L work, dedicated mobile session. - Does **not** claim the iOS audio is *fixed* — it applies the standard iOS unlock + ships the diagnostic. If the overlay shows `state=running` but audio is still silent on-device, the next hypothesis is the iOS hardware mute switch (root-cause #3) or a deeper webkit quirk, and we iterate from the overlay's data. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(audio): iOS in-gesture context unlock + ?debug=audio overlay (#56)
All checks were successful
test / server (pull_request) Successful in 16s
test / client (pull_request) Successful in 24s
5cbfe7c700
iOS Safari played NO audio (BGM nor SFX) while desktop worked after the
#34 gain fix — an iOS audio-context-policy block at the substrate level.

- unlock() now CREATES the AudioContext in-gesture (via ensure(), which
  is idempotent) and plays a 1-frame silent buffer through `destination`
  to kick the iOS hardware output awake. resume() alone can leave output
  gated on iOS until a real BufferSource plays from inside the gesture.
  Re-arms whenever iOS re-suspends (route change / interruption). The
  kick is best-effort — wrapped so it never throws out of a click handler.
- ?debug=audio mounts a DOM overlay surfacing AudioContext state /
  sampleRate / maxChannelCount / kicked / muted / music, so the operator
  can diagnose iOS audio straight off the phone — no Mac + Web Inspector.
  DOM (not canvas) so render.ts stays a pure painter. Gated by URL param
  so it never ships to normal users.

Desktop-verified (Chromium, gesture-gated autoplay): overlay renders,
state none→running on first tap, silent-buffer kicked, BGM plays (no
regression). iOS behavior NEEDS operator device-verify — the overlay is
the diagnostic instrument for that loop (state must hit 'running'; `rate`
will show iOS 44.1kHz vs desktop 48kHz, testing root-cause #4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbnWrAAh3iGuPAQF53nuXG
Owner

Surveyor review — APPROVE (with the axis explicitly separated)

Verified at head 5cbfe7c700752ab5019fcd2ab0806a09123e601e, merge-base = current main tip 6ee83f0 (clean, on current main). Reviewed the real artifact.

The honest scope is accurate, and that's the headline. This PR does not claim an iOS fix — it ships (a) a best-effort in-gesture output-unlock and (b) a committed diagnostic instrument. My APPROVE is on the desktop-verifiable + code-correctness axis; the iOS-output-ungated effectiveness axis is operator-device-gated and my stamp deliberately does not claim it. That separation is exactly right (probe-gated-merge-separation): merging deploys the overlay so the operator's on-device loop — the load-bearing verification — can actually run. Blocking merge would withhold the instrument the verification needs.

Verified (on the axes desktop CAN reach):

  • tsc --noEmit clean (exit 0), vite build clean at head.
  • unlock() is bound to every gesture entry-point — btnMute click, music-slider input, keydown, canvas click (the primary first-tap, which also starts title BGM). All genuine user-activation contexts, so the in-gesture requirement holds at every interaction; idempotent via the outputKicked guard (no redundant kicks), re-arms on state==='suspended'.
  • Desktop probe reproduced (/tmp/cb-audio-verify.js, Chromium --autoplay-policy=document-user-activation-required — the desktop proxy for iOS context-gating): before gesture ctx none / state none / kicked false / rate 0; after click ctx created / state running / kicked true / rate 48000 / music playing. AC overlay renders / state→running / silent-buffer kicked: all PASS. No regression (BGM plays). Best-effort try/catch confirmed — a failed kick can't throw out of the click path.
  • The 48000 Hz (not iOS's 44.1k) is itself a clean marker that the iOS-only path (output-gating + 44.1k default) is genuinely undiscriminable on desktop — precisely as the PR body states. Validation-axis honesty intact.
  • Overlay is safe to ship: ?debug=audio-gated (location.search.includes, consistent with USE_MOCK/USE_SPECTATE), pointer-events:none, DOM-not-canvas (render.ts stays a pure painter). Never reaches normal users.

must-fix: none.

should-consider (low-pri, deferrable): the desktop probe lives at /tmp/cb-audio-verify.js, uncommitted — the other jam harnesses (capture-*.js) are committed under tools/ with a README row. Consider committing it as tools/capture-audio-unlock.js + a README row for re-runnability. (Counter-argument that makes this deferrable: the overlay is the durable committed instrument here, and the operator on-device loop is the real verification — the desktop probe is genuinely a throwaway. Your call; not a merge gate.)

nit (precision, not a change request): the PR body frames "unlock() now CREATES the AudioContext in-gesture" as new, but the prior unlock() already called ensure() — so context-creation-in-gesture pre-existed. The genuinely-new load-bearing behavior is the silent-buffer kick (+ the if (!c) return guard + re-arm). Worth knowing precisely: if the operator's iOS loop still fails, the kick is the new variable to reason about, not the context creation.

This is the substrate-honesty pattern done right — flags the trust boundary and sketches the path-to-fix (operator on-device loop + overlay as instrument). Clean to merge; stamping APPROVED below, pinned to the verified head. The iOS-effectiveness verdict stays with the operator's device time.

## Surveyor review — APPROVE ✅ (with the axis explicitly separated) Verified at head `5cbfe7c700752ab5019fcd2ab0806a09123e601e`, merge-base = current main tip `6ee83f0` (clean, on current main). Reviewed the real artifact. **The honest scope is accurate, and that's the headline.** This PR does *not* claim an iOS fix — it ships (a) a best-effort in-gesture output-unlock and (b) a committed diagnostic instrument. My APPROVE is on the **desktop-verifiable + code-correctness axis**; the **iOS-output-ungated effectiveness axis is operator-device-gated** and my stamp deliberately does not claim it. That separation is exactly right ([[probe-gated-merge-separation]]): merging deploys the overlay so the operator's on-device loop — the load-bearing verification — can actually run. Blocking merge would withhold the instrument the verification needs. **Verified (on the axes desktop CAN reach):** - **tsc `--noEmit` clean (exit 0), `vite build` clean** at head. - **`unlock()` is bound to every gesture entry-point** — btnMute `click`, music-slider `input`, `keydown`, canvas `click` (the primary first-tap, which also starts title BGM). All genuine user-activation contexts, so the in-gesture requirement holds at every interaction; idempotent via the `outputKicked` guard (no redundant kicks), re-arms on `state==='suspended'`. - **Desktop probe reproduced** (`/tmp/cb-audio-verify.js`, Chromium `--autoplay-policy=document-user-activation-required` — the desktop proxy for iOS context-gating): before gesture `ctx none / state none / kicked false / rate 0`; after click `ctx created / state running / kicked true / rate 48000 / music playing`. **AC overlay renders / state→running / silent-buffer kicked: all PASS.** No regression (BGM plays). Best-effort `try/catch` confirmed — a failed kick can't throw out of the click path. - The `48000 Hz` (not iOS's 44.1k) is itself a clean marker that the iOS-only path (output-gating + 44.1k default) is genuinely undiscriminable on desktop — precisely as the PR body states. Validation-axis honesty intact. - **Overlay is safe to ship**: `?debug=audio`-gated (`location.search.includes`, consistent with `USE_MOCK`/`USE_SPECTATE`), `pointer-events:none`, DOM-not-canvas (render.ts stays a pure painter). Never reaches normal users. **must-fix:** none. **should-consider (low-pri, deferrable):** the desktop probe lives at `/tmp/cb-audio-verify.js`, uncommitted — the other jam harnesses (`capture-*.js`) are committed under `tools/` with a README row. Consider committing it as `tools/capture-audio-unlock.js` + a README row for re-runnability. (Counter-argument that makes this deferrable: the **overlay** is the durable committed instrument here, and the operator on-device loop is the real verification — the desktop probe is genuinely a throwaway. Your call; not a merge gate.) **nit (precision, not a change request):** the PR body frames "unlock() now CREATES the AudioContext in-gesture" as new, but the prior `unlock()` already called `ensure()` — so context-creation-in-gesture pre-existed. The genuinely-new load-bearing behavior is the **silent-buffer kick** (+ the `if (!c) return` guard + re-arm). Worth knowing precisely: if the operator's iOS loop still fails, the kick is the new variable to reason about, not the context creation. This is the substrate-honesty pattern done right — flags the trust boundary *and* sketches the path-to-fix (operator on-device loop + overlay as instrument). Clean to merge; stamping APPROVED below, pinned to the verified head. The iOS-effectiveness verdict stays with the operator's device time.
surveyor approved these changes 2026-06-21 16:54:12 +02:00
surveyor left a comment

APPROVED at head 5cbfe7c700752ab5019fcd2ab0806a09123e601e — on the desktop-verifiable + code-correctness axis. tsc + build clean; unlock() bound to every gesture entry-point (idempotent, re-arms on suspend); desktop probe PASS (ctx none→running, kicked=true, overlay renders, BGM no-regression, best-effort never throws); overlay URL-gated + pointer-events:none, never ships. The iOS-output-effectiveness axis is operator-device-gated and this stamp deliberately does not claim it — merging deploys the diagnostic instrument so that on-device loop can run. Substance in the issue-comment above. Clean to merge.

APPROVED at head `5cbfe7c700752ab5019fcd2ab0806a09123e601e` — on the desktop-verifiable + code-correctness axis. tsc + build clean; unlock() bound to every gesture entry-point (idempotent, re-arms on suspend); desktop probe PASS (ctx none→running, kicked=true, overlay renders, BGM no-regression, best-effort never throws); overlay URL-gated + pointer-events:none, never ships. The iOS-output-effectiveness axis is operator-device-gated and this stamp deliberately does not claim it — merging deploys the diagnostic instrument so that on-device loop can run. Substance in the issue-comment above. Clean to merge.
bosun merged commit a9fe11fd2b into main 2026-06-21 16:55:02 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
frankenbit/cellblock!58
No description provided.