From 4c130449ad8f305c828989f54f0fa1b73b074875 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Mon, 1 Jun 2026 21:25:23 -0400 Subject: [PATCH] zebra-spaces: latency panel + 200ms jitter buffer + drop DTX on music + link section moved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Latency panel: - new collapsible section in the right column under role-actions - polls getStats() every 2s across every live PC: sfuPubPC, sfuScreenPC, sfuCameraPC, sfuSubPC, and each mesh peer - per row: name (e.g. 'sfu mic out', 'sfu in (host + screen + cams)', 'peer '), RTT in ms (from candidate-pair.currentRoundTripTime), path kind (LAN/WAN/TURN). Colour-tagged: green <50ms, yellow <150ms, red 150+. Hides when no PC is live. - the SFU subscribe row carries every incoming kind (mic + screen + camera) because they all share sfuSubPC at the WebRTC layer — labelled accordingly so users don't expect three separate rows. Audio chops: - bump playoutDelayHint from 100ms to 200ms — Wi-Fi micro-bursts on weak links can spike past 100ms and the smaller buffer dropped frames - preferStereoOpus now takes { music: bool }; music mode = usedtx=0 because DTX's comfort-noise on/off transitions audibly pop on continuous music signals. Voice mode keeps usedtx=1. Screen-share audio is always music-grade (system audio capture, not voice). UX (per fox): - 'share' section renamed to 'link' and moved directly above the log section, out of the way of the join/role controls. --- web/zebra-spaces.html | 166 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 142 insertions(+), 24 deletions(-) diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html index 91a1de6..d317255 100644 --- a/web/zebra-spaces.html +++ b/web/zebra-spaces.html @@ -110,6 +110,24 @@ font-family: monospace; font-size: 0.9rem; user-select: none; } .camera-tile.needs-tap .tap-play { display: flex; } + + /* latency panel: one row per live PC, fixed-width columns so the numbers + * line up vertically as values bounce. lat-good < 50ms, mid < 150ms, + * bad ≥ 150ms — colour-tagged so the user can scan at a glance. */ + .latency-rows { display: grid; gap: 0.18rem; font-size: 0.78rem; margin-bottom: 0.4rem; } + .lat-row { + display: grid; grid-template-columns: 1fr 4.5rem 3rem; gap: 0.5rem; + align-items: baseline; padding: 0.1rem 0; + border-bottom: 1px dotted #eee; font-family: monospace; + } + .lat-row .lat-name { color: #333; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .lat-row .lat-rtt { text-align: right; font-weight: bold; } + .lat-row .lat-path { text-align: right; color: #888; font-size: 0.7rem; text-transform: uppercase; } + .lat-row.lat-good .lat-rtt { color: #060; } + .lat-row.lat-mid .lat-rtt { color: #b80; } + .lat-row.lat-bad .lat-rtt { color: #b00; } + .lat-row.lat-na .lat-rtt { color: #999; } + h1 { font-family: 'chunkfiveregular', serif; font-size: 3rem; font-weight: normal; letter-spacing: 0.02em; @@ -330,18 +348,6 @@ - - + + + +

log

@@ -736,7 +760,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); + offer.sdp = preferStereoOpus(offer.sdp, musicMode ? 256000 : 40000, { music: musicMode }); await pc.setLocalDescription(offer); await waitForIceGathering(pc); const res = await fetch(SFU_BASE + '/publish?room=' + encodeURIComponent(roomID) + '&pub=' + myKeys.pubHex, { @@ -792,7 +816,9 @@ async function sfuPublishScreen(){ * bar — propagate that into a clean unpublish */ stream.getVideoTracks()[0].addEventListener('ended', () => { sfuUnpublishScreen(); }); const offer = await pc.createOffer(); - offer.sdp = preferStereoOpus(offer.sdp, 256000); + /* 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 }); await pc.setLocalDescription(offer); await waitForIceGathering(pc); const url = SFU_BASE + '/publish?room=' + encodeURIComponent(roomID) @@ -937,7 +963,7 @@ async function sfuSubscribe(){ /* mic audio — set a small jitter-buffer hint so brief network jitter * doesn't cause audible drops. 100ms is enough to absorb typical * Wi-Fi micro-bursts without making conversation feel laggy. */ - try { ev.receiver.playoutDelayHint = 0.1; } catch(_){} + try { ev.receiver.playoutDelayHint = 0.2; } catch(_){} const pubHex = sid; /* cache stream by publisher pubkey so it survives the member's session * uuid changing across leave/rejoin — see flushSfuStreams */ @@ -1108,16 +1134,18 @@ async function setSenderMaxBitrate(sender, bps){ * 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. */ -function preferStereoOpus(sdp, maxAvgBps){ +function preferStereoOpus(sdp, maxAvgBps, opts){ + /* 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) => { /* useinbandfec=1: forward error correction so a single dropped - * packet doesn't audibly chop — Opus reconstructs from FEC. - * usedtx=1: discontinuous transmission elides silence so the - * bandwidth budget goes to actual audio (cuts congestion). */ + * packet doesn't audibly chop — Opus reconstructs from FEC. */ const want = { 'stereo': '1', 'sprop-stereo': '1', 'maxaveragebitrate': String(maxAvgBps), - 'useinbandfec': '1', 'usedtx': '1', + 'useinbandfec': '1', 'usedtx': dtx, }; const parts = fmtp.split(';').map(s => s.trim()).filter(Boolean); const seen = new Set(); @@ -1533,7 +1561,7 @@ async function connectToPeer(uuid, weOffer){ a.srcObject = ev.streams[0] || new MediaStream([ev.track]); /* 100ms jitter buffer absorbs typical Wi-Fi micro-bursts without * adding perceptible conversation lag */ - try { ev.receiver.playoutDelayHint = 0.1; } catch(_){} + try { ev.receiver.playoutDelayHint = 0.2; } catch(_){} stopMeter(uuid); startMeter(uuid, a.srcObject); }; pc.onicecandidate = (ev) => { /* using waitForIceGathering pattern, candidates ignored */ }; @@ -1548,7 +1576,7 @@ async function connectToPeer(uuid, weOffer){ }; if (weOffer){ const offer = await pc.createOffer(); - offer.sdp = preferStereoOpus(offer.sdp, musicMode ? 256000 : 40000); + offer.sdp = preferStereoOpus(offer.sdp, musicMode ? 256000 : 40000, { music: musicMode }); await pc.setLocalDescription(offer); await waitForIceGathering(pc); await sendEncSDP(uuid, 'offer', pc.localDescription); @@ -1731,6 +1759,96 @@ async function refreshCameraList(){ } navigator.mediaDevices.addEventListener('devicechange', refreshCameraList); refreshCameraList(); + +/* ================================================================== + * latency panel — polls getStats() across every live RTCPeerConnection + * and renders one row per PC with RTT (ms) + candidate-pair path kind. + * + * Architecture note: incoming media (mic, screen video, screen audio, + * camera) from every other speaker arrives over the SAME sfuSubPC, so + * 'sfu in' is one row that covers all received streams. Publishers + * have one PC per kind (sfuPubPC, sfuScreenPC, sfuCameraPC), so each + * gets its own row. Mesh peers get one row each. + * ================================================================== */ +async function statsForPC(pc){ + if (!pc) return { rtt: null, path: '' }; + try { + const stats = await pc.getStats(); + let pair = null; + /* WebRTC reports many candidate-pairs; we want the 'nominated' / + * succeeded one that's actually carrying media. Some browsers tag + * it via 'selected' on transport; fall back to scanning. */ + let selectedPairId = ''; + stats.forEach(s => { if (s.type === 'transport' && s.selectedCandidatePairId) selectedPairId = s.selectedCandidatePairId; }); + stats.forEach(s => { + if (s.type !== 'candidate-pair') return; + if (selectedPairId && s.id === selectedPairId){ pair = s; return; } + if (!pair && s.state === 'succeeded' && (s.nominated || s.selected)) pair = s; + }); + if (!pair){ + stats.forEach(s => { if (!pair && s.type === 'candidate-pair' && s.state === 'succeeded') pair = s; }); + } + if (!pair) return { rtt: null, path: '' }; + let path = ''; + stats.forEach(s => { if (s.type === 'local-candidate' && s.id === pair.localCandidateId) path = s.candidateType || ''; }); + const rtt = (typeof pair.currentRoundTripTime === 'number') ? Math.round(pair.currentRoundTripTime * 1000) : null; + return { rtt, path }; + } catch(_){ return { rtt: null, path: '' }; } +} +function rttClass(rtt){ + if (rtt == null) return 'lat-na'; + if (rtt < 50) return 'lat-good'; + if (rtt < 150) return 'lat-mid'; + return 'lat-bad'; +} +function fmtRtt(rtt){ return (rtt == null) ? '—' : rtt + ' ms'; } +function fmtPath(p){ + if (!p) return ''; + if (p === 'host') return 'LAN'; + if (p === 'srflx') return 'WAN'; + if (p === 'prflx') return 'WAN'; + if (p === 'relay') return 'TURN'; + return p; +} +async function refreshLatency(){ + const rows = []; + /* SFU publishers — only present if you're a speaker */ + if (sfuPubPC){ rows.push({ name: 'sfu mic out', pc: sfuPubPC }); } + if (sfuScreenPC){ rows.push({ name: 'sfu screen out', pc: sfuScreenPC }); } + if (sfuCameraPC){ rows.push({ name: 'sfu camera out', pc: sfuCameraPC }); } + /* SFU subscriber — carries every incoming stream from other speakers */ + if (sfuSubPC){ rows.push({ name: 'sfu in (host + screen + cams)', pc: sfuSubPC }); } + /* mesh peers — one row each */ + for (const [uuid, pc] of peers){ + const mm = members.get(uuid); + const handle = (mm && mm.handle) || shortHex(uuid); + rows.push({ name: 'peer '+handle, pc }); + } + const container = $('latency-rows'); + if (!container) return; + if (rows.length === 0){ + $('sec-latency').classList.add('hidden'); + container.innerHTML = ''; + return; + } + $('sec-latency').classList.remove('hidden'); + /* gather all stats in parallel */ + const data = await Promise.all(rows.map(r => statsForPC(r.pc))); + container.innerHTML = ''; + for (let i = 0; i < rows.length; i++){ + const { rtt, path } = data[i]; + const div = document.createElement('div'); + div.className = 'lat-row ' + rttClass(rtt); + const n = document.createElement('span'); n.className = 'lat-name'; n.textContent = rows[i].name; + const r = document.createElement('span'); r.className = 'lat-rtt'; r.textContent = fmtRtt(rtt); + const p = document.createElement('span'); p.className = 'lat-path'; p.textContent = fmtPath(path); + div.appendChild(n); div.appendChild(r); div.appendChild(p); + container.appendChild(div); + } +} +setInterval(refreshLatency, 2000); +/* kick once on load so the panel doesn't show stale '—' for 2s after each join */ +refreshLatency(); /* notice banner — visible callouts for events that affect you directly * (boot, role change). Auto-clears after 8s for info; stays for warn. */ let noticeTimer = null; @@ -1886,8 +2004,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');