音视频编解码与拥塞控制

打通实时媒体的编码与传输链路:对比 Opus/VP8/VP9/H264/AV1 在实时场景的取舍、讲解 Opus 帧长与带内 FEC、拆解 RTP 头字段与 RTCP 反馈报文、剖析 GCC 与 TWCC 拥塞控制原理、说明 Simulcast 与 SVC 的分层策略,并附常见坑与调优建议。

如果说信令与 ICE 解决的是「能不能连上」,那么编解码与拥塞控制解决的就是「连上之后好不好用」。同样的网络条件下,编码器选择、帧长设置、拥塞控制算法会带来天差地别的体验。这一层是实时通信工程中最需要专业判断的部分,也是最难通过「换个库」解决的部分。

本文从编解码的实时特性讲起,一路串到 RTP/RTCP 报文与 GCC 拥塞控制。读完你应该能看懂一个实时系统的传输层在做什么,也能在弱网投诉时迅速判断瓶颈在哪一环。

一、实时场景对编解码的特殊要求

通用编解码追求压缩率,实时编解码还要额外满足三个约束:低延迟、抗丢包、可动态调码率。这三点决定了实时编码器不能使用长 GOP 与 B 帧,必须支持快速重同步。

编解码类型典型码率抗丢包延迟实时适用度
Opus音频6~510 kbps强(带内 FEC)低首选
AAC-LC音频96~256 kbps弱中兼容用
VP8视频0.1~2 Mbps中低通用
VP9视频0.1~3 Mbps中低高质量
H264视频0.1~3 Mbps中低硬件普遍
AV1视频0.1~4 Mbps中较高新场景

选择的核心逻辑是:音频几乎总用 Opus,视频则在 VP8/VP9 与 H264 之间权衡。H264 的优势在于硬件编解码支持最广,移动端功耗低;VP9 压缩率更好但硬件支持参差;AV1 压缩率最高但编码开销大,目前多用于下行分发。

二、Opus 详解

2.1 帧长与码率

Opus 的独特之处在于它支持 2.5 到 60 毫秒的多种帧长,且可以在运行时切换。帧长越短延迟越低,但头部开销占比越高。常见取值如下:

帧长特点适用
2.5 ms延迟最低,开销最大极少使用
10 ms平衡点高互动场景
20 msWebRTC 默认通用通话
40~60 ms开销低,延迟高弱网、纯收听

2.2 带内 FEC 与 DTX

Opus 的两个关键抗丢包特性是带内 FEC(in-band FEC)与不连续传输(DTX)。带内 FEC 在编码时把前一帧的低码率副本嵌入当前帧,丢一个包时可以用后一包恢复;DTX 在检测到静音时停止发包,节省带宽。

const pc = new RTCPeerConnection();
const transceiver = pc.addTransceiver("audio", { direction: "sendrecv" });
const params = transceiver.sender.getParameters();

// 通过 codec 参数影响 Opus 行为(浏览器暴露程度有限,多数靠 SDP fmtp)
console.log(params.codecs); // 查看 opus 的 fmtp 参数

在 SDP 中,Opus 的抗丢包能力通过 a=fmtp 体现:

a=rtpmap:111 opus/48000/2
a=fmtp:111 minptime=10;useinbandfec=1;usedtx=1;maxaveragebitrate=32000

useinbandfec=1 开启带内 FEC,maxaveragebitrate 限制平均码率上限。这两个参数是弱网优化的第一手段。

三、视频编解码的关键概念

3.1 帧类型与依赖

视频帧分为 I 帧(关键帧,可独立解码)、P 帧(前向预测)、B 帧(双向预测)。实时场景通常禁用 B 帧,因为它需要缓存后续帧,增加延迟。

I 帧体积远大于 P 帧,通常为 5 到 10 倍。因此关键帧的频率是码率与恢复能力之间的权衡:I 帧越频繁,新加入者越快看到画面,但平均码率越高。

3.2 GOP 与关键帧请求

GOP(Group of Pictures)是一组从 I 帧开始的帧序列。实时场景的 GOP 通常设为 2 到 5 秒。当接收方丢包严重无法恢复时,会通过 RTCP 发送 PLI(Picture Loss Indication)或 FIR(Full Intra Request)请求发送方立即产生一个关键帧。

// 接收方主动请求关键帧
const receivers = pc.getReceivers();
for (const receiver of receivers) {
  if (receiver.track.kind === "video") {
    // 某些实现提供 requestKeyFrame,或通过 getStats 判断后触发
    console.log(receiver.track.id);
  }
}

频繁请求关键帧会显著抬升码率,形成「丢包-请求关键帧-码率更高-更丢包」的恶性循环。这是弱网下最常见的正反馈陷阱。

3.3 硬件与软件编码

方式优点缺点
硬件编码功耗低、占用 CPU 少参数受限、画质一般
软件编码参数灵活、画质好CPU 占用高、移动端发热

移动端应优先硬件编码以控制功耗,桌面端可根据画质需求选择。浏览器会自动决策,但可以通过 setCodecPreferences 影响编解码选择。

四、RTP 与 RTCP

4.1 RTP 头字段

媒体数据以 RTP 包传输,固定头部 12 字节,字段如下:

字段位宽含义
V2版本,固定为 2
P1是否有填充
X1是否有扩展头
CC4CSRC 计数
M1标记位,视频帧末尾常置 1
PT7载荷类型,与 SDP 的 rtpmap 对应
sequence number16序列号,用于检测丢包与乱序
timestamp32采样时刻,用于音视频同步
SSRC32同步源标识,标识一路媒体流

序列号与时间戳是接收端判断丢包、乱序与抖动的依据。时间戳的时钟频率由编解码决定:Opus 为 48000 Hz,VP8/VP9 为 90000 Hz。

// 手工解析一个 RTP 包的头部
function parseRtpHeader(buf) {
  const version = buf[0] >> 6;
  const padding = (buf[0] >> 5) & 0x1;
  const extension = (buf[0] >> 4) & 0x1;
  const csrcCount = buf[0] & 0x0f;
  const marker = (buf[1] >> 7) & 0x1;
  const payloadType = buf[1] & 0x7f;
  const seq = buf.readUInt16BE(2);
  const timestamp = buf.readUInt32BE(4);
  const ssrc = buf.readUInt32BE(8);
  return { version, padding, extension, csrcCount, marker, payloadType, seq, timestamp, ssrc };
}

4.2 RTCP 反馈报文

RTCP 负责传输控制信息,实时场景常用的报文类型如下:

报文全称作用
SR / RRSender / Receiver Report统计丢包、抖动、RTT
NACKNegative Acknowledgement请求重传特定序列号
PLIPicture Loss Indication请求关键帧
REMBReceiver Estimated Max Bitrate接收端估计带宽
TWCCTransport Wide CC逐包反馈到达时间

其中 TWCC 是现代拥塞控制的基础。它让接收端对每一个收到的包记录到达时间,通过 RTCP 扩展反馈给发送端,发送端据此计算延迟梯度,判断网络是否拥塞。相比仅依赖丢包的旧方案,TWCC 能在丢包发生前就感知拥塞。

4.3 解析 RTCP 反馈

RTCP 报文与 RTP 类似,也是「固定头 + 结构化载荷」。下面这段代码演示如何从裸包中区分 RTCP 类型,并提取 NACK 与 REMB 的关键字段:

const RTCP_TYPES = {
  200: "SR",
  201: "RR",
  205: "NACK",
  206: "PSFB", // PLI / FIR / REMB 都属于 PSFB
  205.5: "TWCC",
};

function parseRtcp(buf) {
  const results = [];
  let offset = 0;

  while (offset + 4 <= buf.length) {
    const firstByte = buf[offset];
    const version = firstByte >> 6;
    const packetType = buf[offset + 1];
    const lengthWords = buf.readUInt16BE(offset + 2);
    const totalBytes = (lengthWords + 1) * 4;

    if (version !== 2) break;

    if (packetType === 205) {
      // NACK:统计被请求重传的序列号
      const pid = buf.readUInt16BE(offset + 8);
      const blp = buf.readUInt16BE(offset + 10);
      results.push({ type: "NACK", pid, blp });
    } else if (packetType === 206) {
      const fmt = firstByte & 0x1f;
      if (fmt === 1) results.push({ type: "PLI" });
      if (fmt === 4) {
        // REMB:解析指数与尾数得到带宽估计
        const exp = buf[offset + 12] >> 2;
        const mantissa = ((buf[offset + 12] & 0x03) << 16) |
          (buf[offset + 13] << 8) | buf[offset + 14];
        const bitrate = mantissa * Math.pow(2, exp);
        results.push({ type: "REMB", bitrate });
      }
    } else if (packetType === 200 || packetType === 201) {
      results.push({ type: packetType === 200 ? "SR" : "RR", lengthWords });
    }

    offset += totalBytes;
  }
  return results;
}

理解 NACK 与 REMB 的字段结构,是排查「重传风暴」与「带宽估计异常」的前提。NACK 的 pid 与 blp 位图一起描述哪些序列号丢失,若短时间内出现大量 NACK,往往意味着上行链路质量恶化或码率超限。

五、拥塞控制

5.1 为什么需要拥塞控制

视频编码器产生的码率必须与网络可用带宽匹配。若发送码率持续超过带宽,路由器队列填满,延迟飙升,最终大量丢包。拥塞控制的目标是动态估计可用带宽,并让编码器与发送速率跟随这个估计。

WebRTC 发送端还有两个配套机制:

  • Pacing:把编码器产出的帧在时间上打散发送,避免瞬时突发打满队列。
  • Probing:主动以更高码率发送探测包,试探带宽上限,以便在带宽恢复时快速提升。

5.2 GCC 与 TWCC

Google Congestion Control(GCC)是 WebRTC 默认的拥塞控制算法,分两部分工作:

  1. 基于延迟的控制器:利用 TWCC 反馈计算到达时间梯度,判断排队延迟是否增长。
  2. 基于丢包的控制器:当丢包率超过阈值时下调目标码率。

两者输出取较小值,作为目标发送码率。这个码率通过 REMB 或 TWCC 反馈链路传递,最终约束编码器的输出。

算法依据优点缺点
GCC(延迟)到达时间梯度丢包前感知拥塞对突发敏感
GCC(丢包)丢包率简单直接滞后
REMB接收端估计实现简单反馈频率低
TWCC逐包反馈精度高依赖 SDP 协商

a=rtcp-fb:96 transport-cc 这一行决定了能否启用 TWCC。缺少它,拥塞控制会退化到仅靠丢包与 REMB,弱网表现明显变差。

5.3 观察带宽估计

setInterval(async () => {
  const stats = await pc.getStats();
  stats.forEach((report) => {
    if (report.type === "candidate-pair" && report.state === "succeeded") {
      console.log("可用上行带宽估计:", report.availableOutgoingBitrate);
      console.log("当前 RTT:", report.currentRoundTripTime * 1000, "ms");
    }
    if (report.type === "outbound-rtp" && report.kind === "video") {
      console.log("实际发送码率:", report.bytesSent, "帧数:", report.framesEncoded);
    }
  });
}, 1000);

availableOutgoingBitrate 是排障弱网问题的关键指标:若它持续低于编码器目标码率,说明网络是瓶颈;若它充足但画质仍差,问题多半在编码侧。

六、分层编码:Simulcast 与 SVC

当接收端网络状况差异很大时,单一码流无法兼顾。Simulcast 让发送方同时编码多个分辨率层,SVC 则在单个码流内分层。

方案机制带宽开销实现复杂度
Simulcast独立编码多路高(上行成倍)低,兼容好
SVC单流分层低高,需 SFU 支持

Simulcast 上行开销大,适合上行带宽充足的场景;SVC(如 VP9 的 K-SVC)上行更省,但对编解码与 SFU 要求高。弱网对抗的具体策略见 弱网对抗与自适应码率 。

七、音视频同步

7.1 为什么会出现不同步

音频与视频是两条独立的 RTP 流,各自有不同的时间戳基准与网络路径,到达时间必然存在差异。若不做同步处理,就会出现「口型对不上」的现象。同步的依据是 RTP 时间戳与 RTCP 中的 NTP 时间,接收端据此把两条流对齐到同一时间轴。

7.2 同步的工程做法

// 通过 getStats 观察两条流的时间基准
async function inspectSync(pc) {
  const stats = await pc.getStats();
  const tracks = new Map();
  stats.forEach((r) => {
    if (r.type === "inbound-rtp") {
      tracks.set(r.kind, {
        jitter: r.jitter,
        packetsLost: r.packetsLost,
        framesDecoded: r.framesDecoded,
      });
    }
  });
  console.log("音频:", tracks.get("audio"));
  console.log("视频:", tracks.get("video"));
}

浏览器的 MediaStreamTrack 已经在底层做了 A/V 同步,应用层通常无需手动对齐。但当音频与视频来自不同的 RTCPeerConnection(如音频走一路、视频走另一路)时,同步就不再自动,需要借助 RTCP SR 中的 NTP 时间戳手工对齐。

7.3 抖动缓冲

抖动缓冲(jitter buffer)是同步的前提。它把到达的包先缓冲一小段时间再播放,以平滑网络抖动。缓冲越大越平滑但延迟越高,缓冲越小延迟越低但容易卡顿。WebRTC 会动态调整抖动缓冲深度,弱网下自动加大以换取连续性。

场景推荐抖动缓冲延迟影响
高质量网络小低
一般公网自适应中
移动弱网大高

八、常见坑清单

  • SDP 中缺少 transport-cc,拥塞控制退化,弱网卡顿。
  • 频繁请求 PLI 导致码率暴涨,形成恶性循环。
  • Opus 未开启 useinbandfec,丢包直接导致音频断续。
  • 视频 GOP 过长,新订阅者等待关键帧时间过久。
  • 忽略 Pacing,瞬时突发打满队列,延迟抖动。
  • 把 availableOutgoingBitrate 当成编码器码率上限而不做缓冲,估计抖动时码率剧烈震荡。
  • 移动端强制软件编码,发热降频后帧率崩塌。
  • 只用丢包判断拥塞,不启用 TWCC,恢复慢。

小结

编解码与拥塞控制构成了实时媒体的传输核心。音频用 Opus 并开启带内 FEC 是几乎无争议的选择;视频则在压缩率、硬件支持与功耗之间权衡。RTP 头字段与 RTCP 反馈报文是理解传输行为的钥匙,而 TWCC 支撑的 GCC 是现代拥塞控制的基础。能否启用 TWCC,往往在一次 SDP 协商时就已注定。下一环的 弱网对抗与自适应码率 会把这些机制组合成可落地的策略,而多人场景下的转发决策则属于 SFU 与 MCU 架构选型 的范畴。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「实时通信」更多文章

  1. 实时通信的端到端测试与压测
  2. 信令服务与房间水平扩展
  3. 低延迟直播:LL-HLS 与 WebRTC 直播