zebra-spaces: re-apply sink on every attach + per-tick ctx.sink telemetry (kill blanka-chrome sink drift)

After caa0548 (fixes A + B for ticket 0001), blanka-chrome was still
silent. Fresh telemetry pinned a third failure mode:

  21:31:31  audio via AudioContext 47e5 sink=c38572ec…   ← good
  …leave + rejoin…
  21:31:33  audio via AudioContext 47e5 sink=default     ← drifted

No `audioCtx sink → c38572ec…` log line between the two attaches.
applySinkToAudioCtx was only invoked inside the `if (!audioCtx)` branch
in attachAudioStreamViaWorklet — a persistent audioCtx whose .sinkId
getter returned '' (system default) after a leave/rejoin never got
its sink re-applied, so every subsequent attach emitted to the system
default speaker instead of the device the user picked in the dropdown.

Fix: when audioCtx already exists AND speakerDeviceId is set AND
audioCtx.sinkId !== speakerDeviceId, call applySinkToAudioCtx() to
restore routing. Idempotent (same-sink call is a noop).

Telemetry: every 5s tick now includes `ctx.sink=…` and `want=…` when
the active sink doesn't match the picked one, so drift is visible
without grepping for the rare attach event.

Test: attach re-applies sink when audioCtx persists across leave/rejoin
and the picked sink drifted. Sandbox now exposes setSpeakerDeviceId so
the test can drive the picked-device path through the real
attachAudioStreamViaWorklet branch.

Ticket 0001 updated with the second-pass telemetry and fix (C).
This commit is contained in:
Russell Ballestrini 2026-06-07 17:42:12 -04:00
parent b9e254172b
commit 4cedd85dc6
No known key found for this signature in database
3 changed files with 115 additions and 6 deletions

View file

@ -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.

View file

@ -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. */

View file

@ -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');
<footer style="margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
<span id="pi-seal" style="color:#777;cursor:default;user-select:none" title="">page integrity</span> &nbsp;·&nbsp; built <span class="stamp-date">2026-06-07</span><br>
md5 <span class="stamp-md5">bb5c7dea19fcb19f88c479d1cb26f24f</span><br>
sha256 <span class="stamp-sha">03e08e3499cf18fac3aec4b5dbeb808e6b3b6bceb5222ca560026c6d65864d38</span><br>
md5 <span class="stamp-md5">1e77ac2d270d5b6b8697257a6d7f43cc</span><br>
sha256 <span class="stamp-sha">6b76bd2486a0b366599cc8e22c1e11d51072e3bf09df3afed6f0208e5af8b0a9</span><br>
<span style="color:#bbb">hashes are of this page with these two fields zeroed — to verify, blank them and re-hash</span><br>
<span style="color:#bbb">one self-contained file — <strong>save a copy</strong> and verify against these hashes; point at your own servers with ?signal= and ?turncred=, or <a href="host-your-own.html" style="color:#999">host your own community</a></span>
</footer>