3、时域与频域分析:傅里叶变换入门、短时傅里叶变换(STFT)、频谱图解读
好,咱们进入第三章。这一章,说实话,是语音信号处理的“地基”。
你想想看,麦克风采集到的声音,是一串随时间变化的电压值。这就是时域信号。但光看这个波形,你能看出它发的是“啊”还是“咿”吗?很难。我们需要换个角度看问题——频域。
我个人习惯把时域和频域的关系,比作一道菜的“配方”和“味道”。时域是菜入口的瞬间感受,频域则是里面放了盐、糖还是辣椒。傅里叶变换,就是那个能把“味道”从“口感”里分离出来的魔法。
3.1 傅里叶变换:从时域到频域的“翻译官”
傅里叶变换的核心思想,说白了就是:任何复杂的周期信号,都可以分解成一系列不同频率、不同幅度的正弦波之和。
数学公式长这样:
X(f) = ∫ x(t) * e^(-j2πft) dt
别被这个积分吓到。在MCU上,我们不会手算这个。我们用的是它的离散版本——离散傅里叶变换(DFT),以及它的快速算法FFT。
核心理解:
- 幅度谱:告诉你每个频率成分有多“强”。比如100Hz的幅度大,说明这个音调很响。
- 相位谱:告诉你每个频率成分的“起始位置”。在语音识别里,相位信息通常被丢弃,我们只保留幅度谱。
我在项目中遇到过一个问题:直接用FFT分析整段语音,结果发现根本分不清“si”和“shi”。为什么?因为傅里叶变换假设信号是平稳的,但语音信号是非平稳的——它的频率成分随时间变化。你想想看,一个字的发音,声母和韵母的频率特征完全不同。
避坑指南:
我曾经在做一个唤醒词检测项目时,直接用4096点的FFT分析整段1秒的音频。结果发现,无论怎么调阈值,误唤醒率都降不下来。后来才意识到,整段FFT把时间信息全丢了,相当于把一部电影压缩成一张照片——你只能知道大概颜色,但看不出情节。
3.2 短时傅里叶变换(STFT):给信号加个“滑动窗口”
为了解决非平稳信号的问题,我们引入了短时傅里叶变换(STFT)。它的思路很朴素:
- 把长信号切成一小段一小段(称为“帧”)
- 假设每一小段内信号是平稳的
- 对每一小段分别做FFT
用公式表示就是:
STFT{x(t)} = X(m, ω) = ∑ x(t) * w(t - m) * e^(-jωt)
其中 w(t) 是窗函数,m 是帧的索引。
这里有几个关键参数,我建议你记牢:
| 参数 | 典型值 | 说明 |
|---|---|---|
| 帧长(Frame Length) | 20-40 ms | 太短频率分辨率差,太长时间分辨率差 |
| 帧移(Hop Length) | 10-20 ms | 相邻帧的重叠部分,通常为帧长的50% |
| 窗函数 | 汉明窗(Hamming) | 减少频谱泄露,比矩形窗好很多 |
| FFT点数 | 512或1024 | 通常取2的整数次幂,且大于帧长 |
我的经验:
在MCU上做STFT,帧长我一般选32ms(对应16kHz采样率就是512个点),帧移16ms(256个点)。这样既能保证频率分辨率(约31Hz),又能保证时间分辨率(每秒约62帧)。这个配置在大多数语音识别任务中表现都不错。
3.3 频谱图:语音的“指纹”
STFT的输出是一个二维复数矩阵。我们把它的幅度值取出来,用颜色深浅表示幅度大小,横轴是时间,纵轴是频率——这就得到了频谱图(Spectrogram)。
频谱图怎么看?我教你几个要点:
- 横条纹:对应某个频率的持续能量,比如元音的共振峰
- 竖条纹:对应某个时刻的突发能量,比如爆破音(p、t、k)
- 亮带:能量集中的区域,通常是语音的主要频率成分
- 暗区:能量较低的区域,可能是清音或静音
举个例子,发“啊”(/a/)这个音时,频谱图上会出现几条明显的亮带,这就是共振峰(Formant)。第一共振峰(F1)大约在800Hz,第二共振峰(F2)大约在1200Hz。而发“咿”(/i/)时,F1会降到300Hz左右,F2会升到2500Hz左右。你看,不同元音的频谱图特征完全不同。
在MCU上实现STFT的伪代码:
// 参数定义
#define FRAME_LEN 512 // 32ms @ 16kHz
#define HOP_LEN 256 // 16ms @ 16kHz
#define FFT_SIZE 512
// 缓冲区
int16_t audio_buffer[FRAME_LEN];
float window[FRAME_LEN];
float fft_input[FFT_SIZE];
float fft_output[FFT_SIZE];
// 初始化汉明窗
for (i = 0; i < FRAME_LEN; i++) {
window[i] = 0.54 - 0.46 * cos(2 * PI * i / (FRAME_LEN - 1));
}
// 主循环:每HOP_LEN个新样本处理一次
while (1) {
// 1. 读取HOP_LEN个新样本到缓冲区尾部
read_audio_samples(&audio_buffer[HOP_LEN], HOP_LEN);
// 2. 加窗
for (i = 0; i < FRAME_LEN; i++) {
fft_input[i] = audio_buffer[i] * window[i];
}
// 补零到FFT_SIZE
for (i = FRAME_LEN; i < FFT_SIZE; i++) {
fft_input[i] = 0;
}
// 3. 执行FFT(使用CMSIS-DSP库)
arm_rfft_f32(&S, fft_input, fft_output);
// 4. 计算幅度谱(只取前FFT_SIZE/2个点)
for (i = 0; i < FFT_SIZE/2; i++) {
mag[i] = sqrt(fft_output[2*i] * fft_output[2*i] +
fft_output[2*i+1] * fft_output[2*i+1]);
}
// 5. 将缓冲区左移HOP_LEN个样本
memmove(audio_buffer, &audio_buffer[HOP_LEN],
(FRAME_LEN - HOP_LEN) * sizeof(int16_t));
}
注意:
在MCU上做FFT,千万别用标准C的数学库函数(比如sin、cos、sqrt),太慢了。一定要用芯片厂商提供的DSP库,比如ARM的CMSIS-DSP、乐鑫的ESP-DSP。我见过有人用标准库做512点FFT,耗时超过100ms,这完全没法用。用CMSIS-DSP的话,同样512点FFT只需要不到1ms。
3.4 从频谱图到特征:MFCC的前奏
频谱图虽然信息丰富,但维度太高了。对于16kHz采样率、512点FFT,每一帧有256个频点。如果直接扔给神经网络,计算量太大。所以我们需要降维。
常用的做法是:
- 梅尔滤波器组:将线性频率映射到梅尔刻度(模拟人耳听觉特性),通常用40个三角滤波器
- 对数运算:对滤波器输出取对数,模拟人耳对声音强度的对数感知
- 离散余弦变换(DCT):进一步去相关,得到MFCC系数
不过,在MCU上做语音识别,我建议你直接使用FBank(滤波器组特征),也就是做完梅尔滤波和对数运算后的40维特征。为什么?因为DCT会破坏特征的局部性,而神经网络(尤其是CNN)更喜欢保留原始结构的FBank。我在多个项目里对比过,FBank在MCU上的识别准确率比MFCC高2-3个百分点。
我的建议:
如果你刚开始做MCU上的语音识别,先别急着上MFCC。直接用频谱图或者FBank特征,配合一个简单的卷积神经网络,效果往往出乎意料地好。等把整个流程跑通了,再回来优化特征提取也不迟。
好了,这一章的内容就到这里。下一章我们会深入讲解梅尔滤波器组的具体实现,以及如何在MCU上高效计算。记住,频谱图是语音识别的“眼睛”,学会看懂它,你就成功了一半。