fix(audio): iOS in-gesture context unlock + ?debug=audio overlay (#56) #58
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "i/56-ios-audio-unlock"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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+ themain.tsconsumer):In-gesture output unlock (
unlock()):unlock()already calledensure()(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 throughdestinationto kick the iOS hardware output awake —resume()alone can leave iOS output gated until a realBufferSourceplays 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.)?debug=audiodiagnostic overlay (main.ts):AudioContextstate/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).render.tsa pure painter (no debug data threaded throughrender()), and the overlay survives every repaint.pointer-events:none) — never ships to normal users, zero production runtime cost.Design decision tree (not just the conclusion)
unlock()vs a one-shotinitAudio()in only the SOLO handler. I put the kick inunlock()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 || suspendedguard keeps it from spammingBufferSources on every keystroke during play while still re-arming on iOS re-suspend. If the chokepoint were ever split per-gesture, a dedicatedinitAudio()called once at first-gesture would be the right shape instead.render()(a signature change to a pure painter) and re-paint per frame. The DOM overlay is decoupled, pollsaudio.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):none — tap to createpre-gesture)stateflips none→running on first tap (context resumed in-gesture; the silent-buffer kick ran)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 readstate(must hitrunning) andrate(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 thetools/capture-*.jsconvention): 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
state=runningbut 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
Surveyor review — APPROVE ✅ (with the axis explicitly separated)
Verified at head
5cbfe7c700752ab5019fcd2ab0806a09123e601e, merge-base = current main tip6ee83f0(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):
--noEmitclean (exit 0),vite buildclean at head.unlock()is bound to every gesture entry-point — btnMuteclick, music-sliderinput,keydown, canvasclick(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 theoutputKickedguard (no redundant kicks), re-arms onstate==='suspended'./tmp/cb-audio-verify.js, Chromium--autoplay-policy=document-user-activation-required— the desktop proxy for iOS context-gating): before gesturectx none / state none / kicked false / rate 0; after clickctx created / state running / kicked true / rate 48000 / music playing. AC overlay renders / state→running / silent-buffer kicked: all PASS. No regression (BGM plays). Best-efforttry/catchconfirmed — a failed kick can't throw out of the click path.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.?debug=audio-gated (location.search.includes, consistent withUSE_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 undertools/with a README row. Consider committing it astools/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 calledensure()— so context-creation-in-gesture pre-existed. The genuinely-new load-bearing behavior is the silent-buffer kick (+ theif (!c) returnguard + 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.
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.