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