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');