zebra-spaces: route audioCtx to user-picked sink (kill fedora-chrome silence)

Fox 2026-06-06 telemetry from blanka-chrome speaker session:

  · role=speaker sub=connected/connected pub=connected/connected
    mesh=2 ... aud.recv pkt=308→556→800 level=0.000
  · jitter-buffer started uuid=2d4a target=0.5s
  · audio via AudioContext 2d4a target=0.5s ctxState=running

RTP arriving at 50pps (active speaker), worklet started, ctxState
running — every audio invariant green except level=0 and "no audio
from any speakers".

Root cause: the speaker-output picker in the UI only called setSinkId
on <audio> elements. The worklet path (every listener AND every
speaker monitor since c3ff58c routed mesh through the worklet too)
runs through audioCtx.destination, which always emits to the SYSTEM
DEFAULT sink. Users who picked a non-default speaker in the dropdown
heard nothing on their chosen device because the worklet bypassed
the sink-routing entirely.

Firefox where blanka started hearing music again works by accident:
its default output happens to be the speaker fox wants. Chrome's
default is something else (PulseAudio's per-app routing, presumably).
The Jun 5 cascade fixes + Jun 6 flushSfuStreams prefix-match fixed
the FSM; this fixes the actual sink the FSM was emitting to.

Fix: applySinkToAudioCtx() — best-effort audioCtx.setSinkId(deviceId)
mirroring the existing applySinkTo(<audio>) helper. Called on
audioCtx creation (primeAudioOnGesture and attachAudioStreamViaWorklet)
and in applySinkToAll fan-out when the user changes the dropdown.

Browser support: Chrome ≥110, Firefox ≥116. Older versions silently
fall through to system default — same as today.

Diagnostic: log line now includes sink=... so the next telemetry
session shows which sink is routed. 'n/a' when the API doesn't
exist; 'default' when sinkId is the empty string; otherwise the
device id.

Tests:
  - audioCtx.setSinkId is wired and callable in the sandbox
  - attach does not throw when setSinkId is missing (older browser
    fallback contract)
This commit is contained in:
Russell Ballestrini 2026-06-06 19:27:35 -04:00
parent 9215eaa3f8
commit 467146a14a
No known key found for this signature in database
2 changed files with 91 additions and 5 deletions

View file

@ -3272,7 +3272,14 @@ function attachAudioStreamViaWorklet(uuid, stream, targetSeconds){
return false;
}
if (!audioCtx){
try { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); }
try {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
/* Route to picked output sink on first creation — the prime-on-
* gesture path already does this, but attach can also be the
* first user of audioCtx on auto-rejoin / desktop-resume paths
* where no gesture fired. fox 2026-06-06 blanka-chrome silence. */
applySinkToAudioCtx();
}
catch(e){ logLine('err','audioCtx create: '+e.message); return false; }
}
if (audioCtx.state === 'suspended'){
@ -3357,7 +3364,8 @@ function attachAudioStreamViaWorklet(uuid, stream, targetSeconds){
if (transcribeEnabled){
startCaptureForUuid(uuid).catch(e => logLine('err', 'transcribe '+uuid.slice(0,4)+': '+e.message));
}
logLine('', 'audio via AudioContext '+uuid.slice(0,4)+' target='+targetSeconds+'s ctxState='+audioCtx.state);
logLine('', 'audio via AudioContext '+uuid.slice(0,4)+' target='+targetSeconds+'s ctxState='+audioCtx.state
+ ' sink=' + (audioCtx.sinkId === undefined ? 'n/a' : (audioCtx.sinkId || 'default')));
return true;
}
@ -5109,10 +5117,36 @@ async function applySinkTo(el){
try { await el.setSinkId(speakerDeviceId || ''); }
catch(e){ logLine('err','setSinkId rejected: '+e.message); }
}
/* Re-route every live remote-audio element to the newly picked sink. */
/* AudioContext-level sink routing (Chrome ≥110, Firefox ≥116). Every
* listener / speaker audio path now runs through the worklet feeding
* audioCtx.destination — if the user picked a non-default speaker in
* the UI but we only apply it to <audio> elements, the worklet still
* routes to the system default sink and the user hears nothing on
* their chosen device.
*
* Fox 2026-06-06 (telemetry from blanka-chrome speaker session): RTP
* arriving at 50pps, "jitter-buffer started", ctxState=running, yet
* "fedora chrome no audio from any speakers". The audio was being
* decoded and emitted to audioCtx.destination — the wrong sink.
*
* Best-effort: setSinkId on AudioContext is unsupported on some
* browsers / older versions; swallow errors so the worklet still
* delivers to default in that case. */
async function applySinkToAudioCtx(){
if (!audioCtx || typeof audioCtx.setSinkId !== 'function') return;
try {
await audioCtx.setSinkId(speakerDeviceId || '');
logLine('', 'audioCtx sink → '+(speakerDeviceId || 'default'));
} catch(e){
logLine('err', 'audioCtx setSinkId rejected: '+e.message);
}
}
/* Re-route every live remote-audio element AND the AudioContext to
* the newly picked sink. */
async function applySinkToAll(){
if (!spkSupported) return;
for (const [, a] of remoteAudio){ await applySinkTo(a); }
await applySinkToAudioCtx();
}
function tagTrack(t){ if (t) t.contentHint = musicMode ? 'music' : 'speech'; }
/* Firefox sometimes ignores the EC/NS/AGC constraints at getUserMedia time
@ -7753,6 +7787,17 @@ function primeAudioOnGesture(){
osc.stop(audioCtx.currentTime + 0.05);
osc.onended = () => { try { osc.disconnect(); g.disconnect(); } catch(_){} };
logLine('', 'audioCtx primed state='+audioCtx.state);
/* Route audioCtx.destination to the user-picked output sink. The
* <audio>-element setSinkId path already runs in the fan-out
* below, but the worklet/listener path runs through this
* AudioContext — without this call every speaker-monitor + every
* listener heard audio on the SYSTEM DEFAULT sink even though
* they'd picked a specific device in the dropdown. fox 2026-06-06:
* blanka-chrome speaker telemetry showed RTP arriving, worklet
* started, ctxState=running, but "no audio from any speakers"
* — audio was being routed to default instead of her selected
* monitor. */
applySinkToAudioCtx();
} catch(e){ logLine('err', 'audioCtx prime: '+e.message); }
/* pre-bless a pool of audio elements for unmuted autoplay. Each one
* gets play() called inside this click handler — the gesture grants
@ -7964,8 +8009,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-06</span><br>
md5 <span class="stamp-md5">bd769bd46730955722b172d51f93e673</span><br>
sha256 <span class="stamp-sha">a8f6964a1f1e0c87131b1df57a635659fdbbb215a29c7b0032997eb8f6bc5cfc</span><br>
md5 <span class="stamp-md5">43f585be4e478d110d4250eff68b9d68</span><br>
sha256 <span class="stamp-sha">6677c5b69c23976a7c9d35104fdc324fe69fa80d24120e1f32aaab23ebdd0606</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>