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)。它的思路很朴素:

  1. 把长信号切成一小段一小段(称为“帧”)
  2. 假设每一小段内信号是平稳的
  3. 对每一小段分别做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个频点。如果直接扔给神经网络,计算量太大。所以我们需要降维。

常用的做法是:

  1. 梅尔滤波器组:将线性频率映射到梅尔刻度(模拟人耳听觉特性),通常用40个三角滤波器
  2. 对数运算:对滤波器输出取对数,模拟人耳对声音强度的对数感知
  3. 离散余弦变换(DCT):进一步去相关,得到MFCC系数

不过,在MCU上做语音识别,我建议你直接使用FBank(滤波器组特征),也就是做完梅尔滤波和对数运算后的40维特征。为什么?因为DCT会破坏特征的局部性,而神经网络(尤其是CNN)更喜欢保留原始结构的FBank。我在多个项目里对比过,FBank在MCU上的识别准确率比MFCC高2-3个百分点。

我的建议:

如果你刚开始做MCU上的语音识别,先别急着上MFCC。直接用频谱图或者FBank特征,配合一个简单的卷积神经网络,效果往往出乎意料地好。等把整个流程跑通了,再回来优化特征提取也不迟。

好了,这一章的内容就到这里。下一章我们会深入讲解梅尔滤波器组的具体实现,以及如何在MCU上高效计算。记住,频谱图是语音识别的“眼睛”,学会看懂它,你就成功了一半。