卷 III · 付账CH 11深度 11/20

把时间轴要回来

傅里叶变换有一个说出来会让人吃惊的缺陷:它完全不知道「什么时候」。一段先唱高音后唱低音的录音,和先低后高的那段,频谱可以一模一样。这一章讲工程上的补救办法,以及上一章那条不等式在补救之后变成了什么形状。

12 ms vs 384 ms像素的形状重叠不买分辨率

▷ 先猜一下

一段 2 秒的录音,采样率 4000 Hz。里面有两样东西:

  • 第 0–1 秒:两个音同时响,400 Hz415 Hz(相差 15 Hz)
  • 第 1.2 秒处:一记 5 毫秒的爆音(一记敲击)

我要画一张频谱图,让人一眼看出「有两个音」并且「爆音发生在哪一刻」。做法是把录音切成一段段,每段单独做变换。

问:窗长取多少能同时做到这两件事?

A 窗长 64(16 ms)。短窗时间定位准,频率也够用 B 窗长 2048(512 ms)。长窗频率分得细,爆音总归也能看到个大概位置 C 都行,关键在于重叠率——把 hop 设小一点就能两全 D 都不行。这正是上一章那条不等式,它在这张图上没得商量

做法:切段

短时傅里叶变换(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 「重叠率能两全」——上面那一栏专门讲了这个。重叠改变的是你画了多少列,不是每列有多糊。这是频谱图使用者最常见的一个误会,而且它很难被自己发现,因为提高重叠之后图确实变好看了。 补一句:真正的出路不是在这张图上找参数,而是换一种基——让不同频率用不同的窗长。那就是下一章。
⟳ 换个域看
STFT 在时域里的样子:拿一个滑动的窗在信号上一段一段地扫过去,每停一次做一次变换。像用放大镜逐段看一份长文档。 同一件事在频域里的样子:一整排中心频率不同、但带宽全都相同的带通滤波器,每一个都盯着自己那一小段频率,输出它的包络随时间的变化。

这两种描述是严格等价的(第 14 章的卷积定理会说明为什么),但第二种描述一下子暴露了 STFT 的毛病:所有滤波器的带宽都一样。在 50 Hz 处,62.5 Hz 的带宽是灾难性的粗糙;在 1800 Hz 处,同样的带宽又细得没有必要。

而人的耳朵、动物的听觉、图像里的边缘,全都是「低频要看细、高频要看快」的。所以自然的下一步就是:让带宽随中心频率成比例变化。做出来的东西叫小波,是下一章。

这一章的一句话

频谱图把时间轴要了回来,代价是每个像素的面积被上一章那条不等式钉死了——你只能选它是宽扁的还是高窄的,而且整张图上所有像素形状相同。

下一章拆掉最后那句话。做法说起来很自然:低频用长窗,高频用短窗。做出来的东西叫小波,而它在特定的数据上有惊人的效率——一个只有三处跳变的 1024 点信号,傅里叶需要 1024 个非零系数,Haar 小波只需要 26 个。不过下一章也会诚实地给出反面的那一列:换成一个纯正弦,两边的比分完全颠倒过来。