From a8b06b590706670295ffec079d7bf117ec5e4efd Mon Sep 17 00:00:00 2001
From: "russell@unturf.com"
Date: Sun, 7 Jun 2026 11:34:29 -0400
Subject: [PATCH] zebra-report: deploy fresh-stream wrap on mic ontrack +
mute-gate mesh swap
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Fixes ticket 0001 (fedora chrome cannot hear speakers) — fox 2026-06-07
telemetry ruled out the sink hypothesis from 0f36e02 and pinned two
coupled defects:
(A) tracks=0 race in handleRemoteSfuTrack mic branch — ev.streams[0]
can be empty at handler-time even when ev.track is live; the
silent fallback ate the listener.
(B) Mesh ontrack swapped the worklet to the mesh stream even when
the mesh track was still muted, orphaning the SFU receiver and
trapping the listener on a silent mesh source.
Mic now mirrors screen/camera/game (new MediaStream([ev.track])) and
the mesh swap waits for 'unmute' / restores SFU after a mute window.
Source commit: caa0548 in zebra-report.git
---
zebra-report/host-your-own.html | 6 +-
zebra-report/how-it-works.html | 6 +-
zebra-report/index.html | 6 +-
zebra-report/zebra-audio.html | 6 +-
zebra-report/zebra-spaces.html | 126 ++++++++++++++++++++++++--------
5 files changed, 107 insertions(+), 43 deletions(-)
diff --git a/zebra-report/host-your-own.html b/zebra-report/host-your-own.html
index f7fe228..8e9971c 100644
--- a/zebra-report/host-your-own.html
+++ b/zebra-report/host-your-own.html
@@ -507,9 +507,9 @@ handle /zebra-spaces-sfu* { reverse_proxy localhost:8092 { flush_interval -1
- page integrity · built 2026-06-06
- md5 f3900aa0c841baa7c61599ff247f8fe1
- sha256 496b0dee4967745d5502d549c30278370cbd228ae75c3020025f9912fa9773d7
+ page integrity · built 2026-06-07
+ md5 97108d6b240474c7e1b3bea7ae889cc4
+ sha256 9ac307a6b7bdedc95d44e5395a984be9ffd68d726459f96432373eb9bb67497f
hashes are of this page with these two fields zeroed — to verify, blank them and re-hash
one self-contained file — save a copy and verify it against these hashes; point it at your own servers with ?signal= and ?turncred=
diff --git a/zebra-report/how-it-works.html b/zebra-report/how-it-works.html
index fcc51c2..498b42c 100644
--- a/zebra-report/how-it-works.html
+++ b/zebra-report/how-it-works.html
@@ -341,9 +341,9 @@ try {
- page integrity · built 2026-06-06
- md5 ea02f55584d8042b03fcbd999e1da240
- sha256 c7cb5f8be8241d4634cca84acd3343c1fbfd0c7053b260bcee05084aa7bd83aa
+ page integrity · built 2026-06-07
+ md5 402c3b4d6e72b8cc6692166fc4683978
+ sha256 5f0a1a8dafd2074081c9189d12455797157901f9e52bb7c4899d4c7b7dd7bf95
hashes are of this page with these two fields zeroed — to verify, blank them and re-hash
one self-contained file — save a copy and verify it against these hashes; point it at your own servers with ?signal= and ?turncred=, or host your own community
diff --git a/zebra-report/index.html b/zebra-report/index.html
index edccd83..3683a81 100644
--- a/zebra-report/index.html
+++ b/zebra-report/index.html
@@ -1747,9 +1747,9 @@ logLine('sys', 'chat content lives in encrypted SRTP audio. no IP packets carry
})();
- page integrity · built 2026-06-06
- md5 e9fb81e6d445184e8a4967dfe56883f9
- sha256 a44a19223961e6a8e4f6010d073ff1e95246d908eb3b27d322fcd7bb17e5ad39
+ page integrity · built 2026-06-07
+ md5 c9fee1030b030fb1277cb89607ef7d8d
+ sha256 12c4070297b0d4d671e84d18891451289968f14b12fc2309f4dc85bef47528be
hashes are of this page with these two fields zeroed — to verify, blank them and re-hash
one self-contained file — save a copy and verify it against these hashes; point it at your own servers with ?signal= and ?turncred=, or host your own community
diff --git a/zebra-report/zebra-audio.html b/zebra-report/zebra-audio.html
index 53fcd5f..f0db38d 100644
--- a/zebra-report/zebra-audio.html
+++ b/zebra-report/zebra-audio.html
@@ -747,9 +747,9 @@ else wirePuppet();
- page integrity · built 2026-06-06
- md5 ba6a5714f597469b0aa61b32657f50f4
- sha256 78b5fb863ec406ec39081a0231fb909eef3abf332e84c0a5f25942f62f49de1b
+ page integrity · built 2026-06-07
+ md5 92054b1353cdbde054776ac5ed46b531
+ sha256 6a3dc7206ba425b7f36e8d04b2fd78a1ad53abb7033ba5a39656071591b33a34
hashes are of this page with these two fields zeroed — to verify, blank them and re-hash
one self-contained file — save a copy and verify it against these hashes; point it at your own servers with ?signal= and ?turncred=, or host your own community
diff --git a/zebra-report/zebra-spaces.html b/zebra-report/zebra-spaces.html
index 01a726b..297925f 100644
--- a/zebra-report/zebra-spaces.html
+++ b/zebra-report/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');
page integrity · built 2026-06-07
- md5 14af06bd5893feb70229dded7e6c5497
- sha256 e603642628f264fa919f55be33e6578d77d2d42858566c2d708beefce091aed5
+ md5 59cd5e54142e3880cfe784df09204e52
+ sha256 9ba89654f5a18538ee2e4ed6d614b8901e1088c4892fcbfc41d0b5a991692517
hashes are of this page with these two fields zeroed — to verify, blank them and re-hash
one self-contained file — save a copy and verify against these hashes; point at your own servers with ?signal= and ?turncred=, or host your own community