From 23698d0be7acc36358cd178e151e8eebd0728412 Mon Sep 17 00:00:00 2001 From: tongtongstudio Date: Fri, 24 Jul 2026 18:08:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=89=A9=E5=B1=95=20WebRTC=20=E7=BB=9F?= =?UTF-8?q?=E8=AE=A1=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 StatsBar 中新增延迟、抖动、丢帧、解码耗时、上传速率和丢包率等指标展示。 重构速率计算逻辑,并从 inbound-rtp 和 candidate-pair 报告中提取相关数据。 --- .../src/components/StatsBar.vue | 8 ++- .../src/services/WebRtcController.js | 53 +++++++++++++++++-- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/WebRTCControllerWeb/src/components/StatsBar.vue b/WebRTCControllerWeb/src/components/StatsBar.vue index d996132..c6f974f 100644 --- a/WebRTCControllerWeb/src/components/StatsBar.vue +++ b/WebRTCControllerWeb/src/components/StatsBar.vue @@ -6,8 +6,14 @@ import { store } from '../store/controllerStore';
分辨率: {{ store.stats ? store.stats.width + '×' + store.stats.height : '-' }} 帧率: {{ store.stats ? store.stats.fps : '-' }} - 解码: {{ store.stats ? store.stats.codec : '-' }} + 编码: {{ store.stats ? store.stats.codec : '-' }} + 延迟: {{ store.stats ? store.stats.latency : '-' }} + 抖动: {{ store.stats ? store.stats.jitter : '-' }} + 丢帧: {{ store.stats ? store.stats.framesDropped : '-' }} + 解码耗时: {{ store.stats ? store.stats.decodeMs : '-' }} + ↑上传: {{ store.stats ? store.stats.upSpeed : '-' }} ↓下载: {{ store.stats ? store.stats.downSpeed : '-' }} + 丢包: {{ store.stats ? store.stats.packetLoss : '-' }} 时长: {{ store.stats ? store.stats.duration : '-' }} 控制通道: {{ store.dataChannelOpen ? '已连接' : '未连接' }} 目标: {{ store.targetDeviceId || '-' }} diff --git a/WebRTCControllerWeb/src/services/WebRtcController.js b/WebRTCControllerWeb/src/services/WebRtcController.js index 565f70b..7dbc5fe 100644 --- a/WebRTCControllerWeb/src/services/WebRtcController.js +++ b/WebRTCControllerWeb/src/services/WebRtcController.js @@ -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'; +}