zebra-report: 320k Opus + drop maxptime + fix Firefox SDP no-op
Choppy persisted after NACK rollback. Dial back to 320k (transparent threshold, much less stress on uploads alongside screen+camera). Drop a=maxptime:120 — receivers + 700ms playout buffer don't handle larger frames well. Fix the SDP regex that silently no-op'd on Firefox publishers (Firefox's Opus fmtp lacks minptime=10 — the matcher required it). Firefox publishers will now actually carry the music codec params.
This commit is contained in:
parent
959d2dc65c
commit
fcbac50caf
1 changed files with 68 additions and 48 deletions
|
|
@ -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');
|
|||
|
||||
<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> · built <span class="stamp-date">2026-06-03</span><br>
|
||||
md5 <span class="stamp-md5">47863bc1f78b52473afd762515165065</span><br>
|
||||
sha256 <span class="stamp-sha">cb05099c3ac7ce007a656e6b3c940d462a7cac9748ba0f4d56642c52684feded</span><br>
|
||||
md5 <span class="stamp-md5">cad5e6b7e0ea29bb564786f6960b5511</span><br>
|
||||
sha256 <span class="stamp-sha">75a63cb186572f644db0788d06b8ab96f2359d5c3e5fbd5a03afc7fae4b5ea9b</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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue