swr_convert 一件事干两件活:改采样率,顺带改声道布局。
它比图像转换更容易出错,因为输出样本数要「算」,
而且它内部握着一点尾巴,不冲空包就永远不出来。
09.05 讲的是画面,这一节讲声音。 两者的 API 形状几乎一样(都是「建上下文、逐帧喂、冲空包」), 差别在于「输出多少」这件事。 图像转换是「一帧进、一帧出」,音频重采样是「N 个样本进、M 个样本出,M 还要算」。 就这一点差异,制造了本课一半的音频 bug。
一、两件事:采样率与声道布局
声卡对声音有固定要求,而文件里的声音五花八门。 重采样就是那个「翻译」:
| 参数 | 文件里可能是 | 声卡可能要 |
|---|---|---|
| 采样率 | 44100 / 48000 / 22050 | 48000 |
| 声道布局 | mono / stereo / 5.1 |
stereo |
| 样本格式 | fltp / s16 / s32 |
s16 |
三行里第一行最常被忽略:很多人以为「44.1k 和 48k 差不多」,直接送进去会听到变调或者卡顿。 差 8.8% 的速率,听起来就是「快了半音」。
第二行也有坑:mono 转 stereo 不是「复制一份」那么简单,
它要做声道映射;反过来 stereo 转 mono 要做混音(通常是取平均)。
这些都不需要你手写,但要知道它在做。
二、输出样本数怎么算
公式很短:输出样本数 ≈ 输入样本数 × 目标采样率 ÷ 源采样率。 但「≈」这个符号是本节的全部内容。
Demo 量了三个组合,理论值和实测最多差一个样本——那一个样本是四舍五入,不是丢的。
为什么会有小数:20480 × 48000 ÷ 44100 是个除不尽的数。 重采样器会把剩下的零点几个样本留在内部,跟下一帧的数据一起处理。 这就是「内部残留」的来源。
这一点对「算总时长」很重要:很多人喜欢用「样本数除以采样率」来算音频有多长, 如果中间做过重采样,这个算法会有毫秒级的误差。 要不要在意,取决于你在做什么——做进度条无所谓,做音视频同步就必须在意。
三、冲空包:尾巴不冲就不出来
Demo 里最值得看的一组数是这个:不冲空包,输出比冲了少十几个样本。
十几个样本听起来不多,换算成时间是零点几毫秒。 但它揭示的规律很重要:「带缓冲的转换器都握着一点尾巴」。
本课已经凑齐三种:解码 decode(None)、重采样 resample(None)、编码 encode(None)。
三处的行为完全一致:不冲不报错,只是少一点东西。
为什么它们都要「握着尾巴」:因为重采样要做插值,插值需要「前后各看几个样本」, 所以最后几个样本它不敢交出去——万一后面还有数据,这几个还能用上。
这个设计在所有流式处理里都一样:「为了不切错边界,宁可多留一点」。 对应的代价就是「你必须告诉它没有更多了」。
四、音频缓冲与声卡回调
重采样之后的数据要交给声卡,而这一步是「推」还是「拉」,决定了架构:
| 模型 | 谁主动 | 典型 API |
|---|---|
| 推送 | 你的程序往里送 | SDL_QueueAudio |
| 回调 | 声卡要数据时叫你 | SDL_OpenAudioDevice 的回调 |
ffplay 用的是第二种:它注册一个回调,声卡每消耗完一块缓冲就喊它一次。 这个设计的好处是「节拍由硬件给」——你不需要自己算「什么时候该送下一块」。
坏处是回调里不能干重活:它跑在音频线程上,卡一下就是「咔」的一声。 所以 ffplay 在回调里只做「从缓冲区拷数据」,重采样放在解码线程里提前做好。
这个分工值得记住:转换可以提前做,拷贝必须当场做。 因为转换慢一点只是延迟,拷贝慢一点就是爆音。
五、变调、变速:不要靠改采样率
想「快放」的时候,一个常见的错误做法是「把采样率改高」: 比如 44100 改成 48000,声音就快了。 这确实能让声音变快,但音调也跟着变了。
| 做法 | 速度 | 音调 | |---|---| | 改采样率 | 变 | 跟着变(变调) | | 时间伸缩(WSOLA 之类) | 变 | 不变 |
第二行才是「快放」的正确做法,它需要专门的算法,不是 swr 能做的。
所以「倍速播放」这个功能,比看起来难得多。
这条经验可以推广到画面: 「跳帧」快而且看着还行,「改帧率」会让人觉得动作变快——两者都是为了「用更短的时间放完」,但观感完全不同。
所以本课一直在强调「参数背后有语义」: 采样率不是「速度旋钮」,它是「一秒采多少个点」的定义。 改它,等于改了「一秒钟有多长」。
六、动手 Demo:音频重采样——样本数、延迟与「冲空包」
这一段量三件事: 输出样本数跟理论值差多少、不冲空包少多少、以及三种目标参数下的行为是否一致。 重点在最后那个「差」字:它是零、是一、还是几十,含义完全不同。
rs = av.AudioResampler(format=dst_fmt, layout=dst_layout, rate=dst_rate)
got = 0
for fr in aframes:
for o in rs.resample(fr):
got += o.samples
for o in rs.resample(None): # 冲空包,少了这行就少一截
got += o.samples
实跑输出:
--------------------------------------------------------------------
音频重采样——样本数、延迟与「冲空包」
--------------------------------------------------------------------
**`swr_convert` 在 ffplay 里负责一件事**:**把解码出来的音频,变成声卡要的格式、声道数和采样率。**
**它比图像转换更容易出错,因为「输入样本数」和「输出样本数」不是一对一的。**
解出 **20 帧音频**,每帧 **1024 个样本**,采样率 **44100**
输入总样本:**20480**(44100 赫兹,0.464 秒)
目标 输出样本数 理论值 差
-------------------------------------------------
48000/mono 22292 22291 1
44100/stereo 20480 20480 0
22050/mono 10240 10240 0
**理论值那一列是按「输入样本数 × 采样率比」算的**,
**实测最多差一个样本**——**那一个样本是四舍五入的结果,不是丢的。**
**这说明重采样器确实把每一个样本都过了一遍。**
**顺带解释「为什么差一个而不是零」**:**20480 × 48000 ÷ 44100 = 22291.16**,
**重采样器会把剩下那零点几个样本留在内部,等下一帧一起处理。**
**这正是「冲空包」存在的理由之一。**
**然后是「不冲空包会怎样」**,这是本课第三次遇到同一个坑:
不冲空包:**22274 个样本**
冲空包:**22292 个样本**
差 **18 个样本**,约 **0.38 毫秒**
**差的这些样本是「重采样器内部的残留」**:**它做插值要用到前几个样本,所以手里总握着一点尾巴。**
**不冲,这点尾巴就永远不出来**——而且**不报错**。
**三种「冲空包」到这里就齐了**:
**解码 `decode(None)`、重采样 `resample(None)`、编码 `encode(None)`。**
**规律完全一样:带缓冲的转换器都要冲,而且不冲不报错。**
第一张表看「差」这一列,它是 0 或 1,说明重采样器把每个样本都算进去了, 差的那一个也只是四舍五入。 这一列如果出现几十上百,那就是真丢数据了。
第二组数是本节的核心:不冲空包比冲了少十几个样本,约零点几毫秒。 数值不大,但它意味着「最后那一小截永远听不到」。
注意「不冲」的时候不报错。这才是它危险的地方:没有异常、没有日志,只是少一点东西。 音频的 bug 大多长这样——不是崩,是「听着有点不对」。
这也解释了为什么音频调试要靠「数样本」: 听感不可靠、崩溃不出现,唯一靠得住的是「输入多少、输出多少、差多少」。 养成随手打印样本数的习惯,能省掉大量排查时间。
最后一个观察:三种目标参数下,「差」这一列都很小, 说明「改采样率」「改声道」「改格式」这三件事,重采样器都是统一处理的,行为一致。 所以你不需要为不同的转换写不同的收尾逻辑。
30 秒实验
- 把输入改成 10 帧,看「不冲」和「冲」的差是不是还是十几。
- 算一下:44100 的 20480 个样本,换成 48000 应该是多少个?
- 判断一下:想让声音「快放但不变调」,能靠改采样率做到吗?
常见误区
- 以为 44.1k 和 48k 差不多。 差 8.8% 的速率,听起来是「快了半音」。
- 不冲空包。 少十几个样本、不报错,但最后那一小截永远听不到。
- 用样本数除以采样率算时长,还当它精确。 中间做过重采样就会有毫秒级误差。
- 靠改采样率做倍速。 速度变了音调也跟着变,真正的倍速要用时间伸缩算法。
自测三问(点开对答案)
1. `swr_convert` 处理哪两件事? —— 采样率转换和声道布局转换,顺带也处理样本格式(`fltp` 到 `s16`)。 2. 为什么输出样本数会比理论值少一个? —— 因为除不尽,重采样器把剩下的小数部分留在内部,等下一帧一起处理。 3. 声卡回调里为什么不能做重采样? —— 回调跑在音频线程上,卡一下就是爆音。所以转换要提前在解码线程做好,回调里只拷贝。数据齐了,接下来是「控制」。暂停、快进、静音这些按钮,在代码里到底是改了什么——答案跟大多数人的直觉不一样,比如「暂停」并不是把线程停下来。
- ☐ (本节完,下一节 09.07 FFplay的播放控制)