From 0d8a567c95a6a9196c8f286fe172bd4446ff3fb8 Mon Sep 17 00:00:00 2001 From: stary <834207172@qq.com> Date: Sun, 24 May 2026 01:01:28 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8B=86=E5=88=86=E5=AA=92=E4=BD=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/public/media-config.js | 80 +++++++++++++++ client/public/store.js | 177 ++++------------------------------ client/public/webrtc-stats.js | 96 ++++++++++++++++++ 3 files changed, 193 insertions(+), 160 deletions(-) create mode 100644 client/public/media-config.js create mode 100644 client/public/webrtc-stats.js diff --git a/client/public/media-config.js b/client/public/media-config.js new file mode 100644 index 0000000..825e598 --- /dev/null +++ b/client/public/media-config.js @@ -0,0 +1,80 @@ +export const AUDIO_CONFIG = { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true +}; + +export const VAD_CONFIG = { + threshold: 15, + debounceTime: 500, + fftSize: 256 +}; + +export const MEDIA_CONSTRAINTS = { + video: { + width: { ideal: 1920, max: 1920 }, + height: { ideal: 1080, max: 1080 }, + frameRate: { ideal: 30, max: 30 } + }, + audio: AUDIO_CONFIG +}; + +export const VIDEO_ONLY_CONSTRAINT = { + video: { + width: { ideal: 1920, max: 1920 }, + height: { ideal: 1080, max: 1080 }, + frameRate: { ideal: 30, max: 30 } + }, + audio: false +}; + +const TARGET_RESOLUTION_BITRATE_MAP = { + 270: 1000000, + 480: 1500000, + 720: 2500000, + 1080: 4000000, + 1440: 6000000 +}; + +export function buildVideoConstraints(savedResolution) { + if (!savedResolution) { + return MEDIA_CONSTRAINTS.video; + } + + return { + width: { ideal: savedResolution.width, max: savedResolution.width }, + height: { ideal: savedResolution.height, max: savedResolution.height }, + frameRate: { ideal: 30, max: 30 } + }; +} + +export function getResolutionLabel(height) { + if (height >= 1440) { + return '2K 1440p'; + } + if (height >= 1080) { + return '1080p 超清'; + } + if (height >= 720) { + return '720p 高清'; + } + return '480p 流畅'; +} + +export function getTargetResolutionBitrate(height) { + return TARGET_RESOLUTION_BITRATE_MAP[height] || 2500000; +} + +export function getAdaptiveVideoBitrate(height) { + const supportedHeights = Object.keys(TARGET_RESOLUTION_BITRATE_MAP).map(Number).sort((a, b) => a - b); + let maxBitrate = TARGET_RESOLUTION_BITRATE_MAP[1080]; + + for (const supportedHeight of supportedHeights) { + if (height <= supportedHeight) { + return TARGET_RESOLUTION_BITRATE_MAP[supportedHeight]; + } + maxBitrate = TARGET_RESOLUTION_BITRATE_MAP[supportedHeight]; + } + + return maxBitrate; +} diff --git a/client/public/store.js b/client/public/store.js index 120387a..5b74918 100644 --- a/client/public/store.js +++ b/client/public/store.js @@ -16,36 +16,16 @@ import { removeParticipant, upsertParticipant } from './participants.js'; - -const AUDIO_CONFIG = { - echoCancellation: true, - noiseSuppression: true, - autoGainControl: true -}; - -const VAD_CONFIG = { - threshold: 15, - debounceTime: 500, - fftSize: 256 -}; - -const MEDIA_CONSTRAINTS = { - video: { - width: { ideal: 1920, max: 1920 }, - height: { ideal: 1080, max: 1080 }, - frameRate: { ideal: 30, max: 30 } - }, - audio: AUDIO_CONFIG -}; - -const VIDEO_ONLY_CONSTRAINT = { - video: { - width: { ideal: 1920, max: 1920 }, - height: { ideal: 1080, max: 1080 }, - frameRate: { ideal: 30, max: 30 } - }, - audio: false -}; +import { + AUDIO_CONFIG, + VAD_CONFIG, + VIDEO_ONLY_CONSTRAINT, + buildVideoConstraints, + getAdaptiveVideoBitrate, + getResolutionLabel, + getTargetResolutionBitrate +} from './media-config.js'; +import { getNetworkQualityFromSummary, summarizeInboundStats } from './webrtc-stats.js'; class CallStateManager { constructor() { @@ -138,13 +118,7 @@ class CallStateManager { // 请求摄像头权限并获取媒体流,启用回声消除 // 使用保存的分辨率(如有),否则使用默认约束 - const videoConstraints = this._savedResolution - ? { - width: { ideal: this._savedResolution.width, max: this._savedResolution.width }, - height: { ideal: this._savedResolution.height, max: this._savedResolution.height }, - frameRate: { ideal: 30, max: 30 } - } - : MEDIA_CONSTRAINTS.video; + const videoConstraints = buildVideoConstraints(this._savedResolution); const stream = await navigator.mediaDevices.getUserMedia({ video: videoConstraints, audio: AUDIO_CONFIG @@ -1064,23 +1038,7 @@ class CallStateManager { const settings = videoTrack ? videoTrack.getSettings() : {}; const height = settings.height || 1080; - const bitrateMap = { - 270: 1000000, - 480: 1500000, - 720: 2500000, - 1080: 4000000, - 1440: 6000000 - }; - // 找到最接近的分辨率对应的比特率 - let maxBitrate = 4000000; - const heights = Object.keys(bitrateMap).map(Number).sort((a, b) => a - b); - for (const h of heights) { - if (height <= h) { - maxBitrate = bitrateMap[h]; - break; - } - maxBitrate = bitrateMap[h]; - } + const maxBitrate = getAdaptiveVideoBitrate(height); params.encodings[0].maxBitrate = maxBitrate; params.encodings[0].scaleResolutionDownBy = 1.0; @@ -1121,7 +1079,7 @@ class CallStateManager { } const track = videoTracks[0]; - const label = height >= 1440 ? '2K 1440p' : height >= 1080 ? '1080p 超清' : height >= 720 ? '720p 高清' : '480p 流畅'; + const label = getResolutionLabel(height); try { // 使用 applyConstraints 在不重新获取流的情况下调整分辨率 @@ -1135,13 +1093,7 @@ class CallStateManager { // 根据分辨率调整编码比特率 // 480p: ~1Mbps, 720p: ~2.5Mbps, 1080p: ~4Mbps, 2K: ~6Mbps - const bitrateMap = { - 270: 1000000, // 480p - 720: 2500000, // 720p - 1080: 4000000, // 1080p - 1440: 6000000 // 2K - }; - const maxBitrate = bitrateMap[height] || 2500000; + const maxBitrate = getTargetResolutionBitrate(height); this._applyMaxBitrate(maxBitrate); // 保存当前分辨率设置到本地存储 @@ -1258,62 +1210,8 @@ class CallStateManager { return; } - let totalPacketsLost = 0; - let totalPacketsReceived = 0; - let inboundRTPCount = 0; - let jitter = 0; - let roundTripTime = 0; - - // 分析统计信息 - stats.forEach(report => { - if (report.type === 'inbound-rtp' && report.mediaType === 'video') { - inboundRTPCount++; - - // 计算丢包率 - if (report.packetsLost !== undefined && report.packetsReceived !== undefined) { - totalPacketsLost += report.packetsLost; - totalPacketsReceived += report.packetsReceived; - } - - // 获取抖动 - if (report.jitter !== undefined) { - jitter = Math.max(jitter, report.jitter); - } - - // 获取往返时间 - if (report.roundTripTime !== undefined) { - roundTripTime = Math.max(roundTripTime, report.roundTripTime); - } - } - }); - - // 计算网络质量指标 - let quality = 'excellent'; - - if (inboundRTPCount > 0) { - // 基于丢包率判断 - const packetLossRate = totalPacketsReceived > 0 ? (totalPacketsLost / (totalPacketsLost + totalPacketsReceived)) : 0; - - // 基于抖动判断 - const jitterMs = jitter * 1000; - - // 基于往返时间判断 - const rttMs = roundTripTime * 1000; - - // 综合评估网络质量 - if (packetLossRate > 0.05 || jitterMs > 100 || rttMs > 300) { - quality = 'poor'; - } else if (packetLossRate > 0.02 || jitterMs > 50 || rttMs > 150) { - quality = 'fair'; - } else if (packetLossRate > 0.01 || jitterMs > 30 || rttMs > 100) { - quality = 'good'; - } else { - quality = 'excellent'; - } - } else { - // 没有收到任何RTP包,设置为无信号状态 - quality = 'no_signal'; - } + const summary = summarizeInboundStats(stats); + const quality = getNetworkQualityFromSummary(summary); // 更新网络质量状态 if (this.state.session.remoteUser.networkQuality !== quality) { @@ -1455,48 +1353,7 @@ class CallStateManager { return; } - let statsSummary = { - video: { - packetsLost: 0, - packetsReceived: 0, - bytesReceived: 0, - jitter: 0, - roundTripTime: 0, - fps: 0, - bitrate: 0 - }, - audio: { - packetsLost: 0, - packetsReceived: 0, - bytesReceived: 0, - jitter: 0 - } - }; - - // 分析统计信息 - stats.forEach(report => { - if (report.type === 'inbound-rtp') { - if (report.mediaType === 'video') { - statsSummary.video.packetsLost = report.packetsLost || 0; - statsSummary.video.packetsReceived = report.packetsReceived || 0; - statsSummary.video.bytesReceived = report.bytesReceived || 0; - statsSummary.video.jitter = report.jitter || 0; - statsSummary.video.roundTripTime = report.roundTripTime || 0; - statsSummary.video.fps = report.framesPerSecond || 0; - - // 计算视频比特率 (kbps) - if (report.bytesReceived && report.timestamp) { - const duration = report.timestamp / 1000; // 转换为秒 - statsSummary.video.bitrate = duration > 0 ? Math.round((report.bytesReceived * 8) / (duration * 1000)) : 0; - } - } else if (report.mediaType === 'audio') { - statsSummary.audio.packetsLost = report.packetsLost || 0; - statsSummary.audio.packetsReceived = report.packetsReceived || 0; - statsSummary.audio.bytesReceived = report.bytesReceived || 0; - statsSummary.audio.jitter = report.jitter || 0; - } - } - }); + const statsSummary = summarizeInboundStats(stats); // 输出详细统计信息 console.log('=== WebRTC Statistics ==='); diff --git a/client/public/webrtc-stats.js b/client/public/webrtc-stats.js new file mode 100644 index 0000000..4852808 --- /dev/null +++ b/client/public/webrtc-stats.js @@ -0,0 +1,96 @@ +function createStatsSummary() { + return { + video: { + packetsLost: 0, + packetsReceived: 0, + bytesReceived: 0, + jitter: 0, + roundTripTime: 0, + fps: 0, + bitrate: 0 + }, + audio: { + packetsLost: 0, + packetsReceived: 0, + bytesReceived: 0, + jitter: 0 + }, + network: { + totalPacketsLost: 0, + totalPacketsReceived: 0, + inboundRtpCount: 0, + jitter: 0, + roundTripTime: 0 + } + }; +} + +export function summarizeInboundStats(stats) { + const summary = createStatsSummary(); + + stats.forEach((report) => { + if (report.type === 'inbound-rtp' && report.mediaType === 'video') { + summary.network.inboundRtpCount++; + + if (report.packetsLost !== undefined && report.packetsReceived !== undefined) { + summary.network.totalPacketsLost += report.packetsLost; + summary.network.totalPacketsReceived += report.packetsReceived; + } + + if (report.jitter !== undefined) { + summary.network.jitter = Math.max(summary.network.jitter, report.jitter); + } + + if (report.roundTripTime !== undefined) { + summary.network.roundTripTime = Math.max(summary.network.roundTripTime, report.roundTripTime); + } + + summary.video.packetsLost = report.packetsLost || 0; + summary.video.packetsReceived = report.packetsReceived || 0; + summary.video.bytesReceived = report.bytesReceived || 0; + summary.video.jitter = report.jitter || 0; + summary.video.roundTripTime = report.roundTripTime || 0; + summary.video.fps = report.framesPerSecond || 0; + + if (report.bytesReceived && report.timestamp) { + const duration = report.timestamp / 1000; + summary.video.bitrate = duration > 0 + ? Math.round((report.bytesReceived * 8) / (duration * 1000)) + : 0; + } + } else if (report.type === 'inbound-rtp' && report.mediaType === 'audio') { + summary.audio.packetsLost = report.packetsLost || 0; + summary.audio.packetsReceived = report.packetsReceived || 0; + summary.audio.bytesReceived = report.bytesReceived || 0; + summary.audio.jitter = report.jitter || 0; + } + }); + + return summary; +} + +export function getNetworkQualityFromSummary(summary) { + const { totalPacketsLost, totalPacketsReceived, inboundRtpCount, jitter, roundTripTime } = summary.network; + + if (inboundRtpCount === 0) { + return 'no_signal'; + } + + const packetLossRate = totalPacketsReceived > 0 + ? totalPacketsLost / (totalPacketsLost + totalPacketsReceived) + : 0; + const jitterMs = jitter * 1000; + const rttMs = roundTripTime * 1000; + + if (packetLossRate > 0.05 || jitterMs > 100 || rttMs > 300) { + return 'poor'; + } + if (packetLossRate > 0.02 || jitterMs > 50 || rttMs > 150) { + return 'fair'; + } + if (packetLossRate > 0.01 || jitterMs > 30 || rttMs > 100) { + return 'good'; + } + + return 'excellent'; +}