第 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 是 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 位整数 | 960 | 2 | 1920 字节 |
| 48 kHz,双声道,16 位整数 | 960 | 2 | 3840 字节 |
| 48 kHz,单声道,32 位浮点数 | 960 | 4 | 3840 字节 |
| 16 kHz,单声道,16 位整数 | 320 | 2 | 640 字节 |
双声道整数和单声道浮点数据恰好一样大,但字节长度没有告诉我们应怎样解释它们。双声道还要区分布局:交错排列写成 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 bytestone.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 环境下,这一秒合成音的实际结果为:
| 检查对象 | 运行结果 |
|---|---|
| 输入 PCM | 96,000 字节,48,000 个每声道样本 |
| Opus 音频包 | 51 个,每个 80 字节 |
| Opus 音频负载合计 | 4080 字节 |
| Ogg Opus 文件整体 | 4322 字节 |
| 解码后的 PCM | 96,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: False4080 与 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 个。
采集、音频处理和编码各有自己的块长。WebRTC 的 Audio Processing Module(音频处理模块,APM)接口处理约 10 毫秒的 PCM 块;libopus 在 48 kHz 下支持包括 480 和 960 样本在内的多种输入帧长。112 应用需要按这些边界拆分或拼接数据,而不是把设备的一次回调直接等同于一个网络包。
每块只慢 2 毫秒,等待也会不断累积 #
现在看一个独立的串行处理模型:任务每 20 毫秒来一块,但处理一块需要 22 毫秒,前一块结束后才能开始下一块。
| 第几块 | 到达时刻 | 开始处理 | 已经等待 |
|---|---|---|---|
| 1 | 0 毫秒 | 0 毫秒 | 0 毫秒 |
| 2 | 20 毫秒 | 22 毫秒 | 2 毫秒 |
| 3 | 40 毫秒 | 44 毫秒 | 4 毫秒 |
| 50 | 980 毫秒 | 1078 毫秒 | 98 毫秒 |
第 50 块的等待是 (50 − 1) × (22 − 20) = 98 毫秒。每次处理都可以成功,处理能力却持续跟不上输入。
扩大队列只能容纳更多等待中的数据。要改变这个模型的积压,需要改变工作量、服务能力或过时数据的处理方式;真实系统还可能有不同的并行与调度安排。这类延迟发生在本机,网络再快也不会消除它。
所以日志除了记录“编码成功”,还应记录进入队列与实际开始处理的时刻。两者的间隔才显示这块声音已经等了多久。
编码之前,哪些问题已经进入声音 #
麦克风记录的信号里,可能同时有人的语音、环境噪声和扬声器的声音。编码器会处理它得到的信号,接收端成功解码并不能自动说明其中只剩下想要的语音。
手机扬声器播放朋友的声音,麦克风若把它录进去并传回,朋友就会听到自己的回声。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 字节,描述的是同一段媒体经过不同表示后的大小。让它最终被听见,还需要正确的格式、顺序、持续供给和设备输出。下一篇会把其中的网络包展开,解释接收日志里的序号缺口、乱序和迟到为什么不能合成一个“丢包”数字。
参考资料 #
资料与接口核对于 2026 年 10 月 14 日。固定队列案例为教学构造;编解码表格是本文合成输入在上述版本下实际运行的结果,未使用通话录音或音质测试集。
MDN:数字音频的基本概念,2026 年 10 月 15 日核对,说明采样、采样率、声道和位深。 ↩︎
libopus 1.6 编码器文档,说明 PCM 输入、交错声道、每声道样本数与编码字节输出。 ↩︎ ↩︎ ↩︎ ↩︎
RFC 3550 §5.1:RTP 报头字段,2003 年 7 月发布。 ↩︎
FFmpeg libswresample 文档,说明重采样、样本格式与声道转换及滤波选项。 ↩︎
RFC 7587 §4.2:Opus RTP 负载结构,2015 年 6 月发布,规定一个 RTP 负载承载一个 Opus 包。 ↩︎
libopus 1.6 解码器文档,说明解码状态、正确输入顺序与 PCM 输出。 ↩︎
W3C Web Audio API 2021 推荐标准:音频输出延迟,2021 年 6 月 17 日发布。 ↩︎
Python 3.9 wave 文档,说明 WAV 参数、采样帧与数据读写接口。 ↩︎
FFmpeg 编码器文档:libopus 参数映射,说明码率、VBR 模式和帧时长。ffprobe 文档说明音频流和包信息的检查方式。 ↩︎
RFC 7845 §4.2 与 §4.4:Ogg Opus 的开头跳过与结尾裁切,2016 年 4 月发布,说明如何将编码数据的样本位置映射到最终呈现范围。 ↩︎
WebRTC 音频处理接口 audio_processing.h,说明约 10 毫秒 PCM 块、采集与播放参考两路输入,以及音频处理组件。链接指向持续更新的主分支,本文以访问日内容为准。 ↩︎ ↩︎
RFC 7874 §5:回声消除,2016 年 5 月发布。 ↩︎
一起讨论
选中正文中的文字,可以针对这段内容发起讨论。
正在读取讨论…
在 GitHub 查看与管理讨论 ↗