拆分媒体
This commit is contained in:
80
client/public/media-config.js
Normal file
80
client/public/media-config.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -16,36 +16,16 @@ import {
|
|||||||
removeParticipant,
|
removeParticipant,
|
||||||
upsertParticipant
|
upsertParticipant
|
||||||
} from './participants.js';
|
} from './participants.js';
|
||||||
|
import {
|
||||||
const AUDIO_CONFIG = {
|
AUDIO_CONFIG,
|
||||||
echoCancellation: true,
|
VAD_CONFIG,
|
||||||
noiseSuppression: true,
|
VIDEO_ONLY_CONSTRAINT,
|
||||||
autoGainControl: true
|
buildVideoConstraints,
|
||||||
};
|
getAdaptiveVideoBitrate,
|
||||||
|
getResolutionLabel,
|
||||||
const VAD_CONFIG = {
|
getTargetResolutionBitrate
|
||||||
threshold: 15,
|
} from './media-config.js';
|
||||||
debounceTime: 500,
|
import { getNetworkQualityFromSummary, summarizeInboundStats } from './webrtc-stats.js';
|
||||||
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
|
|
||||||
};
|
|
||||||
|
|
||||||
class CallStateManager {
|
class CallStateManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -138,13 +118,7 @@ class CallStateManager {
|
|||||||
|
|
||||||
// 请求摄像头权限并获取媒体流,启用回声消除
|
// 请求摄像头权限并获取媒体流,启用回声消除
|
||||||
// 使用保存的分辨率(如有),否则使用默认约束
|
// 使用保存的分辨率(如有),否则使用默认约束
|
||||||
const videoConstraints = this._savedResolution
|
const videoConstraints = buildVideoConstraints(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 stream = await navigator.mediaDevices.getUserMedia({
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
video: videoConstraints,
|
video: videoConstraints,
|
||||||
audio: AUDIO_CONFIG
|
audio: AUDIO_CONFIG
|
||||||
@@ -1064,23 +1038,7 @@ class CallStateManager {
|
|||||||
const settings = videoTrack ? videoTrack.getSettings() : {};
|
const settings = videoTrack ? videoTrack.getSettings() : {};
|
||||||
const height = settings.height || 1080;
|
const height = settings.height || 1080;
|
||||||
|
|
||||||
const bitrateMap = {
|
const maxBitrate = getAdaptiveVideoBitrate(height);
|
||||||
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];
|
|
||||||
}
|
|
||||||
|
|
||||||
params.encodings[0].maxBitrate = maxBitrate;
|
params.encodings[0].maxBitrate = maxBitrate;
|
||||||
params.encodings[0].scaleResolutionDownBy = 1.0;
|
params.encodings[0].scaleResolutionDownBy = 1.0;
|
||||||
@@ -1121,7 +1079,7 @@ class CallStateManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const track = videoTracks[0];
|
const track = videoTracks[0];
|
||||||
const label = height >= 1440 ? '2K 1440p' : height >= 1080 ? '1080p 超清' : height >= 720 ? '720p 高清' : '480p 流畅';
|
const label = getResolutionLabel(height);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 使用 applyConstraints 在不重新获取流的情况下调整分辨率
|
// 使用 applyConstraints 在不重新获取流的情况下调整分辨率
|
||||||
@@ -1135,13 +1093,7 @@ class CallStateManager {
|
|||||||
|
|
||||||
// 根据分辨率调整编码比特率
|
// 根据分辨率调整编码比特率
|
||||||
// 480p: ~1Mbps, 720p: ~2.5Mbps, 1080p: ~4Mbps, 2K: ~6Mbps
|
// 480p: ~1Mbps, 720p: ~2.5Mbps, 1080p: ~4Mbps, 2K: ~6Mbps
|
||||||
const bitrateMap = {
|
const maxBitrate = getTargetResolutionBitrate(height);
|
||||||
270: 1000000, // 480p
|
|
||||||
720: 2500000, // 720p
|
|
||||||
1080: 4000000, // 1080p
|
|
||||||
1440: 6000000 // 2K
|
|
||||||
};
|
|
||||||
const maxBitrate = bitrateMap[height] || 2500000;
|
|
||||||
this._applyMaxBitrate(maxBitrate);
|
this._applyMaxBitrate(maxBitrate);
|
||||||
|
|
||||||
// 保存当前分辨率设置到本地存储
|
// 保存当前分辨率设置到本地存储
|
||||||
@@ -1258,62 +1210,8 @@ class CallStateManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let totalPacketsLost = 0;
|
const summary = summarizeInboundStats(stats);
|
||||||
let totalPacketsReceived = 0;
|
const quality = getNetworkQualityFromSummary(summary);
|
||||||
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';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新网络质量状态
|
// 更新网络质量状态
|
||||||
if (this.state.session.remoteUser.networkQuality !== quality) {
|
if (this.state.session.remoteUser.networkQuality !== quality) {
|
||||||
@@ -1455,48 +1353,7 @@ class CallStateManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let statsSummary = {
|
const statsSummary = summarizeInboundStats(stats);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 输出详细统计信息
|
// 输出详细统计信息
|
||||||
console.log('=== WebRTC Statistics ===');
|
console.log('=== WebRTC Statistics ===');
|
||||||
|
|||||||
96
client/public/webrtc-stats.js
Normal file
96
client/public/webrtc-stats.js
Normal file
@@ -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';
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user