zebra-report: UA-gated audio NACK — Chrome↔Chrome reactive, Firefox honest

RTCRtpSender.getCapabilities probe decides whether to advertise NACK
in our Opus offers. Chromium publishes it (responds to retransmits),
Firefox/Safari omit (can't respond). Receivers honor only what the
sender promised — mixed-browser rooms stay clean, same-browser rooms
get the full reactive recovery.
This commit is contained in:
russell@unturf.com 2026-06-03 18:02:31 -04:00
parent 01bdd802f2
commit 959d2dc65c
No known key found for this signature in database

View file

@ -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');
<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>
md5 <span class="stamp-md5">daa6375c3e46c8a13c287745467f105f</span><br>
sha256 <span class="stamp-sha">534f2a0afe7da5319071f94519988c2f752a8f9919398d5a728582d540b07e46</span><br>
md5 <span class="stamp-md5">47863bc1f78b52473afd762515165065</span><br>
sha256 <span class="stamp-sha">cb05099c3ac7ce007a656e6b3c940d462a7cac9748ba0f4d56642c52684feded</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>
</footer>