feat: 扩展 WebRTC 统计信息
在 StatsBar 中新增延迟、抖动、丢帧、解码耗时、上传速率和丢包率等指标展示。 重构速率计算逻辑,并从 inbound-rtp 和 candidate-pair 报告中提取相关数据。
This commit is contained in:
@@ -6,8 +6,14 @@ import { store } from '../store/controllerStore';
|
|||||||
<div class="stats-bar">
|
<div class="stats-bar">
|
||||||
<span>分辨率: <b>{{ store.stats ? store.stats.width + '×' + store.stats.height : '-' }}</b></span>
|
<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.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.downSpeed : '-' }}</b></span>
|
||||||
|
<span>丢包: <b>{{ store.stats ? store.stats.packetLoss : '-' }}</b></span>
|
||||||
<span>时长: <b>{{ store.stats ? store.stats.duration : '-' }}</b></span>
|
<span>时长: <b>{{ store.stats ? store.stats.duration : '-' }}</b></span>
|
||||||
<span>控制通道: <b>{{ store.dataChannelOpen ? '已连接' : '未连接' }}</b></span>
|
<span>控制通道: <b>{{ store.dataChannelOpen ? '已连接' : '未连接' }}</b></span>
|
||||||
<span>目标: <b>{{ store.targetDeviceId || '-' }}</b></span>
|
<span>目标: <b>{{ store.targetDeviceId || '-' }}</b></span>
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export class WebRtcController {
|
|||||||
this._statsTimer = null;
|
this._statsTimer = null;
|
||||||
this._prevBytes = 0;
|
this._prevBytes = 0;
|
||||||
this._prevTs = 0;
|
this._prevTs = 0;
|
||||||
|
this._prevSentBytes = 0;
|
||||||
|
this._prevSentTs = 0;
|
||||||
this._connectedAt = 0;
|
this._connectedAt = 0;
|
||||||
this._gatheredRelay = false;
|
this._gatheredRelay = false;
|
||||||
this._gatheredSrflx = false;
|
this._gatheredSrflx = false;
|
||||||
@@ -239,7 +241,10 @@ export class WebRtcController {
|
|||||||
|
|
||||||
async collectStats() {
|
async collectStats() {
|
||||||
const stats = await this.pc.getStats();
|
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 = {};
|
const codecs = {};
|
||||||
stats.forEach((r) => {
|
stats.forEach((r) => {
|
||||||
if (r.type === 'inbound-rtp' && r.kind === 'video') {
|
if (r.type === 'inbound-rtp' && r.kind === 'video') {
|
||||||
@@ -247,30 +252,63 @@ export class WebRtcController {
|
|||||||
height = r.frameHeight ?? '-';
|
height = r.frameHeight ?? '-';
|
||||||
fps = r.framesPerSecond ?? '-';
|
fps = r.framesPerSecond ?? '-';
|
||||||
if (r.codecId && codecs[r.codecId]) codec = codecs[r.codecId];
|
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/')) {
|
} else if (r.type === 'codec' && r.mimeType && r.mimeType.startsWith('video/')) {
|
||||||
codecs[r.id] = r.mimeType.substring(6);
|
codecs[r.id] = r.mimeType.substring(6);
|
||||||
} else if (r.type === 'candidate-pair' && r.nominated) {
|
} else if (r.type === 'candidate-pair' && r.nominated) {
|
||||||
bytesReceived = r.bytesReceived ?? 0;
|
bytesReceived = r.bytesReceived ?? 0;
|
||||||
|
bytesSent = r.bytesSent ?? 0;
|
||||||
|
if (typeof r.currentRoundTripTime === 'number') rtt = r.currentRoundTripTime; // 往返时延(秒)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
// 下载速率
|
||||||
let downSpeed = '-';
|
let downSpeed = '-';
|
||||||
if (this._prevTs) {
|
if (this._prevTs) {
|
||||||
const dt = (now - this._prevTs) / 1000;
|
const dt = (now - this._prevTs) / 1000;
|
||||||
if (dt > 0) {
|
if (dt > 0) {
|
||||||
const bps = (bytesReceived - this._prevBytes) / dt;
|
const bps = (bytesReceived - this._prevBytes) / dt;
|
||||||
downSpeed = bps >= 1048576 ? (bps / 1048576).toFixed(1) + ' MB/s'
|
downSpeed = formatSpeed(bps);
|
||||||
: bps >= 1024 ? (bps / 1024).toFixed(1) + ' KB/s' : bps.toFixed(0) + ' B/s';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this._prevBytes = bytesReceived;
|
this._prevBytes = bytesReceived;
|
||||||
this._prevTs = now;
|
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 = '-';
|
let duration = '-';
|
||||||
if (this._connectedAt) {
|
if (this._connectedAt) {
|
||||||
const secs = Math.floor((now - this._connectedAt) / 1000);
|
const secs = Math.floor((now - this._connectedAt) / 1000);
|
||||||
duration = String(Math.floor(secs / 60)).padStart(2, '0') + ':' + String(secs % 60).padStart(2, '0');
|
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() {
|
async close() {
|
||||||
@@ -283,3 +321,10 @@ export class WebRtcController {
|
|||||||
this._pendingCandidates = [];
|
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';
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user