From aed848429324deb13502269d069a547cb91da4c1 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Wed, 3 Jun 2026 18:07:45 -0400 Subject: [PATCH] zebra-spaces: 320k + drop maxptime + fix Firefox SDP regex no-op MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three coupled changes after still-choppy reports on UA-gated NACK build: 1. 510k → 320k Opus everywhere in music mode. 320k is the transparent listening-test threshold (Audible masters at this). 510k is spec ceiling but real-world uploads can't sustain it alongside concurrent screen 6M + camera 4M without dropping audio packets. Choppy at 510k persisted after NACK rollback, so bitrate is the next dial. 2. Drop a=maxptime:120 advertisement. Tried to give Opus larger encode windows for cleaner music at same bitrate. Chrome publishers may have actually honored it and packed at higher ptime, which the receiver side handled poorly (jitter buffer + playoutDelayHint=0.7 weren't tuned for >20ms packetization). Default 20ms stays. 3. Fix preferStereoOpus regex no-op on Firefox publishers. Previous matcher anchored on minptime=10 which Chrome emits but Firefox does not — so for years, Firefox-published mic SDPs went out unmunged: no stereo=1, no maxaveragebitrate cap, no fullband pin, no NACK gate. Now: scan rtpmap for all opus/48000/2 PTs, update existing fmtp lines in place, or insert a fresh fmtp if none exists (Firefox's case). This is why fox saw "audio NACK as sender" diagnostic never fire on Firefox publish — the function early-returned before reaching it. Kept: fullband pins (no bandwidth cost), UA-gated NACK code (honest per-browser advertisement), speaker output picker, 700ms playoutDelayHint. --- web/zebra-spaces.html | 116 +++++++++++++++++++++++++----------------- 1 file changed, 68 insertions(+), 48 deletions(-) diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html index 5bc0e56..e3f552e 100644 --- a/web/zebra-spaces.html +++ b/web/zebra-spaces.html @@ -2161,7 +2161,7 @@ async function sfuPublish(){ for (const tr of micStream.getTracks()){ tagTrack(tr); pc.addTrack(tr, micStream); } setSenderBitrate(pc.getSenders().find(s=>s.track && s.track.kind==='audio')); const offer = await pc.createOffer(); - offer.sdp = preferStereoOpus(offer.sdp, musicMode ? 510000 : 40000, { music: musicMode }); + offer.sdp = preferStereoOpus(offer.sdp, musicMode ? 320000 : 40000, { music: musicMode }); await pc.setLocalDescription(offer); await waitForIceGathering(pc); const res = await fetch(SFU_BASE + '/publish?room=' + encodeURIComponent(roomID) + '&pub=' + myKeys.pubHex, { @@ -2251,7 +2251,7 @@ async function sfuPublishScreen(){ const offer = await pc.createOffer(); /* screen-share audio is always music-grade — system audio capture is * what users actually broadcast, not voice */ - offer.sdp = preferStereoOpus(offer.sdp, 510000, { music: true }); + offer.sdp = preferStereoOpus(offer.sdp, 320000, { music: true }); await pc.setLocalDescription(offer); await waitForIceGathering(pc); const url = SFU_BASE + '/publish?room=' + encodeURIComponent(roomID) @@ -2271,7 +2271,7 @@ async function sfuPublishScreen(){ for (const s of pc.getSenders()){ if (!s.track) continue; if (s.track.kind === 'video') setSenderMaxBitrate(s, 6000000); - if (s.track.kind === 'audio') setSenderMaxBitrate(s, 510000); + if (s.track.kind === 'audio') setSenderMaxBitrate(s, 320000); } /* Screen share CANNOT auto-rebuild like mic + camera — getDisplayMedia * requires a fresh user gesture (transient activation). Auto-calling @@ -2343,7 +2343,7 @@ async function sfuPublishGame(iframe){ } stream.getVideoTracks()[0].addEventListener('ended', () => { sfuUnpublishGame(); }); const offer = await pc.createOffer(); - offer.sdp = preferStereoOpus(offer.sdp, 510000, { music: true }); + offer.sdp = preferStereoOpus(offer.sdp, 320000, { music: true }); await pc.setLocalDescription(offer); await waitForIceGathering(pc); const url = SFU_BASE + '/publish?room=' + encodeURIComponent(roomID) @@ -2360,7 +2360,7 @@ async function sfuPublishGame(iframe){ for (const s of pc.getSenders()){ if (!s.track) continue; if (s.track.kind === 'video') setSenderMaxBitrate(s, 4000000); - if (s.track.kind === 'audio') setSenderMaxBitrate(s, 510000); + if (s.track.kind === 'audio') setSenderMaxBitrate(s, 320000); } /* game-share doesn't auto-rebuild on 'failed' — its capture source is * a user-selected iframe via Region Capture; the user would have to @@ -2924,7 +2924,7 @@ async function setSenderBitrate(sender){ * for transient-heavy material (orchestral, drums). 40 kbps mono is plenty * for speech. Opus VBR only spends what it needs; idle music stays around * 200–300 kbps and only peaks claim the ceiling. */ - p.encodings[0].maxBitrate = musicMode ? 510000 : 40000; + p.encodings[0].maxBitrate = musicMode ? 320000 : 40000; await sender.setParameters(p); } catch(_){} } @@ -2946,41 +2946,61 @@ async function setSenderMaxBitrate(sender, bps){ * * Music mode also pins fullband (maxplaybackrate / sprop-maxcapturerate = * 48000) so Opus can't opportunistically narrow to 16/24 kHz under BWE - * pressure — and advertises NACK + maxptime:120 so receivers tolerate - * larger frames and reactive packet recovery. cbr=0 is explicit so no - * browser quirk silently flips us to constant-rate. */ + * pressure. NACK is gated by RTCRtpSender.getCapabilities so each browser + * only advertises what it can back up. cbr=0 is explicit so no browser + * quirk silently flips us to constant-rate. */ function preferStereoOpus(sdp, maxAvgBps, opts){ const music = !!(opts && opts.music); /* DTX is great for voice (silence is silence) but its comfort-noise * transitions audibly pop on continuous music signals — keep it OFF * in music mode and ON for voice. */ const dtx = music ? '0' : '1'; - let opusPT = null; - sdp = sdp.replace(/a=fmtp:(\d+) ([^\r\n]*minptime=10[^\r\n]*)/g, (m, pt, fmtp) => { - opusPT = pt; - /* useinbandfec=1: forward error correction so a single dropped - * packet doesn't audibly chop — Opus reconstructs from FEC. - * maxplaybackrate / sprop-maxcapturerate=48000: fullband both ways. - * cbr=0: explicit VBR. Opus VBR only spends what it needs; the high - * ceiling is just headroom for transients, not a constant load. */ - const want = { - 'stereo': '1', 'sprop-stereo': '1', - 'maxaveragebitrate': String(maxAvgBps), - 'useinbandfec': '1', 'usedtx': dtx, - 'maxplaybackrate': '48000', 'sprop-maxcapturerate': '48000', - 'cbr': '0', - }; - const parts = fmtp.split(';').map(s => s.trim()).filter(Boolean); - const seen = new Set(); - for (let i = 0; i < parts.length; i++){ - const k = parts[i].split('=')[0]; - seen.add(k); - if (want[k] !== undefined) parts[i] = k + '=' + want[k]; + /* Find every Opus payload type from rtpmap. Anchoring on minptime=10 + * (as we used to) silently no-op'd on Firefox publishers, because + * Firefox emits `a=fmtp:109 maxplaybackrate=48000;stereo=1;useinbandfec=1` + * with no minptime — the entire music-mode SDP munge was skipped on FF + * for years. rtpmap is reliably present for every negotiated codec. */ + const opusPTs = []; + const rtpmapRe = /a=rtpmap:(\d+) opus\/48000\/2/gi; + let mm; + while ((mm = rtpmapRe.exec(sdp)) !== null) opusPTs.push(mm[1]); + if (!opusPTs.length) return sdp; + /* useinbandfec=1: forward error correction so a single dropped + * packet doesn't audibly chop — Opus reconstructs from FEC. + * maxplaybackrate / sprop-maxcapturerate=48000: fullband both ways. + * cbr=0: explicit VBR. Opus VBR only spends what it needs; the high + * ceiling is just headroom for transients, not a constant load. */ + const want = { + 'stereo': '1', 'sprop-stereo': '1', + 'maxaveragebitrate': String(maxAvgBps), + 'useinbandfec': '1', 'usedtx': dtx, + 'maxplaybackrate': '48000', 'sprop-maxcapturerate': '48000', + 'cbr': '0', + }; + for (const pt of opusPTs){ + const fmtpRe = new RegExp('a=fmtp:' + pt + ' ([^\\r\\n]*)', 'g'); + if (fmtpRe.test(sdp)){ + /* update existing fmtp params in place */ + sdp = sdp.replace(new RegExp('a=fmtp:' + pt + ' ([^\\r\\n]*)', 'g'), (_, fmtp) => { + const parts = fmtp.split(';').map(s => s.trim()).filter(Boolean); + const seen = new Set(); + for (let i = 0; i < parts.length; i++){ + const k = parts[i].split('=')[0]; + seen.add(k); + if (want[k] !== undefined) parts[i] = k + '=' + want[k]; + } + for (const k of Object.keys(want)) if (!seen.has(k)) parts.push(k + '=' + want[k]); + return 'a=fmtp:' + pt + ' ' + parts.join(';'); + }); + } else { + /* no fmtp line exists for this PT — insert one right after rtpmap */ + const parts = Object.keys(want).map(k => k + '=' + want[k]); + sdp = sdp.replace( + new RegExp('(a=rtpmap:' + pt + ' opus[^\\r\\n]*\\r?\\n)', 'i'), + '$1a=fmtp:' + pt + ' ' + parts.join(';') + '\r\n' + ); } - for (const k of Object.keys(want)) if (!seen.has(k)) parts.push(k + '=' + want[k]); - return 'a=fmtp:' + pt + ' ' + parts.join(';'); - }); - if (!opusPT) return sdp; + } /* 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 @@ -2995,19 +3015,19 @@ function preferStereoOpus(sdp, maxAvgBps, opts){ * 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'); + for (const pt of opusPTs){ + const nackLine = 'a=rtcp-fb:' + pt + ' nack'; + if (sdp.indexOf(nackLine) === -1){ + sdp = sdp.replace(new RegExp('(a=rtpmap:' + pt + ' opus[^\\r\\n]*\\r?\\n)', 'i'), + '$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 - * advertising the tolerance keeps the negotiation open. */ - if (music && !/a=maxptime:/.test(sdp)){ - sdp = sdp.replace(/(a=rtpmap:\d+ opus[^\r\n]*\r?\n)/, '$1a=maxptime:120\r\n'); - } + /* maxptime advertisement removed: tried a=maxptime:120 to encourage + * larger encode windows, but Chrome publishers under it produced choppy + * playback on receivers (likely the encoder actually packed at higher + * ptime and the receiver's jitter buffer / playoutDelayHint=0.7 didn't + * keep up). Default 20ms packetization stays. */ return sdp; } /* Probe the local browser's audio sender capabilities — does Opus @@ -3822,7 +3842,7 @@ async function connectToPeer(uuid, weOffer){ }; if (weOffer){ const offer = await pc.createOffer(); - offer.sdp = preferStereoOpus(offer.sdp, musicMode ? 510000 : 40000, { music: musicMode }); + offer.sdp = preferStereoOpus(offer.sdp, musicMode ? 320000 : 40000, { music: musicMode }); await pc.setLocalDescription(offer); await waitForIceGathering(pc); await sendEncSDP(uuid, 'offer', pc.localDescription); @@ -4447,8 +4467,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');