// Shared receiver-side WebRTC telemetry for P2P and WHEP sessions. // Values are deliberately normalized here so the viewer pages do not need to // know about browser-specific RTCStatsReport relationships. export class WebRTCStatsSampler { constructor(pc, onStats, intervalMs = 1000) { this.pc = pc; this.onStats = typeof onStats === "function" ? onStats : () => {}; this.intervalMs = intervalMs; this.timer = null; this.running = false; this.previous = new Map(); } start() { if (this.timer || !this.pc) return; this.sample(); this.timer = setInterval(() => this.sample(), this.intervalMs); } stop() { if (this.timer) clearInterval(this.timer); this.timer = null; this.running = false; this.previous.clear(); } async sample() { if (this.running || !this.pc || this.pc.connectionState === "closed") return; this.running = true; try { const report = await this.pc.getStats(); const byId = new Map(); report.forEach((item) => byId.set(item.id, item)); const result = { timestamp: performance.now(), rttMs: null, availableIncomingMbps: null, protocol: "", route: "", video: null, audio: null, }; let selectedPair = null; for (const item of byId.values()) { if (item.type === "transport" && item.selectedCandidatePairId) { selectedPair = byId.get(item.selectedCandidatePairId) || selectedPair; } if (item.type === "candidate-pair" && item.state === "succeeded" && (item.nominated || item.selected)) { selectedPair = selectedPair || item; } } if (selectedPair) { if (Number.isFinite(selectedPair.currentRoundTripTime)) { result.rttMs = selectedPair.currentRoundTripTime * 1000; } if (Number.isFinite(selectedPair.availableIncomingBitrate)) { result.availableIncomingMbps = selectedPair.availableIncomingBitrate / 1_000_000; } const local = byId.get(selectedPair.localCandidateId); const remote = byId.get(selectedPair.remoteCandidateId); result.protocol = String(local?.protocol || remote?.protocol || "").toUpperCase(); result.route = summarizeRoute(local?.candidateType, remote?.candidateType); } for (const item of byId.values()) { if (item.type !== "inbound-rtp" || item.isRemote) continue; const kind = item.kind || item.mediaType; if (kind !== "video" && kind !== "audio") continue; const codec = byId.get(item.codecId); const previous = this.previous.get(item.id); const timestamp = Number(item.timestamp) || performance.now(); let bitrateMbps = null; let decodeMs = null; let processingDelayMs = null; let framesDroppedDelta = null; if (previous && timestamp > previous.timestamp && Number(item.bytesReceived) >= previous.bytesReceived) { bitrateMbps = ((Number(item.bytesReceived) - previous.bytesReceived) * 8) / ((timestamp - previous.timestamp) * 1000); const decodedDelta = (Number(item.framesDecoded) || 0) - previous.framesDecoded; if (decodedDelta > 0) { const decodeDelta = (Number(item.totalDecodeTime) || 0) - previous.totalDecodeTime; const processingDelta = (Number(item.totalProcessingDelay) || 0) - previous.totalProcessingDelay; if (decodeDelta >= 0) decodeMs = (decodeDelta * 1000) / decodedDelta; if (processingDelta >= 0) processingDelayMs = (processingDelta * 1000) / decodedDelta; } const droppedDelta = (Number(item.framesDropped) || 0) - previous.framesDropped; if (droppedDelta >= 0) framesDroppedDelta = droppedDelta; } this.previous.set(item.id, { timestamp, bytesReceived: Number(item.bytesReceived) || 0, framesDecoded: Number(item.framesDecoded) || 0, totalDecodeTime: Number(item.totalDecodeTime) || 0, totalProcessingDelay: Number(item.totalProcessingDelay) || 0, framesDropped: Number(item.framesDropped) || 0, }); const packetsReceived = Number(item.packetsReceived) || 0; const packetsLost = Math.max(0, Number(item.packetsLost) || 0); const packetTotal = packetsReceived + packetsLost; result[kind] = { bitrateMbps, packetsLost, lossPercent: packetTotal > 0 ? (packetsLost * 100) / packetTotal : 0, jitterMs: Number.isFinite(item.jitter) ? item.jitter * 1000 : null, jitterBufferMs: averageJitterBufferMs(item), decodeMs, processingDelayMs, framesDropped: Number.isFinite(item.framesDropped) ? item.framesDropped : null, framesDroppedDelta, framesPerSecond: Number.isFinite(item.framesPerSecond) ? item.framesPerSecond : null, width: Number.isFinite(item.frameWidth) ? item.frameWidth : null, height: Number.isFinite(item.frameHeight) ? item.frameHeight : null, codec: String(codec?.mimeType || "").replace(/^\w+\//, ""), }; } this.onStats(result); } catch (error) { if (this.pc?.connectionState !== "closed") console.debug("webrtc stats unavailable", error); } finally { this.running = false; } } } function averageJitterBufferMs(item) { const delay = Number(item.jitterBufferDelay); const emitted = Number(item.jitterBufferEmittedCount); return Number.isFinite(delay) && emitted > 0 ? (delay * 1000) / emitted : null; } function summarizeRoute(localType, remoteType) { const types = [localType, remoteType].filter(Boolean); if (types.includes("relay")) return "Relay"; if (types.includes("srflx") || types.includes("prflx")) return "P2P"; if (types.includes("host")) return "Direct"; return ""; }