第 02 篇

声音从麦克风到扬声器

沿同一段20毫秒声音,算清PCM样本、回调块、Opus负载与解码输出的变化,用配图和真实编解码实验解释变调、积压及收包后无声。

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

一段声音在发送端有 1920 字节,编码后只剩 80 字节;接收端解码,又得到 1920 字节。声音经历了什么变化?接收日志显示包已到达,为什么扬声器仍可能没有声音?

这条链路会多次改变声音的表示。每一步都需要知道自己拿到的是样本、编码数据还是网络包,并按相应的格式和时间顺序处理。收包成功只走完了其中一段,后面还有解码、格式转换和设备输出。

上一篇计算了声音从采集到播放的等待,也约定每块声音覆盖 20 毫秒。本篇打开这块数据:先弄清里面的数字怎样表示声音,再算它有多少样本、占多少字节,最后检查编码和解码的变化。

正文面向已经读过第一篇的读者。先用这个例子算清单位与处理边界,再生成一秒合成音,检查实际编码和解码结果。基本实验需要 Python 3.9 或更新版本,可选编解码实验还需要支持 libopus 的 FFmpeg 和 ffprobe;实验不采集麦克风,也不实现网络通话。

读完本篇,你应该能从采样率、声道数、样本位数和声音时长算出 PCM 大小,并解释为什么编码负载大小要另算。主线先讲数据表示和单位换算;设备回调、排队与预处理放在实验之后,第一次阅读可以稍后再看。

声音怎样变成一串数字 #

数字音频可以先理解成一张按时间填写的记录表:每隔固定时间,记录一次声音信号的幅度。每条记录中的数值叫样本;每个声道一秒记录多少次,就是采样率。1

例如,下面只是四个相邻样本的假设数值,不是一句话,也不是四个音节:

样本顺序:    0     1     2     3
记录的数值:  0   120   -80    40

正负表示信号相对基准的方向,绝对值表示这个时刻偏离基准的幅度。播放设备按对应的采样节奏消费这一串数值;改变消费节奏,即使数值没改,声音时长也会改变。

算大小还需要两个条件:

  • 声道数:单声道每个采样时刻有一个值;双声道每个时刻分别有左、右两个值。
  • 样本位数,也称位深:本篇用 16 位有符号整数存一个样本。位是存储 0 或 1 的单位,8 位组成一字节,所以一个这样的样本占 2 字节。

“48 kHz、单声道、16 位整数”现在可以逐项读出来:每秒记录 48,000 个采样时刻,每个时刻一个值,每个值占 2 字节。它描述样本如何排列和存放;压缩后每秒占多少数据,还需要另外看编码设置。

同一段声音,为什么要换三种表示 #

发送端的 PCM 样本经编码成为编码字节,再装入网络包;接收端取出编码字节,解码得到 PCM 样本,最后交给音频设备。
同一段声音沿链路改变表示。每一次转换,都有自己的输入格式和输出边界。

PCM 是 Pulse Code Modulation,即脉冲编码调制。在这里,它是一串按固定间隔记录的声音幅度数值,适合音频处理模块和设备按采样节奏读取。

Opus 编码器接收 PCM,把一段声音表示成压缩字节;解码器再从这些字节生成 PCM。2 这些实际承载声音内容的字节,在包中称为负载;它们已不能按“每两个字节一个原始样本”来解释。

传输时还要把编码数据放进包。RTP 是 Real-time Transport Protocol,即实时传输协议;它附上包序、媒体时间和来源等信息,供接收端识别与安排内容。3 压缩声音和添加传输信息发生在不同的边界。

表示本例中的内容谁主要使用它
PCM按采样节奏排列的波形数值采集、音频处理、编码输入与设备输出
Opus 数据一段声音的压缩字节编码器与解码器
RTP 包RTP 报头与 Opus 负载打包、传输、接收与重排

先把这三种对象分开,才知道该用什么单位描述大小,以及失败发生在哪一次转换。

20 毫秒声音有多少个样本 #

采样率 48 kHz 表示每个声道每秒记录 48,000 个样本。20 毫秒是 0.020 秒,因此每声道样本数为:

48,000 样本/秒 × 0.020 秒 = 960 个样本

本例使用单声道、16 位整数,每个样本占 2 字节。PCM 的大小才由此确定:

960 个样本/声道 × 1 个声道 × 2 字节/样本 = 1920 字节

一秒原始 PCM 的数据量为:

48,000 × 1 × 2 = 96,000 字节/秒
96,000 × 8 = 768,000 bit/s = 768 kbit/s

这里计算的只有样本数据,没有文件头或网络头。960 和 1920 分别回答“有多少个每声道样本”和“缓冲里有多少字节”。它们属于同一段声音,单位却不同。

保持 20 毫秒时长不变,再改变格式:

格式每声道样本数每样本字节数PCM 大小
48 kHz,单声道,16 位整数96021920 字节
48 kHz,双声道,16 位整数96023840 字节
48 kHz,单声道,32 位浮点数96043840 字节
16 kHz,单声道,16 位整数3202640 字节

双声道整数和单声道浮点数据恰好一样大,但字节长度没有告诉我们应怎样解释它们。双声道还要区分布局:交错排列写成 L0, R0, L1, R1, ...,平面排列则分开存放左右声道。

libopus 的整数编码接口接收交错 PCM,frame_size 的单位是每声道样本数。2 所以本例调用 opus_encode() 时填 960;改成双声道也仍填 960,输入缓冲则包含 1920 个整数样本、共 3840 字节。把字节数直接填进这个参数,会改变接口理解的输入长度。

样本不变,采样率标签却能改变播放节奏 #

48,000 个样本按 48 kHz 输出,持续 1 秒;若把同一批数据错误标为 16 kHz,持续时间就变成:

48,000 ÷ 16,000 = 3 秒

原本 880 Hz 的合成音也会按三分之一速度输出,频率约为 293.33 Hz。样本字节没有变化,设备读取它们的节奏却变了。

真正重采样到 16 kHz,要按目标采样节奏重新生成样本,并处理目标采样率无法表示的频率成分。4 因此声音变慢或变调时,应一起检查格式字段、样本计数和实际转换,不能只确认“已经拿到 PCM”。

1920 字节怎样变成 80 字节 #

Opus 编码器读取这 960 个每声道样本,产生编码字节。输出大小受码率、声音内容和配置影响,不能再用 PCM 的“样本数乘字节宽度”计算。2

先做一个固定码率的教学预算:若音频负载为 32 kbit/s,每包对应 20 毫秒,那么:

32,000 × 0.020 ÷ 8 = 80 字节

同一段声音因此先是 1920 字节 PCM,再是 80 字节编码负载。24 倍的大小差来自本例给定的格式和码率,不能推广成 Opus 对任意声音的固定压缩比例。

打包器随后添加 RTP 报头,维护来源、包序、媒体时间和格式映射。一个 Opus RTP 负载承载一个完整 Opus 包。5 编码字节数与网络包大小也要分开记录:传输信息没有产生新声音,却会增加发送的数据量。

编码输出后,打包、安全处理和发送调度还可能继续发生。“产生编码字节”“进入发送队列”“实际交给传输”是三个不同的事件。数据持续产生而发送速度不足时,发送队列也会积压。

收到编码数据以后,怎样重新发声 #

接收端要先确认来源与格式,取出负载,并按媒体位置安排处理。libopus 的解码器会保留此前解码时使用的信息,这就是它的“状态”。后面的解码会使用这些信息,因此输入需要按正确顺序逐个提供;不同来源使用各自的解码状态。6 后到的包怎样安排,将在第三篇具体计算。

解码之后,声音又成为 PCM。若解码格式与设备格式不同,还需要转换采样率、声道或样本表示,再按设备要求的块长提交。

例如一次解码得到 20 毫秒、48 kHz、单声道 PCM,共 960 个样本;设备每次取 10 毫秒,就分两次各取 480 个。这里再次出现了块长转换,解码块和设备回调仍不必一样长。

输出系统收到样本之后,队列和设备还可能继续等待。7 播放路由选错、设备静音、样本幅度接近零,或队列没有持续供给,都可能表现为“解码成功但听不见”。反过来,样本持续堆积又会增加等待。检查计数时,需要按各阶段实际的采样率和时间区间对齐。

先用另一种格式算一次 #

假设改成 16 kHz、单声道、16 位整数,仍然每段覆盖 20 毫秒。先写下三个结果,再展开答案:一段有多少样本、多少 PCM 字节;若改为双声道,哪个结果会变?

展开换算过程

20 毫秒先换成 0.020 秒:

每声道样本数 = 16,000 × 0.020 = 320
单声道 PCM = 320 × 1 × 2 = 640 字节
双声道 PCM = 320 × 2 × 2 = 1280 字节

双声道仍然每声道 320 个样本,只是每个采样时刻有两个值。如果把 640 填成“每声道样本数”,就把字节数量与样本数量混在一起了。

另一个独立条件是编码负载预算:若仍给编码器 32 kbit/s、每包 20 毫秒,固定预算仍是 32,000 × 0.020 ÷ 8 = 80 字节。PCM 大小变了,不会自动要求编码负载按同一比例改变。真实输出还受配置与声音内容影响。

用一秒合成音检查这些转换 #

下载音频实验 audio_lab.py。脚本生成 880 Hz、48 kHz、单声道、16 位整数的一秒声音,不使用私人录音。这里的 880 Hz 表示合成声音每秒有 880 次周期变化,48 kHz 则表示每秒记录 48,000 个样本;两个频率描述的对象不同。WAV 是方便保存和检查 PCM 的文件容器;实时链路中的样本可以直接在内存缓冲里流动。

先检查 PCM 与采样率标签 #

运行前先预测:输入有 48,000 个每声道样本,把文件的采样率标签从 48 kHz 改为 16 kHz,样本数、数据字节数、播放时长,分别会不会变?结果应当是前两项不变,时长从 1 秒变为 3 秒;这与重新生成一份 16 kHz 音频不同。

在脚本所在目录,用 Python 3.9 或更新版本运行:

python3 audio_lab.py --output-dir audio-lab-pcm

脚本没有第三方 Python 依赖,会创建新目录;已有同名目录时拒绝覆盖,重复运行需要换一个 --output-dir 名称。关键输出为:

PCM: 48000 Hz; mono; 16-bit; 48000 samples/channel
PCM data: 96000 bytes; WAV file: 96044 bytes
Rate-label demo: same PCM at 16000 Hz lasts 3 s; frequency becomes 293.333 Hz
10 ms callback: 480 samples/channel; 960 bytes
20 ms encoder input: 960 samples/channel; 1920 bytes

tone.wav 使用正确的采样率;wrong-rate.wav 保存相同 PCM 字节,只把采样率标为 16 kHz。它们让前面的变调计算有了实际文件输入。

96,000 字节是样本数据,96,044 字节是这个脚本写出的 WAV 文件大小。WAV 还可以有其他块,因此不能对任意文件都跳过固定的 44 字节。脚本通过 Python wave 模块读格式和数据;其中 getnframes() 统计采样帧,单声道时等于样本数,不是 20 毫秒编码块数。8

再检查真实 Opus 编解码 #

这一步需要已有 ffmpeg、ffprobe,并且 FFmpeg 支持 libopus。先检查:

ffmpeg -version
ffprobe -version
ffmpeg -hide_banner -h encoder=libopus

找不到这些工具时,需要先准备它们;纯 PCM 部分不受影响。然后运行:

python3 audio_lab.py --with-opus --output-dir audio-lab-opus

脚本会自动执行下面的编码设置,不必在同一目录再执行一次:

ffmpeg -hide_banner -loglevel error -n -i audio-lab-opus/tone.wav \
  -c:a libopus -b:a 32k -vbr off -frame_duration 20 audio-lab-opus/tone.opus

-b:a 32k 设置音频负载目标码率,-vbr off 选择固定码率,-frame_duration 20 选择 20 毫秒编码帧。9 .opus 文件使用 Ogg 容器;脚本用 ffprobe 检查音频包,再解码为 decoded.wav。

2026 年 10 月 14 日,在 macOS arm64、Python 3.9.6、FFmpeg 8.1、libopus 1.6.1 环境下,这一秒合成音的实际结果为:

检查对象运行结果
输入 PCM96,000 字节,48,000 个每声道样本
Opus 音频包51 个,每个 80 字节
Opus 音频负载合计4080 字节
Ogg Opus 文件整体4322 字节
解码后的 PCM96,000 字节,48,000 个每声道样本
解码样本是否逐字节等于输入否

对应输出为:

Opus audio packets: 51; sizes: [80] bytes
Opus audio payload total: 4080 bytes
Ogg Opus file total: 4322 bytes
Decoded PCM: 48000 samples/channel; 96000 bytes
PCM byte-identical: False

4080 与 4322 的差别来自音频负载之外的容器和头信息。这说明检查大小时必须明确边界:文件大小不能直接作为网络音频负载大小。

解码后的样本数和时长与输入相同,样本值却没有逐字节还原。这个实验验证了有损编解码后的数据变化,没有评价真人语音的听感或质量。

51 个离线音频包也不能解释为持续通话每秒固定发送 51 个 RTP 包。Ogg Opus 用开头跳过样本和结尾裁切的信息,映射最终呈现的样本范围;本次文件解码后仍是一秒。10 文件包数、编码块数和呈现样本数要分别核对。

具体文件大小和解码样本可能随版本与元数据变化,因此复现实验需要记录参数。这次没有测编码速度、网络带宽或端到端延迟。

下面回到真实程序怎样持续交付声音。这里的回调与预处理是进一步理解设备接口的内容;前面的大小换算与合成音实验不要求先掌握它们。

一次采集回调,为什么不一定产生一个包 #

回调是设备按约定时机把一块数据交给程序、让程序处理的入口。回到前面 48 kHz、单声道、16 位整数的条件,本例的设备每 10 毫秒交出一块声音,每块包含:

48,000 × 0.010 = 480 个样本/声道
480 × 2 = 960 字节单声道 PCM

编码端选择 20 毫秒输入,就需要把两次回调的数据合在一起。第 10 毫秒只有 480 个样本,到第 20 毫秒才收齐 960 个。

48kHz单声道16位PCM,两次10毫秒回调各提供480个样本、960字节,合并后覆盖20毫秒,共960个样本、1920字节;第20毫秒收齐后才能提交编码。
两块回调覆盖相邻的声音区间,合并后成为一次编码输入。横条表示媒体覆盖范围,不表示编码器在这20毫秒内一直执行。

采集、音频处理和编码各有自己的块长。WebRTC 的 Audio Processing Module(音频处理模块,APM)接口处理约 10 毫秒的 PCM 块;libopus 在 48 kHz 下支持包括 480 和 960 样本在内的多种输入帧长。112 应用需要按这些边界拆分或拼接数据,而不是把设备的一次回调直接等同于一个网络包。

每块只慢 2 毫秒,等待也会不断累积 #

现在看一个独立的串行处理模型:任务每 20 毫秒来一块,但处理一块需要 22 毫秒,前一块结束后才能开始下一块。

第几块到达时刻开始处理已经等待
10 毫秒0 毫秒0 毫秒
220 毫秒22 毫秒2 毫秒
340 毫秒44 毫秒4 毫秒
50980 毫秒1078 毫秒98 毫秒

第 50 块的等待是 (50 − 1) × (22 − 20) = 98 毫秒。每次处理都可以成功,处理能力却持续跟不上输入。

在每20毫秒到达一块、每块串行处理22毫秒的模型中,第1、2、3、50块分别等待0、2、4、98毫秒。条长与排队等待成比例。
比较的是开始处理前的排队等待。第50块在980毫秒到达,到1078毫秒才开始;图中未展开第4至49块。

扩大队列只能容纳更多等待中的数据。要改变这个模型的积压,需要改变工作量、服务能力或过时数据的处理方式;真实系统还可能有不同的并行与调度安排。这类延迟发生在本机,网络再快也不会消除它。

所以日志除了记录“编码成功”,还应记录进入队列与实际开始处理的时刻。两者的间隔才显示这块声音已经等了多久。

编码之前,哪些问题已经进入声音 #

麦克风记录的信号里,可能同时有人的语音、环境噪声和扬声器的声音。编码器会处理它得到的信号,接收端成功解码并不能自动说明其中只剩下想要的语音。

手机扬声器播放朋友的声音,麦克风若把它录进去并传回,朋友就会听到自己的回声。AEC 是 Acoustic Echo Cancellation,即声学回声消除。它利用正在播放的信号作为参考,估计其重新进入麦克风的成分,并在采集信号中做相应处理。WebRTC 的接口区分采集流和播放参考流,音频规范也讨论了通话设备的回声消除要求。1112

处理针对的信号问题需要观察的边界
AEC,回声消除本机播放信号又进入麦克风播放参考、采集信号及两者的时间关系
NS,Noise Suppression,降噪风扇、环境等噪声成分处理前后的噪声与语音变化
增益控制输入音量过低或变化样本幅度,以及放大后是否削波

削波是幅度超出表示或输出范围后被截断。16 位有符号整数的范围是 −32768 到 32767,接近上限的样本放大两倍,就无法继续准确表示。

这些组件是否启用、怎样处理,取决于实现。若问题已出现在编码输入里,正常的网络传输也可能把它带到另一端。对照音频处理前后的样本,才能把回声、噪声和幅度问题与网络缺失区分开。

换一个输入,检查能否独立算对 #

一台设备每 10 毫秒交出一块 16 kHz、双声道、16 位整数的 PCM,编码端希望每次处理 20 毫秒。请先算每块的每声道样本数、总字节数,以及一次编码要合并几块。再解释:怎样在样本字节不变时,让文件播放时长翻倍?

展开答案与容易混淆的地方

每块每声道 16,000 × 0.010 = 160 个样本,左右声道合计 320 个数值;每块共 160 × 2 × 2 = 640 字节。合并两块后覆盖 20 毫秒,每声道 320 个样本,共 1280 字节。

若只把采样率标签从 16 kHz 错改为 8 kHz,样本数与字节数不变,播放速度减半,时长翻倍,音调也会降低。这是故意制造的格式错误,不是保持原声音时长的重采样。

若第一问得到 320 个“每声道样本”,回看声道数的含义;若认为编码时仍只需 640 字节,回看一次回调与一次编码覆盖的声音时长。

用边界解释“收到了,却没听见” #

假设接收日志中的包数一直增加,扬声器却没有声音。可以沿下面的边界,逐个确认数据走到了哪里:

已确认的事件接着检查能缩小到的范围
收包事件持续增加来源、格式,以及是否进入媒体缓冲无关数据,或在验证和分流时被拒绝
媒体缓冲取出负载解码错误、输出样本数量负载格式、长度、顺序或解码状态
解码器生成 PCM采样率、声道、布局与幅度转换错误,或数据本来接近静音
PCM 进入输出队列是否持续消费和补充输出回调、积压或供给不足
输出系统接受样本路由、静音与实际输出播放设备及其状态

声音变慢或变调,先核对格式与样本计数;延迟随时间增长,检查队列等待;回声和噪声跟着语音传过来,则对照编码前后的信号。这些判断对应不同边界,需要不同证据。

开头的 1920 字节、80 字节和解码后的 1920 字节,描述的是同一段媒体经过不同表示后的大小。让它最终被听见,还需要正确的格式、顺序、持续供给和设备输出。下一篇会把其中的网络包展开,解释接收日志里的序号缺口、乱序和迟到为什么不能合成一个“丢包”数字。

下一篇:从一段收包记录读懂 RTP

回看上一篇:实时通话为什么不能一直等 · 系列目录

参考资料 #

资料与接口核对于 2026 年 10 月 14 日。固定队列案例为教学构造;编解码表格是本文合成输入在上述版本下实际运行的结果,未使用通话录音或音质测试集。


  1. MDN:数字音频的基本概念,2026 年 10 月 15 日核对,说明采样、采样率、声道和位深。 ↩︎

  2. libopus 1.6 编码器文档,说明 PCM 输入、交错声道、每声道样本数与编码字节输出。 ↩︎ ↩︎ ↩︎ ↩︎

  3. RFC 3550 §5.1:RTP 报头字段,2003 年 7 月发布。 ↩︎

  4. FFmpeg libswresample 文档,说明重采样、样本格式与声道转换及滤波选项。 ↩︎

  5. RFC 7587 §4.2:Opus RTP 负载结构,2015 年 6 月发布,规定一个 RTP 负载承载一个 Opus 包。 ↩︎

  6. libopus 1.6 解码器文档,说明解码状态、正确输入顺序与 PCM 输出。 ↩︎

  7. W3C Web Audio API 2021 推荐标准:音频输出延迟,2021 年 6 月 17 日发布。 ↩︎

  8. Python 3.9 wave 文档,说明 WAV 参数、采样帧与数据读写接口。 ↩︎

  9. FFmpeg 编码器文档:libopus 参数映射,说明码率、VBR 模式和帧时长。ffprobe 文档说明音频流和包信息的检查方式。 ↩︎

  10. RFC 7845 §4.2 与 §4.4:Ogg Opus 的开头跳过与结尾裁切,2016 年 4 月发布,说明如何将编码数据的样本位置映射到最终呈现范围。 ↩︎

  11. WebRTC 音频处理接口 audio_processing.h,说明约 10 毫秒 PCM 块、采集与播放参考两路输入,以及音频处理组件。链接指向持续更新的主分支,本文以访问日内容为准。 ↩︎ ↩︎

  12. RFC 7874 §5:回声消除,2016 年 5 月发布。 ↩︎

一起讨论

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

正在读取讨论…

在 GitHub 查看与管理讨论 ↗

文章讨论