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 的步骤:

  1. 分帧加窗:把信号切成帧,每帧加窗。
  2. FFT:对每帧做 FFT,得到复数频谱。
  3. 取幅度:计算每个频率点的幅度(或功率)。
  4. 对数压缩:取 log,让动态范围更可控。
  5. 映射到颜色:把幅度值映射到灰度或彩色。

下面是我用 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,每一步都有对应的数学操作和工程考量。

频域特征提取流程 原始音频信号 分帧 + 加窗 FFT 变换 幅度/功率谱 关键参数 帧长:256/512/1024 点 帧移:帧长的 25%~50% 窗函数:汉宁/汉明/布莱克曼 FFT 长度:2 的幂次方 采样率:8k/16k/44.1k Hz 对数压缩 (log) Spectrogram 部署优化要点 1. 定点化:浮点转 Q15/Q31,避免硬件浮点单元 2. 查表法:旋转因子、窗函数提前算好存 ROM 3. 内存复用:in-place FFT,省一半内存 4. 跳帧:非实时场景可跳帧,降低计算量

3.7 部署中的常见问题

最后,我总结几个实际部署时容易踩的坑:

  • 频谱泄漏:不加窗或者用矩形窗,频谱会「糊」成一片。我见过有人直接用原始数据做 FFT,结果频率分辨率看起来很高,实际上全是旁瓣干扰。
  • 频率分辨率 vs 时间分辨率:帧长越长,频率分辨率越高,但时间分辨率越低。这是个 trade-off。做音乐分析时我倾向长帧(1024),做语音端点检测时用短帧(256)。
  • 实时性:STFT 的计算量主要来自 FFT。如果帧移是帧长的一半,每秒要做的 FFT 次数是 2×采样率/帧长。16kHz 采样、512 帧长,每秒要做 62.5 次 FFT。在低端 MCU 上,这个量级需要优化。
  • 内存占用:Spectrogram 矩阵的大小是 (帧数 × 频率点数)。如果做长时间分析,内存会爆炸。我一般用环形缓冲区,只保留最近 N 帧。

曾经踩过的坑: 有一次在 RTOS 上做实时语音处理,STFT 的帧移和中断周期没对齐,导致丢帧。后来我把帧移设成中断周期的整数倍,问题才解决。记住:算法参数要和系统时钟对齐,不然再好的算法也白搭。