简介:基于MFCC与GMM的Matlab语音识别工程包,面向语音识别、说话人识别方向的初学者和研究者,演示从语音信号到声学特征再到模型匹配的完整流程。压缩包共28个文件,以.m脚本为主(25个),辅以2个.mat模型数据文件和1个说明文档,整体约1.44MB,结构紧凑。已有321人学习下载,适合边调边学。工程实现覆盖MFCC特征提取的预加重、分帧、FFT、梅尔滤波等环节,并包含基于GMM的声学建模与识别解码逻辑,如wave2mfcc、gmm_estimate、MFCC_feature_compare等函数脚本;同时提供语音端点检测相关代码(epdByVol)和说话人模型数据speakerGmm.mat,可直接运行观察不同说话人的识别效果。说明文档对参数设置和调用关系有相应梳理,便于快速上手。
1. 基于MFCC与GMM的语音识别:传统方案为什么还有人在用
提到语音识别,大部分人想到的是端到端深度学习模型训练和GPU集群。但在小词汇量孤立词识别、说话人确认、嵌入式离线场景里,基于MFCC的GMM方案依然是一个值得认真对待的选择——它不需要GPU,不需要海量数据,几十到几百条音频就能训练出一个可用的模型,跑在普通笔记本上毫无压力。这个标题里的MFCC是特征提取、GMM是分类建模,两者组合起来就是一套完整、可复现、可落地的语音识别系统。它适合的读者是做语音方向课程设计、嵌入式原型验证,或者在低资源场景下需要快速交付识别功能的人。本文按照从特征到模型再到完整系统的顺序,把每一步的原理、参数和坑讲清楚。
2. MFCC特征提取:从波形到一帧特征向量的完整过程
2.1 MFCC计算流程与代码实现
MFCC(梅尔频率倒谱系数)的核心思路是把人耳对频率的非线性感知映射到线性空间。人耳对1kHz以下频率近似线性感知、1kHz以上近似对数感知,Mel刻度正是对这种感知的近似。整个计算流程分七步:预加重、分帧、加窗、FFT、Mel滤波器组、取对数、DCT变换。前两步处理时域信号,后五步把时域信号转到频域再压缩到倒谱域。
先用Python的numpy和scipy搭一个最小实现:
import numpy as np from scipy.fftpack import dct from scipy.signal import lfilter def pre_emphasis(signal, alpha=0.97): # 预加重:一阶高通滤波器,补偿语音高频部分在发声时的能量衰减 return np.append(signal[0], signal[1:] - alpha * signal[:-1]) def frame_signal(signal, frame_len=400, frame_shift=160): # 分帧:帧长25ms,帧移10ms(采样率16kHz时取值) n_frames = 1 + (len(signal) - frame_len) // frame_shift frames = [] for i in range(n_frames): start = i * frame_shift frames.append(signal[start:start + frame_len]) return np.array(frames) def mel_filterbank(n_filters=26, n_fft=512, sr=16000, fmin=0, fmax=None): # 构造Mel滤波器组:将FFT频点映射到Mel刻度后生成三角形滤波器 fmax = fmax or sr // 2 mel_min = 2595 * np.log10(1 + fmin / 700) mel_max = 2595 * np.log10(1 + fmax / 700) mel_points = np.linspace(mel_min, mel_max, n_filters + 2) hz_points = 700 * (10**(mel_points / 2595) - 1) bins = np.floor((n_fft + 1) * hz_points / sr).astype(int) filters = np.zeros((n_filters, n_fft // 2 + 1)) for i in range(1, n_filters + 1): left, center, right = bins[i-1], bins[i], bins[i+1] for k in range(left, center): filters[i-1, k] = (k - left) / (center - left) for k in range(center, right): filters[i-1, k] = (right - k) / (right - center) return filters逻辑说明:预加重系数alpha=0.97是常用值,取值范围一般在0.95到0.99之间,取得太大容易放大高频噪声。分帧参数按16kHz采样率换算,25ms帧长等于400个采样点,10ms帧移等于160个采样点。帧移取帧长的一半是行业惯例,保证相邻帧有50%重叠,避免窗函数两端的数据被过度衰减。Mel滤波器组通常用26个滤波器,有的方案用40个,滤波器数越多频率分辨率越细,但特征维度也随之增加,计算量和GMM的拟合难度都会上升。
2.2 MFCC参数计算与动态特征拼接
有了预加重、分帧和滤波器组之后,完整的MFCC提取代码就是把这些模块串起来:
def mfcc_extract(signal, sr=16000, n_mfcc=13, n_filters=26): # 完整MFCC流程:预加重 -> 分帧 -> 加窗 -> FFT -> Mel滤波 -> log -> DCT signal = pre_emphasis(signal) frames = frame_signal(signal) window = np.hamming(400) # 汉明窗,减少频谱泄漏 frames = frames * window n_fft = 512 mag = np.abs(np.fft.rfft(frames, n=n_fft)) power = mag**2 / n_fft filters = mel_filterbank(n_filters, n_fft, sr) mel_energy = np.dot(power, filters.T) mel_energy = np.maximum(mel_energy, 1e-10) # 防止log(0) log_mel = np.log(mel_energy) mfcc = dct(log_mel, type=2, axis=1, norm='ortho')[:, :n_mfcc] return mfcc这段逻辑不复杂:FFT点数设512是因为400点帧长需要补零到512才能满足FFT要求。取功率谱后与Mel滤波器组做矩阵乘法,得到每个Mel频带的能量,取对数压缩动态范围,再用DCT把频谱包络和细节分开,取前13个系数作为静态特征。DCT的作用类似PCA,把Mel频谱的能量集中到低阶系数上,低阶系数对应声道形状的主要信息,高阶系数对应谐波细节和噪声成分。
实际识别中只用13维静态特征往往不够,因为语音的语调升降、语速变化都体现在帧间变化里。最常见的做法是拼接Delta和Delta-Delta特征,把13维扩成39维:
def delta_mfcc(mfcc, delta_window=2): # 计算一阶差分:用前后各2帧做加权回归近似导数 mfcc = np.pad(mfcc, ((delta_window, delta_window), (0, 0)), mode='edge') delta = np.zeros_like(mfcc) for t in range(delta_window, mfcc.shape[0] - delta_window): for n in range(1, delta_window + 1): delta[t] += n * (mfcc[t + n] - mfcc[t - n]) delta[t] /= 2 * sum(k**2 for k in range(1, delta_window + 1)) return delta[delta_window:-delta_window]参数说明:delta_window=2是业界常用默认值,窗口越大平滑效果越明显,但会损失时间维度上的细节。算完一阶差分后,对delta结果再执行一次同样的操作就得到二阶差分。最终每帧特征由13维静态、13维Delta、13维Delta-Delta组成,共39维,这是传统语音识别里最经典的配置组合。
有一个容易被忽略的点:MFCC维数与GMM的复杂度直接相关。特征维度越高,GMM需要覆盖的空间越大,数据量不够时特征分布会非常稀疏。小数据集上我一般先用13维静态特征跑通流程,再扩到39维看增益,而不是一上来就上高维度——很多采集条件一般的自制数据集里,维度提升带来的增益远小于它引入的过拟合风险。
3. GMM建模:高斯混合模型如何刻画语音特征分布
3.1 GMM的数学直觉与EM训练框架
GMM的核心假设是:某一类语音的MFCC特征向量并非服从单一高斯分布,而是由多个高斯分量加权混合而成。语音特征天然是多模态的——同一个人发不同音节时声道形状不同,基频不同,反映在MFCC特征空间里就是多个聚集区域。一个高斯分布只能刻画一个聚集区域,GMM用多个高斯分量叠加来覆盖这些区域。
记特征向量为x,GMM的概率密度函数是:
p(x|λ) = Σᵢ πᵢ · N(x; μᵢ, Σᵢ)
其中M是高斯分量个数,πᵢ是第i个分量的权重,满足Σπᵢ=1,μᵢ和Σᵢ是该分量的均值向量和协方差矩阵。每个高斯分量对应特征空间中的一个聚类区域,权重表示该区域在整体分布中的占比。当M足够大时,GMM可以逼近任意连续分布,这是它被广泛使用的原因。
模型参数通过EM算法迭代估计。E步:给定当前参数,计算每个特征帧对每个高斯分量的后验概率,也就是"这一帧更像是哪个分量生成的";M步:用这些后验概率作为软标签,重新估计π、μ、Σ。两步交替执行,直到对数似然的变化小于阈值。EM算法保证每次迭代后似然单调不减,但可能收敛到局部最优,所以初始化方法对最终效果有显著影响。
3.2 用Python实现GMM训练
sklearn的GaussianMixture内部实现了完整的EM算法,直接调用即可:
from sklearn.mixture import GaussianMixture def train_gmm(features, n_components=8, max_iter=100, cov_type='diag'): # features: (n_frames, n_dim),一个类别(词或说话人)的MFCC特征矩阵 gmm = GaussianMixture( n_components=n_components, covariance_type=cov_type, # 'diag'对角协方差,'full'全协方差 max_iter=max_iter, reg_covar=1e-4, # 防止协方差矩阵奇异 random_state=0 ) gmm.fit(features) return gmm def compute_avg_log_likelihood(gmm, features): # 返回该特征序列在模型下的平均对数似然 return gmm.score(features) / features.shape[0]逻辑说明:fit完成后可以从gmm.weights_、gmm.means_、gmm.covariances_中取出模型参数,也可以直接用gmm.score计算对数似然。covariance_type选diag还是full取决于数据量:对角协方差假设各维度独立,参数量是O(D·M),不容易过拟合;全协方差能刻画维度间的相关性,但参数量是O(D²·M),数据量不够时极易过拟合。
训练策略上有一个关键点:不是把所有类别数据扔进一个GMM里训练,而是每个类别单独训练一个GMM。识别时对每个待测音频分别计算它在每个GMM下的对数似然,取最大者对应的类别作为识别结果。score返回值是平均对数似然还是总似然取决于实现版本,使用时务必除以帧数做归一化,否则同一句话说得慢、帧数多,总似然就会虚高。
reg_covar=1e-4这个参数很容易被忽略。训练中如果某个类别的数据量太少,某个高斯分量的协方差矩阵会趋向奇异,矩阵求逆时直接崩溃。reg_covar是在协方差矩阵对角线加上一个正数保证可逆性。数据量充足时可以调到1e-5,太大会让协方差估计偏大,降低模型对特征分布的分辨能力。
3.3 初始化方式与分量数对训练的影响
GMM训练对初始化比较敏感。sklearn默认用k-means的结果作为初始均值,这比随机初始化稳定得多。如果数据本身聚类特征明显,k-means初始化能让EM快速收敛到一个较好的局部最优;如果数据分布复杂,EM可能收敛到次优解,表现为训练集似然正常但测试集表现差。
分量数M的选择直接影响模型复杂度。M太小,模型无法覆盖特征分布的多模态性;M太大,每个分量只覆盖少量样本,协方差估计不稳定,模型过拟合到训练数据的细节上。实践中没有一个万能值,需要结合数据量评估:单类数据少于100帧时用2到4个分量,500帧左右用8个,几千帧再考虑16个以上。分量数M的最终确认方法放到最后一章讲,那里有具体的交叉验证流程。
4. 完整识别系统:把MFCC和GMM串成一个可运行的方案
4.1 系统整体流程与文件组织
一个最小可用的识别系统包含训练和识别两条链路。训练链路:读音频→重采样→提取MFCC→按类别训练GMM→保存模型;识别链路:读音频→重采样→提取MFCC→逐类计算对数似然→取最大者作为识别结果。这里的"类别"根据场景而变化——孤立词语音识别里每个词是一个类别,说话人识别里每个说话人是一个类别,文本相关的说话人验证则按"说话人+文本"的组合建模。
文件组织按最直接的惯例来:
data/ train/ word_1/ # 每个类别一个文件夹 sample_01.wav sample_02.wav word_2/ sample_01.wav test/ word_1/ test_01.wav音频统一为16kHz单声道16bit PCM的wav格式,这是MFCC特征标准化最容易踩的坑。如果训练音频是8kHz、识别时音频是16kHz,MFCC的频谱分布会整体错位,模型基本白训练。录音时用手机或普通麦克风都可以,但所有训练和测试音频必须在同一录音环境下采集,否则环境噪声差异会直接成为识别干扰。
4.2 训练与识别的完整代码
import os import pickle import numpy as np from scipy.io import wavfile from scipy.signal import resample_poly def load_audio(path, target_sr=16000): # 读取wav并统一到16kHz、浮点范围[-1, 1] sr, signal = wavfile.read(path) if sr != target_sr: gcd = np.gcd(sr, target_sr) signal = resample_poly(signal, target_sr // gcd, sr // gcd) return signal.astype(np.float32) / 32768.0 def train_system(data_root, out_model='models.pkl'): # 遍历每个类别文件夹,提取MFCC并训练独立GMM models = {} for label in os.listdir(data_root): label_dir = os.path.join(data_root, label) if not os.path.isdir(label_dir): continue all_features = [] for fname in os.listdir(label_dir): if not fname.endswith('.wav'): continue signal = load_audio(os.path.join(label_dir, fname)) mfcc = mfcc_extract(signal) all_features.append(mfcc) features = np.vstack(all_features) models[label] = train_gmm(features, n_components=8) with open(out_model, 'wb') as f: pickle.dump(models, f) return models def recognize(model_path, audio_path): # 加载模型,对单条音频逐类打分并返回最高分类别 with open(model_path, 'rb') as f: models = pickle.load(f) signal = load_audio(audio_path) mfcc = mfcc_extract(signal) scores = {} for label, gmm in models.items(): scores[label] = gmm.score(mfcc) / mfcc.shape[0] return max(scores, key=scores.get), scores逻辑说明:load_audio里把int16采样点除以32768转成浮点范围,这一步不能省。wavfile.read读出来是int16,直接做预加重和FFT后数值会放大到很大量级,DCT结果可能溢出成NaN。resample_poly要求整数倍重采样参数,用gcd除以公约数得到最小整数倍是最稳的做法。
识别时gmm.score返回的是平均对数似然还是总似然,不同版本实现不同。我习惯再手动除以mfcc.shape[0]做一次显式归一化,确保比较基准一致。用总得分排序会偏向帧数多的音频——同一个词说得慢一些帧数就多,总似然必然大,识别结果会被语速干扰。
4.3 拒识与得分归一化
识别系统在实际使用中一定会遇到"模型没见过的类别"输入的情况。比如系统只训练了"开灯关灯"两个词,用户说了"你好",模型仍然会给出一个得分最高的类别。这时需要一个拒识机制:设定一个最低平均对数似然阈值,低于阈值就判定为未知音频。
def recognize_with_threshold(model_path, audio_path, threshold=-30.0): # 带拒识的识别:低于阈值返回unknown label, scores = recognize(model_path, audio_path) if scores[label] < threshold: return 'unknown', scores return label, scores阈值怎么定:采集一批不属于任何类别的音频,计算它们在所有模型下的平均对数似然,取这批得分的最高值作为阈值参考。常见做法是再留一点余量,把阈值设在已知类别最低得分和未知类别最高得分之间。阈值设得太松会把未知音频误判成已知类别,设得太紧会把正常识别也拒掉,这个参数需要反复试听验证。
5. 避坑与排查:MFCC和GMM实践中遇到的典型问题
5.1 模型在训练数据上得分很高,测试时识别错误
现象:训练集上每个类别的平均对数似然都很高,看起来训练非常成功,但换一条没参与训练的音频来测,识别结果频繁出错。
原因:最常见的是过拟合。GMM分量数太多或特征维度过高,模型把训练音频中的个体音色、环境噪声也学进去了,而不是学到语音内容本身的分布。其次是训练数据量太少,每个类别只有一两条音频,模型只见过这一条音频的具体声纹和环境。
解决:优先降低n_components,比如8个分量减到4个,观察验证集得分是否更稳定;或者缩短特征维度,从39维降到13维。另外,每个类别至少准备10条以上不同时段录制的音频,不要同一天连续录完——同一天录音的背景噪声和设备响应高度相似,模型会把环境当内容学进去。
5.2 训练时log-likelihood变成NaN,模型训练直接崩溃
现象:GMM拟合过程中某次迭代后score返回NaN,或者直接报"ValueError: array must not contain infs or NaNs"。
原因:两个来源。一是MFCC提取时功率谱出现0值,取log变成-inf,这个-inf参与协方差计算后产生NaN;二是特征向量里本身有NaN,比如wav文件有坏采样点,或者分帧时索引越界。
解决:在MFCC提取最后加np.isfinite检查,发现NaN帧直接丢弃;mel_energy用np.maximum(value, 1e-10)防御log(0);检查wav采样率是否和程序设定一致,8kHz音频按16kHz读出来时长减半,FFT频谱会整体错位。
5.3 识别时新增类别需要重新训练所有模型
现象:系统已经训练了10个词,现在要加第11个词,于是把所有数据重新训练一遍,训练时间随类别数线性增长。
原因:这种困扰来自对GMM方案的误解。实际上每个类别的GMM参数是独立估计的,新类别只影响最后识别阶段的最大似然比较,不影响已有模型的内部参数。
解决:直接只训练新类别的GMM,保存到已有模型文件里,识别时加载所有模型逐一打分即可。这算GMM方案比深度学习方案省事的一个点——类别扩展不需要触碰原有模型。注意一个例外:如果新旧数据是不同环境、不同设备采集的,特征分布可能漂移,这时重新训练全部模型反而是正确选择。
5.4 EM收敛极慢,迭代200次仍未稳定
现象:GMM训练需要几百次迭代才能收敛,每次迭代耗时也越来越久,整体训练时间远超预期。
原因:特征数据量太大是一个因素——每个类别拼接了上千帧特征,EM每次迭代要遍历所有帧计算后验概率。另一个因素是协方差类型选了full,参数空间大,每次M步计算量大。
解决:先抽样训练,每个类别随机抽取500帧跑通流程,再用全量数据微调。协方差类型尽量用diag,除非确认特征维度间有强相关。max_iter训练时设到100,然后用训练好的模型参数作为初值,在新数据上继续用fit做增量训练。
5.5 增加MFCC系数维度反而识别率下降
现象:把n_mfcc从13调到26,识别率没有提升,某些类别上反而变差了。
原因:MFCC低阶系数已经包含声道形状和语音内容的主要信息,高阶系数主要对应谐波细节和噪声成分。在当前任务里,这些细节增加了GMM拟合的难度,却没有带来区分度收益。
解决:优先保持13维静态或39维(加Delta)的配置,不要盲目增加MFCC维数。识别率上不去先怀疑数据质量和数据量,再怀疑GMM分量数,最后才怀疑特征维度。传统方案里特征工程的上限受限于数据质量,这个顺序能省掉大量无意义的调参时间。
6. 进阶验证:用交叉验证确定GMM的最优分量数
GMM方案最值得花时间确定的超参数就是分量数M。M太小模型表达力不足,M太大过拟合。最可靠的做法是留出法交叉验证:把每个类别的数据按录音文件划分,而不是按帧划分。按帧划分会破坏时间连续性,同一音频的相邻帧高度相关,会让验证结果虚高,得到的M不真实。
具体操作:每个类别10条音频,留3条做验证、7条做训练,多次随机划分取平均。评估标准用验证集上的平均对数似然,而不是识别率,因为平均对数似然直接反映模型对数据的拟合程度。画出M从2到32的得分曲线,选择曲线进入平台期或峰值处的M。如果多条曲线峰值位置不稳定,说明数据量不足,增加数据比调参数更有效。
最后一个实践建议:GMM方案做小词汇量孤立词识别和说话人确认是够用的,但要搬到连续语音识别上就得引入HMM对齐,或者直接转向深度学习的端到端方案。项目启动时先明确这个边界,避免做了一半才发现选型不合适,这是我在传统语音识别方向上最大的教训。数据标准化——统一采样率、统一录音环境、统一前端处理——对这套方案的影响比调整任何单个参数都大,值得多花时间在前端准备上。希望帮到你。
本文还有配套的精品资源,点击获取