diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html
index d317255..9f75c10 100644
--- a/web/zebra-spaces.html
+++ b/web/zebra-spaces.html
@@ -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');