3. 短时傅里叶变换(STFT):原理推导、Librosa实现、频谱图生成与参数调优
傅里叶变换有个硬伤——它只能告诉你信号里有哪些频率,却说不清这些频率在什么时候出现。你想想看,一段语音和一个和弦的频谱可能长得一模一样,但听感完全不同。这就是为什么我们需要短时傅里叶变换(STFT)。
说白了,STFT就是把信号切成一小段一小段,对每一段做傅里叶变换。这样我们就能看到频率随时间的变化。我在做语音分离项目时,STFT几乎是所有预处理的第一步,绕不开它。
3.1 从傅里叶到短时傅里叶:为什么需要加窗?
先回顾一下离散傅里叶变换(DFT)的公式:
X[k] = Σ_{n=0}^{N-1} x[n] · e^{-j(2π/N)kn}
这个公式假设信号是周期性的,而且在整个时间轴上频率不变。但现实中的音频信号,比如人说话、乐器演奏,频率是随时间变化的。直接对整个信号做FFT,得到的是所有时间的平均频谱,细节全丢了。
STFT的做法很简单:
- 用一个滑动窗口把信号切成帧
- 对每一帧加窗(减少频谱泄漏)
- 对加窗后的每一帧做FFT
- 把结果按时间堆叠起来
数学表达式长这样:
X[m, k] = Σ_{n=0}^{N-1} x[n + m·H] · w[n] · e^{-j(2π/N)kn}
其中:
- m 是帧索引
- k 是频率索引
- H 是帧移(hop length)
- w[n] 是窗函数
- N 是帧长(FFT点数)
核心理解:STFT的输出是一个复数矩阵,行对应频率,列对应时间。取模得到幅度谱,取角度得到相位谱。大部分音频特征只用幅度谱,但相位在信号重建时至关重要。
3.2 窗函数的选择:不是所有窗都一样
加窗的目的是让帧的边缘平滑过渡到0,减少频谱泄漏。但不同的窗函数有不同的特性。
我刚开始做STFT时,图省事用了矩形窗,结果频谱图上全是旁瓣干扰,根本看不清谐波结构。后来才明白,矩形窗的旁瓣只比主瓣低13dB,而汉宁窗能到32dB。
| 窗函数 | 主瓣宽度 | 旁瓣衰减 | 适用场景 |
|---|---|---|---|
| 矩形窗 | 最窄 | -13 dB | 瞬态检测、频率精确测量 |
| 汉宁窗 | 中等 | -32 dB | 通用音频分析(最常用) |
| 汉明窗 | 中等 | -43 dB | 语音处理(旁瓣更低) |
| 布莱克曼窗 | 最宽 | -58 dB | 需要极低旁瓣的场景 |
我的经验:做音乐音频分析时,我习惯用汉宁窗。它能在频率分辨率和旁瓣抑制之间取得不错的平衡。如果做的是语音端点检测,汉明窗效果更好,因为它的旁瓣衰减更大,能减少静音段的噪声干扰。
3.3 Librosa实现:三行代码出频谱图
Librosa把STFT封装得非常简洁。核心函数就一个:librosa.stft。
import librosa
import numpy as np
import matplotlib.pyplot as plt
# 加载音频
y, sr = librosa.load('speech.wav', sr=16000)
# 计算STFT
D = librosa.stft(y, n_fft=1024, hop_length=512, win_length=1024, window='hann')
# 转成幅度谱(dB单位)
S_db = librosa.amplitude_to_db(np.abs(D), ref=np.max)
# 显示频谱图
librosa.display.specshow(S_db, sr=sr, hop_length=512, x_axis='time', y_axis='hz')
plt.colorbar(format='%+2.0f dB')
plt.title('STFT Spectrogram')
plt.tight_layout()
plt.show()
这段代码的输出是一个二维数组 D,形状是 (1 + n_fft/2, 帧数)。为什么是 1 + n_fft/2?因为实信号的FFT结果是对称的,我们只保留正频率部分。
注意:librosa.stft 默认的窗函数是汉宁窗,帧长 n_fft=2048,帧移 hop_length=512。如果你不指定参数,它会用这些默认值。但默认值不一定适合你的数据——采样率不同,最优参数也不同。
3.4 参数调优:帧长、帧移、FFT点数
STFT有三个关键参数,它们共同决定了时频分辨率:
- n_fft(帧长):每帧的采样点数。越大,频率分辨率越高,但时间分辨率越低。
- hop_length(帧移):相邻帧之间的偏移量。越小,时间轴越密,但计算量越大。
- win_length(窗长):实际加窗的长度。通常等于n_fft,但也可以小于n_fft(此时做零填充)。
这里有个矛盾:时频不确定性原理。你不可能同时获得高时间分辨率和高频率分辨率。帧长增加,频率分辨率提升,但时间分辨率下降;反之亦然。
我曾经在一个语音识别项目中踩过坑。默认用了2048的帧长,结果辅音(比如/t/、/k/)的爆破音在频谱图上糊成一团,时间分辨率不够。后来把帧长降到512,辅音的时域轮廓就清晰多了。
| 参数 | 调大 | 调小 | 典型值(sr=16000) |
|---|---|---|---|
| n_fft | 频率分辨率↑,时间分辨率↓ | 频率分辨率↓,时间分辨率↑ | 512~2048 |
| hop_length | 时间轴稀疏,计算量↓ | 时间轴密集,计算量↑ | n_fft/4 ~ n_fft/2 |
| win_length | 频率分辨率↑(同n_fft) | 旁瓣特性变化 | 通常等于n_fft |
调参建议:
- 语音分析:n_fft=512~1024,hop_length=128~256,帧长20~40ms
- 音乐分析:n_fft=2048~4096,hop_length=512~1024,帧长40~90ms
- 瞬态检测:n_fft=256~512,hop_length=64~128,帧长10~20ms
3.5 频谱图的可视化与解读
频谱图(Spectrogram)是STFT结果的直观展示。横轴是时间,纵轴是频率,颜色深浅代表能量大小。
Librosa提供了多种频谱图显示方式:
# 线性频率刻度
librosa.display.specshow(S_db, sr=sr, hop_length=512, y_axis='hz')
# 对数频率刻度(更符合人耳感知)
librosa.display.specshow(S_db, sr=sr, hop_length=512, y_axis='log')
# Mel频率刻度(语音分析常用)
librosa.display.specshow(S_db, sr=sr, hop_length=512, y_axis='mel')
我个人习惯用对数频率刻度。人耳对低频更敏感,对数刻度能同时看清低频的基频和高频的谐波。线性刻度在低频段信息太挤,高频段又太空。
读频谱图时,注意几个典型模式:
- 水平条纹:稳定的频率成分,比如元音的共振峰
- 垂直条纹:瞬态事件,比如打击乐、辅音爆破
- 倾斜条纹:频率变化,比如滑音、啁啾信号
- 噪声基底:均匀分布的背景噪声,颜色较浅
3.6 逆STFT:从频谱回到波形
STFT是可逆的。Librosa提供了librosa.istft函数,可以从STFT矩阵重建时域信号。
# 从STFT矩阵重建
y_reconstructed = librosa.istft(D, hop_length=512, win_length=1024, window='hann')
# 验证重建误差
print('MSE:', np.mean((y - y_reconstructed[:len(y)])**2))
重建时有个关键点:相位一致性。STFT的相位信息在重建中至关重要。如果你只保留幅度谱,把相位设成0,重建出来的信号听起来就像噪声。我在做音频风格迁移时吃过这个亏——只改了幅度谱,相位没处理,结果输出全是噼啪声。
避坑指南:逆STFT要求窗函数满足“常数重叠相加(COLA)”条件。汉宁窗、汉明窗都满足,但矩形窗不满足。如果你自定义窗函数,一定要检查COLA条件,否则重建信号会有幅度调制噪声。
3.7 STFT知识体系总览
下面这张图总结了STFT的核心概念和流程:
3.8 实战中的常见问题
最后分享几个我在项目中遇到的坑:
- 频谱图出现竖条纹:通常是帧移太大,相邻帧不连续。把hop_length降到n_fft/4试试。
- 低频能量异常高:可能是直流分量或低频噪声。用高通滤波器预处理一下,或者显示时忽略最低几个频点。
- 重建信号有咔嗒声:窗函数不满足COLA条件,或者帧移和窗长不匹配。检查一下win_length和hop_length的关系。
- 频谱图分辨率不够:增加n_fft,但要注意时间分辨率会下降。如果时间分辨率更重要,考虑用多分辨率STFT。
嗯,STFT这部分内容就到这里。参数调优没有银弹,关键是根据你的音频类型和应用场景去试。我一般会先画几张不同参数的频谱图对比一下,肉眼就能看出哪个参数组合更合适。