zebra-spaces: SFU mic fallback when mesh PC is present-but-not-connected

CLIENT_LOG telemetry showed the phone re-promoted to speaker, published
to SFU successfully, but neither the host nor the other speaker heard
them. Cause: the page's ontrack handler skipped attaching SFU mic
whenever peers.has(uuid) — even if that peer's mesh PC was in
'failed' or 'disconnected' state from an earlier role-change cycle.

Fix two paths:

1. ontrack-side: only skip SFU mic when peers.get(uuid).connectionState
   is actually 'connected'. A stale entry or a failing PC no longer
   blocks the SFU fallback; receiver hears the publisher via SFU until
   mesh actually delivers.

2. mesh-fails-side: when an existing mesh PC transitions to 'failed',
   the audio element was bound to the dying mesh stream. Reach into
   sfuStreamsByPubHex and re-attach the cached SFU stream so the
   listener hears continuous audio while the mesh reconnect runs in
   the background, instead of a silent gap.

Mesh stays the preferred path when it's actually working — only
takes over the audio binding via its own ontrack when 'connected'.
This commit is contained in:
Russell Ballestrini 2026-06-03 12:50:50 -04:00
parent 1e8464e7af
commit 2f1e4813d2
No known key found for this signature in database

View file

@ -2531,10 +2531,17 @@ function handleRemoteSfuTrack(ev){
for (const [uuid, mm] of members){ for (const [uuid, mm] of members){
try { try {
if (mm.pubkey && hex(unb64(mm.pubkey)) === pubHex){ if (mm.pubkey && hex(unb64(mm.pubkey)) === pubHex){
/* speakers get their peers' audio via mesh (lower latency) — /* speakers get their peers' audio via mesh (lower latency)
* skip the duplicate SFU mic. screens + cameras still came * AT THE TIMES THE MESH PC IS CONNECTED. A stale or failing
* through above. */ * mesh PC must NOT block the SFU fallback — that's how the
if (canSpeak(myRole) && peers.has(uuid)) return; * 'phone re-promoted but nobody hears them' regression
* appeared: the peers map still had an entry whose state
* was 'failed', so we skipped SFU and the receiver got no
* audio at all. */
if (canSpeak(myRole)){
const meshPC = peers.get(uuid);
if (meshPC && meshPC.connectionState === 'connected') return;
}
attachSfuTrack(uuid, ev.streams[0]); attachSfuTrack(uuid, ev.streams[0]);
return; return;
} }
@ -3433,6 +3440,11 @@ async function connectToPeer(uuid, weOffer){
if (pc.connectionState === 'failed' && peers.get(uuid) === pc){ if (pc.connectionState === 'failed' && peers.get(uuid) === pc){
logLine('', 'peer '+uuid+' failed — reconnecting'); logLine('', 'peer '+uuid+' failed — reconnecting');
tearPeer(uuid); tearPeer(uuid);
/* Mesh PC just died; the audio element for this peer was bound to
* the dying mesh stream and won't recover on its own. Switch back
* to the cached SFU stream so the user keeps hearing them while
* mesh reconnect attempts run in the background. */
try { attachCachedSfuStreamFor(uuid); } catch(_){}
/* let the offerer drive recovery */ /* let the offerer drive recovery */
setTimeout(()=>{ if (members.has(uuid) && canSpeak(members.get(uuid).role) && canSpeak(myRole)) setTimeout(()=>{ if (members.has(uuid) && canSpeak(members.get(uuid).role) && canSpeak(myRole))
connectToPeer(uuid, myUUID < uuid); }, 1500); connectToPeer(uuid, myUUID < uuid); }, 1500);
@ -4010,8 +4022,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-03</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-03</span><br>
md5 <span class="stamp-md5">e8f5b7a27ce32f07e7433dcc2cdd9b69</span><br> md5 <span class="stamp-md5">795a19a2ea9d79153d00ac83d499e0df</span><br>
sha256 <span class="stamp-sha">c95935103900464bbfa3e5259a0fb2a667bb06fd44084595f1749e03375870e1</span><br> sha256 <span class="stamp-sha">964a8260ab1c11958c6112dbcba47f9dc8e1056214e8ebb7fa6eaca92455075c</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>