4. 模型选型入门

说实话,MFCC 这东西,我刚开始接触的时候也觉得挺玄乎的。一堆数学公式,什么傅里叶变换、梅尔滤波器组,看着就头大。但做了几年嵌入式部署之后,我慢慢发现,MFCC 其实就是把「人耳怎么听声音」这件事,用数学翻译给机器听。

你想想看,我们人耳对频率的感知不是线性的。低频稍微变一点,我们能听出来;高频变化很大,我们反而觉得差不多。MFCC 就是模仿这个特性,把声音信号压缩成一组更紧凑、更鲁棒的特征。说白了,就是让模型别去学那些对唤醒词没用的细节。

核心观点: MFCC 不是唯一的特征提取方式,但在唤醒词这种小模型场景下,它依然是性价比最高的选择。计算量可控,特征维度低,效果稳定。

4.1 MFCC 原理简介:五步搞定

MFCC 的计算流程,我习惯把它拆成五个步骤。每一步都有它的道理,咱们一个一个来看。

MFCC 计算流程 预加重 分帧 + 加窗 FFT 梅尔滤波器组 DCT + 取系数 各步骤作用: 1. 预加重:提升高频分量,补偿口唇辐射损失,让高频信息更明显。 2. 分帧 + 加窗:把连续信号切成短时片段,用汉明窗减少频谱泄漏。 3. FFT:把时域信号转到频域,看看每个频率成分有多强。 4. 梅尔滤波器组:模拟人耳听觉特性,把线性频率映射到梅尔刻度。 5. DCT + 取系数:去相关,保留前 12-13 个系数作为最终特征。

预加重这一步,很多人会忽略。但我告诉你,在唤醒词场景下,高频信息往往包含辅音和摩擦音,这些对区分「嘿」和「嗨」很关键。预加重用一个简单的高通滤波器,把高频拉起来。公式不复杂:y[n] = x[n] - 0.97 * x[n-1]。这个 0.97 就是预加重系数,我一般不动它。

分帧和加窗是绑在一起的。语音信号是非平稳的,但在 20-30 毫秒内可以看成平稳的。所以我们把它切成帧。每帧之间一般有重叠,避免信息丢失。加窗是为了让帧的边缘平滑过渡,减少 FFT 时的频谱泄漏。汉明窗是默认选择,别用矩形窗,那个泄漏太严重了。

FFT 没什么好说的,就是把时域变频域。每个帧变成一组频谱能量值。这里要注意的是 FFT 点数,一般取 512 或 1024,要大于帧长。

梅尔滤波器组是 MFCC 的灵魂。它把线性频率映射到梅尔刻度:Mel(f) = 2595 * log10(1 + f/700)。你看,低频区域滤波器密集,高频区域稀疏。这就是人耳的听觉特性。滤波器数量一般取 40 个,但我在实际项目中试过 26 个,效果也还行,计算量还小一些。

DCT 最后一步,把滤波器组的输出做离散余弦变换。目的是去相关,把能量集中到前面几个系数上。通常取第 2 到第 13 个系数,丢掉第 0 个(因为它代表整体能量,容易受音量影响)。

我的经验: 在嵌入式部署中,我经常只取 12 个 MFCC 系数。再多了,模型参数膨胀,收益却不大。少一点,模型小,推理快,唤醒率几乎不掉。

4.2 librosa 与 python_speech_features:两个常用库

Python 里做 MFCC 提取,最常用的就是 librosa 和 python_speech_features。两个库我都用过,各有千秋。我个人的习惯是:快速验证用 librosa,部署到嵌入式平台用 python_speech_features。

为什么?因为 librosa 依赖多,安装包大,在 ARM 板子上编译能让你怀疑人生。而 python_speech_features 纯 NumPy 实现,轻量,移植方便。

4.2.1 使用 librosa 提取 MFCC

import librosa

# 加载音频,librosa 会自动重采样到 16kHz
y, sr = librosa.load('hey_smart.wav', sr=16000)

# 提取 MFCC,默认 13 个系数
mfcc = librosa.feature.mfcc(
    y=y, sr=sr,
    n_mfcc=13,          # 取 13 个系数(含第 0 个)
    n_fft=512,          # FFT 点数
    hop_length=160,     # 帧移,对应 10ms
    win_length=400,     # 帧长,对应 25ms
    n_mels=40,          # 梅尔滤波器数量
    fmin=0, fmax=8000   # 频率范围
)

print(mfcc.shape)  # (13, 帧数)

librosa 的接口很简洁,一行搞定。但要注意,它返回的 shape 是 (n_mfcc, n_frames),不是我们习惯的 (n_frames, n_mfcc)。我刚开始用的时候就被这个坑过,训练时维度不对,报错半天。

4.2.2 使用 python_speech_features 提取 MFCC

from python_speech_features import mfcc
import scipy.io.wavfile as wav

# 读取音频,返回采样率和信号
rate, sig = wav.read('hey_smart.wav')

# 提取 MFCC
mfcc_feat = mfcc(
    sig, rate,
    winlen=0.025,       # 帧长 25ms
    winstep=0.010,      # 帧移 10ms
    numcep=13,          # 系数个数
    nfilt=40,           # 滤波器数量
    nfft=512,
    lowfreq=0,
    highfreq=8000,
    preemph=0.97        # 预加重系数
)

print(mfcc_feat.shape)  # (帧数, 13)

这个库的输出 shape 是 (n_frames, n_mfcc),更符合我们的直觉。而且它返回的是纯 NumPy 数组,没有额外的对象包装,序列化起来很方便。

对比项 librosa python_speech_features
输出 shape (n_mfcc, n_frames) (n_frames, n_mfcc)
依赖 多(audioread, numba 等) 少(仅 NumPy + SciPy)
安装包大小 较大 极小
ARM 移植难度 高 低
功能丰富度 高(含各种音频处理) 低(仅特征提取)

注意: 两个库的默认参数不完全一致。比如 librosa 默认的 n_fft 是 2048,而 python_speech_features 默认是 512。在切换库时,一定要手动对齐参数,否则提取出来的特征会有差异,模型精度会掉。

4.3 MFCC 参数调优:帧长、帧移、滤波器数量

参数调优,说白了就是找平衡。计算量、时间分辨率、频率分辨率,三者不可兼得。我在这上面踩过不少坑,下面把我的经验分享给你。

4.3.1 帧长(win_length / winlen)

帧长决定了每帧包含多少时间信息。太短了,频率分辨率不够,FFT 算出来频谱很粗糙。太长了,语音的平稳性假设不成立,特征会模糊。

  • 典型值: 20-30ms。我一般用 25ms,对应 16kHz 采样率就是 400 个采样点。
  • 唤醒词场景: 唤醒词通常很短(0.5-1秒),帧长取 25ms 够用了。如果你做的是「小爱同学」这种三音节唤醒词,可以试试 30ms,低频信息更完整。
  • 避坑: 帧长不要超过 50ms。我曾经在一个项目里为了省计算量,把帧长拉到 60ms,结果唤醒率直接掉了 5 个点。后来发现是频谱模糊了,辅音特征被淹没了。

4.3.2 帧移(hop_length / winstep)

帧移控制相邻两帧的重叠程度。帧移越小,帧数越多,时间分辨率越高,但计算量也越大。

  • 典型值: 5-15ms。我常用 10ms,对应 160 个采样点(16kHz)。
  • 为什么是 10ms? 因为语音的基音周期大概在 5-15ms 之间,10ms 的帧移能保证每个基音周期至少被两帧覆盖,不会漏掉关键信息。
  • 极端情况: 如果你做的是低功耗唤醒,帧移可以拉到 20ms。帧数减半,计算量减半。但代价是唤醒词的开头或结尾可能落在帧边界上,导致特征不完整。我试过,唤醒率掉了 2-3 个点,但功耗降了 40%,在某些场景下是可以接受的。

4.3.3 梅尔滤波器数量(n_mels / nfilt)

滤波器数量决定了频率分辨率的精细程度。数量越多,频率信息越丰富,但特征维度也越高。

  • 典型值: 26-40。我一般用 40,这是 librosa 的默认值。
  • 为什么不是越多越好? 因为梅尔滤波器组本身是重叠的,相邻滤波器之间有相关性。超过 40 个之后,增加滤波器带来的信息增益很小,反而增加了 DCT 的计算量。
  • 我的经验: 在资源受限的 MCU 上,我试过 26 个滤波器,配合 12 个 MFCC 系数,效果和 40 个滤波器几乎一样。但计算量减少了 30%。所以,如果你的芯片算力紧张,大胆降到 26。
参数 推荐值 调优方向 影响
帧长 25ms (400 点) 短→长:频率分辨率↑,时间分辨率↓ 太长会模糊辅音
帧移 10ms (160 点) 小→大:帧数↑,计算量↑ 太大可能漏掉关键帧
滤波器数量 40 少→多:特征维度↑,信息量↑ 超过 40 收益递减
MFCC 系数 12 (去掉第 0 个) 少→多:模型参数↑,鲁棒性↑ 超过 16 容易过拟合

调优建议: 先固定帧长 25ms、帧移 10ms、滤波器 40 个,跑一个 baseline。然后单独调每个参数,观察唤醒率的变化。不要同时调多个参数,否则你根本不知道是哪个参数起了作用。

4.4 一个完整的 MFCC 提取示例

下面是我在实际项目中用的提取函数。它把两个库的优点结合起来了:用 python_speech_features 做核心提取,用 librosa 做音频加载(因为 librosa 的音频加载更鲁棒,支持更多格式)。

import numpy as np
import librosa
from python_speech_features import mfcc

def extract_mfcc(audio_path, sr=16000):
    """
    提取 MFCC 特征,返回 (帧数, 13) 的数组
    """
    # 加载音频,统一到 16kHz 单声道
    y, _ = librosa.load(audio_path, sr=sr, mono=True)

    # 提取 MFCC
    mfcc_feat = mfcc(
        y, sr,
        winlen=0.025,       # 25ms 帧长
        winstep=0.010,      # 10ms 帧移
        numcep=13,          # 13 个系数
        nfilt=40,           # 40 个滤波器
        nfft=512,
        lowfreq=0,
        highfreq=sr//2,     # 最高频率到采样率的一半
        preemph=0.97
    )

    # 去掉第 0 个系数(能量系数),保留 2-13
    # 这样输出是 (帧数, 12)
    mfcc_feat = mfcc_feat[:, 1:]

    return mfcc_feat

# 使用示例
features = extract_mfcc('hey_smart.wav')
print(features.shape)  # (帧数, 12)

这个函数我用了很久,在多个唤醒词项目里验证过。你拿过去直接用,基本不用改。

小技巧: 如果你发现唤醒词在某些噪声环境下表现不好,可以试试加上一阶差分和二阶差分。MFCC 只描述了静态特征,差分能捕捉动态变化。但代价是特征维度变成 3 倍(12 + 12 + 12 = 36 维),模型会大一些。