4、特征提取(MFCC):从声音到数字的桥梁
说实话,MFCC 这东西,我刚开始接触的时候也觉得挺玄乎的。一堆数学公式,什么傅里叶变换、梅尔滤波器组,看着就头大。但做了几年嵌入式部署之后,我慢慢发现,MFCC 其实就是把「人耳怎么听声音」这件事,用数学翻译给机器听。
你想想看,我们人耳对频率的感知不是线性的。低频稍微变一点,我们能听出来;高频变化很大,我们反而觉得差不多。MFCC 就是模仿这个特性,把声音信号压缩成一组更紧凑、更鲁棒的特征。说白了,就是让模型别去学那些对唤醒词没用的细节。
核心观点: MFCC 不是唯一的特征提取方式,但在唤醒词这种小模型场景下,它依然是性价比最高的选择。计算量可控,特征维度低,效果稳定。
4.1 MFCC 原理简介:五步搞定
MFCC 的计算流程,我习惯把它拆成五个步骤。每一步都有它的道理,咱们一个一个来看。
预加重这一步,很多人会忽略。但我告诉你,在唤醒词场景下,高频信息往往包含辅音和摩擦音,这些对区分「嘿」和「嗨」很关键。预加重用一个简单的高通滤波器,把高频拉起来。公式不复杂: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 维),模型会大一些。