把时间轴要回来
傅里叶变换有一个说出来会让人吃惊的缺陷:它完全不知道「什么时候」。一段先唱高音后唱低音的录音,和先低后高的那段,频谱可以一模一样。这一章讲工程上的补救办法,以及上一章那条不等式在补救之后变成了什么形状。
一段 2 秒的录音,采样率 4000 Hz。里面有两样东西:
- 第 0–1 秒:两个音同时响,400 Hz 和 415 Hz(相差 15 Hz)
- 第 1.2 秒处:一记 5 毫秒的爆音(一记敲击)
我要画一张频谱图,让人一眼看出「有两个音」并且「爆音发生在哪一刻」。做法是把录音切成一段段,每段单独做变换。
问:窗长取多少能同时做到这两件事?
做法:切段
短时傅里叶变换(STFT)说穿了只有一句话:
把长录音切成一小段一小段(每段 L 个点), 每段乘上一个窗(第 9 章), 每段单独做一次变换, 把每段的谱竖着画成一列,一列列排开。 横轴 = 时间(第几段),纵轴 = 频率,颜色 = 那一格的强度。 这就是频谱图(spectrogram)。
这是你在音频软件、语音识别论文、鸟类观测 App 里见过的那张彩色的图。它的做法就这么朴素,而全部的学问在于L 该取多少。
L 定死了这张图上「像素」的形状
窗长一旦选定,这张图的两个方向的分辨率就同时定死了,而且是相反方向:
采样率 4000 Hz
窗长 L 时间分辨率 频率分辨率 乘积
─────────────────────────────────────────────────────
64 16.0 ms 62.50 Hz 1
256 64.0 ms 15.63 Hz 1
512 128.0 ms 7.81 Hz 1
2048 512.0 ms 1.95 Hz 1
─────────────────────────────────────────────────────
时间分辨率 = L/fs,频率分辨率 = fs/L。**乘积恒等于 1。**
那个恒等于 1 的乘积,就是上一章那条不等式在这张图上的样子。用一句更好记的话:
频谱图上每个「像素」的面积是固定的,你只能选它的形状。
窗短 → 像素又矮又宽(时间准,频率糊)。
窗长 → 像素又高又窄(频率准,时间糊)。
而且更糟的是:整张图上所有像素的形状都一样。你没法说「低频那一块我要窄的,高频那一块我要扁的」——窗长是一个全局参数。这个限制正是下一章要动手拆掉的东西。
把开头那个场景量出来
两个音相差 15 Hz,要分开它们需要频率分辨率好于 15 Hz,也就是 L ≥ 267。而那记爆音只有 5 毫秒,要定准它需要时间分辨率好于 5 ms,也就是 L ≤ 20。两个要求直接打架。实测:
窗长 L 两个音在谱上是几个峰 爆音在图上摊开了多久
────────────────────────────────────────────────────────
64 1 ✗ 糊成一个 12 ms ✓ 定位很准
512 2 ✓ 分得开 64 ms
2048 2 ✓ 分得开 ⟨384 ms⟩ ✗ 摊成一大片
────────────────────────────────────────────────────────
没有一行是两个 ✓。这不是参数没调好——L ≥ 267 和 L ≤ 20 这两个不等式无解,而它们分别来自这段信号的两个成分。要同时满足,只能让不确定性乘积小于 1,那是上一章禁止的。
看 L=2048 那一行:一记 5 毫秒的敲击,在图上摊成了 384 毫秒的一道竖条,摊宽了七十多倍。任何一个见过语音频谱图的人都认得这道竖条——那是爆破音(p、t、k)在图上的样子,而它的宽度不是声音本身的宽度,是你的窗长。
重叠买不到分辨率
实践中切段总是带重叠的:窗长 512,每次只前进 128 个点(叫 hop 或 stride),于是相邻两段有 75% 是重复的数据。
很多人以为重叠率越高,时间分辨率越高。不是。重叠只是让你在时间轴上多画几列,每一列的「时间模糊范围」还是整整一个窗长。这和第 7 章的补零是完全同构的一件事:
| 动作 | 买到了什么 | 没买到什么 |
|---|---|---|
| 补零(频域) | 频率轴上谱线更密,曲线更光滑 | 分辨率(主瓣宽度一点不变) |
| 重叠(时域) | 时间轴上列数更多,画面更平滑 | 时间分辨率(每列还是糊一个窗长) |
重叠有它真正的用处,只是不在这里:它能避免落在窗边缘的事件被漏掉(不重叠时,一个刚好横跨两段边界的短事件会被两个窗的衰减部分同时削弱),也是无缝重建信号(第 9 章提到的完美重构)的必要条件。重叠是为了别丢东西,不是为了看得更细。
梅尔频谱图(mel spectrogram)——语音识别和音频大模型的标准输入——在上面这张图之后还多做了两步,两步都和这一章的物理无关,全是为了迁就人耳:
- 频率轴换成梅尔刻度:低频拉开、高频压缩。因为人耳分辨音高是按比例的(100 与 200 Hz 的差别,听起来等于 1000 与 2000 Hz 的差别),线性频率轴把绝大部分像素浪费在了人耳分不清的高频区。
- 幅度取对数(dB):因为人耳的响度感觉也是对数的。
这两步都不可逆,而且都在丢信息。所以从梅尔频谱图还原音频(声码器要干的事)是个不小的活儿——不只是缺相位(第 4 章),连幅度都是被压过的。「梅尔频谱是音频的表示」这句话要小心:它是为听觉任务优化过的表示,不是音频本身。
用 20 行手写一个 STFT,把上面那张表跑出来:
import numpy as np
fs, N = 4000, 8000
t = np.arange(N) / fs
x = np.where(t < 1.0, np.cos(2*np.pi*400*t) + np.cos(2*np.pi*415*t), 0.0)
x += np.where((t >= 1.2) & (t < 1.205), 6.0, 0.0) # 5 ms 的爆音
def stft(x, L, hop=None):
hop = hop or L//4
w = np.hanning(L)
return np.array([np.abs(np.fft.rfft(x[s:s+L]*w))
for s in range(0, len(x)-L+1, hop)])
for L in (64, 512, 2048):
S = stft(x, L)
print(f'L={L:5d} 时间分辨率 {1000*L/fs:6.1f} ms 频率分辨率 {fs/L:6.2f} Hz '
f'共 {S.shape[0]} 列 × {S.shape[1]} 行')
想真的看到那张图(推荐,效果比读文字强十倍):
import matplotlib.pyplot as plt
fig, ax = plt.subplots(1, 3, figsize=(14, 4))
for a, L in zip(ax, (64, 512, 2048)):
a.imshow(20*np.log10(stft(x, L).T + 1e-9), aspect='auto', origin='lower')
a.set_title(f'L={L}')
plt.show()
你会直接看见:左边那张图上爆音是一道极细的竖线但两个音糊成一条;右边那张两个音是两条清清楚楚的横线,而爆音变成了一道又宽又粗的竖带。同一份数据,同一个算法,只有一个参数不同。
python3 -c "print('把上面那段存成 stft.py,需要 numpy + matplotlib')"
在线跑:Google Colab(numpy 和 matplotlib 都自带)。不写代码的话,Audacity 里把音轨切换成「频谱图」视图,再到轨道设置里改 FFT 长度,能看到同一件事。
- 语音识别的输入层。从 2010 年代的深度学习模型到今天的语音大模型,喂进去的第一样东西几乎都是梅尔频谱图。窗长的标准值是 25 ms、hop 10 ms——25 ms 这个数不是随便定的:它长到能覆盖一个基音周期(男声约 8 ms),短到在这段时间里声道形状还没来得及变。那是在这条不等式上,为「人说话」这件事挑的一个点。
- Shazam 的指纹。做法是在频谱图上找局部极大点(叫峰值星座),再把「两个峰之间的频率和时间差」哈希成指纹。它对窗长的选择极不敏感,因为它只用峰的位置关系——这是个很聪明的绕开办法。
- 鸟鸣与鲸歌识别。生态声学几乎全靠频谱图。鸟鸣的调频扫得极快,所以要用短窗;而鲸的低频叫声要长窗。同一台设备录下来的数据,分析不同物种要用不同的窗长。
- 地震与结构监测。地震波的频谱图上,P 波和 S 波是两道不同斜率的条纹。这里的窗长选择直接决定了震源定位的精度。
- 你的服务监控。把一天的 QPS 曲线做成频谱图,能看出「哪个时段出现了新的周期性」——比如某个定时任务从下午三点开始每 30 秒抖一次。这比看原始曲线有效得多,而窗长的选择就是「我要多快发现它」和「我要多准地知道它的周期」之间的取舍。
「频谱图糊的话,把重叠率调高就行。hop 从 L/4 改成 L/16,时间分辨率就提高四倍。」
调高重叠率之后,你的图确实会变得更宽、更平滑、看起来更精细——因为列数变成了四倍。但每一列仍然是「这一整个窗长里的平均情况」,那记 5 毫秒的爆音在 L=2048 时还是会污染连续 512 毫秒的所有列,只是现在有更多列被污染了。
这和第 7 章的补零是同一个错误的两种穿法:把同一条模糊曲线采样得更密,不会让它变清楚。如果你发现自己在靠调重叠率来「提高分辨率」,那说明真正该动的是窗长——而动窗长就要在另一个方向上付钱。
判据是:问「我这个改动有没有让每个数据点参与的原始样本范围变小」。补零没有(还是那 1024 个点),提高重叠也没有(每列还是那 L 个点)。没有的话,分辨率就没变。
正确答案是 D:都不行。L ≥ 267 和 L ≤ 20 无解,这正是上一章那条不等式。
A 「短窗时间定位准,频率也够用」——时间那半对:L=64 时爆音只摊开 12 ms。频率那半错得离谱:频率分辨率是 62.5 Hz,而两个音才差 15 Hz,实测就是一个峰。 B 「爆音总归能看到个大概位置」——「大概」有多大是关键。L=2048 时那记 5 毫秒的敲击在图上摊成了 384 毫秒。如果你在做的是打击乐对齐、或者定位一次故障发生的时刻,384 毫秒的误差是完全不能接受的。「能看到」和「能用」之间差了七十倍。 C 「重叠率能两全」——上面那一栏专门讲了这个。重叠改变的是你画了多少列,不是每列有多糊。这是频谱图使用者最常见的一个误会,而且它很难被自己发现,因为提高重叠之后图确实变好看了。 补一句:真正的出路不是在这张图上找参数,而是换一种基——让不同频率用不同的窗长。那就是下一章。这两种描述是严格等价的(第 14 章的卷积定理会说明为什么),但第二种描述一下子暴露了 STFT 的毛病:所有滤波器的带宽都一样。在 50 Hz 处,62.5 Hz 的带宽是灾难性的粗糙;在 1800 Hz 处,同样的带宽又细得没有必要。
而人的耳朵、动物的听觉、图像里的边缘,全都是「低频要看细、高频要看快」的。所以自然的下一步就是:让带宽随中心频率成比例变化。做出来的东西叫小波,是下一章。
这一章的一句话
频谱图把时间轴要了回来,代价是每个像素的面积被上一章那条不等式钉死了——你只能选它是宽扁的还是高窄的,而且整张图上所有像素形状相同。
下一章拆掉最后那句话。做法说起来很自然:低频用长窗,高频用短窗。做出来的东西叫小波,而它在特定的数据上有惊人的效率——一个只有三处跳变的 1024 点信号,傅里叶需要 1024 个非零系数,Haar 小波只需要 26 个。不过下一章也会诚实地给出反面的那一列:换成一个纯正弦,两边的比分完全颠倒过来。