zebra-spaces: latency panel + 200ms jitter buffer + drop DTX on music + link section moved
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 <handle>'), 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.
This commit is contained in:
parent
acc7c1c6ab
commit
4c130449ad
1 changed files with 142 additions and 24 deletions
|
|
@ -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 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<section id="sec-share" class="hidden">
|
||||
<h2>share</h2>
|
||||
<p class="note" style="margin-bottom:0.5rem">share only with people you trust to hear the room.</p>
|
||||
<div class="row">
|
||||
<input type="text" id="share-url" readonly>
|
||||
<button id="btn-copy-share" class="small">copy</button>
|
||||
</div>
|
||||
<div id="qr-host" class="row" style="display:none">
|
||||
<canvas id="share-qr" width="220" height="220" aria-label="QR code for this space"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="sec-room" class="hidden">
|
||||
<h2>room</h2>
|
||||
<div id="members"></div>
|
||||
|
|
@ -370,6 +376,24 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<section id="sec-latency" class="hidden">
|
||||
<h2>latency</h2>
|
||||
<div id="latency-rows" class="latency-rows"></div>
|
||||
<span class="note">RTT from <code>candidate-pair.currentRoundTripTime</code> + path kind (host = LAN, srflx = direct WAN, relay = through TURN). Updates every 2s.</span>
|
||||
</section>
|
||||
|
||||
<section id="sec-share" class="hidden">
|
||||
<h2>link</h2>
|
||||
<p class="note" style="margin-bottom:0.5rem">share only with people you trust to hear the room.</p>
|
||||
<div class="row">
|
||||
<input type="text" id="share-url" readonly>
|
||||
<button id="btn-copy-share" class="small">copy</button>
|
||||
</div>
|
||||
<div id="qr-host" class="row" style="display:none">
|
||||
<canvas id="share-qr" width="220" height="220" aria-label="QR code for this space"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>log</h2>
|
||||
<div class="log" id="log"></div>
|
||||
|
|
@ -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');
|
|||
|
||||
<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-02</span><br>
|
||||
md5 <span class="stamp-md5">9add55fa25652241d5380905ec721cd0</span><br>
|
||||
sha256 <span class="stamp-sha">41f154d5aab1f9d388f5dad20036c74ca9ccbfab6784d6479646d1d2493e9064</span><br>
|
||||
md5 <span class="stamp-md5">573cc40858f4c344e5c3b98a6be579b9</span><br>
|
||||
sha256 <span class="stamp-sha">514e57c535e0d0a433eb8992b2414f782042f7f117ee0cd6dc245f57613d27f2</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