返回资讯中心
外部精选
软件工程
#开发者实时流

技术解析:一个语音数据包内部包含什么

来源标题:What's inside one voice packet

一篇文章逐字节解析了一个实时语音 RTP 数据包,说明 RTP、Opus 和 UDP 各自承担什么作用。文章还演示了丢失单个数据包会带来什么代价,并解释为何即使已有 WebSocket 连接,实时语音仍不宜用 TCP 传输。

Hacker News 最新eknowledger2026/11/4 · 今天30 分钟阅读

以下正文由 AI 翻译自 Hacker News 最新(原文版权归原站所有),可能存在偏差,重要内容请以原文为准。

RTP、Opus 和 UDP,以及丢失一个包的代价。

从空气到 80 字节

Alice 的包,逐字节来看

RTP 在 UDP 之上增加了什么

你自己看看

每个包都有截止时间

丢失一个包的代价

UDP 上出现空洞后会发生什么

那么 TCP 适合音频吗?

巴黎时间早上 9:17。Alice 对语音助手说“嘿,今天天气怎么样?”,片刻之后,回答开始播放。

在这一瞬间里,有很多环节必须正确运转,本系列会一层一层地追踪这个过程。我在全球最大的语音助手之一从事实时语音工作,曾多次向加入语音团队的工程师讲解这些内容。假设有人做了多年 Web 服务后加入你的团队。一个合理的首要问题是:我们已经和客户端建立了 WebSocket 连接,为什么不直接通过它发送音频?

这篇文章回答这个问题,从最小但关键的单位开始:Alice 语音的一个包。我们会逐字节构建一个真实的包,看看每个头部的作用,然后故意丢失一个包,看看代价是什么。

我们会用到几个术语,每个都会在首次出现的关键位置得到恰当解释:

Alice 说话时,她的麦克风把变化的空气压力转换成变化的电压,声卡以固定速率测量该电压。每次测量是一个采样,存储为一个数字。我们马上会遇到的编解码器 Opus 以每秒 48,000 个采样工作,而存储每个采样的常见方式是 16 位整数。这个原始的数字流称为 PCM(脉冲编码调制)。如果你想了解采样率能捕捉什么、不能捕捉什么的更详细内容,采样率那篇文章用三个测试音进行了讲解。

语音不是一次发送一个采样。发送方收集一小段,压缩后把这一小段作为一个包发送。20 毫秒是常见的切片长度:这是 Opus over RTP 的默认包时长(RFC 7587 §6.1)。下面是完整过程,从声音到包:

第一行是值得做算术的部分:

WebRTC 要求每个端点都支持 Opus,以及经典电话编解码器 G.711(RFC 7874 §3)。Opus 可以编码 2.5 到 60 ms 的切片,码率从 6 到 510 kb/s 不等(RFC 6716 §2.1.4、§2.1.1)。这里的 80 字节是精确值,因为演示中保持码率恒定。对于真实语音和可变码率,帧大小平均在 80 字节左右,并随着说话内容增大或缩小。

每个包的三分之一是头部。把音频码率减半,头部大小不变,所以流量只下降三分之一,而不是一半。码率那篇文章详细讨论了这对带宽规划意味着什么。

为了展示真实字节而不是编造的字节,本文的演示编码了一个稳定的 440 Hz 音调来代替 Alice 的语音,使用语音通话会用的编解码器,并保持码率恒定,以便让大小精确。它在每个 80 字节帧周围构建头部,并逐层打印出一个包。地址来自为文档保留的地址范围。

从外向内读。每一层回答一个问题,沿途每台机器只读取它需要的部分:路由器读取 IP,接收计算机的操作系统读取 UDP 以找到程序,而该程序读取 RTP。

UDP 把一个数据报和一个端口号交给程序,仅此而已。它对顺序或时间没有任何说明。RTP(实时传输协议)增加了接收方回放音频最需要的三样东西(RFC 3550 §5.1):

每个包的 timestamp 增加 960,而不是 20:它按 48 kHz 时钟计数采样,20 ms 就是 960 个采样。我们的包是其流中的第 11 个,因此携带 10 × 960 = 9,600。对于 Opus,这个时钟始终是 48 kHz,即使音频本身更窄(RFC 7587 §4.1)。

RFC 3550 规定序列号和 timestamp 应从随机值开始。演示中从 1000 和 0 开始,以便数字易于理解。

下面是演示中的 RTP 头部。它有十二个字节,用一行 Python 就能打包:

python↩ WrapCopy build_packet.py 1 def pack(self) -> bytes: 2 first = 2 << 6 # version 2, no padding, no extension, zero CSRCs 3 second = (int(self.marker) << 7) | self.payload_type 4 return struct.pack("!BBHII", first, second, self.sequence, self.timestamp, self.ssrc) !BBHII 就是整个布局:网络字节序,两个单字节,一个 16 位序列号,然后是两个 32 位数字用于 timestamp 和 SSRC。一秒流中的每个包获得下一个序列号和增加 960 的 timestamp:

python↩ WrapCopy build_packet.py 1 packets = [ 2 ipv4(udp(Rtp(sequence=1000 + i, timestamp=i * SAMPLES).pack() + frame), ident=0x3A00 + i) 3 for i, frame in enumerate(frames) 4 这一个表达式就是图中的嵌套关系:Opus 帧位于 RTP 内,RTP 位于 UDP 内,UDP 位于 IP 内。

编码器设置是另一个值得一看的部分,因为 80 字节正是来自这里:

python↩ WrapCopy build_packet.py 1 enc = av.CodecContext.create("libopus", "w") 2 enc.sample_rate = RATE 3 enc.layout = "mono" 4 enc.format = "s16" 5 enc.bit_rate = BITRATE 6 enc.time_base = Fraction(1, RATE) 7 enc.options = {"vbr": "off

正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。

打开原文
去社区讨论 →