在处理webrtc的拉流问题时,需要引入jitterBuffer来处理网络中的抖动、丢包等因素造成的接收包乱序,接收不均匀等情况,而jitterBuffer的特性直接取决于buffer的大小,其会在内部完成对包的排序,加速,减速,等等逻辑。另外还需提到的是NetEQ,其中集成了自适应抖动控制算法,和语音丢包隐藏算法。它包括Jitterbuffer队列部分,解码部分,以及信号处理的部分。我希望慢慢后面都可以谈及这些话题。

jitter buffer

对于JB分成静态和动态,静态。动态会根据包的到达时间和发送时间推测网络状况,根据网络延迟推算出buffer的大小。实现一个JB通常需要一个类map容器队列,因为要支持乱序重拍,而视频帧更是要支持包级别的帧重组。对于静态JB,实际上就是一个固定深度的QUEUE队列(刨除外部数据导致的队列复杂性),就不做多解释

视频的JB实现

对于视频而言,分为IBP帧所以要处理PTS相互之间的关系。因此JitterBuffer被分成三个部分,PacketBuffer(排序与组帧) RtpFrameReferenceFinder(处理帧之间的依赖关系) FrameBuffer(处理抖动)

  • PacketBuffer:关键是判断连续性。
    一个帧是“完整”的(Complete):如果这个帧的 Start 标识包到 End 标识包中间所有的 sequence number 都齐了。一个帧是“连续”的(Continuous):不仅帧本身完整,而且它之前的所有帧也都已经收到了。

例如如果收到了 Frame 1 和 Frame 3,虽然它们各自的包都齐了,但 Frame 3 在 PacketBuffer 里会被标记为“不连续”,因为 Frame 2 缺席。
一旦检测到某个帧是“完整且连续”的,它就会被打包成 RtpFrameObject,抛给下一个模块。

  • RtpFrameReferenceFinder:
    在 H.264/H.265 中,由于 B 帧的存在,Decode Order和Presentation Order是不同的。如果是 VP8/VP9/AV1,还涉及空间层(Spatial Layer)和时间层(Temporal Layer)的依赖。ReferenceFinder 维护最近解码过的帧 ID 列表。

一个典型过程是新帧进来,检查其 required_frame_ids(依赖谁)。如果依赖的帧还没到(Stashed),暂时存起来。如果依赖的帧到了,或者这是个 KeyFrame(不依赖谁),将该帧标记为 Determined(依赖关系已确定,可以调度)。
最后将处理好依赖关系的帧,通过回调(OnCompleteFrame)送入最终的 FrameBuffer。

  • FrameBuffer:这个部分就是真正输出帧的地方,需要用卡尔曼滤波来维护一个状态变量VarNoise,计算出 jitter_delay_ms。即:为了覆盖 95%(或 99%)的抖动概率,我需要额外多存多少毫秒的数据。同时维护一个VCMTiming (时间控制器), 通过

$$TargetDelay = NetDelay + JitterDelay + DecodeDelay + RenderDelay$$

动态调整TargetDelay。下面为buffer的估算,首先需要说明,这里并不是直接去估计帧间的延迟frameDelay,而是需要估计信道的传输速率以及网络实际抖动这两个参数,得到这两个参数后就可以根据这两个参数去得到延迟的估计。为什么不直接通过IAT那样的方式直接得到网络抖动情况呢,原因就在于大帧(如 I 帧)传输时间长,小帧(如 P 帧)传输时间短。如果我们简单地计算到达时间差,大帧带来的延迟会被误认为是网络变差了。

因此核心思路是

1
2
3
4
1. 建立模型:建立一个模型,描述 “帧大小” 与 “传输延迟” 之间的线性关系。
2. 卡尔曼滤波:利用卡尔曼滤波实时更新这个模型的参数(斜率)。
3. 计算残差:用模型预测一个延迟,然后用 实际延迟 - 预测延迟。
4. 估算抖动:这个“残差”(Residual)才是真正的、不可预测的网络噪声。根据这个残差的方差(Variance)来决定 FrameBuffer 的深度。

视频FrameBuffer中的Jb深度估计

  1. 首先对每个视频帧进行帧大小差和传输延迟变化计算:

帧大小差 ($d_size$):$Size_i - Size_{avg}$。即当前帧比平均帧大了多少。
传输延迟变化 ($t_delta$):$(T_{recv,i} - T_{send,i}) - (T_{recv,i-1} - T_{send,i-1})$。即当前帧比上一帧“慢”了多少。

  1. 实时估算最优的 $\theta$
    webrtc假设满足$t_delta = \theta \times d_size + \text{Noise}$。$\theta_0$是信道传输速率的倒数。
    卡尔曼滤波首先根据上一个时刻的$\theta$,来预测当前延迟是多少
    $$Prediction = \theta_{old} \times d_size$$
    然后比较实际延迟和预测延迟
    $$Error = t_delta - Prediction$$
    卡尔曼增益(Kalman Gain, $K$)会决定我们多大程度上相信这个 $Error$。如果测量噪声很大,$K$ 就小(不怎么信);如果模型协方差大,$K$ 就大(赶紧修正)。最终更新 $\theta$:
    $$\theta_{new} = \theta_{old} + K \times Error$$
  2. 残差与抖动方差
    卡尔曼滤波本身不直接输出 Jitter Buffer 的深度,它输出的是外部抖动噪声。

我们拿到了真实残差(Residual):
$$Residual = t_delta - (\theta_{new} \times d_size)$$
这个 $Residual$ 代表了**“排除了因为帧过大导致的拥塞延迟后,纯粹的 random 网络抖动”**。然后,WebRTC 使用指数加权移动平均(EWMA)来计算这个残差的方差(Variance, $\sigma^2$):
$$\sigma^2_{new} = \alpha \cdot \sigma^2_{old} + (1-\alpha) \cdot Residual^2$$
4. 转化JB深度
$$JitterDelay = k \cdot \sqrt{\sigma^2}$$
k通常取2-3之间,物理意义:根据正态分布,$\mu + 2\sigma$ 可以覆盖 95.4% 的概率,$\mu + 3\sigma$ 可以覆盖 99.7% 的概率。动态调整:WebRTC 会根据丢包率或者 NACK 的情况动态调整这个 $k$。如果丢包率高,说明网络很不稳定,它会把 $k$ 调大,增加 buffer 深度,防止因为重传导致的卡顿。

最终送给 FrameBuffer 的深度指令是:
$$TargetDelay = JitterDelay + DecodeTime + RenderDelay$$
其中的RenderDelay由于不同的操作系统和驱动都有所区别,因此一般设置一个经验值(10ms/16ms)或者使用系统回调
而其中的DecodeTime同样是不确定的,由于P帧和B帧还有I帧的不同,P帧一般解码快,因此内部维护了一个低通滤波来平滑解码时间。每次解码完一帧,记录 startTime 和 endTime,算出差值 delta。$New_DecodeTime = \alpha \cdot Last_DecodeTime + (1-\alpha) \cdot Current_Sample$

音频JB实现

对于音频而言,由于不需要IBP帧,所以架构与视频很大不同,在PacketBuffer侧仅需处理简单的网络乱序即可,然后对于opus进行独立解码。整体流程是,接收到RTP后解析头,进行排序(SequenceNumber)后就放进Packet队列,接着开始执行NetEQ的核心步骤,以下为buffer的大小估算核心步骤

  • IAT(包到达间隔)直方图 + 峰值检测

IAT是包延迟的变化,即$$IAT(i) = Delay(i) - Delay(i-1)$$,这个 IAT 值反映了网络抖动的剧烈程度。如果网络极其稳定,IAT 应该接近 0。

构建直方图是根据当前包相对于理想到达时间的延迟值而构建的,由于发送端和接收端的时钟不同步(Clock Drift),$D_{abs}$ 会随着时间不断漂移(比如每一分钟慢 10ms)。NetEQ 维护一个 Base Delay(通常是过去一段时间内的最小延迟)。$$D_{relative} = D_{abs} - Base_Delay$$这个 $D_{relative}$ 才是放入直方图的那个“值”。如果网络极其平稳,$D_{relative}$ 应该接近 0。如果网络发生了一次 50ms 的抖动,$D_{relative}$ 就会变成 50。

每当一个新的包到达,计算出其相对延迟 $d$ 后,直方图会进行一次“呼吸”:遗忘旧的,强化新的。假设当前直方图的第 $i$ 个桶的概率是 $P_i(t)$,遗忘因子是 $f$(通常 $f \approx 0.9993$)。更新公式如下:

$$P_i(t+1) = \begin{cases} P_i(t) \cdot f + (1-f), & \text{if } i = d \quad \text{(命中当前延迟)} \ P_i(t) \cdot f, & \text{if } i \neq d \quad \text{(未命中)} \end{cases}$$

这个公式的物理意义:全局衰减:无论你是哪个桶,先把你的概率乘以 $0.9993$。这意味很久以前发生的抖动,随着时间推移,其概率权值会越来越小(遗忘)。单点强化:对于刚刚发生的这个延迟 $d$,我们在衰减之后,额外加上 $(1-f)$ 的权重。

  • 峰值检测
    建好直方图之后就需要考虑设定buffer来覆盖95% 的延迟情况,这本质上是求**累积分布函数(CDF)**的分位点。

    • 设置目标概率:limit_probability = 0.95 (即 $0.95 \times 2^{30}$)。
    • 累加扫描:从下标 0 开始累加桶里的值。阈值判定:当累加和超过 limit_probability 时,当前的下标 $i$ 就是我们需要的 Target Level。
  • 使用WSOA进行重采样,通过这种方式拉伸或者缩短音频数据来实现同步

状态 条件 (Buffer Level vs Target) 操作 (Operation) DSP 算法 (WSOLA)
正常 Current ≈ Target Normal 正常解码,原样输出。
积压太多 Current >> Target Accelerate (加速) 缩短波形。在不改变音高的情况下,通过 Overlap-Add 删掉一些基音周期。比如解码出 10ms,实际只输出 8ms。
积压太少 Current < Target Preemptive Expand (预扩展) 拉长波形。在不改变音高的情况下,插入一些重复的基音周期。解码出 10ms,实际输出 12ms。
空了(丢包) Buffer Empty Expand (丢包隐藏 PLC) 利用上一帧的波形,通过线性预测或简单的波形复制,出 10ms 的声音来填补空缺。
融合 上一次是 PLC,这次包来了 Merge 将新来的真实数据和之前造的假数据进行平滑混合,避免波形突变产生爆音。

本站由 Edison.Chen 创建。
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。

undefined