综合实战:构建一个视频会议系统(信令+媒体+录制+统计)
终于到了这一章。说实话,每次我带团队做视频会议系统,都会想起自己第一次把信令、媒体、录制、统计全部串起来时的那个下午——画面卡成PPT,录制文件打不开,统计面板全是NaN。嗯,踩过的坑多了,自然就知道该怎么走了。
这一章,我们就把这些模块真正拼在一起。不是Demo级别的玩具,而是一个能跑、能录、能看统计的生产级原型。
整体架构:四个模块怎么协作?
先看一张图,把整个系统的脉络理清楚。我个人习惯在动手写代码之前,先把模块间的数据流画出来,不然写着写着就容易「串线」。
你看,信令负责「握手」,媒体负责「传数据」,录制负责「存下来」,统计负责「看状态」。四个模块各司其职,但数据是互相流动的。我在项目中遇到过最头疼的问题就是:录制模块拿不到媒体流的实时状态,结果录出来的文件全是静音。后来才意识到,统计模块必须给录制模块反馈一个「流健康度」信号。
第一步:信令模块——房间与连接管理
信令说白了就是「谁在哪个房间,谁要和谁连」。我用的是WebSocket + JSON协议,简单直接。
// 信令消息类型定义
const SIGNAL_TYPES = {
JOIN_ROOM: 'join_room',
LEAVE_ROOM: 'leave_room',
OFFER: 'offer',
ANSWER: 'answer',
ICE_CANDIDATE: 'ice_candidate',
ROOM_USERS: 'room_users'
};
// 客户端加入房间
function joinRoom(roomId, userId) {
ws.send(JSON.stringify({
type: SIGNAL_TYPES.JOIN_ROOM,
roomId: roomId,
userId: userId
}));
}
// 服务端转发Offer
function handleOffer(ws, data) {
// 查找目标用户的WebSocket连接
const targetWs = userConnections.get(data.targetUserId);
if (targetWs) {
targetWs.send(JSON.stringify({
type: SIGNAL_TYPES.OFFER,
from: data.fromUserId,
sdp: data.sdp
}));
}
}
第二步:媒体模块——SFU转发与动态码率
媒体层我选的是SFU架构。为什么不是MCU?说白了,MCU混流太吃CPU,而且灵活性差。SFU只管转发,每个客户端收多路流,自己渲染。
// 创建PeerConnection并添加轨道
async function createPeerConnection(remoteUserId, stream) {
const pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
});
// 添加本地音视频轨道
stream.getTracks().forEach(track => {
pc.addTrack(track, stream);
});
// 监听远程流
pc.ontrack = (event) => {
const remoteStream = event.streams[0];
document.getElementById(`remote-video-${remoteUserId}`).srcObject = remoteStream;
};
// ICE候选处理
pc.onicecandidate = (event) => {
if (event.candidate) {
sendSignal({
type: 'ice_candidate',
targetUserId: remoteUserId,
candidate: event.candidate
});
}
};
return pc;
}
这里有个坑:动态码率适配。你想想看,如果某个用户网络变差了,你还给他推1080p,那画面就会一直卡顿。我建议在媒体服务器端做Simulcast(联播),发多个分辨率的流,客户端根据带宽自动切换。
第三步:录制模块——MediaRecorder + 切片存储
录制这块,我直接用的浏览器原生MediaRecorder API。但有个问题:如果只录单路流,回放时看不到其他人的画面。所以我的做法是:在媒体服务器端做合流录制,把多路画面合成一路视频。
// 客户端录制单路流(备用方案)
function startRecording(stream) {
const options = {
mimeType: 'video/webm;codecs=vp9',
videoBitsPerSecond: 1000000 // 1Mbps
};
const recorder = new MediaRecorder(stream, options);
const chunks = [];
recorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunks.push(event.data);
}
};
recorder.onstop = () => {
const blob = new Blob(chunks, { type: 'video/webm' });
uploadToServer(blob, getFileName());
};
recorder.start(5000); // 每5秒切片一次
return recorder;
}
// 服务端合流录制(推荐方案)
// 使用FFmpeg接收多路RTP流,合成为单路MP4
// ffmpeg -i rtp://xxx:port1 -i rtp://xxx:port2 \
// -filter_complex "[0:v][1:v]hstack=inputs=2" \
// -c:v libx264 output.mp4
核心要点:
- 切片时长不要超过10秒,否则录制中断会导致大量数据丢失
- 录制文件命名用
roomId_timestamp_userId格式,方便检索 - 合流录制建议在媒体服务器端做,不要在客户端做——客户端合流会消耗大量CPU,影响通话质量
第四步:统计模块——getStats + 实时面板
统计模块是很多人容易忽略的。但说实话,没有统计面板,你根本不知道系统出了什么问题。我习惯每2秒拉一次getStats,把关键指标渲染到面板上。
// 定期拉取统计信息
function startStatsMonitor(pc, userId) {
setInterval(async () => {
const stats = await pc.getStats();
const report = {};
stats.forEach(report => {
// 视频接收端统计
if (report.type === 'inbound-rtp' && report.kind === 'video') {
report.videoPacketsLost = report.packetsLost;
report.videoJitter = report.jitter;
report.videoBitrate = calculateBitrate(report);
}
// 视频发送端统计
if (report.type === 'outbound-rtp' && report.kind === 'video') {
report.videoBitrateSent = report.bitrate;
report.videoFrameRate = report.framesPerSecond;
}
// 网络候选对统计
if (report.type === 'candidate-pair' && report.state === 'succeeded') {
report.rtt = report.currentRoundTripTime;
}
});
updateStatsPanel(userId, report);
}, 2000);
}
// 更新统计面板
function updateStatsPanel(userId, report) {
document.getElementById(`packet-loss-${userId}`).textContent =
(report.videoPacketsLost || 0) + ' 个';
document.getElementById(`rtt-${userId}`).textContent =
((report.rtt || 0) * 1000).toFixed(0) + ' ms';
document.getElementById(`bitrate-${userId}`).textContent =
((report.videoBitrate || 0) / 1000).toFixed(1) + ' Mbps';
}
| 指标 | 正常范围 | 告警阈值 | 处理建议 |
|---|---|---|---|
| 丢包率 | < 1% | > 5% | 降低视频码率或切换音频编码 |
| RTT | < 100ms | > 300ms | 检查网络链路,考虑切换ICE候选 |
| 视频帧率 | 25-30 fps | < 15 fps | 降低分辨率或关闭Simulcast高码率层 |
| 音频抖动 | < 30ms | > 80ms | 启用音频NetEQ缓冲 |
第五步:模块集成——启动与协调
最后一步,把四个模块串起来。启动顺序很重要:先启动信令服务器,再启动媒体服务器,然后客户端才能连接。录制模块和统计模块可以在客户端连接后动态启动。
// 主启动流程
async function startConference(roomId, userId) {
// 1. 连接信令服务器
const ws = connectSignaling('wss://signaling.example.com');
await waitForConnection(ws);
// 2. 获取本地媒体
const localStream = await navigator.mediaDevices.getUserMedia({
video: { width: 1280, height: 720 },
audio: true
});
// 3. 加入房间,触发PeerConnection创建
joinRoom(roomId, userId);
// 4. 监听新用户加入,创建对应PeerConnection
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'room_users') {
msg.users.forEach(remoteUser => {
if (remoteUser !== userId) {
createPeerConnection(remoteUser, localStream);
}
});
}
};
// 5. 启动录制(可选,由用户点击触发)
document.getElementById('btn-record').onclick = () => {
const recorder = startRecording(localStream);
// 同时录制远程流
remoteStreams.forEach(stream => {
startRecording(stream);
});
};
// 6. 启动统计监控
peerConnections.forEach((pc, remoteUserId) => {
startStatsMonitor(pc, remoteUserId);
});
}
嗯,到这里,一个完整的视频会议系统就搭起来了。你可能会问:这能支撑多少人同时在线?说实话,纯浏览器端SFU方案,单台服务器支撑50-100人没问题。如果超过这个量,就需要考虑集群部署和级联SFU了——那是另一个话题。
最后说一句:别追求一步到位。先把信令和媒体跑通,再加录制,最后上统计。每加一个模块,都要回归测试一下通话质量。我在项目中吃过这个亏——录制模块加完后,发现音频延迟从50ms飙到了200ms,查了半天是录制线程抢了编码器的CPU时间片。
好了,动手试试吧。有问题随时翻翻前面的章节,或者看看API文档。调试的时候多看看统计面板,它会告诉你一切。
公众号:蓝海资料掘金营,微信deep3321