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_multiplier 和 rtt_mult_add_cap 遗留项分析
前面分析 GetJitterEstimate() 函数接口的时候,还没有分析 rtt_multiplier 和 rtt_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_multiplier 或 rtt_mult_add_cap 是否有值,但是实际上,这两个参数默认都没有值,那么默认情况下,delay 值不会累加上一个 rtt 值。
但是这两个变量给这里留了一个口子,用户可以将其开启,让最终的 delay 值更大。
6. 还有一个值得重点关注的问题
实际上这里的测试有一个地方不是很严谨,或者说与实际场景可能不是很符号的地方:
- 这里每轮测试设置的丢包率是固定的,但是实际上算上 nack 占用的码率,丢包率在实际场景中不一定还是固定值
我们在做其它弱网测试时,也要好好考虑这个问题,不然可能在测试环境运行 ok,但是实际跑起来就不行了。

浙公网安备 33010602011771号