zebra-report: deploy fresh-stream wrap on mic ontrack + mute-gate mesh swap

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 <audio> 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
This commit is contained in:
russell@unturf.com 2026-06-07 11:34:29 -04:00
parent 8e762fb100
commit a8b06b5907
No known key found for this signature in database
5 changed files with 107 additions and 43 deletions

View file

@ -507,9 +507,9 @@ handle /zebra-spaces-sfu* { reverse_proxy localhost:8092 { flush_interval -1
</p>
<footer style="max-width:820px;margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
<span style="color:#777">page integrity</span> &nbsp;·&nbsp; built <span class="stamp-date">2026-06-06</span><br>
md5 <span class="stamp-md5">f3900aa0c841baa7c61599ff247f8fe1</span><br>
sha256 <span class="stamp-sha">496b0dee4967745d5502d549c30278370cbd228ae75c3020025f9912fa9773d7</span><br>
<span style="color:#777">page integrity</span> &nbsp;·&nbsp; built <span class="stamp-date">2026-06-07</span><br>
md5 <span class="stamp-md5">97108d6b240474c7e1b3bea7ae889cc4</span><br>
sha256 <span class="stamp-sha">9ac307a6b7bdedc95d44e5395a984be9ffd68d726459f96432373eb9bb67497f</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 it against these hashes; point it at your own servers with ?signal= and ?turncred=</span>
</footer>

View file

@ -341,9 +341,9 @@ try {
</p>
<footer style="max-width:820px;margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
<span style="color:#777">page integrity</span> &nbsp;·&nbsp; built <span class="stamp-date">2026-06-06</span><br>
md5 <span class="stamp-md5">ea02f55584d8042b03fcbd999e1da240</span><br>
sha256 <span class="stamp-sha">c7cb5f8be8241d4634cca84acd3343c1fbfd0c7053b260bcee05084aa7bd83aa</span><br>
<span style="color:#777">page integrity</span> &nbsp;·&nbsp; built <span class="stamp-date">2026-06-07</span><br>
md5 <span class="stamp-md5">402c3b4d6e72b8cc6692166fc4683978</span><br>
sha256 <span class="stamp-sha">5f0a1a8dafd2074081c9189d12455797157901f9e52bb7c4899d4c7b7dd7bf95</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 it against these hashes; point it 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>

View file

@ -1747,9 +1747,9 @@ logLine('sys', 'chat content lives in encrypted SRTP audio. no IP packets carry
})();
</script>
<footer style="max-width:820px;margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
<span style="color:#777">page integrity</span> &nbsp;·&nbsp; built <span class="stamp-date">2026-06-06</span><br>
md5 <span class="stamp-md5">e9fb81e6d445184e8a4967dfe56883f9</span><br>
sha256 <span class="stamp-sha">a44a19223961e6a8e4f6010d073ff1e95246d908eb3b27d322fcd7bb17e5ad39</span><br>
<span style="color:#777">page integrity</span> &nbsp;·&nbsp; built <span class="stamp-date">2026-06-07</span><br>
md5 <span class="stamp-md5">c9fee1030b030fb1277cb89607ef7d8d</span><br>
sha256 <span class="stamp-sha">12c4070297b0d4d671e84d18891451289968f14b12fc2309f4dc85bef47528be</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 it against these hashes; point it 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>

View file

@ -747,9 +747,9 @@ else wirePuppet();
</section>
<footer style="max-width:560px;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">ba6a5714f597469b0aa61b32657f50f4</span><br>
sha256 <span class="stamp-sha">78b5fb863ec406ec39081a0231fb909eef3abf332e84c0a5f25942f62f49de1b</span><br>
<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-07</span><br>
md5 <span class="stamp-md5">92054b1353cdbde054776ac5ed46b531</span><br>
sha256 <span class="stamp-sha">6a3dc7206ba425b7f36e8d04b2fd78a1ad53abb7033ba5a39656071591b33a34</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 it against these hashes; point it 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>

View file

@ -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 <audio> 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');
<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-07</span><br>
md5 <span class="stamp-md5">14af06bd5893feb70229dded7e6c5497</span><br>
sha256 <span class="stamp-sha">e603642628f264fa919f55be33e6578d77d2d42858566c2d708beefce091aed5</span><br>
md5 <span class="stamp-md5">59cd5e54142e3880cfe784df09204e52</span><br>
sha256 <span class="stamp-sha">9ba89654f5a18538ee2e4ed6d614b8901e1088c4892fcbfc41d0b5a991692517</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>