From 44babe490a05c8138198ba004b21efba313af80f Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Wed, 3 Jun 2026 18:02:24 -0400 Subject: [PATCH] zebra-spaces: UA-gated audio NACK via RTCRtpSender.getCapabilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audio NACK is an asymmetric feature — the SENDER has to respond to retransmit requests. Chrome implements both directions; Firefox implements neither for audio. Blanket-advertising NACK in offers from Firefox publishers caused Chrome receivers to wait for retransmits that never arrived and skip audibly. Solution: each browser advertises only what it can back up. Probe via RTCRtpSender.getCapabilities('audio') and look for nack in Opus's rtcpFeedback list. Chrome → true → advertise. Firefox → false → omit. Cached after first call (capabilities are static per UA). Chrome → Chrome: NACK advertised, both sides honor it ✓ Chrome → Firefox: NACK advertised, Firefox ignores (no NACK requests) ✓ Firefox → Chrome: NACK omitted, Chrome never NACK-waits → no skip ✓ Firefox → Firefox: NACK omitted, both sides ignore ✓ No LCD across mixed rooms — each side gets the best contract its own browser can honor. Logs "audio NACK as sender: on|off" once per session for diagnostic. --- web/zebra-spaces.html | 50 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 40 insertions(+), 10 deletions(-) diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html index 40771e9..5bc0e56 100644 --- a/web/zebra-spaces.html +++ b/web/zebra-spaces.html @@ -2981,14 +2981,26 @@ function preferStereoOpus(sdp, maxAvgBps, opts){ return 'a=fmtp:' + pt + ' ' + parts.join(';'); }); if (!opusPT) return sdp; - /* Audio NACK was tried and rolled back: Chrome receivers will request - * retransmits if the sender advertises rtcp-fb:nack on the Opus PT, but - * Firefox senders never reply (Mozilla never shipped the responder - * side). Chrome's jitter buffer waits for recovery that never arrives - * and then skips — audibly choppy in mixed Firefox→Chrome rooms. - * useinbandfec + 700ms playoutDelayHint already cover the loss case - * without protocol churn. Do NOT re-add audio NACK without verifying - * both sides actually implement it for the negotiated PT. */ + /* Audio NACK is asymmetric — the SENDER has to respond to retransmit + * requests. Chrome implements both sides; Firefox implements neither + * for audio (Mozilla never shipped it). Blanket-advertising NACK in + * Firefox-published offers made Chrome receivers wait for retransmits + * that never arrived and skip audibly. + * + * Gate on the actual local capability via RTCRtpSender.getCapabilities: + * - Chrome: opus rtcpFeedback includes nack → we advertise → Chrome + * receivers get reactive recovery, Firefox receivers ignore. + * - Firefox: opus rtcpFeedback has no nack → we don't advertise → + * Chrome receivers won't NACK us → no choppy. + * Each browser only offers what it can back up. No LCD across the + * room, no per-peer signaling needed — the SDP itself is honest. */ + if (senderSupportsAudioNack()){ + const nackLine = 'a=rtcp-fb:' + opusPT + ' nack'; + if (sdp.indexOf(nackLine) === -1){ + sdp = sdp.replace(new RegExp('(a=rtpmap:' + opusPT + ' opus[^\\r\\n]*\\r?\\n)'), + '$1' + nackLine + '\r\n'); + } + } /* Tolerate larger frames from peers (up to 120ms). Bigger encode * windows give Opus more context per packet → cleaner music at the * same bitrate. We can't force OUR encoder's frame size from JS but @@ -2998,6 +3010,24 @@ function preferStereoOpus(sdp, maxAvgBps, opts){ } return sdp; } +/* Probe the local browser's audio sender capabilities — does Opus + * advertise nack feedback? Cached after first call: capabilities are + * static per UA. Returns true on Chromium (Chrome / Edge / Brave) and + * false on Firefox / Safari / older browsers. */ +let _senderNackCached = null; +function senderSupportsAudioNack(){ + if (_senderNackCached !== null) return _senderNackCached; + try { + const caps = (window.RTCRtpSender && RTCRtpSender.getCapabilities) + ? RTCRtpSender.getCapabilities('audio') : null; + const opus = caps && caps.codecs && caps.codecs.find(c => + (c.mimeType || '').toLowerCase() === 'audio/opus'); + _senderNackCached = !!(opus && opus.rtcpFeedback && + opus.rtcpFeedback.some(fb => fb.type === 'nack')); + } catch(_){ _senderNackCached = false; } + logLine('', 'audio NACK as sender: '+(_senderNackCached ? 'on' : 'off')); + return _senderNackCached; +} async function applyMicMode(){ /* re-acquire mic with new constraints, hot-swap onto every live sender * (mesh peers + the SFU publish PC) */ @@ -4417,8 +4447,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');