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

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