Files
overlord/Overlord-Server/public/assets/webrtc-stats.js
T

147 lines
6.3 KiB
JavaScript
Raw Normal View History

// 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;
2026-07-17 13:23:12 -05:00
let intervalLossPercent = 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;
2026-07-17 13:23:12 -05:00
const receivedDelta = (Number(item.packetsReceived) || 0) - previous.packetsReceived;
const lostDelta = (Number(item.packetsLost) || 0) - previous.packetsLost;
const packetDelta = Math.max(0, receivedDelta) + Math.max(0, lostDelta);
if (packetDelta > 0) intervalLossPercent = (Math.max(0, lostDelta) * 100) / packetDelta;
}
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,
2026-07-17 13:23:12 -05:00
packetsReceived: Number(item.packetsReceived) || 0,
packetsLost: Number(item.packetsLost) || 0,
});
const packetsReceived = Number(item.packetsReceived) || 0;
const packetsLost = Math.max(0, Number(item.packetsLost) || 0);
const packetTotal = packetsReceived + packetsLost;
result[kind] = {
bitrateMbps,
packetsLost,
2026-07-17 13:23:12 -05:00
lossPercent: intervalLossPercent ?? (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 "";
}