手写重采样只要十几行:按比例取点,或者按比例插值。 但手写版省掉了抗混叠与跨帧状态,这才是库的价值。
10.1.3 讲了重采样的账,这一节自己动手写一遍。 教材用 C 写,这里用 Python 演示同一套算法——算法本身与语言无关。
一、最近邻:最简单的做法
按比例算下标,取最近的那个点。 原始 6 个样本 升两倍(12 个)
0 100 200 300 400 500 0 0 100 100 200 200 …
升采样就是「每个点重复一次」,降采样就是「隔一个丢一个」。 两行代码,快到可以实时。 代价是台阶:波形变成锯齿状,听起来发毛。 但它在「只要样本数对、不管音质」的场合完全够用,比如某些硬件的固定采样率要求。
二、线性插值:在两点之间补
取相邻两点,按小数部分加权。 做法 结果
原始 0 100 200 300 400 500 线性插值升两倍 0 50 100 150 200 250 …
值不再是重复的,而是平滑过渡。 代码也就十几行,这就是教材里 C 版重采样的核心。 C 里的写法与这里一模一样:一个浮点下标、两层整形、一次加权。 换成 Python 只是把循环换成了数组运算,算式没变。
三、手写版缺的三样
库比手写强的地方,全在这三条里。 缺什么 后果
抗混叠低通 降采样会有金属味杂音 跨帧状态 每帧接缝处会「咔」一下 足够的插值阶数 高频细节丢失,听感发闷
第二条最容易被忽略:手写版每帧单独算,接缝处因为缺少前文,会跳一下。 25 帧一秒,就是每秒二十五次咔哒。 库把滤波器状态存在上下文里,所以跨帧连续。 这就是「为什么要有个 SwrContext 对象」的原因——它存的就是这份状态。
四、教材用 C 写的理由
不是为了让你在生产里手写,是为了看懂每一层。 自己写一遍 得到什么
懂下标怎么算 看懂别人的实现 懂延迟从哪来 不再奇怪为什么要冲包 懂为什么需要上下文 不再把 swr 当黑盒
这三条都是「理解」层面的收益,不是「代码」层面的。 所以教材那几十行 C 值得跟着敲一遍,然后忘掉它、用库。 这个模式在整套书里反复出现:先手写一遍看懂原理,再用库。手写是为了懂,用库是为了对。
五、沙箱能验到什么
C 版跑不了(本机没有编译器),但算法能验。 能验 验不了
按下标取点对不对 C 的内存与指针写法 插值公式对不对 定点运算的精度 输出样本数与理论一致 处理速度
算法与语言无关,这是「用 Python 演 C 算法」的正当理由。 而内存布局、定点优化、SIMD 这些只有真编译才验得到,所以清单里标清楚,不假装跑过。
六、动手 Demo:自己动手写一次重采样,再看库为什么强
用 6 个样本做例子:最近邻升两倍、线性插值升两倍,最后对比库。
import numpy as np
import av
src = np.array([0, 100, 200, 300, 400, 500], dtype=np.int16)
# 最近邻:按比例算下标
up = src[np.floor(np.arange(12) * len(src) / 12.0).astype(int)]
print(up.tolist())
# 线性插值:取相邻两点加权
x = np.arange(12) * len(src) / 12.0
i0 = np.floor(x).astype(int)
i1 = np.minimum(i0 + 1, len(src) - 1)
frac = x - i0
lin = (src[i0] * (1 - frac) + src[i1] * frac).astype(np.int16)
print(lin.tolist())
# 库的做法:冲空包之后样本数正好对上
r = av.AudioResampler(format="s16", layout="mono", rate=16000)
f = av.AudioFrame(format="s16", layout="mono", samples=800)
f.sample_rate = 8000
a = sum(x.samples for x in r.resample(f))
b = sum(x.samples for x in r.resample(None))
print(a, b, a + b)
实跑输出:
--------------------------------------------------------------------
自己动手写一次重采样,再看库为什么强
--------------------------------------------------------------------
**① 最近邻:照着比例取点**
原始 6 个样本:[0, 100, 200, 300, 400, 500]
做法 输出
-----------------------------------------------------------------------
升两倍 [0, 0, 100, 100, 200, 200, 300, 300, 400, 400, 500, 500]
降一半 [0, 200, 400]
**最近邻就是「重复」与「丢弃」**,两行代码搞定,快得不得了。
**代价是台阶**——升采样出来的值是重复的,听着发毛。
**② 线性插值:在两点之间补**
做法 输出
--------------------------------------------------------------------------------
线性插值升两倍 [0, 50, 100, 150, 200, 250, 300, 350, 400, 450, 500, 500]
**值不再是重复的,而是平滑过渡。** 代码也就十行,**这就是 C 语言版重采样的核心。**
**③ 库强在哪:不是插值,是抗混叠**
做法 升采样 降采样 抗混叠
-------------------------------------------------------------
最近邻 重复 丢弃 完全没有
线性插值 平滑 抽点 没有
swr(libswresample) 滤波 低通后抽 **有**
**降采样前必须先低通**:不滤直接抽,高频会「折」到低频去,变成一种听起来像金属声的杂音,
**这个现象叫混叠。**
**手写插值省不掉这一步,而库替你做了。**
**④ 库还替你做了两件事**
库替你做的 表现
-------------------------------------------------------
滤波器状态跨帧保存 多帧连起来不会在接缝处爆音
冲空包把存货吐出来 合计 1600 个样本,等于 800×2
按帧边界对齐 输出帧长由它决定,不用你凑
**手写版最容易被忽略的是第一条**:每帧单独插值,接缝处会跳一下,听感是「每秒二十五次的咔哒」。
**这就是为什么真的做音频不能手写。**
最近邻的值是重复的,线性插值的值是过渡的,一眼就能看出差别。 库的样本数合计 1600,等于 800×2,但必须冲空包才凑齐。
30 秒实验
- 把插值公式里的
frac全当 0(退化成最近邻),看结果与第一组是不是一样。 - 把
i1改成i0(不取下一个点),看输出变成什么。 - 用 8000→8000 转一次,看库是不是原样返回(提示:也会少几个样本)。
常见误区
- 以为手写插值就够了。 缺抗混叠,降采样会有杂音。
- 每帧单独算。 接缝处会咔哒,因为滤波器状态没跨帧保存。
- 把教材的 C 代码当生产代码。 它是教学代码,用库才对。
自测三问(点开对答案)
1. 最近邻重采样的本质是什么? —— 按比例取点,升采样是重复、降采样是丢弃。 2. 手写版最容易缺哪一样? —— 跨帧的滤波器状态,缺了会在接缝处爆音。 3. 既然有库,为什么还要手写一遍? —— 为了看懂下标、延迟、上下文各是什么,之后才用得对。- ☐ (本节完,下一节 10.2.1 FFmpeg与PCM格式)