3. 频域特征提取:从 FFT 到 Spectrogram
说实话,时域信号就像看一个人的背影——你能看到轮廓,但看不清细节。频域分析才是真正让声音「现原形」的手段。这一章,我带你走一遍从 FFT 到 Spectrogram 的完整链路。这些都是声学特征提取的基石,也是我这些年做语音增强、唤醒词检测时最常用的工具。
核心脉络: 连续信号 → 离散采样 → FFT 看全局频谱 → STFT 看时变频谱 → Spectrogram 可视化。每一步都有坑,咱们一个一个踩过去。
3.1 傅里叶变换:从时域到频域的「翻译官」
傅里叶变换的核心思想很简单:任何周期信号都可以分解成不同频率的正弦波之和。你想想看,一个复杂的声音,其实就是一堆不同频率、不同幅度的正弦波叠在一起。
数学上长这样:
X(f) = ∫ x(t) · e^(-j2πft) dt
但在实际工程里,我们处理的是离散采样点。所以用的是离散傅里叶变换(DFT):
X[k] = Σ x[n] · e^(-j2πkn/N), n=0 to N-1
直接算 DFT 的复杂度是 O(N²),N 稍微大一点就扛不住了。我记得第一次在 ARM Cortex-M4 上做实时频谱分析,直接用 DFT,结果 CPU 直接跑满,音频都卡顿了。后来换成 FFT,复杂度降到 O(N log N),才把问题解决。
我的经验: 实际部署时,FFT 长度通常选 256、512、1024 或 2048。选 2 的幂次方,库函数优化得最好。我一般用 512 或 1024,兼顾频率分辨率和实时性。
3.2 FFT 实现与关键参数
FFT 的实现有很多种,最经典的是 Cooley-Tukey 算法。核心就是分治——把大 DFT 拆成小 DFT。这里我不展开推导,直接给一个我在嵌入式上常用的 C 代码片段:
// 基2 FFT,in-place 计算
void fft(float* real, float* imag, int n) {
// 位反转排序
for (int i = 1, j = 0; i < n; i++) {
int bit = n >> 1;
for (; j & bit; bit >>= 1)
j ^= bit;
j ^= bit;
if (i < j) {
// 交换 real[i] 和 real[j], imag[i] 和 imag[j]
}
}
// 蝶形运算
for (int len = 2; len <= n; len <<= 1) {
float w_real = cosf(2 * M_PI / len);
float w_imag = -sinf(2 * M_PI / len);
for (int i = 0; i < n; i += len) {
float wr = 1.0f, wi = 0.0f;
for (int j = 0; j < len / 2; j++) {
float t_real = wr * real[i + j + len/2] - wi * imag[i + j + len/2];
float t_imag = wr * imag[i + j + len/2] + wi * real[i + j + len/2];
real[i + j + len/2] = real[i + j] - t_real;
imag[i + j + len/2] = imag[i + j] - t_imag;
real[i + j] += t_real;
imag[i + j] += t_imag;
// 旋转因子更新
float tmp = wr;
wr = wr * w_real - wi * w_imag;
wi = tmp * w_imag + wi * w_real;
}
}
}
}
这段代码我用了很多年,在 STM32 和 ESP32 上都跑过。注意几个关键点:
- 位反转排序:FFT 的第一步,把输入数据重新排列。很多人在这里写错,我踩过这个坑。
- 旋转因子:用递推方式更新,避免每次重新计算 cos/sin,能省不少时间。
- in-place 计算:直接在原数组上操作,省内存。嵌入式上内存金贵,这个习惯要养成。
3.3 短时傅里叶变换(STFT):给频谱加上时间轴
FFT 有个致命问题:它假设信号是平稳的。但语音、音乐这些信号,频率随时间变化很快。你想想看,一个人说话,每个音节的频率都在变,一个全局 FFT 只能告诉你「整个信号里有哪些频率」,却不知道「这些频率什么时候出现」。
STFT 的解决思路很直接:把信号切成一小段一小段,对每一段做 FFT。数学上:
X(m, k) = Σ x[n] · w[n - m] · e^(-j2πkn/N)
其中 w[n] 是窗函数,m 是帧的起始位置。
STFT 有三个关键参数,我整理了一个表格:
| 参数 | 含义 | 常见取值 | 我的建议 |
|---|---|---|---|
| 帧长 | 每帧的采样点数 | 256, 512, 1024 | 语音用 512(32ms @16kHz),音乐用 1024 |
| 帧移 | 相邻帧的起始间隔 | 帧长的 25%~50% | 我常用 50% 重叠,信息冗余够用 |
| 窗函数 | 减少频谱泄漏 | 汉宁窗、汉明窗、布莱克曼窗 | 通用场景用汉宁窗,频率分辨要求高用布莱克曼 |
避坑指南: 我曾经在做一个唤醒词检测项目时,帧移设得太小(10% 重叠),结果计算量暴增,延迟从 50ms 飙到 200ms。后来改成 50% 重叠,效果几乎没变,延迟降下来了。记住:不是重叠越多越好,够用就行。
3.4 窗函数:别让频谱「漏」了
为什么要加窗?因为我们对信号做了截断。截断在频域上会引入旁瓣,也就是频谱泄漏。窗函数的作用就是让截断变得平滑,减少旁瓣能量。
几种常用窗函数的对比:
| 窗函数 | 主瓣宽度 | 旁瓣衰减 | 适用场景 |
|---|---|---|---|
| 矩形窗 | 最窄 | -13 dB | 瞬态信号检测 |
| 汉宁窗 | 较宽 | -32 dB | 通用语音分析 |
| 汉明窗 | 较宽 | -43 dB | 语音编码 |
| 布莱克曼窗 | 最宽 | -58 dB | 高动态范围频谱分析 |
我个人习惯用汉宁窗。它旁瓣衰减适中,主瓣宽度也能接受。在大多数语音处理场景下,汉宁窗都是最稳妥的选择。
3.5 频谱图(Spectrogram):让声音「看得见」
Spectrogram 就是把 STFT 的结果用图像展示出来。横轴是时间,纵轴是频率,颜色深浅代表能量大小。说白了,就是一张声音的「热力图」。
生成 Spectrogram 的步骤:
- 分帧加窗:把信号切成帧,每帧加窗。
- FFT:对每帧做 FFT,得到复数频谱。
- 取幅度:计算每个频率点的幅度(或功率)。
- 对数压缩:取 log,让动态范围更可控。
- 映射到颜色:把幅度值映射到灰度或彩色。
下面是我用 Python 生成 Spectrogram 的代码,部署时我会把它翻译成 C 或 C++:
import numpy as np
def stft(x, win_len, hop_len, win_type='hann'):
"""短时傅里叶变换"""
# 生成窗函数
if win_type == 'hann':
window = np.hanning(win_len)
elif win_type == 'hamming':
window = np.hamming(win_len)
else:
window = np.ones(win_len)
# 计算帧数
num_frames = 1 + (len(x) - win_len) // hop_len
# 初始化 STFT 矩阵
stft_matrix = np.zeros((win_len // 2 + 1, num_frames), dtype=np.complex64)
for i in range(num_frames):
start = i * hop_len
frame = x[start:start + win_len] * window
spectrum = np.fft.rfft(frame)
stft_matrix[:, i] = spectrum
return stft_matrix
# 生成 Spectrogram
def spectrogram(x, sr=16000, win_len=512, hop_len=256):
stft_mat = stft(x, win_len, hop_len)
mag = np.abs(stft_mat)
# 对数压缩,加小常数防止 log(0)
log_mag = 20 * np.log10(mag + 1e-6)
return log_mag
部署小技巧: 在嵌入式上,我一般不用对数压缩,直接用幅度平方(功率谱)。因为 log 运算在 MCU 上很慢,而且很多模型(比如 CNN)对线性输入也能学得很好。省掉 log,能省 30% 的计算量。
3.6 知识体系总览
下面这张图把本章的核心逻辑串起来了。从原始信号到 Spectrogram,每一步都有对应的数学操作和工程考量。
3.7 部署中的常见问题
最后,我总结几个实际部署时容易踩的坑:
- 频谱泄漏:不加窗或者用矩形窗,频谱会「糊」成一片。我见过有人直接用原始数据做 FFT,结果频率分辨率看起来很高,实际上全是旁瓣干扰。
- 频率分辨率 vs 时间分辨率:帧长越长,频率分辨率越高,但时间分辨率越低。这是个 trade-off。做音乐分析时我倾向长帧(1024),做语音端点检测时用短帧(256)。
- 实时性:STFT 的计算量主要来自 FFT。如果帧移是帧长的一半,每秒要做的 FFT 次数是 2×采样率/帧长。16kHz 采样、512 帧长,每秒要做 62.5 次 FFT。在低端 MCU 上,这个量级需要优化。
- 内存占用:Spectrogram 矩阵的大小是 (帧数 × 频率点数)。如果做长时间分析,内存会爆炸。我一般用环形缓冲区,只保留最近 N 帧。
曾经踩过的坑: 有一次在 RTOS 上做实时语音处理,STFT 的帧移和中断周期没对齐,导致丢帧。后来我把帧移设成中断周期的整数倍,问题才解决。记住:算法参数要和系统时钟对齐,不然再好的算法也白搭。