MFCC+GMM语音识别完整链路:从特征提取到模型训练与判决
简介:基于MFCC与GMM的语音识别Matlab工程,面向语音识别初学者、课程设计与科研人员,完整实现了从语音预处理、特征提取到模型训练与识别的全流程。压缩包共28个文件,以25个m脚本为主,涵盖预加重、分帧加窗、端点检测、特征比对等核心环节,另有2个mat文件保存训练好的GMM模型与语音数据,1个txt说明文档梳理整体思路。包体仅1.44MB,轻量易用,已有321人学习下载。通过研读代码,可掌握MFCC倒谱系数的提取细节、GMM参数估计的EM算法以及Viterbi解码的工程实现,并借助包内提供的话者模型与语音数据直接开展说话人识别实验。整体代码模块清晰、可读性强,适合作为课程设计或入门语音识别项目的参考。
1. 一个 zip 压缩包,装下从 MFCC 到 GMM 的完整语音识别链路
如果你的任务是在离线环境里识别几十个固定命令词,或者区分会议室里几个固定的说话人,先别急着上深度学习。我做过一个类似的嵌入式项目,CPU 资源紧张、没有 GPU、也不能联网,最后压箱底的反而是这套老链路:MFCC 提取特征,GMM 建模,最大对数似然判决。这个 zip 压缩包里装的正是这条链路的完整实现——从音频文件到 MFCC 向量,再到 GMM 训练与识别打分,每一步都有可运行的脚本。刚入手语音识别的人可以两天内跑通一个词表有限的识别系统,想快速验证算法的熟手也能拿它当基线。下面按我实际调通这套代码的顺序,把特征、建模、踩坑一次讲清楚。
2. MFCC 特征提取:把一段语音变成 39 维向量,再谈识别
2.1 为什么是 MFCC:人耳听觉特性与倒谱解耦
语音信号是非平稳的,但在一二十毫秒的尺度上可以近似看成平稳,所以语音识别第一步一定是分帧。MFCC 就是在这种短时分析框架下提炼出来的特征。它做的第一件事是把频谱放到 Mel 轴上——人耳对频率的感知不是线性,低频分辨能力远高于高频,Mel 尺度按临界带宽拟合了这条非线性感知轴。把频谱映射过去再取对数,既模拟了听觉响度规律,又让特征对噪声没那么敏感。
接下来的倒谱运算(取 log 后做 DCT)把频谱包络和精细结构分开。语义信息主要承载在包络上,也就是声道形状;而基频、发声抖动这些个体差异落在精细结构里,对应 DCT 后的高阶系数。所以只保留前十几个倒谱系数,反而能丢掉音高和说话人习惯带来的干扰——这是 MFCC 比直接拿频谱当特征更好用的核心原因。
但静态 MFCC 只描述某一帧的频谱形状,语音的走向信息(比如音节起始段的频率爬升)是缺失的。于是标准做法再把 MFCC 序列做一阶差分和二阶差分,分别近似速度与加速度,和静态系数拼在一起,成为 13+13+13=39 维的特征向量。这套 39 维特征从 90 年代沿用至今,是传统语音识别的事实标准,也是 zip 包里 GMM 建模的直接输入。
2.2 用 Python 实现 MFCC 提取:一套可以直接抄的脚本
下面这段函数在我手上跑了不下十个数据集。一个原则:训练和识别共用同一个函数,禁止各自复制一份改参数。血的教训,后面避坑章还会专门讲。
import numpy as np import librosa def extract_mfcc(wav_path, sr=16000, n_mfcc=13, n_mels=26, win_len=0.025, hop_len=0.010, pre_emph=0.97): # 1) 统一采样率、强制单声道,数据进模型之前必须同尺度 y, _ = librosa.load(wav_path, sr=sr, mono=True) # 2) 预加重:一阶高通补偿高频衰减,系数 0.97 是经典取值 y = np.append(y[0], y[1:] - pre_emph * y[:-1]) # 3) 分帧加窗提取 MFCC:帧长 25ms、帧移 10ms、汉明窗 n_fft = int(sr * win_len) # 16000 * 0.025 = 400 点 hop = int(sr * hop_len) # 16000 * 0.010 = 160 点 mfcc = librosa.feature.mfcc( y=y, sr=sr, n_mfcc=n_mfcc, n_mels=n_mels, n_fft=n_fft, hop_length=hop, window='hamming' ) # 4) 一阶、二阶差分与静态系数拼接,得到 [帧数, 39] d1 = librosa.feature.delta(mfcc, order=1) d2 = librosa.feature.delta(mfcc, order=2) feat = np.vstack([mfcc, d1, d2]).T return feat逻辑说明:librosa.load 的内部实现会先判断原始采样率,再用重采样器统一到 sr。这一行的意义在于:训练语料是 16k、现场录音是 8k 的坑不会再出现,所有文件进来都是 16000。预加重系数 0.97 做的运算是 y[t] - 0.97*y[t-1],相当于一阶高通滤波;系数取 0.95~0.99 都行,这里取 0.97 是 Kaldi 和 HTK 里最常用的默认值,刻意改小会让高频能量不足,改得太大低频又被压得过狠。汉明窗是默认选型,旁瓣衰减比矩形窗大,频谱泄漏少;librosa 的 mfcc 内部自带取 log 的保护,不会出现 log(0) 直接给 inf 的情况,这一点比手写 FFT 流程省心。
| 参数 | 取值 | 选择理由 |
|---|---|---|
| sr | 16000 | 语音识别最常用的采样率,覆盖 8k 电话语音和 44.1k 采集音频 |
| win_len | 25ms | 短时平稳假设的有效区间,频域分辨率约 40Hz,够分辨元音共振峰 |
| hop_len | 10ms | 帧与帧重叠 60%,给差分特征提供连续上下文 |
| n_mels | 26 | Mel 滤波器组数量,16k 下 26~40 个差异很小,26 算力和效果折中 |
| n_mfcc | 13 | DCT 后系数能量集中在前 13 个,高阶系数多为噪声 |
| pre_emph | 0.97 | 经典高通系数,恢复语音高频能量 |
调用这个函数,每个 wav 会得到一个二维数组,行数是帧数,列数是 39。拿到手先打印形状确认数据流是通的:
python -c " from extract_mfcc import extract_mfcc feat = extract_mfcc('data/word_open_01.wav') print(feat.shape) # 期望输出类似 (帧数, 39) "如果 feat.shape 的列数不是 39,多半是 n_mfcc 或差分阶数被改过。列数对了,再往下走 GMM 才有意义。
2.3 六个必调参数:帧长、帧移、滤波器数、维数、预加重、差分
帧长和帧移是这套链路里最先被乱调的两个参数。帧长一旦拉到 50ms,频域分辨率变高了,但时间分辨率变差,语音里那些 10~20ms 的爆破音会被相邻的平稳段平均掉;帧长压到 10ms 则反过来,共振峰在频域上分不开。所以 25ms 不是一个随便拍的值,它是人耳听觉和傅里叶分析之间的折中。帧移控制相邻帧的信息重叠度,10ms 是默认;为了省计算量调成 30ms 的后果是:一个单字命令可能只被切出两帧,GMM 根本学不到起音段的动态变化。
滤波器数和 MFCC 维数是一对容易混淆的参数。n_mels 决定 Mel 滤波器组把频谱切成多少段,等价于"频谱包络的采样密度";n_mfcc 决定 DCT 之后保留前多少个系数,等价于"包络的截断精度"。常见错误是 n_mels=40、n_mfcc=40 一起上,结果把频谱细节里的噪声也学进去了。我一般 16k 音频固定 n_mels=26、n_mfcc=13,如果对噪声敏感,先把 n_mfcc 降到 12 试,而不是加滤波器。
预加重和差分这两个参数在语音识别里几乎不需要动。0.97 的高通系数是几代工具链沉淀下来的默认值;差分阶数加到二阶就够,再往上只让特征维度膨胀,对准确率没有可观察的提升。唯一要留意的是采样率变化时帧长对应的点数要重算:8k 采样率下 25ms 是 200 点,如果用 16k 下的 400 点去分帧,实际帧长变成 50ms,整个特征的时域粒度就变了。这也是很多"换个数据集就失灵"的隐藏原因。
3. 高斯混合模型 GMM:给每个说话人或词建一个概率模型
3.1 GMM 凭什么描述语音特征分布
高斯混合模型(Gaussian Mixture Model, GMM)本质是一个软聚类和密度估计模型。它假设特征空间里每个点由 K 个高斯分布按权重混合生成,每个高斯负责一片区域。语音的 MFCC 特征在空间里不是单峰分布:同一个说话人在不同语气、不同字音下的频谱包络差异很大,单高斯只能描述一个椭圆形的集中区域,描述不了多模态分布;而 K 个高斯加权求和,理论上可以逼近任意连续分布。这正是 GMM 在语音识别里长期作为标准建模方案的原因。
语音识别里 GMM 的使用方式很直白:每个建模单元训练一个 GMM。建模单元可以是一个命令词,也可以是一个说话人。训练时把这一类别的所有音频帧全部无顺序地倒进 GMM,让它拟合这批点的分布。注意 GMM 不建模时序——帧的先后顺序它不管,时序信息要么由更高层的隐马尔可夫模型处理,要么在小词表场景下直接用整段统计量代替,这决定了 GMM 适合命令词识别和说话人识别这类任务。
对比一下常见替代方案能更清楚 GMM 的位置。KNN 需要保存大量特征向量,在线推理要遍历距离,速度慢且占用大;单高斯参数太少,拟合不了语音这种多模态分布;神经网络又需要大批量数据和 GPU。GMM 在参数量、拟合能力和推理速度之间是传统方案里最平衡的一个,这也是它当年银行电话语音识别、多语种小词表识别里遍地开花的原因。
3.2 EM 训练 GMM:初始化、E 步、M 步与收敛判定
训练 GMM 的标准算法是期望最大化(EM),它分两步交替:E 步用当前参数算每帧特征属于每个高斯的后验概率,M 步用这些后验概率重新估计均值、协方差和权重,不断迭代直到对数似然不再明显上升。EM 对初始值敏感,直接随机初始化容易掉进坏的局部最优,所以实际工程里总是先用 KMeans 把数据粗分几团,再用这些团的中心初始化高斯。sklearn 的 GaussianMixture 把这套流程封装好了,zip 里大概率也是这么写的。
from sklearn.mixture import GaussianMixture def train_gmm(feat, n_components=16, max_iter=200, reg_covar=1e-3): """ feat: [帧数, 39],一个说话人/一个词的全部训练音频拼接后的特征 """ gmm = GaussianMixture( n_components=n_components, covariance_type='diag', # 对角协方差:参数少,小样本下不容易过拟合 max_iter=max_iter, init_params='kmeans', # kmeans 初始化,比随机初始化稳定得多 reg_covar=reg_covar, # 协方差对角加小值,防止零方差导致似然爆炸 random_state=42 ) gmm.fit(feat) return gmm逻辑说明:covariance_type='diag' 是语音识别里最常用的折中。用 full 协方差时参数数量随特征维度平方增长,39 维就是 39×40 个协方差参数,小样本下很容易过拟合;diag 只保留对角元素,参数数量线性增长。n_components 是高斯成分数,词表识别通常 8~32 个,说话人识别看录音时长:每人录音短就 8~16,录音长到 30 秒以上可以加到 64。成分数太多会把环境噪声也建成一个高斯,后面避坑章细讲。reg_covar=1e-3 在协方差矩阵对角线上加一个小常数,防止某些成分因为样本太少算出零方差——零方差一旦出现,计算对数似然时直接除零。random_state 固定下来,保证同一份数据每次训练结果一致,方便调参对比。
训练完可以看 gmm.lower_bound_,这是 EM 收敛后的对数似然下界估计。它是个负数,绝对值越小通常代表拟合越好。但如果这个值比同类别其他模型高很多,要警惕过拟合。max_iter=200 是兜底,一般几十轮就收敛了。还有一个习惯是训练前打印 feat.shape 和 feat 里的 NaN 数量,很多玄学问题都出在这两步。
3.3 识别判决:从每帧似然到整段音频的分数
训练阶段给每个类别(词或说话人)各训一个 GMM,识别阶段把测试音频的特征矩阵分别送到每个 GMM 里,计算哪个模型生成这段特征的概率最高。注意语音帧数差异很大,长音频帧数多,直接用总对数似然求和会让长的音频占便宜,所以工程上普遍用平均对数似然作为判决分数。
def recognize(gmms, labels, feat): """ gmms: 每个类别一个 GMM feat: [帧数, 39],待识别音频的特征 """ scores = {} for label, gmm in zip(labels, gmms): # score_samples 返回每帧的对数似然,对帧数求平均 per_frame = gmm.score_samples(feat) scores[label] = float(np.mean(per_frame)) best = max(scores, key=scores.get) return best, scores逻辑说明:score_samples 给的是每帧特征在当前 GMM 下的对数似然,值都是负数。因为不同录音时长不同、帧数不同,均值比求和公平。返回的 scores 是一个字典,键是类别名,值是平均对数似然,取最大者作为判决结果。这个函数只做相对比较,分数本身的绝对值含义不大。
实际落地上我还会加一个拒识逻辑:当最高分低于某个阈值时,宁可判为"未知"也不硬给一个类别。阈值可以从验证集上统计正确样本的最低分来定,一般取正确样本最低分再往下放 1~2 个单位的余量。这个细节在命令词识别里特别重要,能挡住许多无意义语音的误触发。
4. 避坑:MFCC+GMM 语音识别最容易翻车的 5 个现场
4.1 采样率不统一,训练和测试特征对不上
现象:训练完模型在测试集上准确率只剩三成,代码不报错,特征维度也对,一头雾水。
原因:训练语音是 16k 录的,测试语音是手机 8k 采样的。特征提取函数里没传固定 sr,librosa.load 按原始采样率读取,虽然最后输出维度都是 39,但每个维度对应的实际频率范围完全不一样。模型等于拿着一套频率标尺去量另一套标尺的数据,识别率崩掉是必然。
解决:在特征提取函数里强制 sr=16000,并且批量处理脚本开头打印每个 wav 的真实采样率做核对。尚未可信的框架里不要开自动适配,宁可统一重采样慢一点,也不要让混合采样率的文件悄悄混进数据集。
4.2 训练数据太少,GMM 过拟合出"虚拟"分布
现象:训练时每个类别的对数似然都很高,但验证集上模型把同一个人读的另外几句话拒掉,甚至两个人互相混淆。
原因:每个类别只有十几秒录音,却设了 n_components=32。几百帧要喂 32 个高斯,每个高斯只分到几十帧,模型开始记住录音里的环境噪声和麦克风特性,而不是稳定的语音特征。这就是典型的过拟合。
解决:把 n_components 降到 8~16,covariance_type 设 diag,reg_covar 提到 1e-2。另一个前置处理是端点检测:用短时能量把静音段去掉再提取 MFCC,GMM 就不会专门为静音建一个高斯。数据真的太少时,加一点变速、加噪的数据增强比硬加高斯成分有效得多。
4.3 分帧参数乱改,短音和爆破音被切碎
现象:单字命令词比如"开""关"识别率非常差,同一个音频跑两次结果还不一样。
原因:开头和结尾的爆破音、塞音只有几十毫秒,帧移调到 30ms 后这些瞬态信息会被切进不同帧甚至漏掉。MFCC 的差分特征对时域对齐又很敏感,帧一错位,差分值面目全非。
解决:老老实实回到 25ms 帧长、10ms 帧移。如果命令词本身很短,帧长可以降到 20ms、帧移 5ms,牺牲一点计算量换时间分辨率。分帧参数在任何情况下都要写进配置文件,不要靠人肉记忆。
4.4 对数似然算出来是 NaN,模型直接罢工
现象:训练到一半 lower_bound 变成 nan,或者 score_samples 输出一堆 nan,识别脚本直接崩。
原因:特征里混进了 NaN 或 inf。常见来源是静音段的能量取对数得到负无穷,极端情况是某些帧全零,差分后仍为零,某个高斯协方差为 0,求逆时除零。
解决:训练前对特征做一次清洗,把 NaN 和极值替换成可控的数值。GMM 构造参数里设 reg_covar=1e-3,给协方差对角加保险丝。librosa 内部的取 log 有 floor 保护,但差分拼接后自己还是要检查一遍。训练脚本里加个断言,发现 NaN 直接定位到具体 wav 文件,比事后查模型快得多。
4.5 训练和推理两套特征参数,模型变成黑匣子
现象:训练时模型好好的,推理时加载进来报维度错误,或者不报错但识别结果完全随机。
原因:训练脚本和识别脚本分别写在两个文件里,各自调了一次特征提取。后来有人把识别侧脚本的 n_mfcc 从 13 改成了 20,模型输入是 39 维,推理时喂进来 60 维,代码不报错是因为某些路径里没用显式维度校验,直接硬算。
解决:把 extract_mfcc 放在独立模块,训练和推理都 import 同一个函数。模型保存时把特征参数(sr、n_mfcc、n_mels、帧长、帧移)连同 GMM 参数一起写入配置文件,推理时先校验维度再打分。这是我最常见也最隐蔽的翻车点,十次里有八次是它。
5. 从 zip 到能用的系统:验证闭环与三条进阶路线
5.1 先跑通最小闭环
拿到 zip 后不要急着调参。用包里的示例数据或者自己录 3 个词、每人 5 遍,训练 3 个 GMM,再拿一段没参与训练的录音做识别,确认输出分数不是 nan、判决结果符合预期。这一步把维度对不上、路径写错、模型没保存成功的问题全部暴露出来,比在完整数据集上排错快一个数量级。
5.2 评估指标怎么定
词表识别看两个数字:准确率和混淆矩阵。准确率反映整体水平,混淆矩阵告诉你哪些词互相混淆,比如"开"和"关"在安静环境下表现差,通常是起音段信息不足。说话人识别建议用等错误率 EER,通过调节判决阈值在漏报和误报之间找平衡。交叉验证时按说话人或录制批次切分,不要随机切帧——同一段录音的帧高度相关,随机切等于让测试集偷看训练数据。
5.3 三条进阶路线
数据少时走 GMM-UBM:用大量无关语音训练一个通用背景模型,再对每个说话人做 MAP 自适应,能明显缓解单人样本不足的问题。数据多了可以升级到 i-vector 加 PLDA,把 GMM 的超向量投影到低维空间,这是说话人识别在深度学习之前的主流方案。真到数据海量时再换端到端模型。不管是中文还是多语种小词表场景,MFCC+GMM 的参数几乎不用改就能跑,这是它到今天仍有价值的核心原因。
我自己最大的教训是:永远不要把训练和推理的特征提取代码分开维护,一次参数不一致,几天排查就白费。这个 zip 包里如果只有一个文件最值得珍惜,那就是那个被所有人统一调用的特征提取函数。希望帮到你。
本文还有配套的精品资源,点击获取