diff --git a/docs/tickets/0001-fedora-chrome-cannot-hear-speakers.md b/docs/tickets/0001-fedora-chrome-cannot-hear-speakers.md index 843a4f7..5f4b056 100644 --- a/docs/tickets/0001-fedora-chrome-cannot-hear-speakers.md +++ b/docs/tickets/0001-fedora-chrome-cannot-hear-speakers.md @@ -168,11 +168,49 @@ for >5s, swap the worklet back to the cached SFU stream — same pattern the `connectionState === 'failed'` path already uses at line 6755. +## 2026-06-07 second telemetry pass — sink DRIFT identified + +After deploying (A) + (B), blanka-chrome was still silent. Fresh +telemetry pinned a different per-attach failure mode: + +``` +21:31:30 audio attach uuid=47e5 target=0.5s (current pool=1) +21:31:31 audio via AudioContext 47e5 target=0.5s ctxState=running sink=c38572ec… +21:31:31 jitter-buffer started uuid=47e5 target=0.5s +…leave/rejoin cycle… +21:31:33 audio attach uuid=47e5 target=0.5s (current pool=1) +21:31:33 audio via AudioContext 47e5 target=0.5s ctxState=running sink=default ← drifted +``` + +No `audioCtx sink → c38572ec…` log line ever appeared. So +`applySinkToAudioCtx()` either never ran or hit the +`typeof audioCtx.setSinkId !== 'function'` early-return at the moment +of execution. The earlier sink fix only invoked +`applySinkToAudioCtx()` inside the `if (!audioCtx)` branch — a +persistent audioCtx whose `.sinkId` getter returned `''` (default) was +never re-corrected. + +## Fix (C) — re-apply sink on every attach when it drifted + +`attachAudioStreamViaWorklet` now: if `audioCtx` already exists AND +`speakerDeviceId` is set AND `audioCtx.sinkId !== speakerDeviceId`, +call `applySinkToAudioCtx()`. Idempotent — same-sink call is a no-op. + +Also added per-tick `ctx.sink=` to the telemetry stream so sink drift +is visible on every 5s line instead of needing to find the rare attach +log. When the active sink doesn't match the picked one, the line +includes `want=…` so the mismatch jumps out. + ## Status notes -- **2026-06-07 (in-progress):** sink hypothesis ruled out by fresh - telemetry; root cause is (A) tracks=0 race + (B) mesh-swap orphan. - Implementing both fixes in this branch. +- **2026-06-07 (in-progress, fix C deployed):** sink-drift on + audioCtx-persistent path identified + patched. Need a third telemetry + pass to confirm the per-tick `ctx.sink=` line now stays on the + picked device across leave/rejoin. +- **2026-06-07 (fixes A + B deployed):** sink hypothesis from 467146a + ruled out by fresh telemetry; (A) tracks=0 race + (B) mesh-swap + orphan landed in caa0548. blanka-chrome was still silent, leading + to fix (C) above. - **2026-06-06:** the Jun 5 cascade + flushSfuStreams prefix-match fix (`0ce1339`) helped firefox but not chrome. Telemetry above ruled out the FSM / attach defects. diff --git a/test/listener-audio-attach.test.js b/test/listener-audio-attach.test.js index 0499d6e..1f57e5b 100644 --- a/test/listener-audio-attach.test.js +++ b/test/listener-audio-attach.test.js @@ -252,6 +252,15 @@ function makeBrowser(role){ return el; } function applySinkTo(){ return Promise.resolve(); } + function applySinkToAudioCtx(){ + /* sandbox stub for the sink-routing fan-out — real impl awaits + * audioCtx.setSinkId(speakerDeviceId). Tests that care about + * the setSinkId call inspect ctx._setSinkCalls in the override + * below; this stub just hits the same code path. */ + try { audioCtx.setSinkId(speakerDeviceId || ''); } catch(_){} + } + let speakerDeviceId = ''; + function setSpeakerDeviceId(id){ speakerDeviceId = id || ''; } function startMeter(){} function stopMeter(){} function registerLipSyncAudio(){} @@ -320,7 +329,7 @@ function makeBrowser(role){ ' attachAudioStreamViaWorklet, attachListenerStreamViaAudioContext,\n' + ' attachSfuTrack, flushSfuStreams, setWorkletStream, detachListenerStream,\n' + ' handleRemoteSfuTrack,\n' + - ' triggerWorkletReady, setRole,\n' + + ' triggerWorkletReady, setRole, setSpeakerDeviceId,\n' + ' __seedLipSync,\n' + ' get audioCtx(){ return audioCtx; },\n' + ' get workletReady(){ return workletReady; },\n' + @@ -1076,6 +1085,38 @@ test('audioCtx.setSinkId is called on creation so the worklet routes to the user truthy(typeof b.ctx.setSinkId === 'function', 'setSinkId is wired'); }); +test('attach re-applies sink when audioCtx persists across leave/rejoin and the picked sink drifted (kills blanka-chrome silent-after-rejoin)', () => { + /* Fox 2026-06-07 blanka-chrome telemetry showed: + * - first attach: audio via AudioContext ... sink=c38572ec… (good) + * - leave + re-enter the space + * - second attach: audio via AudioContext ... sink=default (BAD — sink reverted) + * - no `audioCtx sink → c38572ec…` log between them + * + * Root cause: applySinkToAudioCtx() only ran inside the + * `if (!audioCtx)` branch — fresh ctx → apply sink. A persistent + * audioCtx that somehow had its sinkId getter return '' (default) + * stayed default forever, because attach saw audioCtx already + * existed and skipped the apply. + * + * Pin the fix: when audioCtx exists AND speakerDeviceId is set AND + * the ctx's current sinkId !== speakerDeviceId, attach must call + * setSinkId again to restore routing. */ + const b = makeBrowser('listener'); + addMember(b, UUID_A, PUB_A); + const calls = []; + b.ctx.setSinkId = function(id){ + calls.push(id); + /* simulate setSinkId behaviour: the getter now reflects the call */ + b.ctx.sinkId = id; + return Promise.resolve(); + }; + b.ctx.sinkId = ''; /* default sink */ + b.api.setSpeakerDeviceId('dev-1234'); /* user picked a specific device */ + b.api.attachAudioStreamViaWorklet(UUID_A, makeStream(), RECV); + truthy(calls.includes('dev-1234'), + 'attach called setSinkId("dev-1234") to restore the picked sink on the persistent audioCtx'); +}); + test('audioCtx without setSinkId support (older browser) does not throw — silent fallback to default', () => { /* Firefox <116 and Chrome <110 don't have audioCtx.setSinkId. * applySinkToAudioCtx must check before calling. */ diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html index 3ae6ece..8ba8adc 100644 --- a/web/zebra-spaces.html +++ b/web/zebra-spaces.html @@ -3281,6 +3281,19 @@ function attachAudioStreamViaWorklet(uuid, stream, targetSeconds){ applySinkToAudioCtx(); } catch(e){ logLine('err','audioCtx create: '+e.message); return false; } + } else if (speakerDeviceId + && typeof audioCtx.setSinkId === 'function' + && audioCtx.sinkId !== speakerDeviceId){ + /* audioCtx survived a leave→re-enter but its sinkId drifted back + * to '' (system default). Re-apply the picked sink so the worklet + * keeps emitting to the device the user picked in the dropdown. + * Fox 2026-06-07 blanka-chrome telemetry: first attach + * `sink=c38572ec…`, a leave/rejoin later, next attach + * `sink=default` and no `audioCtx sink → …` success line — + * applySinkToAudioCtx only ran inside the `!audioCtx` branch above, + * so a persistent audioCtx with reset sinkId stayed default + * forever. Idempotent: same-sink call is a noop. */ + applySinkToAudioCtx(); } if (audioCtx.state === 'suspended'){ audioCtx.resume().catch(()=>{}); @@ -5830,6 +5843,23 @@ async function dumpTelemetry(){ /* reset per-tick whisper counters */ _whisperTick.sent = 0; _whisperTick.dropped = 0; _whisperTick.emitted = 0; _whisperTick.totalLatencyMs = 0; + /* audioCtx sink visibility on EVERY tick — the one-shot 'sink=…' in + * the attach log only captures the moment of attach; if the sink + * drifts later (Chrome's audioCtx.sinkId getter can revert to '' on + * device-changes, leave/rejoin, suspend/resume), this surfaces it + * every 5s instead of needing to grep for the rare attach event. + * Fox 2026-06-07 blanka-chrome: per-attach sink looked fine but the + * silent-listener state persisted because the GETTER value drifted + * between attaches. */ + if (audioCtx){ + const cs = (audioCtx.sinkId === undefined) ? 'n/a' + : (audioCtx.sinkId || 'default'); + const want = speakerDeviceId || 'default'; + parts.push('ctx.sink=' + (cs.length > 12 ? cs.slice(0,8) : cs) + + (cs !== want && audioCtx.sinkId !== undefined + ? ' want=' + (want.length > 12 ? want.slice(0,8) : want) + : '')); + } /* receiver stats from sfuSubPC (the main listener path) */ if (sfuSubPC && typeof sfuSubPC.getStats === 'function'){ try { @@ -8183,8 +8213,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');