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:
parent
b9e254172b
commit
4cedd85dc6
3 changed files with 115 additions and 6 deletions
|
|
@ -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
|
pattern the `connectionState === 'failed'` path already uses at line
|
||||||
6755.
|
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
|
## Status notes
|
||||||
|
|
||||||
- **2026-06-07 (in-progress):** sink hypothesis ruled out by fresh
|
- **2026-06-07 (in-progress, fix C deployed):** sink-drift on
|
||||||
telemetry; root cause is (A) tracks=0 race + (B) mesh-swap orphan.
|
audioCtx-persistent path identified + patched. Need a third telemetry
|
||||||
Implementing both fixes in this branch.
|
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
|
- **2026-06-06:** the Jun 5 cascade + flushSfuStreams prefix-match fix
|
||||||
(`0ce1339`) helped firefox but not chrome. Telemetry above ruled out
|
(`0ce1339`) helped firefox but not chrome. Telemetry above ruled out
|
||||||
the FSM / attach defects.
|
the FSM / attach defects.
|
||||||
|
|
|
||||||
|
|
@ -252,6 +252,15 @@ function makeBrowser(role){
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
function applySinkTo(){ return Promise.resolve(); }
|
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 startMeter(){}
|
||||||
function stopMeter(){}
|
function stopMeter(){}
|
||||||
function registerLipSyncAudio(){}
|
function registerLipSyncAudio(){}
|
||||||
|
|
@ -320,7 +329,7 @@ function makeBrowser(role){
|
||||||
' attachAudioStreamViaWorklet, attachListenerStreamViaAudioContext,\n' +
|
' attachAudioStreamViaWorklet, attachListenerStreamViaAudioContext,\n' +
|
||||||
' attachSfuTrack, flushSfuStreams, setWorkletStream, detachListenerStream,\n' +
|
' attachSfuTrack, flushSfuStreams, setWorkletStream, detachListenerStream,\n' +
|
||||||
' handleRemoteSfuTrack,\n' +
|
' handleRemoteSfuTrack,\n' +
|
||||||
' triggerWorkletReady, setRole,\n' +
|
' triggerWorkletReady, setRole, setSpeakerDeviceId,\n' +
|
||||||
' __seedLipSync,\n' +
|
' __seedLipSync,\n' +
|
||||||
' get audioCtx(){ return audioCtx; },\n' +
|
' get audioCtx(){ return audioCtx; },\n' +
|
||||||
' get workletReady(){ return workletReady; },\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');
|
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', () => {
|
test('audioCtx without setSinkId support (older browser) does not throw — silent fallback to default', () => {
|
||||||
/* Firefox <116 and Chrome <110 don't have audioCtx.setSinkId.
|
/* Firefox <116 and Chrome <110 don't have audioCtx.setSinkId.
|
||||||
* applySinkToAudioCtx must check before calling. */
|
* applySinkToAudioCtx must check before calling. */
|
||||||
|
|
|
||||||
|
|
@ -3281,6 +3281,19 @@ function attachAudioStreamViaWorklet(uuid, stream, targetSeconds){
|
||||||
applySinkToAudioCtx();
|
applySinkToAudioCtx();
|
||||||
}
|
}
|
||||||
catch(e){ logLine('err','audioCtx create: '+e.message); return false; }
|
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'){
|
if (audioCtx.state === 'suspended'){
|
||||||
audioCtx.resume().catch(()=>{});
|
audioCtx.resume().catch(()=>{});
|
||||||
|
|
@ -5830,6 +5843,23 @@ async function dumpTelemetry(){
|
||||||
/* reset per-tick whisper counters */
|
/* reset per-tick whisper counters */
|
||||||
_whisperTick.sent = 0; _whisperTick.dropped = 0;
|
_whisperTick.sent = 0; _whisperTick.dropped = 0;
|
||||||
_whisperTick.emitted = 0; _whisperTick.totalLatencyMs = 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) */
|
/* receiver stats from sfuSubPC (the main listener path) */
|
||||||
if (sfuSubPC && typeof sfuSubPC.getStats === 'function'){
|
if (sfuSubPC && typeof sfuSubPC.getStats === 'function'){
|
||||||
try {
|
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">
|
<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> · built <span class="stamp-date">2026-06-07</span><br>
|
<span id="pi-seal" style="color:#777;cursor:default;user-select:none" title="">page integrity</span> · built <span class="stamp-date">2026-06-07</span><br>
|
||||||
md5 <span class="stamp-md5">bb5c7dea19fcb19f88c479d1cb26f24f</span><br>
|
md5 <span class="stamp-md5">1e77ac2d270d5b6b8697257a6d7f43cc</span><br>
|
||||||
sha256 <span class="stamp-sha">03e08e3499cf18fac3aec4b5dbeb808e6b3b6bceb5222ca560026c6d65864d38</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">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>
|
<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>
|
</footer>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue