feat: 扩展 WebRTC 统计信息

在 StatsBar 中新增延迟、抖动、丢帧、解码耗时、上传速率和丢包率等指标展示。
重构速率计算逻辑,并从 inbound-rtp 和 candidate-pair 报告中提取相关数据。
This commit is contained in:
2026-07-24 18:08:56 +08:00
parent cdf3d5da46
commit 23698d0be7
2 changed files with 56 additions and 5 deletions

View File

@@ -6,8 +6,14 @@ import { store } from '../store/controllerStore';
<div class="stats-bar">
<span>分辨率: <b>{{ store.stats ? store.stats.width + '×' + store.stats.height : '-' }}</b></span>
<span>帧率: <b>{{ store.stats ? store.stats.fps : '-' }}</b></span>
<span>: <b>{{ store.stats ? store.stats.codec : '-' }}</b></span>
<span>: <b>{{ store.stats ? store.stats.codec : '-' }}</b></span>
<span>延迟: <b>{{ store.stats ? store.stats.latency : '-' }}</b></span>
<span>抖动: <b>{{ store.stats ? store.stats.jitter : '-' }}</b></span>
<span>丢帧: <b>{{ store.stats ? store.stats.framesDropped : '-' }}</b></span>
<span>解码耗时: <b>{{ store.stats ? store.stats.decodeMs : '-' }}</b></span>
<span>上传: <b>{{ store.stats ? store.stats.upSpeed : '-' }}</b></span>
<span>下载: <b>{{ store.stats ? store.stats.downSpeed : '-' }}</b></span>
<span>丢包: <b>{{ store.stats ? store.stats.packetLoss : '-' }}</b></span>
<span>时长: <b>{{ store.stats ? store.stats.duration : '-' }}</b></span>
<span>控制通道: <b>{{ store.dataChannelOpen ? '已连接' : '未连接' }}</b></span>
<span>目标: <b>{{ store.targetDeviceId || '-' }}</b></span>

View File

@@ -23,6 +23,8 @@ export class WebRtcController {
this._statsTimer = null;
this._prevBytes = 0;
this._prevTs = 0;
this._prevSentBytes = 0;
this._prevSentTs = 0;
this._connectedAt = 0;
this._gatheredRelay = false;
this._gatheredSrflx = false;
@@ -239,7 +241,10 @@ export class WebRtcController {
async collectStats() {
const stats = await this.pc.getStats();
let width = '-', height = '-', fps = '-', codec = '-', bytesReceived = 0;
let width = '-', height = '-', fps = '-', codec = '-';
let bytesReceived = 0, bytesSent = 0;
let jitter = 0, framesDropped = 0, packetsLost = 0, packetsReceived = 0;
let framesDecoded = 0, totalDecodeTime = 0, rtt = null;
const codecs = {};
stats.forEach((r) => {
if (r.type === 'inbound-rtp' && r.kind === 'video') {
@@ -247,30 +252,63 @@ export class WebRtcController {
height = r.frameHeight ?? '-';
fps = r.framesPerSecond ?? '-';
if (r.codecId && codecs[r.codecId]) codec = codecs[r.codecId];
jitter = r.jitter ?? 0; // 接收抖动(秒)
framesDropped = r.framesDropped ?? 0; // 累计丢帧数
packetsLost = r.packetsLost ?? 0; // 累计丢包数
packetsReceived = r.packetsReceived ?? 0; // 累计接收包数
framesDecoded = r.framesDecoded ?? 0; // 累计解码帧数
totalDecodeTime = r.totalDecodeTime ?? 0; // 累计解码耗时(秒)
} else if (r.type === 'codec' && r.mimeType && r.mimeType.startsWith('video/')) {
codecs[r.id] = r.mimeType.substring(6);
} else if (r.type === 'candidate-pair' && r.nominated) {
bytesReceived = r.bytesReceived ?? 0;
bytesSent = r.bytesSent ?? 0;
if (typeof r.currentRoundTripTime === 'number') rtt = r.currentRoundTripTime; // 往返时延(秒)
}
});
const now = Date.now();
// 下载速率
let downSpeed = '-';
if (this._prevTs) {
const dt = (now - this._prevTs) / 1000;
if (dt > 0) {
const bps = (bytesReceived - this._prevBytes) / dt;
downSpeed = bps >= 1048576 ? (bps / 1048576).toFixed(1) + ' MB/s'
: bps >= 1024 ? (bps / 1024).toFixed(1) + ' KB/s' : bps.toFixed(0) + ' B/s';
downSpeed = formatSpeed(bps);
}
}
this._prevBytes = bytesReceived;
this._prevTs = now;
// 上传速率
let upSpeed = '-';
if (this._prevSentTs) {
const dt = (now - this._prevSentTs) / 1000;
if (dt > 0) {
const bps = (bytesSent - this._prevSentBytes) / dt;
upSpeed = formatSpeed(bps);
}
}
this._prevSentBytes = bytesSent;
this._prevSentTs = now;
let duration = '-';
if (this._connectedAt) {
const secs = Math.floor((now - this._connectedAt) / 1000);
duration = String(Math.floor(secs / 60)).padStart(2, '0') + ':' + String(secs % 60).padStart(2, '0');
}
return { width, height, fps, codec, downSpeed, duration, dcState: this.dataChannel ? this.dataChannel.readyState : 'none' };
// 延迟(往返时延)
const latency = rtt != null ? (rtt * 1000).toFixed(0) + ' ms' : '-';
// 抖动
const jitterMs = jitter ? (jitter * 1000).toFixed(1) + ' ms' : '-';
// 解码耗时(平均每帧解码时间)
const decodeMs = (framesDecoded > 0 && totalDecodeTime > 0)
? (totalDecodeTime / framesDecoded * 1000).toFixed(1) + ' ms' : '-';
// 丢包率
const totalPackets = packetsLost + packetsReceived;
const lossRate = totalPackets > 0 ? (packetsLost / totalPackets * 100).toFixed(1) + '%' : '-';
return {
width, height, fps, codec, downSpeed, upSpeed, duration,
latency, jitter: jitterMs, framesDropped, decodeMs, packetLoss: lossRate,
dcState: this.dataChannel ? this.dataChannel.readyState : 'none',
};
}
async close() {
@@ -283,3 +321,10 @@ export class WebRtcController {
this._pendingCandidates = [];
}
}
// 将字节/秒格式化为可读速率
function formatSpeed(bps) {
if (!bps || bps <= 0) return '0 B/s';
return bps >= 1048576 ? (bps / 1048576).toFixed(1) + ' MB/s'
: bps >= 1024 ? (bps / 1024).toFixed(1) + ' KB/s' : bps.toFixed(0) + ' B/s';
}