zebra-spaces: fresh-stream wrap on mic ontrack + mute-gate mesh swap (kill fedora-chrome silence)

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 <audio> fallback — telemetry: "sfu attach
b1a7 fresh=1 tracks=0" + "meter for b1a7: MediaStream has no audio
track". Screen/camera/game already wrap ev.track in a fresh
MediaStream; mic now does the same. Single track, guaranteed live,
every time.

(B) Mesh ontrack unconditionally swaps the worklet's source to the
mesh stream — even when the mesh track is still muted (no RTP). The
SFU receiver becomes orphaned (no decoder), and if the mesh track
never unmutes the listener hears silence with no automatic SFU
restore. Gate the swap on track-not-muted: swap immediately if mesh
is already flowing, otherwise wait for 'unmute'. Add a mute-watchdog
that swaps back to the cached SFU stream after MESH_MUTE_WINDOW_MS
of mesh silence — same shape as the existing
connectionState=='failed' restore path, but driven by track-level
mute instead of PC-level failure.

Hard refresh + leave/enter worked because the fresh negotiation
delivered the mesh track already unmuted, so the eager swap landed
on a live source. Now the same swap waits for live source instead of
hoping for one.

Tests:
  - handleRemoteSfuTrack: ev.streams[0] empty but ev.track live →
    fresh MediaStream wrap rescues the attach (chain built, audible
    path verified)

Ticket 0001 updated with the telemetry comparison and the new
diagnosis. Mesh-mute-watchdog has no dedicated test yet (the mesh
ontrack lives in connectToPeer which the listener test harness
doesn't extract) — pin in multi-peer-mesh.test.js next pass.
This commit is contained in:
Russell Ballestrini 2026-06-07 11:33:57 -04:00
parent 1ed5263fbc
commit caa0548113
No known key found for this signature in database
7 changed files with 224 additions and 48 deletions

View file

@ -88,13 +88,91 @@ telemetry session shows which sink the worklet is routed to.
- `audioCtx without setSinkId support (older browser) does not throw — - `audioCtx without setSinkId support (older browser) does not throw —
silent fallback to default` silent fallback to default`
## 2026-06-07 telemetry — sink hypothesis ruled out
Fresh `/var/log/zebra-spaces-signal.log` capture (00:20:01 — fxhp host
publishing real music, blanka-chrome speaker):
```
audio via AudioContext 085b target=0.5s ctxState=running sink=default
mesh stream swapped into worklet for 085b — buffer cushion now applies to mesh too
...
aud.recv pkt=14622 lost=1 bytes=9117575 jitter=0.0100 level=0.000 jbuf=? lp=0.1s
```
Comparison — fxhp-phone in the same room, same window, same publisher:
```
aud.recv pkt=11161 lost=1 bytes=7154201 jitter=0.0090 level=0.001 jbuf=0.49s lp=0.0s
```
- sink IS routed (deployed fix `467146a` confirmed live — later capture
shows `sink=c38572ec…` = the user-picked deviceId, NOT default).
- 50 pps RTP, 9.1 MB of stereo Opus arriving on blanka-chrome's SFU sub PC.
- `jbuf=?` = `jitterBufferEmittedCount === 0` = receiver **decodes
nothing**. There is no consumer wired to the SFU audio receiver.
- fxhp-phone on the same SFU stream has `jbuf=0.49s` + `level=0.001`
(native decoder running) — so the SFU is fine, the problem is per-
client wiring.
So: **sink routing was not the bug, or was only part of it.** With the
sink fix applied, fedora chrome still hears silence.
## Real root cause
Two coupled defects, both visible in `handleRemoteSfuTrack` (line 4700)
and the mesh `pc.ontrack` (line 6693):
**(A) `tracks=0` race at mic ontrack.** Chrome can fire `ontrack` where
`ev.streams[0]` exists but has 0 tracks at handler-time (the track is
added a microtask later, or MSID-supplant merges new + dead tracks).
Screen / camera / game already work around this by constructing a fresh
`new MediaStream([ev.track])` and passing that. **Mic does not.** It
caches `ev.streams[0]` directly into `sfuStreamsByPubHex` and hands the
same reference to `attachSfuTrack`. When that fires before the track
shows up on the stream, `attachAudioStreamViaWorklet` rejects with
`liveAudio.length === 0` → falls through to the silent `<audio>` path
→ logs `sfu attach b1a7 fresh=1 tracks=0` + `meter for b1a7: MediaStream
has no audio track` (both verbatim in today's capture).
**(B) Mesh swap orphans the SFU receiver.** Mesh `pc.ontrack` calls
`setWorkletStream(uuid, meshStream)` unconditionally — it does NOT
verify that mesh is actually emitting audio. The worklet's
`MediaStreamSource` is now bound to the mesh track. The SFU receiver
has no consumer, so its decoder doesn't run (matches `jbuf=?`,
`level=0`). If the mesh track is muted or DTX-silent, the listener
hears nothing — and there is no automatic SFU restore until mesh PC
actually transitions to `failed` (line 6739). Mesh that stays
`connected` but silent traps the listener forever.
Hard refresh + leave/enter fixes both: fresh PC negotiation → fresh
ontrack with the live track in the stream → attach succeeds on SFU
before mesh comes up → mesh swap eventually replaces source but by
then SFU receiver is fine and… actually no, this still ends up on
mesh. The reason hard-refresh works is that mesh negotiation is fresh
and the new mesh track arrives in an `unmuted` state because audio is
already flowing. The mesh swap then lands on a real audio source.
## Fix (in progress)
**(A)** In `handleRemoteSfuTrack` mic branch, mirror the screen/camera
pattern: `const s = new MediaStream([ev.track]); sfuStreamsByPubHex.set(pubHex, s); attachSfuTrack(uuid, s)`.
Guarantees the stream handed to `attachAudioStreamViaWorklet` contains
exactly the live track, every time.
**(B)** In mesh `pc.ontrack`, gate the `setWorkletStream` swap on
`!ev.track.muted` (or `addEventListener('unmute', swap)` if it starts
muted). If the mesh track never unmutes, keep SFU as the worklet
source. Add a watchdog: if a swapped-in mesh track returns to muted
for >5s, swap the worklet back to the cached SFU stream — same
pattern the `connectionState === 'failed'` path already uses at line
6755.
## Status notes ## Status notes
- **2026-06-07 (open):** awaiting fox retest after reload. Next telemetry - **2026-06-07 (in-progress):** sink hypothesis ruled out by fresh
should show `sink=<device-id>` (not `default`) on the `audio via telemetry; root cause is (A) tracks=0 race + (B) mesh-swap orphan.
AudioContext` line. If chrome still silent with `sink=<correct-id>`, Implementing both fixes in this branch.
hypothesis shifts to PulseAudio per-app routing or chrome's WebAudio
sink negotiation.
- **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.

View file

@ -775,6 +775,40 @@ test('handleRemoteSfuTrack: track-ended drops the cache entry (so next peer-join
falsy(b.api.sfuStreamsByPubHex.has(PUB_A), 'cache dropped on track ended'); falsy(b.api.sfuStreamsByPubHex.has(PUB_A), 'cache dropped on track ended');
}); });
test('handleRemoteSfuTrack: ev.streams[0] empty but ev.track live → fresh MediaStream wrap rescues the attach (kills the tracks=0 race)', () => {
/* Chrome can fire ontrack where ev.streams[0] is the supplant-merged
* stream object that does NOT yet contain ev.track at handler-time
* (the track is added a microtask later). Without the fresh-stream
* wrap, attachAudioStreamViaWorklet sees liveAudio.length === 0 and
* rejects silent <audio> fallback telemetry shows
* `fresh=1 tracks=0` + `meter ... MediaStream has no audio track`,
* and the SFU receiver is never wired to a decoder (jbuf=?, level=0).
* Fox 2026-06-07 blanka-chrome telemetry pinned exactly this shape.
*
* Mic ontrack must mirror screen/camera/game: build
* `new MediaStream([ev.track])` and use THAT for both the cache and
* attachSfuTrack. Then the worklet path always sees the live track. */
const b = makeBrowser('listener');
addMember(b, UUID_A, PUB_A);
/* simulate the race: real live track, but ev.streams[0] still empty */
const emptyStream = new FakeMediaStream([]);
emptyStream.id = PUB_A.slice(0, 16);
const liveTrack = makeAudioTrack();
b.api.handleRemoteSfuTrack({
streams: [emptyStream],
track: liveTrack,
receiver: { playoutDelayHint: 0, jitterBufferTarget: 0 },
});
truthy(b.api.sfuStreamsByPubHex.has(PUB_A), 'cache primed under full pubHex');
const cached = b.api.sfuStreamsByPubHex.get(PUB_A);
truthy(cached !== emptyStream, 'cached stream is FRESH, not the empty ev.streams[0]');
eq(cached.getAudioTracks().length, 1, 'cached stream contains exactly the live track');
eq(cached.getAudioTracks()[0], liveTrack, 'cached track === ev.track');
truthy(b.api.listenerAudioNodes.has(UUID_A), 'chain built — attach did NOT bail out on tracks=0');
const node = b.api.listenerAudioNodes.get(UUID_A);
truthy(reachable(node.src, b.ctx.destination), 'audible path through worklet');
});
test('handleRemoteSfuTrack: own publish echo is skipped (sid prefix matches myKeys.pubHex)', () => { test('handleRemoteSfuTrack: own publish echo is skipped (sid prefix matches myKeys.pubHex)', () => {
const b = makeBrowser('listener'); const b = makeBrowser('listener');
/* myKeys.pubHex = 'f'*64 in our stub */ /* myKeys.pubHex = 'f'*64 in our stub */

View file

@ -1747,9 +1747,9 @@ logLine('sys', 'chat content lives in encrypted SRTP audio. no IP packets carry
})(); })();
</script> </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"> <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> <span style="color:#777">page integrity</span> &nbsp;·&nbsp; built <span class="stamp-date">2026-06-07</span><br>
md5 <span class="stamp-md5">e9fb81e6d445184e8a4967dfe56883f9</span><br> md5 <span class="stamp-md5">c9fee1030b030fb1277cb89607ef7d8d</span><br>
sha256 <span class="stamp-sha">a44a19223961e6a8e4f6010d073ff1e95246d908eb3b27d322fcd7bb17e5ad39</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">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> <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> </footer>

View file

@ -507,9 +507,9 @@ handle /zebra-spaces-sfu* { reverse_proxy localhost:8092 { flush_interval -1
</p> </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"> <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> <span style="color:#777">page integrity</span> &nbsp;·&nbsp; built <span class="stamp-date">2026-06-07</span><br>
md5 <span class="stamp-md5">f3900aa0c841baa7c61599ff247f8fe1</span><br> md5 <span class="stamp-md5">97108d6b240474c7e1b3bea7ae889cc4</span><br>
sha256 <span class="stamp-sha">496b0dee4967745d5502d549c30278370cbd228ae75c3020025f9912fa9773d7</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">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> <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> </footer>

View file

@ -341,9 +341,9 @@ try {
</p> </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"> <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> <span style="color:#777">page integrity</span> &nbsp;·&nbsp; built <span class="stamp-date">2026-06-07</span><br>
md5 <span class="stamp-md5">ea02f55584d8042b03fcbd999e1da240</span><br> md5 <span class="stamp-md5">402c3b4d6e72b8cc6692166fc4683978</span><br>
sha256 <span class="stamp-sha">c7cb5f8be8241d4634cca84acd3343c1fbfd0c7053b260bcee05084aa7bd83aa</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">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> <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> </footer>

View file

@ -747,9 +747,9 @@ else wirePuppet();
</section> </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"> <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> <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">ba6a5714f597469b0aa61b32657f50f4</span><br> md5 <span class="stamp-md5">92054b1353cdbde054776ac5ed46b531</span><br>
sha256 <span class="stamp-sha">78b5fb863ec406ec39081a0231fb909eef3abf332e84c0a5f25942f62f49de1b</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">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> <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> </footer>

View file

@ -4838,11 +4838,24 @@ function handleRemoteSfuTrack(ev){
* was handing the dead stream to attachAudioStreamViaWorklet on the * was handing the dead stream to attachAudioStreamViaWorklet on the
* NEXT peer-joined with matching pubkey, racing the live ontrack. * NEXT peer-joined with matching pubkey, racing the live ontrack.
* Fox 2026-06-05 telemetry: peer-joined u=64b0 fired "audio attach * Fox 2026-06-05 telemetry: peer-joined u=64b0 fired "audio attach
* fresh=1 tracks=0" before the real attach. */ * fresh=1 tracks=0" before the real attach.
sfuStreamsByPubHex.set(pubHex, ev.streams[0]); *
* 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 { try {
ev.track.addEventListener('ended', () => { ev.track.addEventListener('ended', () => {
if (sfuStreamsByPubHex.get(pubHex) === ev.streams[0]){ if (sfuStreamsByPubHex.get(pubHex) === s){
sfuStreamsByPubHex.delete(pubHex); sfuStreamsByPubHex.delete(pubHex);
logLine('', 'sfu cache drop pub='+pubHex.slice(0,4)+' — track ended'); 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); logLine('', 'sfu mic taking over for '+uuid+' — mesh state='+meshState);
} }
} }
attachSfuTrack(uuid, ev.streams[0]); attachSfuTrack(uuid, s);
return; return;
} }
} catch(_){} } catch(_){}
@ -6700,33 +6713,84 @@ async function connectToPeer(uuid, weOffer){
* cushion (0.5s for speakers). Source swap is seamless because * cushion (0.5s for speakers). Source swap is seamless because
* the worklet's queue holds 0.5s of decoded samples and both * the worklet's queue holds 0.5s of decoded samples and both
* sources contain identical content at slightly different * 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]); const stream = ev.streams[0] || new MediaStream([ev.track]);
if (!setWorkletStream(uuid, stream)){ const swapToMesh = () => {
/* fallback: no existing worklet (AudioContext failed at SFU if (!setWorkletStream(uuid, stream)){
* attach time). Create one fresh with mesh as the source. /* fallback: no existing worklet (AudioContext failed at SFU
* Small initial silence while the buffer fills. */ * attach time). Create one fresh with mesh as the source.
attachAudioStreamViaWorklet(uuid, stream, SPEAKER_PLAYOUT_DELAY_SEC); * 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.onicecandidate = (ev) => { /* using waitForIceGathering pattern, candidates ignored */ };
pc.onconnectionstatechange = () => { 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"> <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> <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> md5 <span class="stamp-md5">59cd5e54142e3880cfe784df09204e52</span><br>
sha256 <span class="stamp-sha">e603642628f264fa919f55be33e6578d77d2d42858566c2d708beefce091aed5</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">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>