webrtc 视频 jitterbuffer 效果测试与深入分析

参考:
https://blog.csdn.net/qq_44660239/article/details/142891642
https://www.nxrte.com/jishu/webrtc/28485.html
https://www.cnblogs.com/ishen/p/15000909.html

1. 概述

所谓实践出真知,网络上已经有了很多分析视频 jitterbuffer 算法原理和代码的文章,本篇博客不分析其原理,而是主要对去抖模块的核心 --- JitterEstimator 对象进行单元测试,然后基于单元测试的结果,分析和挖掘去抖算法的一些特性。
这篇博客我相信能够为后续针对不同场景下对 jitterbuffer 的优化提供一些感性认识和理论指导。
注意,本篇文章基于 m110 版本。

2. 单元测试

jitter_estimator.cc::JitterEstimator 对象负责计算每个完整的视频帧,应该等待多少时间后再解码和播放,才能使整个视频流能流畅、平稳的播放出来。这里对 jitterbuffer 的测试,也是测试这个模块。

2.1 函数接口

jitter_estimator.cc::JitterEstimator 对象其实际上就 2 个主要接口:

  // Updates the jitter estimate with the new data.
  //
  // Input:
  //          - frame_delay      : Delay-delta calculated by UTILDelayEstimate.
  //          - frame_size       : Frame size of the current frame.
  void UpdateEstimate(TimeDelta frame_delay, DataSize frame_size);

  // Returns the current jitter estimate and adds an RTT dependent term in cases
  // of retransmission.
  //  Input:
  //          - rtt_multiplier   : RTT param multiplier (when applicable).
  //          - rtt_mult_add_cap : Multiplier cap from the RTTMultExperiment.
  //
  // Return value              : Jitter estimate.
  TimeDelta GetJitterEstimate(double rtt_multiplier,
                              absl::optional<TimeDelta> rtt_mult_add_cap);

其中:

  • UpdateEstimate() 函数是其输入接口
  • GetJitterEstimate() 函数是其输出接口

对于 UpdateEstimate() 函数的输入参数:

  • frame_delay 即根据前后两个完整帧计算出来的帧到达时间的抖动值 (具体可以看其它文章的分析,这里不详细介绍)
  • frame_size 即当前帧的字节大小

对于 GetJitterEstimate() 函数的输入参数,放到后面再解释。

2.2 测试代码

完整测试代码如下,具体不再解释,看注释即可:

#include "system_wrappers/include/clock.h"
#include "rtc_base/logging.h"
#include "api/field_trials.h"
#include "api/units/time_delta.h"
#include "api/units/data_size.h"
#include "rtc_base/random.h"
#include "rtc_base/time_utils.h"
#include "modules/video_coding/timing/jitter_estimator.cc"

void do_jitter_test() {
  webrtc::SimulatedClock clock_(0);
  std::unique_ptr<webrtc::FieldTrials> field_trials = webrtc::FieldTrials::CreateNoGlobal("");
  webrtc::JitterEstimator estimator(&clock_, *field_trials);

  // 帧率
  int fps = 30;
  // 帧间隔(ms)
  int interval_ms = 1000 / fps;
  // i 帧大小(字节)
  int i_frame_size = 300000;
  // p 帧大小(字节)
  int p_frame_size = 20000;
  // 跑多少帧
  int loop = fps * 60 * 1;
  // p 帧抖动最大值,此值随机
  int p_delay_max_ms = 10;
  // i 帧抖动值, 简单起见,此值固定
  int i_delay_max_ms = p_delay_max_ms + 10;
  // p 帧丢包率
  uint32_t p_drop_probability = 0.60 * 100;
  // 丢包随机对象
  webrtc::Random drop_random(rtc::TimeMicros());
  // 抖动随机对象
  webrtc::Random delay_random(rtc::TimeMicros() + 10000);
  // rtt 值(ms)
  int rtt_ms = 100;
  // 丢包重传后,delay value 需要累加上 rtt
  int p_delay_cumulative = 0;
  // 上一帧如果丢弃了,下一帧的抖动值特殊处理(一个实测传入 UpdateEstimate() 的 delay 序列如下: ... 1 ms, 0 ms, 200 ms, -186 ms, -1 ms, -11 ms ...)
  int p_delay_last_cumulative = 0;

  for (int i = 0; i < loop;) {
    // 是否可以生成一个 i 帧
    bool is_i_frame = (i % fps) == 0;
    // 最终输入到 JitterEstimator 模块的 frame_delay 值
    int delay_value;
    // 最终输入到 JitterEstimator 模块的 frame_size 值
    int frame_size;

    if (is_i_frame) {   // 如果是 i 帧
      delay_value = i_delay_max_ms;
      frame_size = i_frame_size;
      ++i;
    } else {  // 如果是 p 帧
      if (drop_random.Rand(99) < p_drop_probability) {   // 随机丢 p 帧
        p_delay_cumulative += rtt_ms;
        // 丢包后,走下一轮
        continue;
      } else {
        // 正常随机抖动值
        delay_value = ((delay_random.Rand(1) == 1) ? 1 : -1) * (delay_random.Rand(p_delay_max_ms));
        // 加上丢包后的累积延迟
        delay_value += p_delay_cumulative;
        // 如果上一帧丢了,当前帧需要考虑上一帧的 delay 值(一般是一个负数抖动值)
        delay_value -= p_delay_last_cumulative;

        p_delay_last_cumulative = p_delay_cumulative;
        p_delay_cumulative = 0;
        frame_size = p_frame_size;
        ++i;
      }
    }

    // 送给 JitterEstimator 模块计算 jitter delay 值
    estimator.UpdateEstimate(webrtc::TimeDelta::Millis(delay_value), webrtc::DataSize::Bytes(frame_size));
    // 获取 JitterEstimator 模块计算的 jitter delay 值
    webrtc::TimeDelta jitter_delay = estimator.GetJitterEstimate(0, absl::nullopt);
    // 实际上 clock 在 JitterEstimator 没啥太大作用,但是这里还是递增下
    clock_.AdvanceTimeMilliseconds(interval_ms);

    rtc::gnuplot_write("jitter_test_delay_value", delay_value, interval_ms * i);
    rtc::gnuplot_write("jitter_test_jitter_delay", jitter_delay.ms(), interval_ms * i);

    RTC_LOG(LS_INFO) << "jitter_delay: " << jitter_delay.ms()
                     << ", delay_value: " << delay_value
                     << ", p_delay_last_cumulative: " << p_delay_last_cumulative;
  }
}

其中,rtc::gnuplot_write() 是我自己封装的写关键数据的函数,数据会写入文件然后通过 gnuplot 绘制出来。

gnuplot 绘制脚本如下:

# 设置输出为 png 图片格式
set terminal pngcairo size 1920,1080
set output 'jitter-test.png'

# 设置图像名称
set title 'jitter-test'

# 设置坐标轴名称
set xlabel '时间轴(ms)'
set ylabel '延时值(ms)'

# 设置网格线
set grid

# 设置图例位置
set key top left

# 设置线条样式和颜色 (lc: 线条颜色; lw: 线条宽度, 默认 1; pt: 点类型, 可设置 0-14; ps: 点大小, 默认 1)
set style line 1 lc rgb '#FF0000' lw 1 pt 0 ps 1.5   # 红色实线,带圆点
set style line 2 lc rgb '#0066FF' lw 2 pt 0 ps 1.5   # 蓝色实线,带方块
set style line 3 lc rgb '#0000FF' lw 1 pt 0 ps 1.5  

# 绘制两条曲线 (using 1:2: 使用第1列作为X轴,第2列作为Y轴; with linespoints: 绘图样式, 同时显示线条和数据点; ls 1: 使用预定义的线条样式1)
plot 'gnuplot_jitter_test_delay_value.txt' using 1:2 with linespoints ls 1 title '输入 jitter', \
     'gnuplot_jitter_test_jitter_delay.txt' using 1:2 with linespoints ls 2 title '输出 delay'

3. 测试结果

这里主要是通过修改 p_drop_probability 变量,观察不同丢包率下的表现。

3.1 无丢包曲线

3.2 丢包 5% 曲线

3.2 丢包 10% 曲线

3.2 丢包 20% 曲线

3.2 丢包 30% 曲线

3.2 丢包 40% 曲线

3.2 丢包 50% 曲线

3.2 丢包 60% 曲线

4. 结果分析

下面根据上面不同丢包率下的测试结果图示,分析其一些有趣的现象。

4.1 输入 jitter 随着丢包率变化而变化

当发生丢包时,包会进行重传,且随着丢包率升高:

  • 每个包被丢弃的概率升高,所以图像中平均 jitter 也会升高
  • 同一个包被多次重传的概率也会升高,所以图像单个 jitter 的值也会越来越大

4.2 只有无丢包场景下,输出 delay 才能完全覆盖 jitter

不同丢包率下,输入的 jitter 不一样,算法输出的 delay 值也不一样,且随着丢包率越高,算法输出的 delay 值也越高,这符合预期。
但是只有无丢包场景下,算法输出 delay 值才能完全覆盖 jitter,即要真正实现完美的平滑播放,只有无丢包场景下才能实现。

4.3 随着丢包率上升,卡顿感会越来越明显

例如:

  • 30% 丢包场景下,jitter 最高的一个包与 delay 之间的差值大概 380ms 左右,所以最终播放的时候,这个帧可能导致视频播放卡 380ms。
  • 60% 丢包场景下,jitter 最高的一个包与 delay 之间的差值大概 700ms 左右,所以最终播放的时候,这个帧可能导致视频播放卡 700ms,造成明显的播放卡顿。

且 60% 丢包相比 30% 丢包:

  • 有更多的包 jitter 值超过 delay
  • jitter 与 delay 值的差距更大

所以,丢包率越高,会导致视频播放卡顿感越明显。

4.4 delay 值会快速收敛到一个相对稳定的值

能够看到不管什么丢包率,delay 值会快速收敛,然后在一个稳定值附近小范围波动。

5. 算法原理简单分析

这里针对上述的测试和结果分析,结合一下 jitter delay 估计算法的原理,再简单分析一下。

5.1 jitter delay 的核心原理

我们都知道 jitter delay 估计算法需要先得到如下几个子项值:

  • 预估发送端传输速率 C
  • 一段时间内的最大帧大小 F_max
  • 一段时间内的平均帧大小 F_min
  • 链路传输噪声 Noise

得到这些值之后,就能得到 delay 值了:

  jitter_delay = (F_max - F_min) / C + Noise

我们仔细看这个公式,其实主要分为了两项,下面拆分并分析一下。

5.1.1 (F_max - F_min) / C

F_max - F_min 即得到了不同帧,由于帧大小不同的原因,导致的帧字节大小的 diff 值,单位为 bytes
C 即预估的发送端传输带宽,单位为 bytes/ms
所以 (F_max - F_min) / C 翻译为中文就是,帧大小的 diff 值,在发送端发送速率为 C 的速度下,需要传输多少时间,这个时间即由于帧大小或者说 pacer 模块,而导致的抖动值。

5.1.2 Noise 项

很多人可能会忽略这个 Noise 项,但是实际上在上面的测试中,由于帧大小是固定的,所以最终 (F_max - F_min) / C 项对最终的 delay 值影响较小,主要是 Noise 项对最终的 delay 值影响较大。
例如我有打印出 JitterEstimator 对象内部的一些日志:

(jitter_estimator.cc:405): CalculateEstimate jitter delay: 57.8058, size diff: 297707, estimate[0]: 64000, noise: 53.1542

可以看到,57.8058 ms 的 jitter delay 值中,noise 项占了 53.1542 ms

5.2 JitterEstimator 是一个好算法吗

通过前面的分析,我们可能会问,JitterEstimator 是一个好算法吗,要回答这个问题,要先看其要解决哪些问题:

  • 抹平 由于帧大小不同 和 pacer 模块,导致的 jitter
  • 抹平网络传输中的抖动,包括 链路抖动、丢包重传 等导致的 jitter

下面我们分析一下这两个问题。

5.2.1 帧大小不同和 pacer 模块,导致的 jitter

上面测试代码中,i_delay_max_ms 被设置为了固定 20ms,p_delay_max_ms 在 [-10ms, 10ms] 随机波动,那么 delay 要到 30ms 才能完全覆盖 jitter。
刚好,0% 丢包场景下,delay 值就是 32ms 左右,其能够完全覆盖 jitter,这即能够说明,这个问题 JitterEstimator 能够较好的解决。

5.2.2 网络传输中的抖动,导致的 jitter

在上面一系列的丢包测试中,随着 jitter 升高,delay 值也是跟着升高的,且能够覆盖大部分的 jitter,这能够说明,这个问题 JitterEstimator 也能够较好的解决。
但是唯一的问题就是,delay 值无法完全覆盖 jitter,总是有一些包由于重传次数太多,会超过 delay,对于这个问题,可能就是算法设计的取舍问题了:

  • 如果要完全覆盖 jitter,那么是快升快降,还是快升满降 delay 的策略呢
  • 不管是快升快降,还是快升满降策略,都会导致 delay 值剧烈波动,波动值太大,就会影响大部分正常帧的播放平稳性
  • 如果完全覆盖 jitter,但是后面不降低或者很缓慢的降低 delay,那么视频播放延时值就会一直很大,而这又是与 webrtc 的理念相悖的,webrtc 是为低延迟而生的

所以,结合 webrtc 的设计理念,JitterEstimator 算是很好的解决了这个问题。

那么这个问题就没有解决办法了吗?其实不然,解决办法在发送端:

  • 发送端通过 GCC 模块,降低发送码率,从而降低丢包率,这样 JitterEstimator 模块又能愉快的工作了

5.3 rtt_multiplierrtt_mult_add_cap 遗留项分析

前面分析 GetJitterEstimate() 函数接口的时候,还没有分析 rtt_multiplierrtt_mult_add_cap 这两个参数,下面来分析一下,见 JitterEstimator::GetJitterEstimate() 函数内用到其的地方:

TimeDelta JitterEstimator::GetJitterEstimate(
    double rtt_multiplier,
    absl::optional<TimeDelta> rtt_mult_add_cap) {
  // 获取 jitter delay 值
  TimeDelta jitter = CalculateEstimate() + OPERATING_SYSTEM_JITTER;
  Timestamp now = clock_->CurrentTime();

  if (now - latest_nack_ > kNackCountTimeout)
    nack_count_ = 0;

  ...
  // jitter delay 值根据 nack 信息,进行修正

  if (nack_count_ >= kNackLimit) {
    if (rtt_mult_add_cap.has_value()) {
      jitter += std::min(rtt_filter_.Rtt() * rtt_multiplier,
                         rtt_mult_add_cap.value());
    } else {
      jitter += rtt_filter_.Rtt() * rtt_multiplier;
    }
  }
...
}

JitterEstimator 对象中,会统计 nack 的次数,并将结果记录到 nack_count_ 变量中。
可以看到,当 nack 的次数大于 nack 阈值 kNackLimit (默认为 3) 时,会在得到的 delay 值上,再额外累加一个 rtt 值。
是否会真的累加,取决于 rtt_multiplierrtt_mult_add_cap 是否有值,但是实际上,这两个参数默认都没有值,那么默认情况下,delay 值不会累加上一个 rtt 值。
但是这两个变量给这里留了一个口子,用户可以将其开启,让最终的 delay 值更大。

6. 还有一个值得重点关注的问题

实际上这里的测试有一个地方不是很严谨,或者说与实际场景可能不是很符号的地方:

  • 这里每轮测试设置的丢包率是固定的,但是实际上算上 nack 占用的码率,丢包率在实际场景中不一定还是固定值

我们在做其它弱网测试时,也要好好考虑这个问题,不然可能在测试环境运行 ok,但是实际跑起来就不行了。

posted @ 2025-06-20 14:31  重返科韵路  阅读(213)  评论(0)    收藏  举报