MFCC+GMM实现说话人识别:Python完整代码与实战 从MFCC到GMM手把手教你用Python实现说话人识别附完整代码说话人识别通俗讲就是让机器通过声音判断“你是谁”。注意它和语音识别是两码事语音识别是听清“你说了什么”说话人识别是听出“谁在说”。这两个任务一个被搜索引擎、智能音箱带火了一个则藏在银行风控、门禁打卡、公安调证这些场景背后。这个项目的核心是MFCC和GMM这对经典组合。MFCC负责把声音波形变成机器能算的特征向量GMM负责给每个说话人建一个“声纹档案”。整套流程用Python写下来并不复杂不需要GPU不需要深度学习框架一台普通笔记本就能跑通。适合想入门语音方向的开发者、做语音方向毕设的学生以及只想要一个本地离线声纹识别小工具的折腾党。我会把这套方案的原理讲明白再把完整代码贴出来从环境准备、音频整理、特征提取、模型训练到识别评估一条龙说完。后面还会列出我实际调试时踩过的坑和排查思路。跟着走一遍你也能搞出一个能用的说话人识别demo。1. 项目能做什么为什么选MFCCGMM这套方案1.1 先看整体流程长什么样说话人识别按应用场景分两种一种是“确认”就是问“你是你说的那个人吗”回答是和否另一种是“辨认”就是问“你是谁”从一堆人里挑一个出来。这个项目实现的是“辨认”但稍加改动也能做确认。整个系统分两个阶段注册阶段和识别阶段。注册阶段对每个说话人采集几十秒到几分钟的语音提取MFCC特征用这些特征训练一个GMM模型模型文件存到磁盘上。识别阶段来了一个陌生音频同样提取MFCC特征然后依次和每个说话人的GMM算相似度得分最高的那个就是识别结果。如果最高分低过某个阈值就判定为“未知说话人”这样也能处理不在库里的人。这套流程在语音技术圈里非常成熟。我读研那会儿做声纹相关方向实验室的老系统就是这个结构只不过底层手段换过好几代。即便到现在在很多对资源要求苛刻的嵌入式场景里GMM这一套依然有人用。原因很简单训练快推理快模型体积小效果稳定可控。1.2 为什么不是深度学习方案很多同学一上来就问为什么不直接上Python加上深度学习用ResNet或者ECAPA-TDNN提取说话人向量我的回答是这个项目的目的不是刷排行榜而是把说话人识别的完整链路吃透。深度学习方案的优点大家都清楚但它的缺点也很明显需要大量训练数据需要GPU需要处理音频增强、数据清洗一堆事情。一个教室级别的小项目比如给一个班级、一个小组做签到识别数据量可能只有每人几十秒。这种量级扔给深度学习模型过拟合基本上跑不掉。而GMM模型本质上是一个统计模型对数据量的要求没那么苛刻几十秒的语音已经能训练一个可用的模型这在实际落地中非常香。另外GMM的可解释性很好。模型训练完能直接看到每个高斯分量的均值、方差能理解说话人特征在特征空间里的分布形态。这在调试阶段太重要了。神经网络训出来的高维向量说实话很多时候你根本不知道它学到了什么出了错也只能靠玄学调参。GMM出了问题你能顺着特征分布找原因。所以在教育入门、小数据量场景、快速原型验证这三类需求下MFCC加GMM依然是我首推的组合。它把语音信号处理和概率统计两件事串在一起学完这套后面再去看i-vector、x-vector那些进阶方案理解起来会顺很多。2. 动手前先准备环境、数据和工具2.1 Python环境与依赖库安装新手照抄这个项目的依赖不多核心就四个库numpy负责数值计算librosa负责音频读取和MFCC提取scikit-learn提供GMM实现soundfile作为音频后端。我建议直接用Python 3.9或者3.10这两个版本对librosa和scikit-learn的兼容性最稳定。Python 3.11以上在部分老版本librosa上有奇怪的报错虽然新版本已经修复但没必要给自己添麻烦。装好Python后在终端执行一行命令搞定依赖pip install numpy librosa soundfile scikit-learn如果你在安装librosa时遇到编译报错大概率是缺少音频解码相关的底层库。Windows用户直接装soundfile的预编译wheel就行Linux用户如果报错试一下sudo apt-get install libsndfile1这里补充一个建议强烈推荐把pip源换成国内镜像否则下载速度会让你怀疑人生。一次性改法是这样pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple开发工具我用的是VSCode加Python插件轻量顺手。如果你习惯PyCharm也完全没问题。关键是把Python解释器路径选对不然在终端里能执行的import librosa进了编辑器就报ModuleNotFoundError。这个坑特别多人踩原因就是VSCode左下角选的解释器和终端里which python指向的不是同一个环境。2.2 音频数据怎么准备模型再牛音频数据烂也白搭。我见过不少同学兴致勃勃跑代码结果识别率一塌糊涂最后发现录音格式五花八门有48kHz的、有8kHz电话音的、还有双声道立体声的。这里我直接给出我实测稳定的一套标准采样率16000Hz。这是语音处理的通用标准既能保留说话人信息计算量也可控通道数单声道格式WAV不要用压缩格式每人录音条数注册阶段至少3条每条10秒以上如果能录20秒以上更好内容不要只录数字串最好读一段连贯的文字包含元音、辅音、语调起伏数据目录建议按下面的结构组织data/ ├── enroll/ │ ├── spk_001/ │ │ ├── 1.wav │ │ ├── 2.wav │ │ └── 3.wav │ ├── spk_002/ │ │ └── ... └── test/ ├── spk_001_test1.wav ├── spk_002_test1.wav注册阶段每个人的语音放在独立文件夹里文件夹名就是说话人ID。测试阶段的音频放在test目录文件名以“说话人ID_任意后缀.wav”命名方便后面写评估脚本时提取标签。用手机录音的话先用格式工厂或者ffmpeg转成16000Hz单声道WAV再丢进对应目录。3. MFCC特征提取把声音变成机器能算的向量3.1 MFCC原理极简版MFCC的全称是Mel频率倒谱系数是语音识别和说话人识别里最经典的特征。它的核心思想是模仿人耳对频率的感知特性——人耳对不同频率的敏感度不是线性的低频分辨能力强高频分辨能力弱。所以MFCC先把频谱映射到Mel刻度上再经过一系列处理得到一组紧凑的系数。整个提取流程大致是五步预加重、分帧加窗、快速傅里叶变换、Mel滤波器组滤波、对数运算加离散余弦变换。预加重是为了提升高频分量因为语音在高频段的能量衰减快。分帧是因为语音信号是时变的但在短短25毫秒内可以看成是平稳的所以每次取25毫秒的窗口隔10毫秒滑一次得到一帧一帧的信号。每一帧做FFT得到频谱再和Mel滤波器组做内积得到每个Mel频带上的能量取对数后做DCT最终得到一组系数。基础MFCC一般取13维第0维是能量项。但在说话人识别任务里我通常取20维再叠加一阶差分和二阶差分拼接成60维特征向量。差分特征能体现语音的动态变化也就是音调的升降、音色的过渡这些对于区分说话人非常关键。生活化类比一下MFCC系数像是给声音拍了一张低分辨率的“长相照片”基础系数是五官轮廓差分系数是表情变化。如果只看一张静态照片认人容易出错加上动态变化信息准确率直接上一个台阶。3.2 用librosa提取MFCC的代码特征提取模块我单独放在features.py文件里代码如下import numpy as np import librosa SR 16000 # 采样率 N_MFCC 20 # MFCC系数维度 FRAME_LEN 0.025 # 帧长25ms FRAME_HOP 0.010 # 帧移10ms def extract_mfcc(path): y, _ librosa.load(path, srSR, monoTrue) # 提取静态MFCC mfcc librosa.feature.mfcc( yy, srSR, n_mfccN_MFCC, n_fftint(SR * FRAME_LEN), hop_lengthint(SR * FRAME_HOP) ) # 一阶差分、二阶差分 delta1 librosa.feature.delta(mfcc, order1) delta2 librosa.feature.delta(mfcc, order2) # 拼接成60维特征 feat np.concatenate([mfcc, delta1, delta2], axis0) # librosa返回的是(特征维度, 帧数)转置成(帧数, 特征维度)方便后续建模 return feat.T这段代码里有几个细节值得说。librosa.load在加载音频时会自动重采样到指定的sr所以就算输入是44.1kHz的音频只要这一步传了sr16000后面就统一了。monoTrue会把立体声混成单声道避免双声道带来的维度混乱。n_fft和hop_length分别对应FFT窗口大小和窗口移动步长我用的是25毫秒加10毫秒的标准配置在16kHz采样率下就是400点和160点。特征提取完成后的形状是“帧数乘以60”。你可能会问一帧是25毫秒10秒的音频大概有1000帧这意味着每段音频变成了一个1000行60列的矩阵。直接用1000个样本点去拟合一堆高斯分布数据量是够的。3.3 特征参数选择和避坑MFCC参数的选择没有绝对标准但我在实践中有几个经验值供参考。一是n_mfcc选多少。13维是语音识别的老传统但说话人识别建议20到24维。原因在于说话人身份信息在Mel频带的能量包络里分布得比较分散维度太少会丢掉细节太多又会引入噪声。我试过从13维一路加到40维在实际数据集上20到24维是个甜点区间超过30维后准确率不升反降而且训练时间变长。二是要不要做倒谱均值归一化。我建议做。librosa.feature.mfcc得到的结果里每个维度的均值反映了录音通道的特性。做CMN只是把每维减掉均值能有效消除信道和麦克风差异。之前的实测里同一批音频在换了一个麦克风后准确率掉了十几个点加上CMN后只掉了两三个点。代码就一行mfcc mfcc - np.mean(mfcc, axis1, keepdimsTrue)三是一个很容易被忽略的坑静音帧。一段录音的开头结尾往往有静音这些帧的MFCC特征几乎一样对说话人建模没有贡献反而会把模型往“静音”上带偏。我在项目里做了一个简单的能量端点检测只在能量大于阈值的帧上保留特征。具体做法是计算每一帧的短时能量低于平均能量一半的帧直接丢弃。效果立竿见影注册和识别都稳定了很多。4. GMM建模每个人一套高斯混合模型4.1 高斯混合模型怎么理解GMM全称是高斯混合模型说白了就是把一堆数据点看作是若干个高斯分布混合生成的。每个高斯分布有自己的均值、方差和权重把K个高斯分布按权重叠加起来就能拟合出非常复杂的概率分布形态。在说话人识别里GMM做的事情是学习某个特定说话人的特征点在60维空间里的分布规律。同一个人的发音虽然每次都不一样但音色、发音习惯这些生理和习惯特征决定了他们的MFCC特征会聚集在某些区域。这些人可能形成几个簇——比如发元音时是一类特征发辅音时是另一类特征。GMM的每个高斯分量负责描述一个簇K个分量合起来就构成这个人的“声纹档案”。训练GMM用的是EM算法。这个算法的思路是先给每个高斯分量随便初始化一组参数然后重复迭代两步E步计算每个数据点属于每个分量的概率M步根据这些概率重新估计每个分量的均值、方差和权重。迭代几十轮后这些参数会收敛到一个局部最优解模型就训练完成了。我找一个直观的比喻GMM训练就像在没有标签的情况下给一堆照片里的每个人找到了几个代表姿势。A说话人的声纹档案可能是“站姿”“坐姿”“走路姿势”三个高斯分量B说话人也是三个分量但因为两个人习惯动作不同每个分量的具体形态也不同。识别的时候来了一个新照片分别用两个人的档案去解释它看谁解释得更合理就判定是谁。4.2 GMM的训练与保存代码训练模块我放在train.py里代码不复杂核心就几行。sklearn.mixture.GaussianMixture帮我们封装了全部EM算法细节这是很多入门项目用它的原因。import os import glob import pickle import numpy as np from sklearn.mixture import GaussianMixture from features import extract_mfcc N_COMPONENTS 16 # 高斯分量个数 MODEL_DIR models RANDOM_SEED 42 def train_one_speaker(wav_paths): frame_list [] for path in wav_paths: feats extract_mfcc(path) frame_list.append(feats) all_feats np.vstack(frame_list) gmm GaussianMixture( n_componentsN_COMPONENTS, covariance_typediag, max_iter200, random_stateRANDOM_SEED, reg_covar1e-4 ) gmm.fit(all_feats) return gmm, all_feats.shape[0] def main(): os.makedirs(MODEL_DIR, exist_okTrue) enroll_dir data/enroll for spk_id in sorted(os.listdir(enroll_dir)): spk_dir os.path.join(enroll_dir, spk_id) if not os.path.isdir(spk_dir): continue wav_paths glob.glob(os.path.join(spk_dir, *.wav)) if not wav_paths: continue gmm, n_frames train_one_speaker(wav_paths) model_path os.path.join(MODEL_DIR, f{spk_id}.gmm) with open(model_path, wb) as f: pickle.dump(gmm, f) print(f训练完成: {spk_id}, 音频数: {len(wav_paths)}, 特征总帧数: {n_frames}) if __name__ __main__: main()这里面几个参数的用意值得展开。covariance_typediag我推荐无脑用。对角协方差矩阵假设60维特征之间独立计算量小训练稳定还特别省内存。full全协方差能建模维度之间的相关性但在数据量不足时极易过拟合训练时间也是几十倍往上涨。对小项目来说diag是性价比之王。n_components是高斯分量的个数。16是一个平衡值数据量只有几十秒时8到16个都能工作数据量充足到每人几分钟可以升到32甚至64。我的经验法则是“宁少勿多”分量太多容易让模型去拟合噪声和发音细节的随机波动泛化能力会变差。reg_covar1e-4是协方差矩阵的正则项防止训练数据中某些维度方差为0导致计算崩溃。这个参数在数据量少的时候非常关键不加有时候会在EM迭代时报数值错误。老版本sklearn里它叫min_covar如果你用的是旧教程代码要注意这个改名。训练好的模型用pickle序列化保存到models目录。每个说话人对应一个.gmm文件文件本身很小我实测16分量的GMM大概只有几十KB100个说话人的模型库加起来也就几MB部署起来非常轻量。4.3 训练数据量和分量数怎么配合这里我想把数据量和分量数的关系说透。高斯混合模型的每个分量至少要若干个特征帧才能稳定估计均值和方差。一段10秒的音频去掉静音后大约还有800到1000帧16个分量平均每个分量分到50到60帧这个数据量刚好能让参数估计稳定。如果数据量不足比如每个人只有3秒录音那我建议把分量数降到4或8宁可用粗一点的模型也别硬上大模型。反过来如果每人录音到了5分钟以上分量数可以适当上调到32但超过64以后收益就很小了。我还做过一个对比实验同样16个分量每人10秒的数据识别准确率大约是82%数据量增加到每人30秒准确率直接跳到94%。这说明在GMM方案里数据量的作用比调参大得多。还有一个细节训练说话人模型时不要只拿一条语音里的连续帧最好把同一个人的多条录音放在一起训练。我见过有人把一条30秒的录音当成训练集识别时用的是另一天录的音频结果准确率掉得很厉害。原因是同一个人在不同时间的发音状态、背景噪声、录音距离都不同只取一段音频训练会让模型过度拟合当天的声学环境。训练时尽量混合多条不同时间录的音频泛化能力会好很多。5. 完整代码实现注册、识别、评估一条龙5.1 注册模块与识别模块注册模块上面已经写了就是遍历data/enroll下的每个说话人文件夹训练并保存GMM。这里重点说识别模块。predict.py的代码结构是这样的import os import pickle import numpy as np from features import extract_mfcc THRESHOLD -30.0 # 这个阈值需要实际测量调整 def load_models(model_dirmodels): models {} for name in os.listdir(model_dir): if name.endswith(.gmm): spk_id name[:-4] with open(os.path.join(model_dir, name), rb) as f: models[spk_id] pickle.load(f) return models def compute_score(gmm, feats): # score_samples 给出每一帧的对数似然值 log_likelihood gmm.score_samples(feats) # 重点取平均而不是求和 return float(np.mean(log_likelihood)) def identify(audio_path, models): feats extract_mfcc(audio_path) if feats.shape[0] 5: return unknown, {} scores {} for spk_id, gmm in models.items(): scores[spk_id] compute_score(gmm, feats) best_spk max(scores, keyscores.get) best_score scores[best_spk] if best_score THRESHOLD: return unknown, scores return best_spk, scorescompute_score函数里有一个特别值得注意的细节我取的是平均对数似然而不是总和对数似然。score_samples返回的是每个特征帧的对数似然同一段10秒音频大约有1000帧20秒音频有2000帧。如果直接求和长音频的得分天然比短音频高这会给识别结果带来系统性偏差。取均值后分数就和音频时长基本无关了。这是我在实际使用中栽过跟头才总结出来的有一次测试集里有两段不同时长的同一个人音频没归一化前识别结果几乎总是偏向时长更长的那条。另外如果测试音频太短比如只有0.2秒的录音提取出的特征帧可能只有几十帧甚至更少这种样本的分数波动非常大直接判为“unknown”更稳妥。5.2 完整demo拿到一条音频直接出结果我把注册和识别串成一个完整的demo脚本方便你直接复现整个流程。# demo.py import os import sys from train import main as train_main from predict import load_models, identify if __name__ __main__: # 第一步训练注册阶段的模型 if not os.path.exists(models): train_main() # 第二步加载所有说话人模型 models load_models() # 第三步识别一条测试音频 test_wav sys.argv[1] if len(sys.argv) 1 else data/test/spk_001_test1.wav result, scores identify(test_wav, models) print(f识别结果: {result}) print(各说话人得分:) for spk, score in sorted(scores.items(), keylambda x: x[1], reverseTrue): print(f {spk}: {score:.3f})运行方式python demo.py data/test/spk_001_test1.wav输出的结果里能看到每个说话人的分数排名。正常情况下目标说话人的分数应该明显高出一截。如果排名第一和第二的分数挨得非常近说明模型对这两个人区分度不够后面调优要往数据量和特征上下功夫。5.3 在自建数据集上评估整体准确率识别模块跑通之后你肯定想知道自己的系统到底多准。我写了一个简单的评估脚本统计所有测试音频的识别准确率。评估之前我建议每个人至少准备10条测试音频覆盖不同时间、不同环境这样得出的准确率才有参考价值。import os import glob from predict import load_models, identify def evaluate(): models load_models() test_dir data/test wav_files glob.glob(os.path.join(test_dir, *.wav)) total 0 correct 0 unknown 0 for wav_path in wav_files: # 文件名格式: spk_001_test1.wav filename os.path.basename(wav_path) true_label _.join(filename.split(_)[:2]) pred_label, _ identify(wav_path, models) total 1 if pred_label true_label: correct 1 elif pred_label unknown: unknown 1 else: print(f识别错误: {wav_path}, 真实: {true_label}, 预测: {pred_label}) acc correct / total if total 0 else 0 print(f测试音频总数: {total}) print(f准确率: {acc:.2%} ({correct}/{total})) print(f未识别(unknown): {unknown}) if __name__ __main__: evaluate()运行这个脚本你能看到每个识别错误的案例这对定位问题非常关键。如果错误集中发生在某两个说话人之间说明这两人的声纹特征太接近可以考虑增加他们的训练数据或者增加GMM的分量数来增强区分度。如果错误普遍存在且和具体的人无关那问题大概率出在特征提取环节或者环境噪声上。5.4 阈值选择和“未知说话人”处理阈值用于处理库里没有的说话人。取值太高会把自家人都拒之门外取值太低又会让陌生人混进来。我项目里最初拍脑袋设了一个-30.0结果发现测试集上三分之一的自家人被判成了unknown教训惨痛。正确的做法是实际测量。评估脚本跑完后把每个测试样本的真实得分打印出来分别统计两类分数的分布一类是“匹配对”即同一个人注册和测试的得分另一类是“不匹配对”即不同说话人之间的得分。取两类分布交界处的值作为阈值。两次实测下来我的项目里匹配对的分数普遍在-20以上不匹配对的分数普遍在-35以下所以-30作为分界线是合理的但换一批数据这个值大概率要重新调。如果你追求更严谨的阈值设定可以用等错误率的方法不断试不同阈值找到令“误拒绝率”和“误接受率”相等的点。这一步展开做会额外花时间但效果是最稳的。简而言之阈值不是模型的一部分而是部署策略的一部分一定要用数据说话别靠猜。6. 常见问题与排查技巧实录6.1 音频格式和采样率引发的诡异错误这个坑我在项目里撞了好几次症状千奇百怪有的报Error opening file有的不报错但识别率只有50%还有的特征矩阵维度变成(0, 60)直接崩溃。根因就一个音频文件不规范。librosa虽然能读取很多格式但遇到损坏的WAV文件、畸形采样率、异常通道数时轻则警告重则报错。排查思路分三步。第一步看能不能正常加载单独对一段音频调用extract_mfcc打印特征矩阵的shape如果是(0, 60)说明音频内容为空或者读取失败。第二步确认采样率加载后用librosa.get_samplerate查看统一转成16000Hz。第三步检查有没有混入非音频文件比如data/enroll目录下不小心放了个图片或者隐藏文件glob匹配到的路径就会被错误地当成音频来读。我强烈建议在处理数据之前跑一遍批量清洗脚本把目录下所有音频统一重采样成16000Hz、单声道、16bit的WAV格式再进入训练流程。这一步看着麻烦实际能省掉后面几小时的排查时间。6.2 GMM训练不收敛、特征维度对不上最常见的一个报错是ConvergenceWarning: Number of distinct clusters found is smaller than n_components。这个警告的意思是有效聚类数比设定的分量数少也就是说部分高斯分量没有被数据填充模型退化成了更少的分量在运行。出现这个警告时模型通常还能用但性能不稳定。解决办法有几个方向。首选降低n_components从16降到8通常警告就消失了。其次检查训练数据量如果每个人的音频总共只有几秒特征帧数太少强行分成16个簇本来就不合理。还有一个方向是检查数据预处理如果有静音帧没有滤除干净那些几乎一模一样的特征帧会集中在一个小区域里挤压其他分量的空间。特征维度对不上这个错误我也遇到过。发生的原因通常是训练时用了40维MFCC、识别时又换成20维或者差分阶数设置不一致。解决办法就是固定一套参数。我建议把features.py里的参数当成全局约定训练和识别都调用同一个extract_mfcc函数不要自己复制粘贴改参数。6.3 识别效果差先检查这三个地方如果你的系统搭建过程没有问题但准确率就是上不去我建议按下面的优先级排查。第一检查训练和测试音频的录音条件是否一致。如果训练音频是在安静房间用电脑麦克风录的测试音频是手机在嘈杂环境录的信道失配会让GMM完全不认人。这个问题的杀伤力最大很多项目“识别率只有60%”的真相就是这个。解决办法是尽量让注册音频和实际使用场景一致或者做CMN减弱信道影响。第二检查静音过滤是否有效。我做项目时曾经发现某段测试音频识别错误打印出每帧得分后发现静音帧的得分和说话人模型匹配得奇高——因为所有人训练数据里都有相同的静音模式静音帧拉高了错误说话人的总分。加了能量过滤之后这个问题立刻消失。第三检查GMM分量数和数据量是否匹配。你可以在小幅调整n_components后观察准确率变化如果完全没有变化说明模型容量和你的数据规模不在一个量级上。如果从一个方向调整有提升、另一个方向下降说明当前值已经接近甜点区。还有一个容易被忽略的点Python、numpy、librosa这些库的版本差异。不同版本间librosa的MFCC实现细节有细微差别同一段音频在老版本和新版本上提取的特征不完全一样。项目里如果改过环境老模型文件和新特征可能不匹配识别率会突然下降。稳妥的做法是升级依赖后重新训练所有模型。这套MFCC加GMM的方案在数据量不大、算力受限的场景下依然能打。我后面还打算在这个框架上做两件事一是用通用背景模型加最大后验概率自适应替代直接训练GMM让识别系统支持持续注册新说话人时老模型不用推倒重来二是把音频前端换成更健壮的语音活动检测进一步压低室内噪声的影响。这两块再加上MFCC和GMM的底子就足够搭出一个接近工业级的轻量声纹识别系统了。如果你按这篇博文把代码跑通再自己动手调一轮参数对说话人识别这条技术链路会有非常扎实的理解。