1. RTC 到底是个什么东西,为什么突然又火了
RTC,全称 Real-Time Communication,中文一般叫实时通信或实时音视频。如果你做过音视频通话、在线课堂、视频会议、连麦直播、远程协作白板这类产品,那你一定绕不开它。但如果你只是听说过这个词,可能会觉得它离自己很远——其实你每天用的微信语音、在线会议、甚至某些智能门铃的实时对讲,底层跑的都是 RTC 这套东西。
我最早接触 RTC 是在一个远程协作项目里,当时的需求很简单:两个用户之间要能实时语音通话,同时共享一块白板。听起来不难对吧?但真正动手之后才发现,从采集、编码、传输到渲染,每一个环节都有坑。延迟稍微高一点,用户就会觉得“卡”;网络稍微抖一下,声音就断了;回声消除没做好,两个人同时说话就炸了。这些问题逼着我把 RTC 的整个链路从头到尾啃了一遍。
这篇文章不是教科书式的概念罗列,而是把我这些年做 RTC 相关项目踩过的坑、总结的经验、以及那些文档里不会写的细节,一次性讲清楚。不管你是刚接触音视频开发的新手,还是已经做过几个项目但总觉得“哪里不太对”的老手,应该都能从里面找到对自己有用的东西。我会从整体架构讲到核心模块,从参数调优讲到问题排查,尽量做到“看完就能上手,上手就能跑通”。
2. RTC 整体架构与核心链路拆解
2.1 一条音频数据从麦克风到扬声器经历了什么
很多人做 RTC 的时候,习惯直接调 API,能通就行。但一旦出了问题,比如延迟高、杂音大、断断续续,就完全不知道从哪里下手。原因很简单:你不清楚数据在链路里到底经过了哪些处理。所以我先把这条链路完整走一遍。
假设现在有两个用户 A 和 B 在通话。A 对着麦克风说话,声音首先被采集。采集这一步看似简单,其实涉及采样率、位深、声道数三个关键参数。采样率常见的是 8kHz、16kHz、44.1kHz、48kHz,RTC 场景下一般用 16kHz 或 48kHz。16kHz 足够覆盖人声的主要频段(300Hz 到 3.4kHz),而且数据量小,适合网络传输。48kHz 则是为了更好的音质,但带宽消耗会明显增加。
采集完之后是前处理,这一步是 RTC 里最容易被忽视但最影响体验的环节。前处理包括回声消除(AEC)、噪声抑制(ANS)、自动增益控制(AGC)三个核心模块。回声消除解决的是“你说话的时候能从扬声器里听到自己的声音”这个问题;噪声抑制负责把键盘声、风扇声、空调声这些稳态噪声压下去;自动增益控制则是让音量保持在一个稳定的水平,不会因为用户离麦克风忽远忽近而忽大忽小。
前处理之后是编码。编码的目的是压缩数据,减少带宽占用。常见的音频编码格式有 Opus、AAC、G.711、G.729 等。RTC 场景下 Opus 是绝对的主流,因为它在低延迟和音质之间做到了很好的平衡,而且支持动态码率调整。编码之后数据被打包成 RTP 包,通过 UDP 传输到对端。这里为什么用 UDP 而不是 TCP?因为 RTC 对延迟极度敏感,TCP 的重传机制会导致延迟累积,而 UDP 允许丢包,丢包之后通过 FEC(前向纠错)或 PLC(丢包隐藏)来补偿,整体体验反而更好。
到达对端之后,数据先经过 jitter buffer(抖动缓冲),用来平滑网络抖动带来的包到达时间不均匀。jitter buffer 的大小很关键:太小了,网络一抖就卡顿;太大了,延迟就上去了。一般会根据网络状况动态调整,初始值设在 50ms 到 200ms 之间。然后解码、后处理(比如去混响)、渲染到扬声器。整条链路走下来,理想情况下端到端延迟可以控制在 150ms 以内,超过 400ms 用户就会明显感觉到“对话不同步”。
2.2 为什么 RTC 和普通直播是两套完全不同的技术栈
很多人会把 RTC 和直播搞混,觉得都是音视频传输,应该差不多。但实际上,两者的技术选型和优化目标完全不同。直播的核心诉求是“覆盖广、成本低、画质高”,对延迟的容忍度比较高,一般 3 到 10 秒都能接受。所以直播通常用 RTMP 推流、HLS 或 FLV 拉流,底层基于 TCP,牺牲延迟换稳定性和画质。
RTC 的核心诉求是“低延迟、强交互”,延迟必须控制在几百毫秒以内,否则对话就没法进行。所以 RTC 用 UDP、用 Opus 这种低延迟编码、用 jitter buffer 和 FEC 来对抗网络问题。两者的架构差异可以用一个简单的表格来对比:
| 维度 | RTC | 普通直播 |
|---|---|---|
| 传输协议 | UDP 为主 | TCP 为主 |
| 典型延迟 | 100ms 到 400ms | 3s 到 10s |
| 编码格式 | Opus、VP8、H.264 | AAC、H.264、H.265 |
| 抗丢包策略 | FEC、PLC、重传 | TCP 重传 |
| 适用场景 | 通话、会议、连麦 | 秀场、赛事、监控 |
这个差异决定了你在做 RTC 的时候,不能照搬直播那套优化思路。比如直播里常用的“加大缓冲区来抗抖动”,在 RTC 里就是自杀行为,因为缓冲区越大延迟越高。RTC 的思路是“用计算换延迟”,通过更复杂的抗丢包算法和更激进的码率调整来保证实时性。
2.3 主流 RTC 方案选型:自研、开源还是云服务
做 RTC 项目,第一个要做的决策就是:自己从头搭,还是用开源方案,还是直接买云服务。这三条路我都走过,各有各的适用场景。
自研适合什么情况?如果你的产品对 RTC 有非常特殊的定制需求,比如要在极端网络环境下工作、要支持特殊的编码格式、要和自己的业务系统深度耦合,那自研可能是唯一的选择。但自研的成本极高,一个完整的 RTC 团队至少需要音频算法、视频算法、网络传输、客户端开发四个方向的人,而且没有一两年根本打磨不出来。
开源方案是很多中小团队的首选。常见的开源 RTC 方案有 WebRTC、Janus、Mediasoup、Jitsi 等。WebRTC 是浏览器原生支持的,最大的优势是端侧不用装任何插件,而且 Google 在背后持续维护,音频处理模块(AEC、ANS、AGC)的质量非常高。但 WebRTC 的服务端部分比较弱,需要自己搭配 SFU(选择性转发单元)来支持多人通话。Mediasoup 和 Janus 就是专门做 SFU 的开源实现,性能不错,但部署和调优有一定门槛。
云服务则是“花钱买省心”的路子。国内几家主流云厂商都提供 RTC 服务,按分钟计费,支持多人通话、录制、混流等功能。优点是接入快、稳定性有保障、全球节点覆盖;缺点是成本随规模线性增长,而且定制能力有限。我的建议是:如果你的团队没有音视频领域的积累,产品又需要快速上线,先用云服务跑通业务逻辑,等规模上来之后再考虑逐步自研或切换到开源方案。
3. 核心模块深入:音频处理、视频编码与网络传输
3.1 音频前处理三大件:AEC、ANS、AGC 到底怎么调
音频前处理是 RTC 里最“玄学”的部分,因为它的效果很难用客观指标衡量,更多是靠主观听感。但恰恰是这部分,决定了用户会不会觉得“这个通话质量不行”。
先说 AEC(回声消除)。它的原理是:扬声器播放的声音会被麦克风重新采集,形成回声。AEC 通过自适应滤波器来估计这条回声路径,然后从麦克风信号里把回声减掉。听起来简单,但实际调的时候有几个关键点。第一,延迟对齐必须准确。如果扬声器播放和麦克风采集之间的延迟估计错了,滤波器就完全失效。第二,双讲检测要灵敏。所谓双讲,就是双方同时说话。这时候如果 AEC 还在强行消除,就会把人声也消掉,导致“说着说着声音没了”。第三,非线性残留要处理好。扬声器本身有失真,滤波器只能处理线性部分,非线性残留需要额外的后处理来压制。
再说 ANS(噪声抑制)。它的目标是把稳态噪声(风扇、空调、键盘)压下去,同时尽量保留人声。传统方法用谱减法,简单但容易产生“音乐噪声”——就是那种听起来像水声的残留噪声。现在主流方案用深度学习模型来做,效果明显更好,但计算量也更大。调 ANS 的时候,关键参数是抑制强度和语音保真度的平衡。抑制太弱,噪声还在;抑制太强,人声会发闷、失真。我的经验是:先把抑制强度设在中档,然后根据实际场景微调。如果是安静环境,可以调低一点;如果是嘈杂环境,可以调高,但要接受一定的人声损失。
最后说 AGC(自动增益控制)。它的作用是让输出音量保持稳定,不会因为用户离麦克风远近而变化。AGC 有两个关键参数:目标电平和增益范围。目标电平一般设在 -18dBFS 到 -12dBFS 之间,太低会导致音量不够,太高容易削波。增益范围决定了最大能放大或缩小多少,一般设在 -20dB 到 +30dB 之间。调 AGC 的时候要注意:不要让它对瞬态声音(比如咳嗽、拍手)反应太快,否则会出现“音量忽大忽小”的抽吸感。
实操心得:音频前处理的调试一定要在真实设备上做,模拟器和虚拟声卡的结果和真机差别很大。而且不同手机、不同耳机的麦克风特性完全不同,最好准备几台主流设备做交叉测试。
3.2 视频编码选型:H.264、H.265 还是 VP8/VP9
视频编码是 RTC 里带宽消耗最大的部分,选对编码格式能省下大量带宽。常见的选项有 H.264、H.265、VP8、VP9、AV1。它们各有优劣,选择的时候要综合考虑兼容性、压缩效率、计算复杂度和专利成本。
H.264 是目前兼容性最好的编码格式,几乎所有浏览器、手机、硬件都支持硬编硬解。它的压缩效率一般,但在低延迟场景下表现稳定,而且硬件支持成熟,CPU 占用低。如果你的产品需要覆盖尽可能多的设备,H.264 是默认选择。
H.265 的压缩效率比 H.264 高 30% 到 50%,在同等画质下能省不少带宽。但它的专利问题比较复杂,而且硬件支持不如 H.264 广泛,低端设备上可能只能软编,CPU 占用会很高。所以 H.265 更适合对带宽敏感、且目标设备比较新的场景。
VP8 和 VP9 是 Google 主导的开放编码格式,没有专利费用。VP8 的压缩效率和 H.264 差不多,VP9 则接近 H.265。WebRTC 默认支持 VP8 和 VP9,所以在浏览器场景下用起来很方便。但 VP9 的编码复杂度较高,移动端软编可能吃不消。
AV1 是最新的开放编码格式,压缩效率比 H.265 还高 20% 左右,而且没有专利问题。但它的编码复杂度极高,目前主要靠硬件加速,软件编码在实时场景下基本不可行。所以 AV1 在 RTC 里还处于早期阶段,适合对带宽极度敏感且设备支持硬编的场景。
| 编码格式 | 压缩效率 | 兼容性 | 计算复杂度 | 专利成本 |
|---|---|---|---|---|
| H.264 | 基准 | 极好 | 低 | 有 |
| H.265 | 高 30-50% | 一般 | 中高 | 有 |
| VP8 | 基准 | 好 | 低 | 无 |
| VP9 | 高 30-50% | 较好 | 高 | 无 |
| AV1 | 高 50%+ | 较差 | 极高 | 无 |
实际选型的时候,我的建议是:优先用 H.264,因为兼容性和硬件支持最好;如果带宽紧张且设备支持,可以协商切换到 H.265 或 VP9;AV1 暂时观望,等硬件普及再说。
3.3 网络传输的抗丢包策略:FEC、PLC 和重传怎么配合
RTC 用 UDP 传输,丢包是常态。抗丢包策略直接决定了弱网下的体验。常见的策略有三种:FEC(前向纠错)、PLC(丢包隐藏)和重传。
FEC 的原理是发送冗余数据,让接收端在丢包时能恢复出原始数据。比如每发 10 个包,额外发 2 个冗余包,这样丢 1 到 2 个包也能恢复。FEC 的优点是延迟低,不需要等待重传;缺点是会增加带宽消耗,而且丢包率太高时也无能为力。FEC 的强度需要根据网络状况动态调整:网络好的时候少发冗余,节省带宽;网络差的时候多发冗余,保证恢复能力。
PLC 是在丢包已经发生、且无法恢复时,通过算法“猜”出丢失的数据。对于音频,PLC 可以根据前后帧的频谱特征来生成一个近似的声音,虽然不完美,但比直接静音或爆音好得多。对于视频,PLC 一般是重复上一帧或者做简单的运动补偿,效果有限,但能避免画面完全卡住。
重传是最后的兜底手段。接收端发现丢包后,向发送端请求重传。重传的优点是能精确恢复数据,缺点是会增加延迟。所以重传一般只用于关键帧或者对延迟不敏感的数据。在 RTC 里,重传通常和 FEC 配合使用:FEC 先扛住大部分丢包,重传只处理 FEC 恢复不了的情况。
注意事项:FEC 的冗余比例不是越高越好。我见过有人把冗余设到 50%,结果带宽翻倍,网络反而更拥塞了。一般建议冗余比例控制在 10% 到 30% 之间,根据实测丢包率动态调整。
4. 完整实操:从零搭建一个可用的 RTC 通话 Demo
4.1 环境准备与依赖安装
说了这么多原理,接下来动手搭一个能跑的通话 Demo。我选 WebRTC 作为基础,因为它端侧支持最好,浏览器直接就能用,不需要装任何东西。服务端用一个简单的信令服务器加一个 SFU,信令服务器负责交换 SDP 和 ICE candidate,SFU 负责转发媒体流。
先准备环境。你需要一台有公网 IP 的服务器,操作系统用 Ubuntu 20.04 或 22.04 都行。然后安装 Node.js,因为信令服务器和 SFU 的管理接口都用 Node.js 写起来比较方便。安装命令如下:
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash - sudo apt-get install -y nodejs node -v npm -v接下来安装 mediasoup,这是一个高性能的 SFU 实现。它分为两部分:服务端的 mediasoup 库和客户端的 mediasoup-client 库。服务端安装:
mkdir rtc-demo && cd rtc-demo npm init -y npm install mediasoup客户端部分我们直接用浏览器原生的 WebRTC API,不需要额外安装。但为了简化信令交互,我会用 socket.io 来做信令通道:
npm install socket.io express环境准备好之后,目录结构大概是这样:
rtc-demo/ ├── server/ │ ├── index.js # 信令服务器和 SFU 管理 │ └── config.js # 配置参数 ├── client/ │ ├── index.html # 客户端页面 │ └── client.js # 客户端逻辑 └── package.json4.2 信令服务器与 SFU 的搭建过程
信令服务器的核心作用是帮两个客户端交换连接信息。WebRTC 本身不规定信令怎么传,你可以用 WebSocket、HTTP 甚至短信,只要双方能交换 SDP 和 ICE candidate 就行。这里用 socket.io 来实现。
先写服务端入口server/index.js。第一步是创建 mediasoup 的 worker。worker 是 mediasoup 的处理单元,每个 worker 是一个独立的子进程,可以充分利用多核 CPU。一般根据 CPU 核心数来创建 worker 数量:
const mediasoup = require('mediasoup'); const config = require('./config'); let workers = []; let nextWorkerIdx = 0; async function createWorkers() { const numWorkers = Object.keys(require('os').cpus()).length; for (let i = 0; i < numWorkers; i++) { const worker = await mediasoup.createWorker({ rtcMinPort: 40000, rtcMaxPort: 40100, logLevel: 'warn', }); worker.on('died', () => { console.error('worker died, exiting'); process.exit(1); }); workers.push(worker); } } function getWorker() { const worker = workers[nextWorkerIdx]; nextWorkerIdx = (nextWorkerIdx + 1) % workers.length; return worker; }然后是创建 router。router 相当于一个房间,每个房间有自己的音视频转发规则。创建 router 的时候需要指定支持的媒体编码:
async function createRouter(worker) { return await worker.createRouter({ mediaCodecs: [ { kind: 'audio', mimeType: 'audio/opus', clockRate: 48000, channels: 2, }, { kind: 'video', mimeType: 'video/VP8', clockRate: 90000, parameters: { 'x-google-start-bitrate': 1000, }, }, { kind: 'video', mimeType: 'video/H264', clockRate: 90000, parameters: { 'packetization-mode': 1, 'profile-level-id': '42e01f', 'level-asymmetry-allowed': 1, }, }, ], }); }接下来是处理客户端的连接。每个客户端连接后,需要创建 transport。transport 分为 send transport 和 recv transport,分别负责发送和接收媒体。创建 transport 的时候需要指定监听 IP,如果是公网服务器,要填公网 IP:
async function createWebRtcTransport(router) { const transport = await router.createWebRtcTransport({ listenIps: [ { ip: '0.0.0.0', announcedIp: config.publicIp, }, ], enableUdp: true, enableTcp: true, preferUdp: true, initialAvailableOutgoingBitrate: 1000000, }); transport.on('dtlsstatechange', (dtlsState) => { if (dtlsState === 'closed') { transport.close(); } }); return transport; }信令部分用 socket.io 来处理。客户端连接后,先加入房间,然后创建 transport,交换 SDP,最后开始推流或拉流。核心逻辑如下:
const express = require('express'); const http = require('http'); const socketIo = require('socket.io'); const app = express(); const server = http.createServer(app); const io = socketIo(server); const rooms = new Map(); io.on('connection', (socket) => { socket.on('joinRoom', async ({ roomId }, callback) => { let room = rooms.get(roomId); if (!room) { const worker = getWorker(); const router = await createRouter(worker); room = { router, peers: new Map() }; rooms.set(roomId, room); } room.peers.set(socket.id, { socket }); callback({ routerRtpCapabilities: room.router.rtpCapabilities }); }); socket.on('createTransport', async ({ roomId, direction }, callback) => { const room = rooms.get(roomId); const transport = await createWebRtcTransport(room.router); const peer = room.peers.get(socket.id); peer[direction + 'Transport'] = transport; callback({ id: transport.id, iceParameters: transport.iceParameters, iceCandidates: transport.iceCandidates, dtlsParameters: transport.dtlsParameters, }); }); socket.on('connectTransport', async ({ roomId, direction, dtlsParameters }, callback) => { const room = rooms.get(roomId); const peer = room.peers.get(socket.id); const transport = peer[direction + 'Transport']; await transport.connect({ dtlsParameters }); callback(); }); socket.on('produce', async ({ roomId, kind, rtpParameters }, callback) => { const room = rooms.get(roomId); const peer = room.peers.get(socket.id); const transport = peer.sendTransport; const producer = await transport.produce({ kind, rtpParameters }); peer.producers = peer.producers || []; peer.producers.push(producer); callback({ id: producer.id }); }); socket.on('disconnect', () => { for (const [roomId, room] of rooms) { if (room.peers.has(socket.id)) { room.peers.delete(socket.id); if (room.peers.size === 0) { room.router.close(); rooms.delete(roomId); } break; } } }); }); server.listen(3000, () => { console.log('signaling server running on port 3000'); });4.3 客户端采集、编码与推拉流实现
客户端部分用浏览器原生 API。先写 HTML 页面,包含两个 video 元素,一个显示本地流,一个显示远端流:
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>RTC Demo</title> </head> <body> <video id="localVideo" autoplay muted playsinline></video> <video id="remoteVideo" autoplay playsinline></video> <button id="joinBtn">加入房间</button> <script src="/socket.io/socket.io.js"></script> <script src="client.js"></script> </body> </html>客户端逻辑的核心是:获取本地媒体流、创建 transport、推流、拉流。先获取本地流:
const socket = io('http://localhost:3000'); let device; let sendTransport; let recvTransport; let producers = []; let consumers = []; async function getLocalStream() { return await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, }, video: { width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 30 }, }, }); }然后加载 mediasoup-client 的 device,创建 transport:
async function joinRoom() { const stream = await getLocalStream(); document.getElementById('localVideo').srcObject = stream; socket.emit('joinRoom', { roomId: 'test-room' }, async (data) => { device = new mediasoupClient.Device(); await device.load({ routerRtpCapabilities: data.routerRtpCapabilities }); // 创建 send transport socket.emit('createTransport', { roomId: 'test-room', direction: 'send' }, async (params) => { sendTransport = device.createSendTransport(params); sendTransport.on('connect', ({ dtlsParameters }, callback, errback) => { socket.emit('connectTransport', { roomId: 'test-room', direction: 'send', dtlsParameters, }, callback); }); sendTransport.on('produce', ({ kind, rtpParameters }, callback) => { socket.emit('produce', { roomId: 'test-room', kind, rtpParameters, }, ({ id }) => { callback({ id }); }); }); // 推流 const audioTrack = stream.getAudioTracks()[0]; const videoTrack = stream.getVideoTracks()[0]; const audioProducer = await sendTransport.produce({ track: audioTrack }); const videoProducer = await sendTransport.produce({ track: videoTrack }); producers.push(audioProducer, videoProducer); }); // 创建 recv transport socket.emit('createTransport', { roomId: 'test-room', direction: 'recv' }, async (params) => { recvTransport = device.createRecvTransport(params); recvTransport.on('connect', ({ dtlsParameters }, callback, errback) => { socket.emit('connectTransport', { roomId: 'test-room', direction: 'recv', dtlsParameters, }, callback); }); }); }); } document.getElementById('joinBtn').addEventListener('click', joinRoom);拉流部分需要监听服务端的新 producer 通知,然后创建 consumer:
socket.on('newProducer', async ({ producerId, kind }) => { if (!recvTransport) return; socket.emit('consume', { roomId: 'test-room', producerId, rtpCapabilities: device.rtpCapabilities, }, async (params) => { const consumer = await recvTransport.consume({ id: params.id, producerId: params.producerId, kind: params.kind, rtpParameters: params.rtpParameters, }); consumers.push(consumer); const remoteStream = new MediaStream(); remoteStream.addTrack(consumer.track); document.getElementById('remoteVideo').srcObject = remoteStream; }); });服务端需要在有新的 producer 时通知房间里的其他 peer:
socket.on('produce', async ({ roomId, kind, rtpParameters }, callback) => { const room = rooms.get(roomId); const peer = room.peers.get(socket.id); const transport = peer.sendTransport; const producer = await transport.produce({ kind, rtpParameters }); peer.producers = peer.producers || []; peer.producers.push(producer); // 通知其他 peer for (const [peerId, otherPeer] of room.peers) { if (peerId !== socket.id) { otherPeer.socket.emit('newProducer', { producerId: producer.id, kind, }); } } callback({ id: producer.id }); });4.4 参数调优与实测数据记录
Demo 跑通之后,接下来是调优。我实测了几组参数,记录如下:
| 参数 | 默认值 | 调优值 | 效果 |
|---|---|---|---|
| jitter buffer 初始大小 | 100ms | 60ms | 延迟降低约 40ms,弱网下卡顿略增 |
| FEC 冗余比例 | 20% | 15% | 带宽节省约 5%,丢包恢复率基本不变 |
| 视频起始码率 | 1000kbps | 800kbps | 弱网下更稳定,画质略有下降 |
| Opus 码率 | 32kbps | 24kbps | 带宽节省 25%,语音清晰度基本无感 |
| AGC 目标电平 | -18dBFS | -15dBFS | 音量更饱满,但削波风险增加 |
调优的核心思路是:先保证功能可用,再逐步优化延迟和带宽。不要一上来就追求极限参数,那样很容易把系统调崩。我的建议是每次只调一个参数,观察至少 10 分钟,确认稳定后再调下一个。
实操心得:调 jitter buffer 的时候,一定要在真实网络环境下测。我在局域网里把 jitter buffer 调到 20ms 都没问题,一到 4G 网络就疯狂卡顿。后来改成动态调整,网络好的时候 40ms,网络差的时候自动升到 200ms,体验就稳定多了。
5. 常见问题排查与避坑指南
5.1 声音断续、回声、延迟高的排查思路
声音断续是最常见的问题,原因可能有很多。第一步先看网络丢包率,如果丢包超过 5%,那基本就是网络问题,需要加强 FEC 或者降低码率。如果丢包正常但声音还是断续,那可能是 jitter buffer 太小,网络抖动导致包被丢弃。这时候可以适当增大 jitter buffer,但要注意延迟会上升。
回声问题一般是 AEC 没调好。先确认 AEC 是否开启,然后检查延迟对齐是否准确。如果 AEC 开了但还有回声,可能是扬声器音量太大导致非线性失真,可以尝试降低音量或者开启非线性残留抑制。另外,如果用户用的是外放而不是耳机,回声问题会更严重,这种情况下可以提示用户戴耳机。
延迟高的问题需要分段排查。先测采集延迟,再测编码延迟,再测网络延迟,最后测渲染延迟。一般来说,采集和渲染延迟加起来不超过 50ms,编码延迟不超过 30ms,网络延迟不超过 200ms,整体就在可接受范围内。如果某一环节延迟特别高,就针对性地优化。比如编码延迟高,可能是分辨率太高或者编码器性能不够,可以降低分辨率或者换硬编。
5.2 弱网环境下卡顿、花屏、断连的应对
弱网是 RTC 最大的敌人。卡顿、花屏、断连这三个问题在弱网下几乎必然出现,关键是怎么把影响降到最低。
卡顿一般是 jitter buffer 和 FEC 配合不好。我的经验是:先开 FEC,冗余比例设 20%;如果还卡,再增大 jitter buffer;如果还卡,就降低码率。降低码率是最有效的手段,因为带宽需求小了,丢包率自然就下来了。视频码率可以从 1000kbps 降到 500kbps 甚至 300kbps,画质会下降但至少不卡。
花屏通常是视频丢包导致的。如果丢的是关键帧,后续的 P 帧都无法解码,画面就会花。解决办法是请求关键帧重传。在 WebRTC 里可以通过 RTCP 的 PLI 或 FIR 消息来请求关键帧。但关键帧数据量大,弱网下重传关键帧可能会加剧拥塞,所以要控制请求频率。
断连一般是 ICE 连接断开。可能是网络切换(比如从 WiFi 切到 4G)导致的,也可能是长时间没有数据往来导致 NAT 映射过期。解决办法是开启 ICE 重启,在网络变化时重新协商连接。另外,定期发送 keepalive 包可以防止 NAT 映射过期。
| 问题 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 声音断续 | 丢包、jitter buffer 太小 | 查看丢包率、jitter 统计 | 加强 FEC、增大 jitter buffer |
| 回声 | AEC 未开启或延迟对齐错误 | 检查 AEC 配置、测试延迟 | 开启 AEC、重新对齐延迟 |
| 延迟高 | 编码延迟、网络延迟 | 分段测量各环节延迟 | 降低分辨率、优化网络路径 |
| 花屏 | 视频丢包、关键帧丢失 | 查看视频丢包统计 | 请求关键帧重传 |
| 断连 | ICE 断开、NAT 过期 | 查看 ICE 状态 | 开启 ICE 重启、发送 keepalive |
5.3 那些文档里不会写的踩坑经验
第一个坑:不要迷信默认参数。WebRTC 的默认参数是针对通用场景调的,但你的场景可能完全不同。比如默认的 AGC 目标电平是 -18dBFS,但在嘈杂环境下这个值可能偏低,导致对方听不清。我一般会把目标电平调到 -15dBFS,然后根据实测微调。
第二个坑:音频前处理的顺序很重要。正确的顺序是 AEC -> ANS -> AGC。如果顺序错了,比如先 AGC 再 AEC,AGC 会把回声也放大,导致 AEC 更难消除。这个顺序在 WebRTC 里是固定的,但如果你自己实现前处理链路,一定要注意。
第三个坑:视频编码的码率控制模式要选对。CBR(恒定码率)适合带宽稳定的场景,VBR(可变码率)适合带宽波动的场景。RTC 一般用 VBR,因为网络状况随时在变。但 VBR 的问题是码率波动大,可能会瞬间超过带宽上限导致丢包。所以最好用 CBR 加一个最大码率限制,兼顾稳定性和灵活性。
第四个坑:测试的时候一定要用真实设备。模拟器和虚拟声卡的结果和真机差别很大,尤其是音频前处理部分。我见过在模拟器上 AEC 效果完美,一到真机就回声严重的情况。所以至少准备三台不同品牌的手机做交叉测试。
第五个坑:日志要打全,但不要打太多。RTC 的日志量非常大,如果全开会导致性能下降。我的做法是:正常运行时只打 warn 和 error,排查问题时临时开 debug,问题解决后马上关掉。另外,关键指标(丢包率、延迟、码率)要定期打点上报,方便后续分析。
6. 一些个人体会和后续可以折腾的方向
RTC 这个领域,入门容易精通难。调通一个 Demo 可能只需要一天,但要把延迟、音质、弱网体验都做到优秀,需要大量的实测和调优。我自己的经验是:不要追求一步到位,先把核心链路跑通,然后一个指标一个指标地优化。每次优化之后都要做回归测试,确保没有引入新的问题。
后续如果想继续深入,有几个方向可以折腾。一是自己实现音频前处理算法,比如用深度学习做 ANS,效果会比传统方法好很多。二是研究 SFU 的级联架构,支持大规模多人通话。三是探索 AV1 在 RTC 里的应用,等硬件支持普及之后,带宽能省不少。四是做端到端的质量监控,用数据驱动优化,而不是靠感觉调参。
最后分享一个小技巧:如果你在调 AEC 的时候总是调不好,可以先关掉 ANS 和 AGC,单独调 AEC。因为 ANS 和 AGC 会改变信号特征,干扰你对 AEC 效果的判断。等 AEC 调好了,再逐个开启其他模块,这样排查问题会清晰很多。