第 05 篇

带宽没跟上,声音为什么越排越晚

从 32 kbit/s 音频为何挤不进 40 kbit/s 的传输预算讲起,计算报头开销与排队增长,区分降低编码码率、消化积压和 Pacing,再用可运行模型检查等待发生在哪里。

Hex4C59 12 分钟阅读 更新于 2026-10-17

手机一直在采集声音,编码器也一直成功输出,发送日志里没有明显报错。通话开始时接话还算及时,过一会儿,对方却越来越晚才听到你说的话。

这种延迟可以发生在包丢失以前:新数据产生得比路径处理得快,暂时发不走的内容就排在前面。第四篇讨论怎样补回缺失声音;这次先检查原包和恢复数据有没有挤进同一条越来越长的队列。

本篇沿用前面的 20 毫秒音频块。需要知道样本、编码数据和网络包的区别,也需要第一篇的播放期限概念;不会写程序仍能跟着计算。读完应当能够算完整发送预算,判断队列会增长、保持还是回落,并区分降低编码码率与控制发送节奏。

文中的容量、包长和时刻均为教学设定。Python 实验只推进一条计算时间轴,不采集声音、不发网络包,也不测任何设备或网络的真实性能。

32 kbit/s 的声音,为什么用掉 48 kbit/s #

码率表示每秒对应多少比特数据;1 字节是 8 比特,本篇使用十进制单位,1 kbit/s 等于每秒 1000 比特。先沿用第二篇的预算:编码音频为 32 kbit/s,每包覆盖 20 毫秒声音。

每秒包数 = 1000 ÷ 20 = 50 包
每包编码音频 = 32,000 ÷ 50 ÷ 8 = 80 字节

80 字节只包含编码音频。沿用第三篇的报头条件,每包还有 20 字节 IPv4 头、8 字节 UDP 头和 12 字节 RTP 固定头,共 40 字节。这里没有 IP 选项、RTP 扩展或安全附加字段:

每个 IP 包 = 80 + 40 = 120 字节
每秒 IP 数据 = 120 × 50 = 6000 字节
IP 层码率 = 6000 × 8 = 48,000 bit/s = 48 kbit/s

“IP 层”是本次记账的边界:把完整 IP 包都算进去,尚未计入其外面的链路层封装。接下来给出的传输能力,也必须使用这个边界,才能比较大小。

本文说的可用传输能力,是这条流在当前路径上能够使用的处理速率,不是套餐宣传的宽带速率,也不是路由器无线连接显示的最高速率。为了先看清因果,假设它固定为 IP 层的 40 kbit/s,只服务本文这条流。

于是,编码器虽然只产生 32 kbit/s,实际需要处理的 IP 数据却是 48 kbit/s。每秒多来的 8 kbit,必须等待、被丢弃,或通过改变产出等办法消除;把暂存空间扩大,不会让这条路径每秒多处理数据。

恢复数据也要用同样的边界记账。RFC 4588 要求原始数据与重传一起受可接受的发送速率约束。若统计的完整 Opus 包已经含有带内 FEC,就不能再把同一份冗余加一次。12

每 20 毫秒来一包,每包却要忙 24 毫秒 #

现在只看一个瓶颈:它是路径上限制这条流处理速度的阶段。包按到达顺序处理,前一包完成后才能处理下一包,这叫先入先出。没有丢弃,开始时也没有积压。

40 kbit/s 相当于每秒 5000 字节,每毫秒 5 字节。处理一个 120 字节包需要:

120 ÷ 5 = 24 毫秒

这里的“处理”特指按给定速率完成该包传输所需的时间,不含传播到对端、解码和设备输出。包来到瓶颈的时刻,也不是麦克风采样时刻。

第几个包来到瓶颈开始处理开始前已等待处理完成
10 毫秒0 毫秒0 毫秒24 毫秒
220 毫秒24 毫秒4 毫秒48 毫秒
340 毫秒48 毫秒8 毫秒72 毫秒
50980 毫秒1176 毫秒196 毫秒1200 毫秒

每个新包与上一包相隔 20 毫秒,瓶颈却要多忙 4 毫秒。第 50 个包前面累计的等待是:

(50 − 1) × (24 − 20) = 196 毫秒

196 毫秒还只是排队。第 50 个包从来到瓶颈到处理完成,要用 196 + 24 = 220 毫秒;之后还有其他阶段。即使每包最后都能送到,这种持续增长也会消耗第一篇讨论的播放预算。实时媒体的拥塞控制需要兼顾可用吞吐与低延迟,不能只检查是否发生丢包。3

下面比较两组从空队列开始的情况:保持 32 kbit/s 编码音频,或从第一包起改为 16 kbit/s。两组都保持每包 20 毫秒与每包 40 字节报头。先预测:把传输能力调到 48 kbit/s,保持原码率的曲线还会往上走吗?

改变假设的传输能力,比较两种编码码率下前 50 包的排队等待;不含传输本包所需时间 · 在独立窗口中查看

48 kbit/s 时,120 字节恰好需要 20 毫秒,原方案也能在下一包到来时完成上一包;从空队列开始,等待不再增长。这个结论依赖固定能力、固定包长和均匀到达,实际短时波动仍可能造成排队。

降低码率,先改变的是什么 #

降低编码码率,是让同样时长的声音使用更少的编码数据表示。这里仍采集并播放一秒声音,每包仍覆盖 20 毫秒;没有通过少采一段声音或放慢播放来减少数据。质量取舍与编码模式、内容和配置有关,本篇不测听感。Opus 提供码率和可变码率控制,目标值也不能保证真实短窗口里每包都一样大。4

为方便计算,下面暂把每包固定为按目标换算出来的大小,传输能力仍为 40 kbit/s:

编码音频码率每包音频加上报头后的每包大小总加入速率有积压时的变化
32 kbit/s80 字节120 字节48 kbit/s每秒多 8 kbit
24 kbit/s60 字节100 字节40 kbit/s已有积压保持
16 kbit/s40 字节80 字节32 kbit/s每秒少 8 kbit,直到不再积压

降到 24 kbit/s,可以停止继续积压,但不一定消除已经存在的等待。 想象前面已经排着一批数据,之后每秒又来 40 kbit、发走 40 kbit:旧数据确实在离开,新数据也等量补进,队列规模保持。

如果希望排队回落,需要在这段时间里让新加入量小于可处理量,或者采用其他有效的过期数据处理策略。以 16 kbit/s 编码方案为例,IP 数据加入为 32 kbit/s,每秒有 40 − 32 = 8 kbit 用于消化积压。

独立设定:调整时,排在新包前面的待传工作为 1000 字节,即 8000 bit。在均匀加入、能力固定且不丢弃的连续速率模型中:

消化积压所需时间 = 8000 ÷ (40,000 − 32,000) = 1 秒

“消化积压”指整体等待回落,不是原来的那 1000 字节一直不动。它们会先被发送,后来的新数据也会继续排队,只是队列逐渐缩短。下面的逐包实验会看到,第一包等待 200 毫秒,而第 51 包在第 1000 毫秒到来时,已经不再需要排队。

真实通话还需要给能力波动、控制流量和恢复流量留出空间。这里的 16、24、32 是为了分清增长、保持和回落而设的三组数,不是推荐码率,也没有测出适用于所有通话的安全余量。

Pacing 改的是发送时机,不会凭空增加容量 #

除了少产生数据,还可以把一批待发包分散到不同的发送时刻,这叫 Pacing,发送平滑。负责安排这些时刻的组件常称为 Pacer。WebRTC 的相关文档说明,编码后成批交来的包可能造成短时突发,Pacer 先把它们放进队列,再安排发送;多路媒体还需要调度。5

这解释了 Pacing 为什么有用:平均量合适,也可能在一个很短的窗口里集中送出过多数据。但如果长期产出已经超过能力,仅把发送排得更均匀,仍然要为多出来的数据找地方等待。

继续使用原方案:每 20 毫秒来一个 120 字节包,IP 数据总量为 48 kbit/s。现在在瓶颈前加一个理想 Pacer,按 IP 层 40 kbit/s 安排相邻包,首包立即提交,后续两包的最小发送间隔为:

120 × 8 ÷ 40,000 = 0.024 秒 = 24 毫秒

第 2 包在 20 毫秒产生,Pacer 到 24 毫秒才提交它;第 50 包在 980 毫秒产生,到 1176 毫秒才提交。由于间隔与下游处理能力匹配,下游不再额外排队,但 Pacer 自己已经积下等待。

原来的编码产出保持不变第 50 包在 Pacer 等待在下游等待两处合计等待
不加 Pacer0 毫秒196 毫秒196 毫秒
理想 Pacer 设为 40 kbit/s196 毫秒0 毫秒196 毫秒

这是两级固定速率模型的结果,没有证明真实系统里两处等待总能恰好互换。它说明:只看下游队列清空,可能漏掉本机积压;控制持续产出和安排发送时机,需要一起考虑。

本模型的 Pacer 只按固定间隔放行包:距离上一次提交还没过满规定时间,下一个包就继续等。它不是 WebRTC Pacer 的实现。真实实现还涉及包优先级、可配置的开销记账与其他调度行为,不能直接把本文 IP 层参数填进一个未核对统计范围的接口。5

运行模型,先预测再核对 #

下载 send_queue.py。需要 Python 3.9 或更新版本,没有第三方依赖。在保存脚本的目录运行:

python3 send_queue.py

2026 年 10 月 15 日,使用 Python 3.9.6 运行默认的 50 包案例,关键输出为:

IP packet: 120 bytes; service: 24 ms
Offered IP rate: 48 kbit/s
Capacity: 40 kbit/s; initial queue: 0 bytes
Last packet: pacer wait=0 ms; bottleneck wait=196 ms; total wait=196 ms

输出中的 arrival_ms 是来到本模型第一个阶段的时刻;finish_ms 是瓶颈处理完成时刻。所有时刻都在同一条构造时间轴上,不是程序实际执行了多久。内部用有理数计算,避免把浮点舍入当作刚好赶上的差异。

接着只加 Pacer。运行前先判断两处等待分别会变成多少:

python3 send_queue.py --pacer-kbps 40

应看到 Pacer 等待 196 毫秒、瓶颈等待 0 毫秒,合计仍为 196 毫秒。再给两次独立实验各放入 1000 字节初始积压,分别改变新数据的编码码率:

python3 send_queue.py --payload-kbps 24 --initial-queue-bytes 1000
python3 send_queue.py --payload-kbps 16 --initial-queue-bytes 1000 --packets 51

第一条命令里,第一包和第 50 包都等待 200 毫秒。第二条里,第一包等 200 毫秒,第 2 包等 196 毫秒,最终第 51 包等待为零。初始积压都设在瓶颈前,没有同时给 Pacer 填入另一份初始数据。

若希望检查每一行,可以创建 CSV:

python3 send_queue.py --csv queue-trace.csv

默认运行只打印结果;指定 --csv 才新建文件,已有同名文件时拒绝覆盖。也可调整 --capacity-kbps、--interval-ms、--overhead-bytes 与 --packets。当前固定包长模型要求按参数换算出的音频字节数为整数,不会把半个字节伪装成实际包长。

脚本没有编码器、真实网络、包丢弃、传播时延或播放调度,也没有自动估计容量。命令行输出与交互图都在检查已声明的模型;它们不能证明真实网络有 40 kbit/s 能力,或某个码率一定不会迟到。

现实里不知道容量,怎样开始调整 #

前面是题目直接给出 40 kbit/s。真实通话需要根据反馈估计当前可用能力,并据此调整,这个过程属于拥塞控制。其中 BWE(Bandwidth Estimation,带宽估计)负责形成供控制使用的能力估计;它不是从网卡读到一个永远准确的上限。

先把三个量分开:编码器实际产生多少、发送端实际提交多少、接收端实际收到多少。它们还要采用同一统计边界和对应的时间窗口。

例如,在稳定、无丢失、无积压的独立条件下,接收端一秒收到 4000 字节 IP 数据,对应 32 kbit/s。下面两个假设世界都能产生这个观察:

假设的可用能力实际发送实际接收
40 kbit/s32 kbit/s32 kbit/s
100 kbit/s32 kbit/s32 kbit/s

表里的能力是给定假设,32 kbit/s 才是观察到的接收吞吐。只发这么多、也收到这么多,不能单独证明路径最多只能承受这么多。RFC 8836 也明确区分应用没有更多数据可发与网络约束。3

接收端还可以反馈哪些包到了、何时到达;发送端结合对应的发送时刻、大小和队列情况分析,再调整编码产出、发送安排及恢复预算。RFC 8888 给出了一种携带包接收与到达时间等信息的 RTCP 拥塞反馈格式;反馈格式提供观察信息,具体控制算法仍需决定怎样使用。6

反馈也需要传输时间,编码目标变化主要影响后续新数据,队列里的包不会因一次调整自动消失。因此排查“已经降码率,为什么还延迟”时,需要继续看实际产出是否下降、总预算是否漏算、队列是否回落。本篇先建立这些量的关系,尚未实现容量估计或完整控制算法。

换一组数字,检查能否独立判断 #

统一使用本篇的 IP 层边界:每包 20 毫秒音频,每包 40 字节报头,可用能力固定 40 kbit/s,不丢弃。

  1. 编码音频改为 20 kbit/s,每包多少音频字节、总共多少字节,总加入速率是多少?从空队列开始,会持续积压吗?
  2. 仍用 20 kbit/s 方案,开始时已有 500 字节积压。连续速率模型中需多久消化?能否通过再扩大暂存空间缩短这个时间?
  3. 编码音频保持 32 kbit/s,只把 Pacer 设为 40 kbit/s。下游等待变成零,是否说明通话等待已经解决?
  4. 一秒收到 4000 字节 IP 数据,是否足以把可用能力设为恰好 32 kbit/s?还缺什么判断?
展开换算、答案与回看位置
  1. 音频字节为 20,000 × 0.020 ÷ 8 = 50,加报头是 90 字节。每秒 50 包,总量为 90 × 50 × 8 = 36,000 bit/s,即 36 kbit/s,小于 40 kbit/s。本题均匀输入、空队列起步,不会持续积压。若得到 20 kbit/s 总量,回看第一节的统计边界。
  2. 初始积压 500 × 8 = 4000 bit,剩余能力 40,000 − 36,000 = 4000 bit/s,需 1 秒。扩大空间没有改变这两个速率。逐包检查可运行 python3 send_queue.py --payload-kbps 20 --initial-queue-bytes 500 --packets 51,第 51 包在 1000 毫秒到来时等待为零。
  3. 没有。本文固定模型里,第 50 包改为在 Pacer 等待 196 毫秒,总等待保持。若只看到下游的零就判断成功,回看两级等待表。
  4. 不足以。这只给出该窗口实际接收的吞吐;还需确认实际发送量、应用是否有更多数据、等待与丢失的变化,并结合反馈估计。40 与 100 kbit/s 两个假设世界都与这个接收结果相容。

排查时,把产出、提交和收到连起来 #

一条“目标码率已降低”的日志,没有说明编码器实际输出、协议开销与旧积压发生了什么。对同一时间窗口,可以关联以下边界:

记录要回答的问题
编码目标与实际 Opus 字节数新产出是否真的下降,短窗口是否仍有突发
报头、恢复与控制数据的记账范围总量有没有漏算或重复计算
进入与离开 Pacer 的时刻本机有多少数据、最旧数据已经等了多久
交给底层传输后的发送与反馈记录等待是否还发生在后续路径,哪些包何时被接收
播放期限与实际使用结果送到的数据是否仍赶上原来的声音区间

尤其要分清“交给底层传输”与“对端收到”。包离开一个应用队列之后,系统或路径上的其他队列仍可能等待;本机调度慢也可能造成积压,不能把所有队列增长都直接归因于网络容量下降。WebRTC 的 Pacer 文档把包交给底层传输后的部分明确放在其职责之外。5

开头那通越来越慢的电话,首先要检查的是同一统计范围内,持续加入的数据是否超过实际可用能力。编码音频 32 kbit/s 加上本例报头后已经是 48 kbit/s;40 kbit/s 的阶段每包多忙 4 毫秒,等待便逐包累积。减少新产出可以停止增长或消化积压,Pacing 则安排数据何时离开本机,两者要对照整条路径判断。

第六篇继续处理容量并不直接已知的问题:怎样比较发送间隔与到达间隔,从记录里看到传输耗时的变化,以及这些观察还不能证明什么。

回看第四篇:丢包后,重传、FEC 和 PLC 怎么选 · 系列目录

参考资料 #

资料核对于 2026 年 10 月 15 日。本文所有速率、排队时刻和等待结果都来自已声明的教学模型,脚本执行用于核对模型计算,没有对应真实网络或通话质量测试。


  1. RFC 4588 §7:重传与拥塞控制,2006 年 7 月发布,要求将原始数据与重传共同纳入可接受的发送速率。 ↩︎

  2. RFC 7587 §3.3:Opus 带内 FEC,2015 年 6 月发布,说明恢复冗余随 Opus 包携带;完整包大小已包含其中实际存在的数据。 ↩︎

  3. RFC 8836 §1—§2:实时交互媒体的拥塞控制要求,2021 年 1 月发布,讨论低延迟、应用受限、排队与网络竞争条件。 ↩︎ ↩︎

  4. libopus 1.6 编码器控制 API,说明目标码率与可变码率设置;本文没有执行真实编码或评价这些目标的听感。 ↩︎

  5. WebRTC 官方 Paced Sending 文档,说明发送平滑、排队、记账与底层传输边界。链接指向持续更新的主分支,本文只引用访问日核对的机制说明。 ↩︎ ↩︎ ↩︎

  6. RFC 8888 §3:用于拥塞控制的 RTCP 反馈,2021 年 1 月发布,说明包接收、到达时刻等反馈,以及发送和接收记录的位置要求。 ↩︎

一起讨论

选中正文中的文字,可以针对这段内容发起讨论。

正在读取讨论…

在 GitHub 查看与管理讨论 ↗

文章讨论