diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html
index 9c2eaac..59112b3 100644
--- a/web/zebra-spaces.html
+++ b/web/zebra-spaces.html
@@ -3904,6 +3904,47 @@ async function dumpTelemetry(){
});
} catch(e){ parts.push('stats.err=' + e.message); }
}
+ /* publisher-side stats — what we are actually emitting on the wire.
+ * Catches publisher stalls (X11 compositor blocking the audio thread
+ * during a window wiggle is a real one). gap = now - lastPacketSent
+ * — a gap > frame-interval is a wire stall. rtt is the most recent
+ * round-trip from remote-inbound-rtp (the SFU's view of our sender). */
+ const pubPCs = [
+ ['mic.send', sfuPubPC],
+ ['cam.send', sfuCameraPC],
+ ['scr.send', sfuScreenPC],
+ ['game.send', sfuGamePC],
+ ];
+ for (const [label, pc] of pubPCs){
+ if (!pc || typeof pc.getStats !== 'function') continue;
+ try {
+ const stats = await pc.getStats(null);
+ const nowMs = Date.now();
+ let out = null, remoteIn = null;
+ stats.forEach(r => {
+ if (r.type === 'outbound-rtp' && (r.kind === 'audio' || r.kind === 'video')){
+ if (!out || (r.packetsSent|0) > (out.packetsSent|0)) out = r;
+ }
+ if (r.type === 'remote-inbound-rtp') {
+ if (!remoteIn || (r.packetsLost|0) > (remoteIn.packetsLost|0)) remoteIn = r;
+ }
+ });
+ if (!out) continue;
+ const gap = out.lastPacketSentTimestamp
+ ? ((nowMs - out.lastPacketSentTimestamp) / 1000).toFixed(1) + 's'
+ : '?';
+ let s = label + ' pkt=' + (out.packetsSent|0) +
+ ' bytes=' + (out.bytesSent|0) +
+ ' gap=' + gap;
+ if (out.kind === 'video') s += ' frames=' + (out.framesEncoded|0);
+ if (remoteIn) {
+ s += ' rtt=' + ((remoteIn.roundTripTime||0).toFixed(3)) +
+ ' rlost=' + (remoteIn.packetsLost|0) +
+ ' rjit=' + ((remoteIn.jitter||0).toFixed(4));
+ }
+ parts.push(s);
+ } catch(_){}
+ }
/* every