抖动缓冲:同一条网络轨迹下,三种策略谁更好?

Posted on in Tech

Abstract 抖动缓冲:同一条网络轨迹下,三种策略谁更好?
Authors Walter Fan
Category Tech
Version v1.0
Updated 2026-11-01
License CC-BY-NC-ND 4.0

大纲

展开看看
  • 问题:网络包到达时间不齐,播放端却必须每 10ms 吐一块音频——缓冲浅了卡顿,深了延迟
  • 三个量容易混:缓冲预算 / 目标水位 / 平均等待 / 缓存跨度,不是一个数
  • 玩法:小固定低延迟易欠载、大固定最稳但等待最长、自适应随到达延迟动态调整
  • 实验:同一段真人语音、同一网络轨迹、同一 seed,公平对比
  • 结果:没有银弹——自适应中等待少欠载,但抖动峰值当下照样卡一下
  • 延伸:PLC 静音补偿 vs Rebuffer 重新蓄水,是另一层的取舍

做音视频这些年,我对“抖动缓冲”一直有个模糊的印象:不就是给网络波动留点缓冲嘛,留多一点不就稳了吗?可真等我要给一个播放端调这个参数,才发现水面下全是取舍。缓冲线程深一分,卡顿少一分,可延迟又涨一分;浅一分,延迟下来了,又容易缺音频。到底多大的缓冲才算好?变量太多,我决定不再拍脑袋——写了个小程序,把三种策略放在同一条网络轨迹上,同一段真人语音,跑给你看。

一句话先撂在开头:没有哪种策略在所有网络下都最好。 小固定低延迟但容易卡,大固定最稳但等待长,自适应是动态找平衡——它比小固定稳、比大固定快,但真撞上突然的抖动峰值,它照样会卡那么一下。


问题:包到得不齐,声音却不能断

电话或者实时音频里,发送端把声音切成一个个 10ms 的小包,隔 10ms 发出一个。听起来很规律,对吧?可网络不是铁轨,这些包到达的时间并不整齐。有的路上堵,晚到 30ms;有的吃了一记突发,晚到一整段;极少数干脆丢了。

而播放端是个死心眼:它每 10ms 必须往声卡里吐 480 个样本。该播的那一小段音频如果还没到,你有两个选择:吐出个空洞(听起来就是咔哒一声,人声变断续),或者等一等——等意味着把后面所有的声音都往后推,也就是增加了延迟。

抖动缓冲(jitter buffer)就是夹在网络和播放之间的那个蓄水池。 先到的包攒在池子里,播放端从池子里慢慢取水。池子深,抗得住网络波动,但水在池子里呆得久,延迟高;池子浅,延迟低,可一波颠簸就把池子抽干了。

这个词你可能见过:WebRTC 里的 NetEQ、各类语音网关、视频播放器的秒开,背后都藏着这个东西。它解决的问题不是“包没到”,而是“包到了但到得不整齐”——这是两码事,丢包是另一套叫丢包隐藏(PLC)的功夫,后面会讲。


三个容易混的量:预算、水位、等待

动手之前,得先把几个概念分清,不然数字摆出来全是误会。我们做实验时会用到这些:

量 一句话 干什么用
缓冲预算(target) 为网络波动预留的时间,比如 20ms 固定策略里保持常数,是配置
目标水位(target level) 预算 + 40ms 算法前视 播放决策的比较基准,是控制目标
平均等待(wait) 音频从进桶到真正播出的时间 最终测量结果,是感受
缓存跨度(span) 桶里最远那包离游标多远 表示“已经有数据”,不代表能连续播

注意,这三/四个数不是一回事。“小固定 20ms”不是说整个缓冲只等 20ms。 我们给所有策略都额外加了 40ms 的算法前视,用来做波形匹配和交叠拼接(WSOLA)。所以 20ms 预算实际对应约 60ms 的目标水位,实测平均等待又约 61.5ms。配置、控制基准、测量结果,三个姿势不同。

那 40ms 前视是什么?处理当前这一小段之前,先往后多缓存一小段,好找一段波形相似的音频,让新旧片段平滑衔接,而不是“咔嚓”一下从一块硬切到另一块。这些都是已经收到的样本,不算偷看未来。代价就是多等一点点。


三种策略:玩法各不同

这次同台竞技的是三个选手:

  • 小固定(fixed-20):缓冲只留 20ms 预算。等待最少,但网络一颠簸就容易缺水。
  • 大固定(fixed-150):预留 150ms 预算。最稳,但就算网络已经很听的话了,它还是老老实实等那么久。
  • 自适应(adaptive):初始 60ms,范围 40–200ms。它一边走一边观察到达延迟,高了就加缓冲,稳了就慢慢收——这才是名字里“自适应”的意思。

自适应具体怎么动?它维护一个最近 2 秒的网络延迟分布,算出一个 P95(95% 的包都不会超过的时间)作为目标,再加一点余量。规则是快升慢降:一检测到延迟抬头,立刻把目标往上调;要往下降,得等 500ms 且每次最多降 10ms。这样它既跟得上突发,又不会在小波动里来回抖。

下面是慢慢吃水的部分——WSOLA(波形相似性交叠相加)。缓冲不够了怎么办?少播一点源音频,把已有的声音拉长一点点,比如每 10ms 少推进 0.5ms;积压太多了,就多推进 0.5ms 把声音缩短一点。它保持音高不变,不至于变成怪腔怪调。注意它只移动播放的比例,方向完全由上面的水位判断决定。

一句话:自适应靠“观察到达时间 + 快升慢降的目标 + WSOLA 微调消费”这套组合,去追那个平衡点。


实验:同一轨迹下才叫公平

我下面这段是真实跑出来的。做法是:用同一段 45 秒的真人中文语音(先生读的《朝花夕拾》),先生成同一条网络轨迹(含恒定延迟、随机抖动、一段突发高峰、若干乱序包),再用同一个随机种子,分别喂给三个策略,让它们各自跑完整段。这样唯一的差别就只在策略本身——别的都被锁死了。

用的工具就是我在 ajb-demo 这个项目里写的一个小 C++ 程序,纯离线模拟,不接真声卡、不靠运气,全用固定的虚拟时钟,结果可复现。想自己跑一遍:

make compare INPUT=assets/real/mandarin.wav SCENARIO=phased SEED=42

下面这张表是 45 秒真人语音、phased(有突发)场景的结果:

策略 启动延迟 ms 平均缓冲等待 ms PLC 静音补偿 ms 迟到包 未播放源占比
小固定 20ms 90 61.4 2050 205 4.6%
大固定 150ms 220 187.0 0 0 0%
自适应 130 85.7 159.5 7 0.35%

数据讲得很清楚:

  • 小固定最省等待(61ms),但它最惨——补偿了 2050ms 的静音,丢了 205 个的迟到包,约 4.6% 的内容没能播出来。这就是“卡顿”的本体。
  • 大固定一个包都没丢、一条静音都没补,稳如老狗。代价是平均等待 187ms——打语音时那种“慢半拍”的感觉就是这么来的。
  • 自适应居中:等待 85.7ms,只补了 159.5ms 静音(约为小固定的 1/13),迟到包只有 7 个。它用中等延迟,换来了接近大固定的连续。

换一段更狠的轨迹(恒定延迟加到 100ms、抖动加大),结论方向不变:自适应落后于小固定的等待,但补偿大幅减少;大固定依旧最稳最慢。自适应的长相,永远是“中间的”。


没有银弹:自适应也会卡那么一下

看到这你可能想问:那直接用自适应不就完了?不完全是。 自适应的“聪明”也不免费,它有两个天生的软肋:

  • 它要“先吃亏才知道涨”。 快升慢降是基于已经看到的到达时间。第一次撞上从未见过的突发高峰时,它反应不过来,那一瞬间照样缺音频——所以表里它还有 159.5ms 的补偿,不是零。
  • 5% 的微调很温柔。 它每 10ms 只多或少消费 0.5ms,要挪动 100ms 的水位差,得攒将近 2 秒。所以“涨目标”和“水位真的涨上来”之间,隔着一段慢吞吞的爬坡。

换句话说,自适应防的是“反复的小颠簸”,防不住“措手不及的一记重拳”。真遇到后者,你听出来的还是“卡了一下”,只不过很快它会自己缓过来。

所以“哪个策略最好”这个问法,本身问错了。正确的问法是:你的业务更怕延迟,还是更怕断续?

  • 实时语音、互动直播,宁可偶尔卡一下也不愿慢半拍——小固定更对胃口。
  • 电话会议、有字幕的字幕对齐、讲究稳定的场景,慢一点没关系但不能断——大固定稳。
  • 网络时好时坏、想两头都要一点——自适应是那个找平衡的务实选择。

一句话:没有恒定的“最佳缓冲”,只有跟着你的网络和容忍度走的“够用的缓冲”。


再往下:卡顿本身也有两种治法的取舍

还有个容易忽略的点。真缺了当前这段音频,播放端也有两条路,这跟“小/大/自适应”是正交的两个层面:

欠载策略 做法 代价
PLC 静音补偿 用一段极短的淡变+静音把空洞补上,继续往前播 保持时间线,但迟到的那点音频可能被跳过,能听出中断
Rebuffer 重新蓄水 停下来等,冻结播放位置,让迟到音频攒够再续 能保留等得到的音频,但产生停顿和额外延迟

对按块生成、可靠送达的语音合成(TTS),偶尔生成慢了,用 Rebuffer 保留整句更自然;实时对话更在意响应时限,就用 PLC 别停。但 Rebuffer 救不回真正丢了的包——等到底也等不来。这两条路怎么选,是另一层判断,别和缓冲大小混在一起。


想亲手试试?代码在 GitHub 上

上面的数字不是我拍脑袋编的,你把代码拉下来就能复现。我把它做成了一个独立的小项目:ajb-demo(C++20)。它做了一件很适合学习的事:把网络和播放放进同一个确定的循环里,再用真声卡把你听到的和数据对上。 随机、时间全靠固定种子,所以同样的输入、同样的轨迹,跑几次结果一模一样——你可以放心在参数间来回比,不会因为“今天运气好”得出幻觉。

跑起来不难,需要 C++20 编译器、CMake、uv,macOS 上再装个 GStreamer:

brew install gstreamer pkgconf        # macOS
git clone git@github.com:walterfan/ajb-demo.git
cd ajb-demo
uv sync --locked
make deps
make build
make compare                          # 离线对比,无需声卡

跑完会生成三轨 WAV 和 CSV,out/ 里见。想一边听一边看曲线,用浏览器版最直观:

make web          # 浏览器打开 http://127.0.0.1:8765

网页里能换策略、调网络参数、四轨试听、看水位和延迟曲线,还能下载结果。默认用的是一段 45 秒的真人中文语音(鲁迅《朝花夕拾》),按 1 / 2 / 3 就能从头切换小固定、大固定和自适应试听——卡顿和“慢半拍”一听就分得清。

还想动手调?TUI 里默认就是 phased 场景:前 5 秒平稳,5–10 秒加抖动,10–15 秒来一波突发,15 秒后恢复。先选 constant 看预缓冲,再选 burst 听 1 和 2,最后切到自适应轨道,按 n 一帧一帧看目标怎么升、SLOW 怎么攒水位、恢复阶段 FAST 怎么把延迟拉回来。 愿意钻进去,比读十篇讲 NetEQ 的文章都管用。


总结:自适应是权衡,不是答案

回到开头那句话,这次用数据钉死:抖动缓冲没有银弹。 小固定省延迟但欠载,大固定最连续但等待长,自适应用中等延迟换接近大固定的连续——它是动态权衡的执行者,不是万能解。

它是怎么做到的?无非两件事:观察(跟着到达时间的分布走,快升慢降调目标)和微调(用 WSOLA 一点点改变消费速度)。原理不玄,胜在不要人去手调那个数。

如果下次有人问你“多大的抖动缓冲合适”,别只报一个数。先问他:你这边的网络什么样,你更怕卡还是更怕慢? 然后把同一段轨迹、同一种网络条件跑给他看,让数据替他选。


全文思维导图

@startmindmap
<style>
mindmapDiagram {
  node {
    BackgroundColor #F8F9FA
    RoundCorner 10
    Padding 10
    FontSize 13
  }
  :depth(0) {
    BackgroundColor #1E3A5F
    FontColor white
    FontSize 18
    FontStyle bold
  }
  :depth(1) {
    FontSize 15
    FontStyle bold
  }
  :depth(2) {
    FontSize 13
  }
}
</style>

* 抖动缓冲:三种策略谁更好
** 问题
*** 包到得不齐,声音不能断
*** 缓冲浅了卡顿,深了延迟
** 三个易混的量
*** 缓冲预算 target
*** 目标水位 target level
*** 平均等待 wait
*** 缓存跨度 span
** 三种策略
*** 小固定 20ms:低延迟易欠载
*** 大固定 150ms:最稳最快
*** 自适应:观察+快升慢降+WSOLA
** 实验结果
*** 小固定 61ms/补偿2050ms
*** 大固定 187ms/零欠载
*** 自适应 86ms/补偿160ms
** 没有银弹
*** 自适应也会卡一下
*** 看业务更怕延迟还是断续
** 延伸:PLC vs Rebuffer
*** PLC 静音补偿,保持时间线
*** Rebuffer 停等,保留迟到音频
@endmindmap

抖动缓冲:三种策略谁更好 - 思维导图


本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。 欢迎在我的个人网站 https://www.fanyamin.com 访问原文并评论。

相关文章