From caa0548113925b9de048df20769f8300b9a30acb Mon Sep 17 00:00:00 2001
From: Russell Ballestrini
Date: Sun, 7 Jun 2026 11:33:57 -0400
Subject: [PATCH] zebra-spaces: fresh-stream wrap on mic ontrack + mute-gate
mesh swap (kill fedora-chrome silence)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Fox 2026-06-07 telemetry on blanka-chrome ruled out the sink-routing
hypothesis from 467146a:
audio via AudioContext 085b target=0.5s ctxState=running sink=default
mesh stream swapped into worklet for 085b
...
aud.recv pkt=14622 lost=1 bytes=9117575 jitter=0.0100 level=0.000 jbuf=? lp=0.1s
50 pps stereo Opus arriving on the SFU sub PC, sink routed to the
picked device, but jbuf=? (jitterBufferEmittedCount=0) + level=0 — the
native decoder consumes nothing because no MediaStreamSource is bound
to the SFU receiver's track. fxhp-phone same room same window decodes
fine (jbuf=0.49s level=0.001), so the SFU is healthy and the defect is
per-client wiring.
Two coupled defects:
(A) tracks=0 race in handleRemoteSfuTrack mic branch. Chrome can fire
ontrack with ev.streams[0] still empty at handler-time (the live track
arrives a microtask later, or MSID-supplant merges new+dead tracks).
Caching ev.streams[0] then handing it to attachSfuTrack drops the
listener into the silent
diff --git a/web/how-it-works.html b/web/how-it-works.html
index fcc51c2..498b42c 100644
--- a/web/how-it-works.html
+++ b/web/how-it-works.html
@@ -341,9 +341,9 @@ try {
diff --git a/web/zebra-audio.html b/web/zebra-audio.html
index 53fcd5f..f0db38d 100644
--- a/web/zebra-audio.html
+++ b/web/zebra-audio.html
@@ -747,9 +747,9 @@ else wirePuppet();
diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html
index 01a726b..297925f 100644
--- a/web/zebra-spaces.html
+++ b/web/zebra-spaces.html
@@ -4838,11 +4838,24 @@ function handleRemoteSfuTrack(ev){
* was handing the dead stream to attachAudioStreamViaWorklet on the
* NEXT peer-joined with matching pubkey, racing the live ontrack.
* Fox 2026-06-05 telemetry: peer-joined u=64b0 fired "audio attach
- * fresh=1 tracks=0" before the real attach. */
- sfuStreamsByPubHex.set(pubHex, ev.streams[0]);
+ * fresh=1 tracks=0" before the real attach.
+ *
+ * Wrap in a FRESH MediaStream containing only ev.track — same shape
+ * screen/camera/game use at lines 4759-4784. Chrome can fire ontrack
+ * where ev.streams[0] is the supplant-merged stream object containing
+ * the dead old track AND nothing else yet (the live track gets added
+ * asynchronously after the handler runs). Caching ev.streams[0]
+ * directly captured the empty/dead version; attachSfuTrack then logged
+ * `fresh=1 tracks=0` + `meter ... MediaStream has no audio track`
+ * and fell through to the silent path. Fox 2026-06-07
+ * telemetry on blanka-chrome confirmed: SFU sub PC carrying real
+ * 50 pps stereo Opus, jbuf=? + level=0 because no decoder consumed
+ * the orphan track. */
+ const s = new MediaStream([ev.track]);
+ sfuStreamsByPubHex.set(pubHex, s);
try {
ev.track.addEventListener('ended', () => {
- if (sfuStreamsByPubHex.get(pubHex) === ev.streams[0]){
+ if (sfuStreamsByPubHex.get(pubHex) === s){
sfuStreamsByPubHex.delete(pubHex);
logLine('', 'sfu cache drop pub='+pubHex.slice(0,4)+' — track ended');
}
@@ -4885,7 +4898,7 @@ function handleRemoteSfuTrack(ev){
logLine('', 'sfu mic taking over for '+uuid+' — mesh state='+meshState);
}
}
- attachSfuTrack(uuid, ev.streams[0]);
+ attachSfuTrack(uuid, s);
return;
}
} catch(_){}
@@ -6700,33 +6713,84 @@ async function connectToPeer(uuid, weOffer){
* cushion (0.5s for speakers). Source swap is seamless because
* the worklet's queue holds 0.5s of decoded samples and both
* sources contain identical content at slightly different
- * network delays. */
+ * network delays.
+ *
+ * Gate the swap on track-not-muted. ev.track.muted=true means
+ * "no RTP yet"; it transitions to false on the first decoded
+ * packet ('unmute' event). Swapping the worklet to a muted mesh
+ * track orphans the SFU receiver (no decoder consumes it →
+ * jbuf=? + level=0 on telemetry) while the worklet plays silence
+ * from mesh — the silent-room state seen on fedora chrome
+ * (ticket 0001, fox 2026-06-07). If the mesh track is already
+ * unmuted at ontrack-time, swap immediately. Otherwise wait for
+ * 'unmute' before touching the worklet. */
const stream = ev.streams[0] || new MediaStream([ev.track]);
- if (!setWorkletStream(uuid, stream)){
- /* fallback: no existing worklet (AudioContext failed at SFU
- * attach time). Create one fresh with mesh as the source.
- * Small initial silence while the buffer fills. */
- attachAudioStreamViaWorklet(uuid, stream, SPEAKER_PLAYOUT_DELAY_SEC);
+ const swapToMesh = () => {
+ if (!setWorkletStream(uuid, stream)){
+ /* fallback: no existing worklet (AudioContext failed at SFU
+ * attach time). Create one fresh with mesh as the source.
+ * Small initial silence while the buffer fills. */
+ attachAudioStreamViaWorklet(uuid, stream, SPEAKER_PLAYOUT_DELAY_SEC);
+ }
+ /* mesh path is peer-to-peer between two speakers (you'd never be
+ * in mesh as a pure listener). Always conversational latency
+ * here — fixed at SPEAKER_PLAYOUT_DELAY_SEC, no role check
+ * needed. */
+ try { ev.receiver.playoutDelayHint = SPEAKER_PLAYOUT_DELAY_SEC; } catch(_){}
+ try { ev.receiver.jitterBufferTarget = SPEAKER_PLAYOUT_DELAY_SEC * 1000; } catch(_){}
+ /* lip-sync: rebind the audio receiver for this publisher to the
+ * MESH receiver since mesh is now what's feeding the worklet.
+ * Its jbuf (~50ms native) + worklet (0.5s) ≈ ~0.55s total audio
+ * delay. Video receivers will re-target to match on the next
+ * worklet 'buffered' message. Fox 2026-06-04: "video would need
+ * to be slid depending on the mode to keep it in sync." */
+ try {
+ const mm = members.get(uuid);
+ const pubHex = mm && mm.pubkey ? hex(unb64(mm.pubkey)) : null;
+ if (pubHex && ev.receiver) registerLipSyncAudio(pubHex, uuid, ev.receiver);
+ } catch(_){}
+ stopMeter(uuid); startMeter(uuid, stream);
+ logLine('', 'mesh stream swapped into worklet for '+uuid.slice(0,4)+' — buffer cushion now applies to mesh too');
+ };
+ /* Mesh-stale fallback: once swapped in, if the mesh track returns
+ * to muted (RTP stopped flowing) for >MESH_MUTE_WINDOW_MS, swap
+ * the worklet back to the cached SFU stream so the listener keeps
+ * hearing the publisher even if mesh stalls without dropping the
+ * PC's connectionState. Same idea as the connectionState='failed'
+ * restore path (below), but driven by track-level mute instead of
+ * PC-level failure. */
+ const MESH_MUTE_WINDOW_MS = 5000;
+ let muteTimer = null;
+ ev.track.addEventListener('mute', () => {
+ if (muteTimer) return;
+ muteTimer = setTimeout(() => {
+ muteTimer = null;
+ if (!ev.track.muted) return;
+ try {
+ const mm = members.get(uuid);
+ const pubHex = mm && mm.pubkey ? hex(unb64(mm.pubkey)) : null;
+ const sfuStream = pubHex ? sfuStreamsByPubHex.get(pubHex) : null;
+ if (sfuStream && setWorkletStream(uuid, sfuStream)){
+ const sfuRx = pubHex ? sfuAudioReceivers.get(pubHex) : null;
+ if (sfuRx) registerLipSyncAudio(pubHex, uuid, sfuRx);
+ logLine('', 'mesh muted >'+(MESH_MUTE_WINDOW_MS/1000)+'s for '+uuid.slice(0,4)+' — worklet swapped back to SFU');
+ }
+ } catch(_){}
+ }, MESH_MUTE_WINDOW_MS);
+ });
+ ev.track.addEventListener('unmute', () => {
+ if (muteTimer){ clearTimeout(muteTimer); muteTimer = null; }
+ /* if we were on SFU because mesh went muted, swap forward to
+ * mesh again now that RTP is flowing. swapToMesh is idempotent
+ * (setWorkletStream is a no-op if the same source is already
+ * connected). */
+ swapToMesh();
+ });
+ if (!ev.track.muted){
+ swapToMesh();
+ } else {
+ logLine('', 'mesh track for '+uuid.slice(0,4)+' arrived muted — staying on SFU until unmute');
}
- /* mesh path is peer-to-peer between two speakers (you'd never be
- * in mesh as a pure listener). Always conversational latency
- * here — fixed at SPEAKER_PLAYOUT_DELAY_SEC, no role check
- * needed. */
- try { ev.receiver.playoutDelayHint = SPEAKER_PLAYOUT_DELAY_SEC; } catch(_){}
- try { ev.receiver.jitterBufferTarget = SPEAKER_PLAYOUT_DELAY_SEC * 1000; } catch(_){}
- /* lip-sync: rebind the audio receiver for this publisher to the
- * MESH receiver since mesh is now what's feeding the worklet.
- * Its jbuf (~50ms native) + worklet (0.5s) ≈ ~0.55s total audio
- * delay. Video receivers will re-target to match on the next
- * worklet 'buffered' message. Fox 2026-06-04: "video would need
- * to be slid depending on the mode to keep it in sync." */
- try {
- const mm = members.get(uuid);
- const pubHex = mm && mm.pubkey ? hex(unb64(mm.pubkey)) : null;
- if (pubHex && ev.receiver) registerLipSyncAudio(pubHex, uuid, ev.receiver);
- } catch(_){}
- stopMeter(uuid); startMeter(uuid, stream);
- logLine('', 'mesh stream swapped into worklet for '+uuid.slice(0,4)+' — buffer cushion now applies to mesh too');
};
pc.onicecandidate = (ev) => { /* using waitForIceGathering pattern, candidates ignored */ };
pc.onconnectionstatechange = () => {
@@ -8084,8 +8148,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');