WebRTC NetEq 源码 分析:网络来的音频,怎样变成每 10 ms 连续播放的声音
WebRTC NetEq 源码分析:网络来的音频,怎样变成每 10 ms 连续播放的声音
本文介绍 WebRTC NetEq 的缓存、延时估计、时间伸缩与丢包补偿机制,并结合默认控制器的源码展开。
类名和参数对应本文分析的实现,不同 WebRTC 版本及 Field Trial 配置可能有所不同。
文中的数值用于解释计算过程,不是网络实测结果。
文章目录
视频 Jitter Buffer 要解决的问题,可以概括成“这一帧什么时候交给解码器,什么时候显示”。
音频也需要抗抖动,但它有一个更苛刻的约束:播放设备会持续要数据。
假设当前音频为 48 kHz。NetEq 每次输出 10 ms,也就是每声道 480 个采样点。
不管网络刚刚送来了三个包,还是已经几十毫秒没有新包,它都得面对下一次取音频。
多出来的数据不能无限堆着,缺少的数据也不能靠阻塞音频线程来等。
NetEq 就工作在这两个节奏之间:
- 网络侧按包到达,可能突发、乱序、丢失。
- 播放侧按固定时长消费 PCM,需要持续、平滑地前进。
理解 NetEq,最有用的切入点是跟着这两个节奏走:包进来以后放在哪里,播放时从哪里取,
缓存偏多、偏少和真正缺包时又分别做什么。类之间的关系,可以在这个过程中慢慢补齐。
一、先把接收和播放两条线分开
在 WebRTC 音频接收链路中,RTP 载荷经过 ChannelReceive::OnReceivedPayloadData() 进入
NetEq::InsertPacket()。接收线程做的是提交数据,这个调用本身并不意味着立刻播放。
播放由另一条调用链驱动。音频设备请求数据,经 AudioTransportImpl::NeedMorePlayData()、
音频混音器,最终来到 ChannelReceive::GetAudioFrameWithInfo() 和 NetEq::GetAudio()。
设备原始回调的块长可能因平台而异;这里讨论的 10 ms,是 NetEq 向上层输出音频的粒度。
这里有两个容易被混在一起的缓存。
PacketBuffer 保存编码数据。插入时,NetEq 会识别 payload type、处理 RED、调用
解码器的载荷解析接口,把可解码的帧放进按时间组织的队列。一个 RTP 包和一个内部编码帧
不必永远一一对应,具体取决于编码格式及其 ParsePayload() 实现。
SyncBuffer 保存 PCM。它既保留算法需要的历史音频,也保存已经生成、尚未播放的音频。
FutureLength() 关注的是后面这一部分。
因此,一次 GetAudio() 不等于解码一个 RTP 包。如果 SyncBuffer 中已经有足够的
待播放采样,本轮可以直接取出 10 ms;只有需要补充 PCM 时,才继续提取编码帧、解码或
做丢包补偿。这也是编码帧长度与播放输出长度可以不同的原因。
1.1 RTP 时间戳负责“排在音频的哪里”
序列号适合描述 RTP 包是否缺失、是否重传;RTP 时间戳描述的是音频在媒体时间轴上的位置。
以 48 kHz 为例,时间戳增加 480,表示前进了 10 ms,而不是“又收到一个包”。
立体声也一样。左右声道在同一时刻采样,时间戳按每声道采样位置推进,不会因为有两个
声道就把增量乘二。
当前 NetEq 还有 TimestampScaler,用于处理某些编码器的 RTP 时钟与内部采样率不同的
情况。读具体编码格式时,需要分别确认 RTP clock rate 与解码采样率,不能默认二者总是相同。
1.2 为什么不能只缓存固定数量的包
缓存三个包到底是多少延时,首先取决于每包包含多少音频。三个 10 ms 包是 30 ms,
三个 60 ms 包就是 180 ms。即使包时长固定,网络状况也不会一直不变。
更麻烦的是,中间有缺口时,队列看上去覆盖了 100 ms 的时间轴,并不代表真的存有
100 ms 可以连续解码的音频。后面分析统计量时,还会回到这个区别。
NetEq 因而需要回答两个问题:当前网络值得等多久,以及实际播放位置距离这个目标还有多远。
二、NetEq 观察的“延时”,从哪里算出来
先考虑一组每包 20 ms 的音频。发送端媒体时间依次是 0、20、40、60 ms,
接收端观察到的到达时间依次是 1000、1020、1060、1065 ms。
| 包 | 媒体时间,相对首包 | 本地到达时间 | 到达间隔 | 媒体间隔 |
|---|---|---|---|---|
| A | 0 ms | 1000 ms | — | — |
| B | 20 ms | 1020 ms | 20 ms | 20 ms |
| C | 40 ms | 1060 ms | 40 ms | 20 ms |
| D | 60 ms | 1065 ms | 5 ms | 20 ms |
C 明显晚了一些,D 又追了上来。但本地的 1000 ms 和发送端的 0 ms 不在同一个时钟上,
不能直接相减后宣称“网络单向延时为 1000 ms”。
可以比较的是两个差值:
d i = max ( 0 , ( a i − a r e f ) − r i − r r e f f s ) d_i=\max\left(0,\ (a_i-a_{\mathrm{ref}}) -\frac{r_i-r_{\mathrm{ref}}}{f_s}\right) di=max(0, (ai−aref)−fsri−rref)
其中,
a
a
a 表示接收侧到达时间,
r
r
r 表示展开后的 RTP 时间戳。
公式中时间统一用毫秒,
f
s
f_s
fs 用 samples/ms;48 kHz 对应 48 samples/ms。
代入上面的数据,以 A 为参考,A、B、C、D 的相对延时分别是 0、0、20、5 ms。
发送端与接收端之间未知的固定时钟偏移,在差分中被消掉了。
这个量描述的是相对参考路径多等了多久,不能据此恢复真实的绝对单向网络时延。
2.1 当前实现使用 PacketArrivalHistory
对应代码是 PacketArrivalHistory::Insert() 和 GetPacketArrivalDelayMs()。
默认 DecisionLogic 创建一个 2000 ms 的历史窗口。这个窗口按 RTP 媒体时间裁剪,
不是简单地保留“本地时钟最近两秒调用 Insert 的所有包”。
参考包也不是永远固定为第一个包。实现通过单调队列维护窗口内较小的
“到达时间减媒体时间”偏移,把它作为当前参考;另外维护较大的偏移,用来观察延时范围。
参考会随着窗口移动而变化。
还有一个对日志分析很有用的细节:这里的到达刻度来自 TickTimer。
NetEqImpl::GetAudioInternal() 推进 tick,默认一个 tick 为 10 ms。
它并不是直接取网卡时间戳,也不是把 RtpPacketInfo 中更精细的到达时间原样代入。
所以上表的 1065 ms 是便于解释差分的连续时间示例;在当前实现的实际计算中,
还要受到 tick 粒度以及整数毫秒换算的影响。两个很接近的到包事件,可能落在同一个 tick。
乱序也有专门处理:合格的乱序包会被记入历史,但不会像最新媒体时间的包那样更新
最小、最大单调队列;它的相对延时仍可交给乱序优化器。不能把整个历史结构理解成
“把所有到包时间丢进一个普通最小值窗口”。
三、目标缓存延时:既要抗晚到,也要考虑乱序
包晚到了 20 ms,并不表示立刻把目标延时设成 20 ms。一个偶发样本和持续抖动,需要的
处理并不相同。当前 DelayManager 把这个问题分成两部分。
第一部分是 UnderrunOptimizer:如果缓存太浅,后续播放会不会断粮。
第二部分是 ReorderOptimizer:对于乱序晚到的包,多等一会儿是否值得。
3.1 先观察一段时间里最坏的晚到
默认参数下,UnderrunOptimizer 先收集约 500 ms 内的最大相对延时,再把这个峰值
送进直方图。注意,窗口在后续 Update() 到来、且经过时间超过门限时结算,
不是另起一个严格每 500 ms 唤醒的线程。
默认启用乱序优化器时,乱序样本不会送进这条欠载统计,而是参与下一节的乱序估计。
如果关闭乱序优化器,欠载统计才会同时接收这部分样本。
假设一段时间的到包相对延时是:
0, 0, 10, 20, 0, 40, 10, 0 ... ms
进入直方图的是这段时间里的 40 ms,而不是把这些值逐个等权放进去。
这样统计关注的是“一段播放时间里遇到的峰值”,短暂但足以造成断续的突发晚到不会
轻易被大量正常包稀释。
直方图有 100 个桶,每个桶宽 20 ms;默认分位参数为 0.95。
找到对应桶后,返回该桶上沿:
B u n d e r = ( i 0.95 + 1 ) × 20 m s B_{\mathrm{under}}=(i_{0.95}+1)\times20\ \mathrm{ms} Bunder=(i0.95+1)×20 ms
例如分位落在索引 2 的桶,即约 40~60 ms 区间,建议延时就是 60 ms。
旧统计还会逐渐遗忘,默认遗忘因子为 0.983,启动时另有权重处理。
这里的“95%”应当读成带遗忘的峰值直方图分位参数。它既不是原始逐包延时的
精确第 95 百分位,也不是“保证 95% 的包一定可以播放”。
3.2 等一个乱序包,代价是什么
乱序优化器把正常按序到达的样本放在低延时桶,把乱序样本按相对延时放入相应桶。
接着比较不同等待长度的代价。
把源码中的 Q30 定点数运算换成便于阅读的实数形式,可以写成:
C ( B ) ≈ max ( 0 , B − B u n d e r ) + 100 c l o s s P l a t e ( B ) C(B)\approx\max(0,B-B_{\mathrm{under}}) +100\,c_{\mathrm{loss}}\,P_{\mathrm{late}}(B) C(B)≈max(0,B−Bunder)+100clossPlate(B)
第一项表示超过基础抗抖动建议后,还需要额外等待多少毫秒;第二项把仍然来不及播放的
乱序概率换算成等待代价。默认
c
l
o
s
s
=
20
c_{\mathrm{loss}}=20
closs=20,含义是用 20 ms 的代价衡量
一个百分点的损失。
这是桶上的离散搜索,不是连续优化。源码在桶索引
i
i
i 上用
B
=
i
×
20
B=i\times20
B=i×20 ms
计算额外等待项,减去截至该桶的概率质量后得到剩余概率,最终返回所选桶的上沿。
因此,上式解释的是代价结构,不能忽略桶边界后拿它复刻逐毫秒结果。
直觉上,如果多等 20 ms 能明显减少乱序导致的丢弃,它可能值得;
如果只有极少数包晚得离谱,让所有音频都为它们持续等待就未必划算。
3.3 算法建议还要经过外部约束
DelayManager 取两个优化器建议的较大值。尚未形成有效估计时,默认启动目标为 80 ms。
之后 DecisionLogic::TargetLevelMs() 还会经过 DelayConstraints。
约束主要有三类:
| 约束 | 在当前实现中的含义 |
|---|---|
| 有效最小延时 | 综合基础最小值与 SetMinimumDelay() 提交的要求,后者可来自音视频同步 |
| 最大延时 | 非零时作为上限;设置为零表示取消这一上限 |
| 缓存容量 | 已知包时长时,目标不超过最大包容量对应时长的 75% |
简化地说,就是先抬到有效最小值,再施加上限。基础最小值本身也会先限制在可用范围内。
这里的“最小”并不是无条件压过所有配置,容量和上限冲突时不能只看一个字段。
默认参数集中看如下,便于和实际构建的 Field Trial 对照:
| 参数 | 默认值 | 作用 |
|---|---|---|
| 启动目标 | 80 ms | 估计尚未建立时使用 |
| 历史窗口 | 2000 ms | 维护媒体时间与到达时间的相对关系 |
| 欠载统计重采样间隔 | 500 ms | 先观察一段时间的延时峰值 |
| 欠载直方图分位参数 | 0.95 | 从峰值分布选择等待长度 |
| 欠载直方图遗忘因子 | 0.983 | 逐渐降低旧样本影响 |
| 乱序优化 | 默认启用 | 单独权衡乱序等待 |
| 乱序直方图遗忘因子 | 0.9993 | 维护乱序概率分布 |
| 每百分点损失代价 | 20 ms | 乱序优化的延时与损失权衡 |
四、有了目标,播放位置怎样追上它
缓存延时不像一个普通变量,赋值以后就能生效。
假设已经有 100 ms 音频排在播放设备前面,把目标从 100 改成 60,并不会让这些音频
凭空消失。反过来,目标从 40 改成 80,也不能让刚收到的数据自动多出 40 ms。
NetEq 主要通过音频时间伸缩改变消费媒体内容的速度:
Accelerate:让一段真实音频稍微缩短,逐步消化积压。PreemptiveExpand:让一段真实音频稍微变长,逐步攒出等待余量。
对上层来说,输出仍然是 10 ms;变化的是这 10 ms 输出对应的原始媒体内容进度。
4.1 当前版本比较的不是一个简单的队列包数
在 DecisionLogic::ExpectedPacketAvailable() 中,当前待解码包正好位于期望时间戳,
且允许做时间伸缩时,代码先计算播放位置:
playout_timestamp = target_timestamp - sync_buffer_samples
target_timestamp 是接下来需要接续的媒体位置;SyncBuffer 中还有尚未播放的采样,
因此要减掉这一部分,才能回到当前播放侧的位置。
随后调用 PacketArrivalHistory::GetDelayMs(playout_timestamp),得到这个播放位置
相对历史参考的延时,记为
D
D
D。
这里传入的是播放时间戳,而不是最新到达包的时间戳。同一个历史模型,既能评价一个
到包事件晚了多少,也能评价当前播放位置落在参考时间线后面多少。
设目标为 L L L,高门限为 H H H,则当前代码的主要条件是:
L = T a r g e t L e v e l M s ( ) L=\mathrm{TargetLevelMs()} L=TargetLevelMs()
H = L + G e t M a x D e l a y M s ( ) + 20 m s H=L+\mathrm{GetMaxDelayMs()}+20\ \mathrm{ms} H=L+GetMaxDelayMs()+20 ms
在满足状态限制的前提下:
D >= 4H → 请求 FastAccelerate
允许普通变速,且 D >= H → 请求 Accelerate
允许普通变速,且 D < L → 请求 PreemptiveExpand
其他 → Normal
这个版本的主要变速门限并不是“平滑后的缓存长度大于目标就加速”。
BufferLevelFilter 仍在工作,其他判断和统计也会用到它,但不能用旧版本的简化描述
替换这里实际使用的播放位置相对延时。
例如,
L
=
60
L=60
L=60 ms,历史最大相对延时为 40 ms,则
H
=
120
H=120
H=120 ms。
在状态允许时,
D
=
50
D=50
D=50 ms 倾向预扩展,
D
=
90
D=90
D=90 ms 正常播放,
D
=
130
D=130
D=130 ms 才进入普通加速区间。目标和高门限之间留有一段余量,避免轻微波动就反复变速。
4.2 决策允许,不代表本轮必然完成变速
普通时间伸缩有间隔限制,相关常量 kMinTimescaleInterval 为 5 个 tick,
即默认刻度下约 50 ms。它还受上一次运行模式、DTMF、禁止变速配置等条件约束。
快速加速的请求分支较早判断,但真正执行快速模式还要看
NetEq::Config::enable_fast_accelerate,这个开关默认是 false。
执行层也需要足够的 PCM,典型处理窗口约 30 ms。若本轮凑不出需要的音频,
或找不到合适的相关片段,操作可能不能按请求完成。分析日志时要同时区分
控制器请求的 Operation 和实际执行后的 Mode。
五、加速与预扩展到底改了什么
直接把音频播放采样率调快,会连带改变音高。NetEq 的时间伸缩采用的是在合适的信号片段上
做相关性分析、重叠和拼接,让音频长度变化,同时尽量维持听感。
以近似周期性的有声音频为例,相邻几个周期的波形比较接近。
加速时可以在匹配的位置缩短一段,再用交叠过渡连接起来;预扩展则利用相近的片段增加长度。
无声、低能量与非周期信号有不同的处理条件,不能把所有音频都想象成可以随手剪掉一个周期。
从收支关系看更容易理解。若一次处理把原来 N N N 个采样缩成 N − Δ N N-\Delta N N−ΔN 个:
相同的输出时长,消耗了更多原始媒体内容
→ 媒体播放位置前进得更快
→ 积压逐渐减少
预扩展正好相反:
用较少的原始媒体内容填满相同的输出时长
→ 媒体播放位置前进得稍慢
→ 新到数据有机会积攒起来
这里没有把设备的 48 kHz 改成另一个频率,也不是按包随意丢弃编码音频。
到设备侧的 PCM 仍满足固定输出格式。
因此,目标延时变化通常是一段收敛过程。看到目标已经升高、当前延时还没有同步升高,
不一定意味着配置没有生效;要看后续有没有预扩展,以及是否有持续可用的真实音频。
六、真正缺包以后,Expand、PLC 和 Merge 各做什么
上面讨论的是“该播放的包已经在了”。如果到了要取音频的时候,期望的包还没到,
处理方式就不同了。
图中省略了部分错误保护和具体 CNG 分支,但保留了最关键的区别:
有未来包,不等于中间缺口已经被填上。
6.1 先尝试解码器自己的补偿能力
进入缺包处理时,当前 NetEqImpl 会尝试解码器的 GeneratePlc()。
如果解码器能生成相应音频,就使用编码器相关的 PLC;未生成可用数据时,再走 NetEq
的通用 Expand。
PLC 是 Packet Loss Concealment。它根据已有信号和解码状态合成缺失时段的声音,
并没有恢复网络中丢失的原始信息。
通用 Expand 利用历史 PCM 构造可接续的波形,并随着缺失持续而衰减。
缺一小段时,连续性可能维持得不错;缺失越来越长时,算法能做的事会越来越有限。
连续丢包后听到声音变弱、转向噪声或静音,应结合这条路径分析。
6.2 PreemptiveExpand 和 Expand 不能混读
两者名字里都有 Expand,但触发条件与目的不同:
| 操作 | 真实音频是否可用 | 目的 |
|---|---|---|
| PreemptiveExpand | 有可处理的真实音频 | 放慢媒体进度,增加缓冲余量 |
| Expand / Codec PLC | 应接续的音频缺失或不可用 | 为本轮播放合成替代信号 |
| Merge | 真实音频重新可接续 | 把补偿信号平滑接回真实音频 |
因此,预扩展比例偏高和丢包补偿比例偏高,不能得出同一个结论。
前者可能是目标缓存上调或节奏调整,后者才更直接地说明播放缺少了真实音频。
6.3 包重新来了,也可能再等一小段
如果下一包的时间戳在未来,FuturePacketAvailable() 会比较时间戳缺口、
已经生成的补偿采样数以及缓存覆盖的时长,判断是否到了恢复时机。
从通用 Expand 恢复时,通常需要 Merge 处理接缝;从 codec PLC、CNG 恢复时走相应分支,
并不是所有恢复都强制经过同一个 Merge 算法。
此外,PostponeDecode() 会避免在长时间安静或严重衰减后刚来一点数据就马上恢复,
紧接着又断粮。当前条件涉及目标缓存的 50%、上一轮是否在 CNG/Expand 状态,
以及 Expand 的衰减程度;缓存中存在未来 DTX/CNG 信息时也会影响判断。
这解释了一个常见现象:抓包看见新音频已经到达,播放却没有在这一刻立刻恢复。
需要先看媒体时间戳和状态机,不能只拿到包时刻判断。
6.4 DTX、CNG、FEC 和 NACK 各有边界
DTX 是发送侧有意减少安静时段的数据;CNG 根据噪声参数生成舒适噪声。
这一期间缺少普通语音包,不能直接记成网络连续丢包。NetEq 对相关状态有独立分支,
内部 CNG 持续过久也有超时处理。
FEC 和 RED 提供的是冗余信息,在格式、配置和到达时机允许时可用于恢复音频;
PLC 则是没有原始内容时的信号补偿。这两类能力不能混为一谈。
NACK 需要额外的往返时间。重传包即使最终收到了,若媒体播放位置已经越过它,
也不一定还有价值。NetEq 支持可选的 NACK 相关逻辑,但它不会因为“启用了重传”
就让每次 GetAudio() 停下来等待。
七、把几种状态放回一次网络波动里
设一条音频流原本稳定,接收侧的等待余量也足够。网络随后出现一次短暂阻塞。
刚开始没有新包时,SyncBuffer 里已经解码的 PCM 仍可输出;PacketBuffer 里先前积攒的
编码帧也还能继续解码。因此,“网络停了 20 ms”不等于“声音立刻断了 20 ms”。
阻塞超过已有余量后,期望音频接不上,NetEq 才进入 PLC/Expand。
如果后来一次到达一批包,还要区分哪些包已过播放时机、哪些包能够接续、哪些包位于未来。
恢复时可能发生 Merge,而延时估计也会观察到这次晚到。
如果目标随后升高,预扩展有助于建立新的余量;如果一批数据使播放延时过大,
后面又可能发生加速。这些操作可以出现在同一段故障的不同阶段,并不矛盾。
因此,看到“先 Expand,后 Accelerate”的日志,不应简单解释成算法在相反方向上乱调。
前一个动作解决的是眼前缺音频,后一个动作解决的可能是恢复后的积压。
八、编码帧不是 10 ms,为什么还能按 10 ms 播放
音频包携带多少毫秒的内容,由编码格式和打包方式决定,不必跟随播放回调的粒度。
例如,48 kHz 下的一帧 20 ms 音频,解码后是每声道 960 个采样,
刚好可以分成两次 10 ms 输出。一帧 60 ms 的音频则可以支撑六次输出。
如果帧长不能被 10 ms 整除,仍然可以通过 PCM 缓存衔接。
为了看清跨帧取数的过程,假设某个解码器每帧输出每声道 1024 个采样,采样率为 48 kHz:
T f r a m e = 1024 48000 × 1000 ≈ 21.333 m s T_{\mathrm{frame}}=\frac{1024}{48000}\times1000 \approx21.333\ \mathrm{ms} Tframe=480001024×1000≈21.333 ms
而 NetEq 每轮取每声道 480 个采样:
T o u t = 480 48000 × 1000 = 10 m s T_{\mathrm{out}}=\frac{480}{48000}\times1000=10\ \mathrm{ms} Tout=48000480×1000=10 ms
两者不整除时,一次输出可以先取上一帧剩余的 PCM,再接上下一帧的 PCM。
这张图只展示正常播放时的采样收支,忽略启动历史、交叠区与时间伸缩,也不限定编码格式。
实际取数由 SyncBuffer 管理,编码帧的边界不需要成为播放回调的边界。
8.1 时间戳跟随媒体时长,不能跟随取数次数
假设 RTP 时钟与采样率均为 48 kHz,一包携带一帧连续音频,那么 20 ms 帧的
RTP 时间戳应增加 960,上述 1024 采样帧则应增加 1024。
不能因为播放侧每次请求 10 ms,就把所有编码包的 RTP 增量都改成 480。
立体声也要区分每声道采样数与 PCM 数组长度。每声道 960 个采样,可以存成
1920 个交错数值,但代表的媒体时长仍然是 20 ms,RTP 增量也仍然是 960。
对于不能整除毫秒的帧长,也不应逐帧截断后再累计播放进度。
某些策略计算确实会暂时使用整数毫秒,例如容量估算;实际媒体推进应依靠采样数与
RTP 时间戳,避免把毫秒量化误差积成长期漂移。
8.2 帧长还会影响缓存的收支节奏
每包 20 ms 的音频,在到达节奏平稳时,大致每两次播放请求补充一包;
每包 60 ms 时则补充得更少、更集中。因而即使网络没有明显抖动,
缓存长度也可能随着“整包进入、每 10 ms 消费”的节奏上下变化。
帧长还影响一次缺包涉及的音频时长。缺一个 20 ms 包与缺一个 60 ms 包,
包数统计相同,留给 PLC 的缺口却不同。评估音频质量时应结合媒体时长,
不能只比较丢了几个包。
因此,排查缓存异常前,应先确认载荷能解析成完整编码帧、解码长度与声明一致,
并且 RTP 时间线连续。编码帧边界和播放块边界不同是正常现象,
两者描述的总媒体时长不同才是问题。
九、看统计时,先确认“延时”到底是哪一个
NetEq 的统计字段很多,同名或近似命名很容易让人误读。
当前实现至少要区分下面几组量。
9.1 目标、平滑缓存和当前缓存
TargetLevelMs() 是控制目标。
FilteredCurrentDelayMs() 则把控制器平滑后的 PacketBuffer 时间跨度,
加上 SyncBuffer 中的待播放采样,再换算成毫秒:
FilteredCurrentDelay
= (filtered_packet_buffer_span + sync_buffer_future_samples) / sample_rate
这个值也会被音视频同步模块使用。但时间跨度中可能包含缺口,不能把它当成
“现在一定能连续播这么久的真实音频”。
网络统计中的 current_buffer_size_ms 使用当前 PacketBuffer 的样本数量估计,
再加 SyncBuffer 待播放量,和上述平滑跨度不是同一种口径。
因此,同一时刻两个数字不同,不一定是统计出错。
9.2 累计 jitter buffer delay 需要除以样本数
StatisticsCalculator::JitterBufferDelay() 累加的主体是:
jitter_buffer_delay_ms += packet_waiting_time_ms * num_samples
jitter_buffer_emitted_count += num_samples
这个统计在 NetEqImpl::ExtractPackets() 提取编码包时记入。
所以原始累计字段不能直接作为“当前缓存了多少毫秒”展示。
对一段观测区间,更合适的读法是:
D ‾ w a i t = Δ j i t t e r _ b u f f e r _ d e l a y _ m s Δ j i t t e r _ b u f f e r _ e m i t t e d _ c o u n t \overline{D}_{\mathrm{wait}} =\frac{\Delta\mathrm{jitter\_buffer\_delay\_ms}} {\Delta\mathrm{jitter\_buffer\_emitted\_count}} Dwait=Δjitter_buffer_emitted_countΔjitter_buffer_delay_ms
分母非零时,这给出按音频样本加权的编码包等待时长。
它不包含完整的后续 PCM 等待、音频设备缓冲,更不是端到端嘴到耳延时。
若使用上层转换后的统计字段,还要确认其时间单位是否已从毫秒转换成秒。
jitter_buffer_target_delay_ms 同样按样本累计目标延时。
名称容易误导的 jitter_buffer_minimum_delay_ms,在这条调用路径中记录的是
UnlimitedTargetLevelMs(),也就是未经过外部延时约束的算法目标,
不是用户设置的最小延时参数。
这个区别很有用:如果约束后的目标长期大于算法建议,差额可能来自业务最小延时
或音视频同步,而不全是网络抖动。
9.3 补偿与时间伸缩,要一起看
部分网络统计中的比例采用 Q14 表示,换成普通比例需要除以 16384。
不要把 8192 这样的原始值直接解释成百分比。
累计的 concealed samples、时间伸缩插入或移除的 samples,适合取区间差值观察:
| 现象 | 优先结合的证据 |
|---|---|
| 声音断续,concealed samples 增加 | 到包空档、时间戳缺口、晚包、解码失败 |
| 延时升高,预扩展增多 | 网络目标是否上调,是否收到同步最小延时要求 |
| 延时偏高,加速持续发生 | 突发积压、媒体时间戳速率、收发时钟节奏 |
| 包最终都到了,仍有补偿 | 是否超过播放时限,是否出现突发晚到或接收任务停顿 |
| 长时间播放后缓冲不断漂移 | 编码帧长、RTP clock rate、声道采样计数与整数截断 |
持续的时钟节奏差也可能表现为长期需要少量时间伸缩,不能仅凭出现 Accelerate
就断定链路拥塞。反过来,codec PLC 与通用 Expand 的输出效果不同,
只看“解码调用成功”也不足以判断真实音频是否一直连续。
十、实际排查可以从一段 10 ms 输出倒着找
假设问题是“每隔一段时间就有一次短促卡音”。比较有效的顺序是先找出那一轮
GetAudio() 的输出类型和实际 Mode。
如果是正常输出,但扬声器仍卡,继续往混音、设备回调和线程调度查;
如果是 PLC/Expand,回到这一轮期望的 RTP 时间戳,找它对应的包是否到达、何时到达、
载荷是否被成功解析。
接着再看目标和播放延时。目标很低且多次欠载,可能是约束或估计适应不够;
目标已很高但依然没有对应数据,则更需要检查突发阻塞、包完整性和时间线。
为了把一次播放异常与对应的数据关联起来,一组有用的记录应至少包含:
RTP:SSRC、sequence number、timestamp、payload type、到达批次
解码:载荷长度、每声道帧长、声道数、解码结果
NetEq:Operation、实际 Mode、目标延时、播放延时、缓存量
播放:10 ms 取数是否持续、设备侧是否及时消费
重点不是给每一个采样都打日志,而是用同一条媒体时间轴把包、解码和播放对起来。
尤其不要用大量同步文件日志干扰音频回调,否则测量本身也可能制造调度问题。
十一、源码阅读入口
下表对应本文使用的当前实现。阅读时可以先看调用入口,再看决策和算法,
不必一开始就逐行钻进信号处理代码。
| 路径 | 主要入口或职责 |
|---|---|
audio/channel_receive.cc | OnReceivedPayloadData()、GetAudioFrameWithInfo(),接收与播放两端入口 |
audio/audio_transport_impl.cc | NeedMorePlayData(),播放请求进入混音链路 |
api/neteq/neteq.h | NetEq 配置、操作类型与统计接口 |
modules/audio_coding/neteq/neteq_impl.cc | InsertPacket()、GetAudioInternal()、ExtractPackets() 及操作执行 |
modules/audio_coding/neteq/packet_buffer.cc | 编码帧保存、排序、提取与容量管理 |
modules/audio_coding/neteq/sync_buffer.cc | PCM 历史与待播放部分 |
modules/audio_coding/neteq/packet_arrival_history.cc | RTP 时间展开、相对到达延时与历史窗口 |
modules/audio_coding/neteq/delay_manager.cc | 组合欠载和乱序建议,读取默认配置 |
modules/audio_coding/neteq/underrun_optimizer.cc | 峰值重采样、延时直方图与分位数 |
modules/audio_coding/neteq/reorder_optimizer.cc | 乱序等待代价 |
modules/audio_coding/neteq/delay_constraints.cc | 最小延时、最大延时与容量限制 |
modules/audio_coding/neteq/decision_logic.cc | 本轮 Normal、Expand、Merge 和时间伸缩决策 |
modules/audio_coding/neteq/accelerate.cc | 缩短真实音频 |
modules/audio_coding/neteq/preemptive_expand.cc | 延长真实音频 |
modules/audio_coding/neteq/expand.cc、merge.cc | 通用缺包补偿及恢复接缝 |
modules/audio_coding/neteq/statistics_calculator.cc | 缓存等待、补偿与时间伸缩统计 |
api/audio_codecs/audio_decoder.h | 解码器的采样率、声道数、帧解析与丢包补偿接口 |
延伸阅读:视频 Jitter Buffer 分析稿、
CSDN 视频 Jitter Buffer 文章。
NetEq 只能决定音频自身怎样平稳向前走。如果视频天然比音频慢了 100 ms,
还需要另一层控制把两路播放约束联系起来。这个过程放在
《WebRTC 音视频同步源码分析:从 RTP 时间戳到扬声器与画面》
中继续展开。
火山引擎视频云技术社区,是面向 AI 音视频开发者的技术交流平台。这里汇聚源自抖音、豆包等亿级 DAU 产品的 RTC、直播、点播、AI 媒体处理、音视频互动技术,提供接入指南、最佳实践、性能调优、场景案例、Demo 代码、开源项目、白皮书和 API 文档。社区汇聚官方工程师与一线开发者,为 AI 视频通话、数字人、AI 视频处理等应用的开发与落地提供技术支持。
更多推荐
所有评论(0)