From 2f1e4813d20e9023e8b2ada106410523dbb87819 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Wed, 3 Jun 2026 12:50:50 -0400 Subject: [PATCH] zebra-spaces: SFU mic fallback when mesh PC is present-but-not-connected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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'. --- web/zebra-spaces.html | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html index ab5456b..231fd54 100644 --- a/web/zebra-spaces.html +++ b/web/zebra-spaces.html @@ -2531,10 +2531,17 @@ function handleRemoteSfuTrack(ev){ for (const [uuid, mm] of members){ try { if (mm.pubkey && hex(unb64(mm.pubkey)) === pubHex){ - /* speakers get their peers' audio via mesh (lower latency) — - * skip the duplicate SFU mic. screens + cameras still came - * through above. */ - if (canSpeak(myRole) && peers.has(uuid)) return; + /* speakers get their peers' audio via mesh (lower latency) + * AT THE TIMES THE MESH PC IS CONNECTED. A stale or failing + * mesh PC must NOT block the SFU fallback — that's how the + * '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]); return; } @@ -3433,6 +3440,11 @@ async function connectToPeer(uuid, weOffer){ if (pc.connectionState === 'failed' && peers.get(uuid) === pc){ logLine('', 'peer '+uuid+' failed — reconnecting'); 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 */ setTimeout(()=>{ if (members.has(uuid) && canSpeak(members.get(uuid).role) && canSpeak(myRole)) connectToPeer(uuid, myUUID < uuid); }, 1500); @@ -4010,8 +4022,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');