zebra-spaces: music-mode polish — 510k Opus, fullband pins, NACK, speaker picker

Codec headroom (Tier 1):
  - Opus 256 kbps → 510 kbps (spec ceiling) at every music-mode site:
    mic publish (SFU + mesh), screen audio, game-share audio. Both
    SDP maxaveragebitrate and RTP-level encodings[0].maxBitrate raised.
  - Pin Opus fullband: maxplaybackrate=48000, sprop-maxcapturerate=48000
    so BWE pressure can't opportunistically narrow to 16/24 kHz.
  - cbr=0 explicit (VBR — Opus only spends what it needs).

Loss resilience (Tier 2):
  - Audio NACK feedback (a=rtcp-fb:<opus_pt> nack) injected after Opus
    rtpmap. Reactive packet recovery, near-zero overhead, ignored by
    browsers that don't honor it.
  - a=maxptime:120 in music mode advertises tolerance for larger frames
    from peers (more encode context = cleaner music at same bitrate).

Playback chain (Tier 3):
  - New speaker output selector (setSinkId) so listeners can route peer
    audio to studio monitors / external DAC. Hidden on Safari (no
    setSinkId on HTMLMediaElement). Persists to localStorage; applied
    to every remote <audio> on creation and on user switch.

Live sessions need leave+enter to pick up the new SDP (per CLAUDE.md —
existing RTCPeerConnections are locked to whatever was negotiated at
creation time).

make stamp updates web/zebra-spaces.html footer date + hashes.
This commit is contained in:
Russell Ballestrini 2026-06-03 17:45:50 -04:00
parent 1dde43fc69
commit 23a4649b37
No known key found for this signature in database

View file

@ -688,6 +688,9 @@ try {
<div class="row" id="row-mic-controls">
<select id="mic-select" title="audio input device — applies once you have the mic"><option value="">input default</option></select>
</div>
<div class="row" id="row-speaker-controls">
<select id="speaker-select" title="audio output device — route peers' audio to a specific speaker or DAC"><option value="">output default</option></select>
</div>
<div class="row" id="row-music-mode">
<label class="note"><input type="checkbox" id="music-mode"> music mode — raw mic, no echo/noise cancellation (for playing audio through it)</label>
</div>
@ -1285,6 +1288,7 @@ const ID_KEY = 'zebra-spaces-id-v1';
const HANDLE_KEY = 'zebra-spaces-handle-v1';
const MUSIC_MODE_KEY = 'zebra-spaces-music-mode-v1';
const MIC_DEV_KEY = 'zebra-spaces-mic-device-v1';
const SPK_DEV_KEY = 'zebra-spaces-spk-device-v1';
const CAM_DEV_KEY = 'zebra-spaces-cam-device-v1';
const THEME_KEY = 'zebra-theme-v1';
/* sessionStorage (per-tab) — tracks which call this tab is in so a
@ -1521,9 +1525,10 @@ $('handle').value = myHandle;
* last-picked mic + camera deviceIds. Declared up here so the restore
* runs before their downstream `let` would put them in the temporal
* dead zone; downstream code now reads from these existing bindings. */
let musicMode = false, micDeviceId = '', cameraDeviceId = '';
let musicMode = false, micDeviceId = '', speakerDeviceId = '', cameraDeviceId = '';
try { musicMode = localStorage.getItem(MUSIC_MODE_KEY) === '1'; } catch(_){}
try { micDeviceId = localStorage.getItem(MIC_DEV_KEY) || ''; } catch(_){}
try { speakerDeviceId = localStorage.getItem(SPK_DEV_KEY) || ''; } catch(_){}
try { cameraDeviceId = localStorage.getItem(CAM_DEV_KEY) || ''; } catch(_){}
if ($('music-mode')) $('music-mode').checked = musicMode;
renderIdentity();
@ -1592,7 +1597,11 @@ const sfuStreamsByPubHex = new Map(); // pubHex -> MediaStream
function attachSfuTrack(uuid, stream){
let a = remoteAudio.get(uuid);
if (!a){ a = document.createElement('audio'); a.autoplay = true; document.body.appendChild(a); remoteAudio.set(uuid, a); }
if (!a){
a = document.createElement('audio'); a.autoplay = true;
document.body.appendChild(a); remoteAudio.set(uuid, a);
applySinkTo(a);
}
a.srcObject = stream;
/* programmatic play in case autoplay policy needs the nudge after a track
* swap — caller already had a user gesture (entered the space) */
@ -2152,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 ? 256000 : 40000, { music: musicMode });
offer.sdp = preferStereoOpus(offer.sdp, musicMode ? 510000 : 40000, { music: musicMode });
await pc.setLocalDescription(offer);
await waitForIceGathering(pc);
const res = await fetch(SFU_BASE + '/publish?room=' + encodeURIComponent(roomID) + '&pub=' + myKeys.pubHex, {
@ -2242,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, 256000, { music: true });
offer.sdp = preferStereoOpus(offer.sdp, 510000, { music: true });
await pc.setLocalDescription(offer);
await waitForIceGathering(pc);
const url = SFU_BASE + '/publish?room=' + encodeURIComponent(roomID)
@ -2257,12 +2266,12 @@ async function sfuPublishScreen(){
await pc.setRemoteDescription({ type:'answer', sdp: ans.sdp });
sfuScreenPC = pc; sfuScreenPeerID = ans.peer_id;
/* raise the RTP-level caps: 6 Mbps for video (high-detail 1080p screen),
* 256 kbps for audio (transparent stereo Opus). The codec-level cap was
* already raised via preferStereoOpus(). */
* 510 kbps for audio (Opus spec ceiling, transparent stereo). The codec-level
* cap was already raised via preferStereoOpus(). */
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, 256000);
if (s.track.kind === 'audio') setSenderMaxBitrate(s, 510000);
}
/* Screen share CANNOT auto-rebuild like mic + camera — getDisplayMedia
* requires a fresh user gesture (transient activation). Auto-calling
@ -2334,7 +2343,7 @@ async function sfuPublishGame(iframe){
}
stream.getVideoTracks()[0].addEventListener('ended', () => { sfuUnpublishGame(); });
const offer = await pc.createOffer();
offer.sdp = preferStereoOpus(offer.sdp, 256000, { music: true });
offer.sdp = preferStereoOpus(offer.sdp, 510000, { music: true });
await pc.setLocalDescription(offer);
await waitForIceGathering(pc);
const url = SFU_BASE + '/publish?room=' + encodeURIComponent(roomID)
@ -2351,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, 256000);
if (s.track.kind === 'audio') setSenderMaxBitrate(s, 510000);
}
/* game-share doesn't auto-rebuild on 'failed' — its capture source is
* a user-selected iframe via Region Capture; the user would have to
@ -2798,6 +2807,50 @@ async function refreshMicList(){
else micDeviceId = sel.value;
} catch(e){ logLine('err','could not list inputs: '+e.message); }
}
/* Speaker output picker — routes peer audio to a specific sink (studio
* monitors, external DAC, headphones). The codec gains stop at the
* <audio> element; this is the one knob that actually upgrades the
* playback chain itself.
*
* setSinkId() needs a non-default deviceId, and labels are gated behind
* mic permission (same gUM gate as enumerateDevices for inputs). Safari
* lacks setSinkId on HTMLMediaElement entirely — we hide the row in
* that case so it doesn't look broken. */
const spkSupported = (typeof HTMLMediaElement !== 'undefined') &&
('setSinkId' in HTMLMediaElement.prototype);
async function refreshSpeakerList(){
if (!spkSupported){
const row = $('row-speaker-controls'); if (row) row.classList.add('hidden');
return;
}
try {
const devs = await navigator.mediaDevices.enumerateDevices();
const outs = devs.filter(d=>d.kind==='audiooutput');
const sel = $('speaker-select'); if (!sel) return;
sel.innerHTML = '';
if (!outs.length){ sel.innerHTML = '<option value="">output default</option>'; return; }
outs.forEach((o,i)=>{
const opt = document.createElement('option');
opt.value = o.deviceId; opt.textContent = 'output ' + (o.label || ('speaker '+(i+1)));
sel.appendChild(opt);
});
if (speakerDeviceId && outs.some(o=>o.deviceId===speakerDeviceId)) sel.value = speakerDeviceId;
else speakerDeviceId = sel.value;
} catch(e){ logLine('err','could not list outputs: '+e.message); }
}
/* Apply current speakerDeviceId to a single <audio> element. Safe to call
* before the element is in the DOM. Browsers that lack setSinkId silently
* skip — we feature-check at the top so this stays cheap on cold paths. */
async function applySinkTo(el){
if (!spkSupported || !el || typeof el.setSinkId !== 'function') return;
try { await el.setSinkId(speakerDeviceId || ''); }
catch(e){ logLine('err','setSinkId rejected: '+e.message); }
}
/* Re-route every live remote-audio element to the newly picked sink. */
async function applySinkToAll(){
if (!spkSupported) return;
for (const [, a] of remoteAudio){ await applySinkTo(a); }
}
function tagTrack(t){ if (t) t.contentHint = musicMode ? 'music' : 'speech'; }
/* Firefox sometimes ignores the EC/NS/AGC constraints at getUserMedia time
* for non-mic sources (e.g. PulseAudio monitor) and applies its default
@ -2867,9 +2920,11 @@ async function setSenderBitrate(sender){
try {
const p = sender.getParameters();
if (!p.encodings || !p.encodings.length) p.encodings = [{}];
/* 256 kbps stereo Opus is roughly transparent for music; 40 kbps mono is
* plenty for speech */
p.encodings[0].maxBitrate = musicMode ? 256000 : 40000;
/* 510 kbps is the Opus spec ceiling — transparent stereo with headroom
* for transient-heavy material (orchestral, drums). 40 kbps mono is plenty
* for speech. Opus VBR only spends what it needs; idle music stays around
* 200300 kbps and only peaks claim the ceiling. */
p.encodings[0].maxBitrate = musicMode ? 510000 : 40000;
await sender.setParameters(p);
} catch(_){}
}
@ -2887,19 +2942,33 @@ async function setSenderMaxBitrate(sender, bps){
/* munge the offer SDP so Opus negotiates stereo + a high maxaveragebitrate.
* Browsers omit stereo=1 unless they're sure the track is stereo, and the
* codec-level maxaveragebitrate cap (separate from RTP-level maxBitrate)
* has to be raised explicitly for music to actually use the headroom. */
* has to be raised explicitly for music to actually use the headroom.
*
* 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. */
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 = !(opts && opts.music) ? '1' : '0';
return sdp.replace(/a=fmtp:(\d+) ([^\r\n]*minptime=10[^\r\n]*)/g, (m, pt, fmtp) => {
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. */
* 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();
@ -2911,6 +2980,23 @@ function preferStereoOpus(sdp, maxAvgBps, opts){
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;
/* Add NACK feedback for Opus if not already present — reactive packet
* recovery, near-zero overhead. Browsers that don't honor it will
* ignore the line. */
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
* 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');
}
return sdp;
}
async function applyMicMode(){
/* re-acquire mic with new constraints, hot-swap onto every live sender
@ -3584,7 +3670,7 @@ async function ensureMicAndUI(){
* mic and unblock mute. Toggling music-mode before getting a mic is fine —
* micConstraints() reads the live `musicMode` flag whenever we re-acquire. */
try {
await getMic(); await refreshMicList();
await getMic(); await refreshMicList(); await refreshSpeakerList();
$('btn-mute').disabled = false;
/* restore the user's last mute choice. Hard refresh keeps
* sessionStorage so reconnecting in muted state is preserved
@ -3656,7 +3742,11 @@ async function connectToPeer(uuid, weOffer){
setSenderBitrate(pc.getSenders().find(s=>s.track && s.track.kind==='audio'));
pc.ontrack = (ev) => {
let a = remoteAudio.get(uuid);
if (!a){ a = document.createElement('audio'); a.autoplay = true; document.body.appendChild(a); remoteAudio.set(uuid, a); }
if (!a){
a = document.createElement('audio'); a.autoplay = true;
document.body.appendChild(a); remoteAudio.set(uuid, a);
applySinkTo(a);
}
a.srcObject = ev.streams[0] || new MediaStream([ev.track]);
/* 700ms jitter buffer — matches the SFU path. Cross-Wi-Fi and
* cellular hand-off jitter ate into the old 400ms cushion and
@ -3702,7 +3792,7 @@ async function connectToPeer(uuid, weOffer){
};
if (weOffer){
const offer = await pc.createOffer();
offer.sdp = preferStereoOpus(offer.sdp, musicMode ? 256000 : 40000, { music: musicMode });
offer.sdp = preferStereoOpus(offer.sdp, musicMode ? 510000 : 40000, { music: musicMode });
await pc.setLocalDescription(offer);
await waitForIceGathering(pc);
await sendEncSDP(uuid, 'offer', pc.localDescription);
@ -4143,6 +4233,14 @@ $('mic-select').addEventListener('change', async (e) => {
try { localStorage.setItem(MIC_DEV_KEY, micDeviceId); } catch(_){}
if (micStream){ try { await applyMicMode(); } catch(err){ logLine('err','input switch failed: '+err.message); await refreshMicList(); } }
});
if ($('speaker-select')){
$('speaker-select').addEventListener('change', async (e) => {
speakerDeviceId = e.target.value;
try { localStorage.setItem(SPK_DEV_KEY, speakerDeviceId); } catch(_){}
await applySinkToAll();
logLine('', 'output: '+(e.target.selectedOptions[0]?.textContent || 'default'));
});
}
$('music-mode').addEventListener('change', async (e) => {
musicMode = e.target.checked;
try { localStorage.setItem(MUSIC_MODE_KEY, musicMode ? '1' : '0'); } catch(_){}
@ -4152,6 +4250,7 @@ $('music-mode').addEventListener('change', async (e) => {
if (navigator.mediaDevices && navigator.mediaDevices.addEventListener){
navigator.mediaDevices.addEventListener('devicechange', () => {
refreshMicList();
refreshSpeakerList();
/* belt-and-suspenders for browsers that don't fire track.onended on
* device disappearance (Firefox/BT swap can leave the track in
* 'live' state but emitting silence). If our current track has
@ -4169,6 +4268,7 @@ if (navigator.mediaDevices && navigator.mediaDevices.addEventListener){
* until mic permission is granted; deviceIds still populate so the user
* sees how many inputs exist. */
refreshMicList();
refreshSpeakerList();
/* tab-close strong-leave: when the page is about to unload, send 'bye'
* BEFORE the WS gets torn down by the browser. Without this, closing a
@ -4317,8 +4417,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">06817aa05772655ad595a070eb160055</span><br>
sha256 <span class="stamp-sha">5ddf2dc0fb7e2ecf91e3c0c0b88b9ccd386d068c74df5975c0f509803352164c</span><br>
md5 <span class="stamp-md5">45650495593e448837687d642021f48c</span><br>
sha256 <span class="stamp-sha">0a00722851d1749740821391946ccf77e8172ae5c024f564b8897fb05e020bc6</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>