zebra-spaces: latency panel adds packet loss % + jitter ms — pinpoint chops
Three numbers per row now: RTT, loss%, jitter, plus path tag. Loss is computed as a delta vs the prior poll (not lifetime cumulative) so a spike during chops shows immediately instead of being averaged into a session-long denominator. Row colour bumps to the worst of the three: green RTT with 5% loss reads red. Reading guide: - RTT < 50 / loss < 0.5% / jitter < 20ms → green (clean) - RTT < 150 / loss < 2% / jitter < 50ms → yellow - otherwise → red What pattern correlates with your chops: - loss spikes + green RTT → network: TURN or upstream queue - jitter spikes + clean loss → buffering / Wi-Fi micro-bursts - all clean but chops continue → source-side (PulseAudio loopback, encoder starvation)
This commit is contained in:
parent
4c130449ad
commit
18e6410820
1 changed files with 87 additions and 34 deletions
|
|
@ -116,17 +116,19 @@
|
|||
* 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;
|
||||
display: grid; grid-template-columns: 1fr 3.6rem 2.6rem 2.6rem 2.6rem; gap: 0.35rem;
|
||||
align-items: baseline; padding: 0.12rem 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-rtt, .lat-row .lat-loss, .lat-row .lat-jit { text-align: right; font-weight: bold; }
|
||||
.lat-row .lat-loss::before { content: 'loss '; color: #888; font-weight: normal; font-size: 0.65rem; }
|
||||
.lat-row .lat-jit::before { content: 'jit '; color: #888; font-weight: normal; font-size: 0.65rem; }
|
||||
.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; }
|
||||
.lat-row.lat-good .lat-rtt, .lat-row.lat-good .lat-loss, .lat-row.lat-good .lat-jit { color: #060; }
|
||||
.lat-row.lat-mid .lat-rtt, .lat-row.lat-mid .lat-loss, .lat-row.lat-mid .lat-jit { color: #b80; }
|
||||
.lat-row.lat-bad .lat-rtt, .lat-row.lat-bad .lat-loss, .lat-row.lat-bad .lat-jit { color: #b00; }
|
||||
.lat-row.lat-na .lat-rtt, .lat-row.lat-na .lat-loss, .lat-row.lat-na .lat-jit { color: #999; }
|
||||
|
||||
h1 {
|
||||
font-family: 'chunkfiveregular', serif;
|
||||
|
|
@ -1770,15 +1772,15 @@ refreshCameraList();
|
|||
* 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: '' };
|
||||
/* per-PC stats keyed by an arbitrary ID so we can compute deltas
|
||||
* (packetsLost, packetsReceived) across polls — % loss is a delta over
|
||||
* a delta, not a cumulative. The pcStatsPrev object holds the last poll. */
|
||||
const pcStatsPrev = new Map();
|
||||
async function statsForPC(pc, key){
|
||||
if (!pc) return { rtt: null, path: '', lossPct: null, jitterMs: null };
|
||||
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 = '';
|
||||
let pair = null, selectedPairId = '';
|
||||
stats.forEach(s => { if (s.type === 'transport' && s.selectedCandidatePairId) selectedPairId = s.selectedCandidatePairId; });
|
||||
stats.forEach(s => {
|
||||
if (s.type !== 'candidate-pair') return;
|
||||
|
|
@ -1788,12 +1790,38 @@ async function statsForPC(pc){
|
|||
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: '' }; }
|
||||
let path = '', rtt = null;
|
||||
if (pair){
|
||||
stats.forEach(s => { if (s.type === 'local-candidate' && s.id === pair.localCandidateId) path = s.candidateType || ''; });
|
||||
if (typeof pair.currentRoundTripTime === 'number') rtt = Math.round(pair.currentRoundTripTime * 1000);
|
||||
}
|
||||
/* sum packetsLost + packetsReceived across inbound-rtp (subscriber
|
||||
* side: many incoming streams). For publishers we use the remote
|
||||
* report (remote-inbound-rtp tells the sender what its peer lost +
|
||||
* the jitter at the receiver). Both directions are interesting. */
|
||||
let pktsLost = 0, pktsBase = 0, jitter = 0, jitterSamples = 0;
|
||||
stats.forEach(s => {
|
||||
if (s.type === 'inbound-rtp' && typeof s.packetsLost === 'number'){
|
||||
pktsLost += s.packetsLost;
|
||||
pktsBase += (s.packetsReceived || 0) + s.packetsLost;
|
||||
if (typeof s.jitter === 'number'){ jitter += s.jitter; jitterSamples++; }
|
||||
} else if (s.type === 'remote-inbound-rtp' && typeof s.packetsLost === 'number'){
|
||||
pktsLost += s.packetsLost;
|
||||
/* remote report doesn't include packetsReceived; we use it only
|
||||
* for loss when no inbound-rtp is present (publish-side PC) */
|
||||
if (typeof s.jitter === 'number'){ jitter += s.jitter; jitterSamples++; }
|
||||
}
|
||||
});
|
||||
/* convert cumulative loss/received into a delta vs last poll so the
|
||||
* percentage reflects what's happening NOW, not the session lifetime */
|
||||
const prev = pcStatsPrev.get(key) || { lost: 0, base: 0 };
|
||||
const dLost = Math.max(0, pktsLost - prev.lost);
|
||||
const dBase = Math.max(0, pktsBase - prev.base);
|
||||
pcStatsPrev.set(key, { lost: pktsLost, base: pktsBase });
|
||||
const lossPct = dBase > 0 ? (100 * dLost / dBase) : (dLost > 0 ? 100 : 0);
|
||||
const jitterMs = jitterSamples > 0 ? Math.round(1000 * jitter / jitterSamples) : null;
|
||||
return { rtt, path, lossPct, jitterMs };
|
||||
} catch(_){ return { rtt: null, path: '', lossPct: null, jitterMs: null }; }
|
||||
}
|
||||
function rttClass(rtt){
|
||||
if (rtt == null) return 'lat-na';
|
||||
|
|
@ -1810,19 +1838,42 @@ function fmtPath(p){
|
|||
if (p === 'relay') return 'TURN';
|
||||
return p;
|
||||
}
|
||||
function fmtLoss(v){ return (v == null) ? '—' : (v < 0.05 ? '0%' : v.toFixed(1)+'%'); }
|
||||
function fmtJitter(v){ return (v == null) ? '—' : v + 'ms'; }
|
||||
function rowClass(rtt, lossPct, jitterMs){
|
||||
/* worst of three signals drives the colour, so a green RTT with
|
||||
* 5% loss still flags red */
|
||||
let worst = 'lat-good';
|
||||
const bump = c => { const order = ['lat-na','lat-good','lat-mid','lat-bad']; if (order.indexOf(c) > order.indexOf(worst)) worst = c; };
|
||||
if (rtt == null) bump('lat-na');
|
||||
else if (rtt < 50) bump('lat-good');
|
||||
else if (rtt < 150) bump('lat-mid');
|
||||
else bump('lat-bad');
|
||||
if (lossPct != null){
|
||||
if (lossPct < 0.5) bump('lat-good');
|
||||
else if (lossPct < 2) bump('lat-mid');
|
||||
else bump('lat-bad');
|
||||
}
|
||||
if (jitterMs != null){
|
||||
if (jitterMs < 20) bump('lat-good');
|
||||
else if (jitterMs < 50) bump('lat-mid');
|
||||
else bump('lat-bad');
|
||||
}
|
||||
return worst;
|
||||
}
|
||||
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 }); }
|
||||
if (sfuPubPC){ rows.push({ name: 'sfu mic out', pc: sfuPubPC, key: 'pub' }); }
|
||||
if (sfuScreenPC){ rows.push({ name: 'sfu screen out', pc: sfuScreenPC, key: 'screen' }); }
|
||||
if (sfuCameraPC){ rows.push({ name: 'sfu camera out', pc: sfuCameraPC, key: 'camera' }); }
|
||||
/* SFU subscriber — carries every incoming stream from other speakers */
|
||||
if (sfuSubPC){ rows.push({ name: 'sfu in (host + screen + cams)', pc: sfuSubPC }); }
|
||||
if (sfuSubPC){ rows.push({ name: 'sfu in (host + screen + cams)', pc: sfuSubPC, key: 'sub' }); }
|
||||
/* 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 });
|
||||
rows.push({ name: 'peer '+handle, pc, key: 'mesh-'+uuid });
|
||||
}
|
||||
const container = $('latency-rows');
|
||||
if (!container) return;
|
||||
|
|
@ -1833,16 +1884,18 @@ async function refreshLatency(){
|
|||
}
|
||||
$('sec-latency').classList.remove('hidden');
|
||||
/* gather all stats in parallel */
|
||||
const data = await Promise.all(rows.map(r => statsForPC(r.pc)));
|
||||
const data = await Promise.all(rows.map(r => statsForPC(r.pc, r.key)));
|
||||
container.innerHTML = '';
|
||||
for (let i = 0; i < rows.length; i++){
|
||||
const { rtt, path } = data[i];
|
||||
const { rtt, path, lossPct, jitterMs } = 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);
|
||||
div.className = 'lat-row ' + rowClass(rtt, lossPct, jitterMs);
|
||||
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 lo = document.createElement('span'); lo.className = 'lat-loss'; lo.textContent = fmtLoss(lossPct);
|
||||
const j = document.createElement('span'); j.className = 'lat-jit'; j.textContent = fmtJitter(jitterMs);
|
||||
const p = document.createElement('span'); p.className = 'lat-path'; p.textContent = fmtPath(path);
|
||||
div.appendChild(n); div.appendChild(r); div.appendChild(lo); div.appendChild(j); div.appendChild(p);
|
||||
container.appendChild(div);
|
||||
}
|
||||
}
|
||||
|
|
@ -2004,8 +2057,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">573cc40858f4c344e5c3b98a6be579b9</span><br>
|
||||
sha256 <span class="stamp-sha">514e57c535e0d0a433eb8992b2414f782042f7f117ee0cd6dc245f57613d27f2</span><br>
|
||||
md5 <span class="stamp-md5">6a2292078a9f6fa8c5fb156b60d670bb</span><br>
|
||||
sha256 <span class="stamp-sha">f857a87db69a0f93a63350630b7856f851dc55ccf84b7ebed763a2a0502ca3ef</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