From 4a336a27024f2d5ff05889844342e1503fa75196 Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Mon, 1 Jun 2026 21:05:37 -0400 Subject: [PATCH] zebra-report: kill audio chops (FEC+DTX+jitter buffer) + clear tiles on unshare Mirror source 353037b. Needs SFU proxy.unturf.com#60a620f deployed for the NACK + transport-cc feedback that browsers use to retransmit lost audio packets and react to congestion. --- zebra-report/zebra-spaces.html | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/zebra-report/zebra-spaces.html b/zebra-report/zebra-spaces.html index 2df97b6..c46f6bf 100644 --- a/zebra-report/zebra-spaces.html +++ b/zebra-report/zebra-spaces.html @@ -928,19 +928,29 @@ async function sfuSubscribe(){ const kind = sid.slice(colon + 1); /* skip echo of our own publish — we already render a local preview */ if (myKeys && pubHex === myKeys.pubHex) return; + /* video kinds: when the publisher unshares, the SFU stops the + * transceiver and the corresponding remote track fires 'ended'. + * Wire that to the tile remover so the listener's UI matches the + * publisher's state instead of holding a frozen last frame. */ if (kind === 'screen'){ screenStreams.set(pubHex, ev.streams[0]); renderScreenTile(pubHex, ev.streams[0]); + ev.track.addEventListener('ended', () => removeScreenTile(pubHex)); return; } if (kind === 'camera'){ cameraStreams.set(pubHex, ev.streams[0]); renderCameraTile(pubHex, ev.streams[0]); + ev.track.addEventListener('ended', () => removeCameraTile(pubHex)); return; } logLine('', 'sfu: unknown kind '+kind+' from '+shortHex(pubHex)); return; } + /* mic audio — set a small jitter-buffer hint so brief network jitter + * doesn't cause audible drops. 100ms is enough to absorb typical + * Wi-Fi micro-bursts without making conversation feel laggy. */ + try { ev.receiver.playoutDelayHint = 0.1; } catch(_){} const pubHex = sid; /* cache stream by publisher pubkey so it survives the member's session * uuid changing across leave/rejoin — see flushSfuStreams */ @@ -1113,7 +1123,15 @@ async function setSenderMaxBitrate(sender, bps){ * has to be raised explicitly for music to actually use the headroom. */ function preferStereoOpus(sdp, maxAvgBps){ return sdp.replace(/a=fmtp:(\d+) ([^\r\n]*minptime=10[^\r\n]*)/g, (m, pt, fmtp) => { - const want = { 'stereo': '1', 'sprop-stereo': '1', 'maxaveragebitrate': String(maxAvgBps) }; + /* useinbandfec=1: forward error correction so a single dropped + * packet doesn't audibly chop — Opus reconstructs from FEC. + * usedtx=1: discontinuous transmission elides silence so the + * bandwidth budget goes to actual audio (cuts congestion). */ + const want = { + 'stereo': '1', 'sprop-stereo': '1', + 'maxaveragebitrate': String(maxAvgBps), + 'useinbandfec': '1', 'usedtx': '1', + }; const parts = fmtp.split(';').map(s => s.trim()).filter(Boolean); const seen = new Set(); for (let i = 0; i < parts.length; i++){ @@ -1526,6 +1544,9 @@ async function connectToPeer(uuid, weOffer){ let a = remoteAudio.get(uuid); if (!a){ a = document.createElement('audio'); a.autoplay = true; document.body.appendChild(a); remoteAudio.set(uuid, a); } a.srcObject = ev.streams[0] || new MediaStream([ev.track]); + /* 100ms jitter buffer absorbs typical Wi-Fi micro-bursts without + * adding perceptible conversation lag */ + try { ev.receiver.playoutDelayHint = 0.1; } catch(_){} stopMeter(uuid); startMeter(uuid, a.srcObject); }; pc.onicecandidate = (ev) => { /* using waitForIceGathering pattern, candidates ignored */ }; @@ -1878,8 +1899,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');