简介:一份基于高斯混合模型(GMM)的Matlab说话人识别系统实现,面向语音信号处理与说话人识别方向的初学者和研究者,可直接用于学习GMM建模、MFCC特征提取及分类器设计。资源包共22个文件,以19个.m源码脚本和3个.mat数据文件为主,整体大小2.69MB,包含完整的训练与识别流程:从语音分帧、加窗、mel频率倒谱系数提取,到GMM初始化和EM迭代训练,再到基于对数似然比的判别决策,并提供配套语音特征数据,便于读者复现和二次开发。已有210人学习下载,适合在Matlab环境中边运行边理解GMM说话人识别系统的实现细节。通过研究代码结构,可以掌握gmdistribution/fitgmdist等函数用法、BIC模型选择思路以及等错误率等评估方法,是一份兼顾理论与实践的工具资料。
1. 为什么说话人识别系统到今天还在用GMM
一段 3 秒的注册语音,CPU 上几十秒训练完一个模型,识别一次的耗时在毫秒级——这是 GMM 说话人识别系统到今天仍是工程基线的直接原因。说话人识别有两个任务:对一段语音判断“这是谁”的闭集识别,以及验证对方是否就是他自称那个人的说话人确认。高斯混合模型(GMM)的做法是把说话人的声学特征分布建模成若干高斯分量的加权和,注册时为每人训练一个模型,识别时用对数似然做判决。
哪怕 x-vector 这类深度说话人嵌入已经刷新了各类评测榜单,GMM 依然是中小项目里最常见的起点——几十秒注册语音、训练快、参数可解释。这套 MATLAB 方案会依次覆盖 MFCC 前端提取、GMM 的 EM 训练、打分判决与阈值标定,所有代码都能在 MATLAB 里直接复现。
2. 从语音到MFCC:GMM说话人识别系统的前端特征提取
GMM 建模的不是波形本身,而是把语音切成一帧一帧后压缩出来的特征向量。特征这一步基本决定整个系统的上限:模型再精巧,喂进去的都是噪声特征,最后分数一定乱。MFCC(Mel 频率倒谱系数)能成为说话人识别默认前端,不是因为它在所有评测里最好,而是它在“保留音色信息、压低维度、扛通道差异”三者之间给了最稳的平衡点。
2.1 选MFCC而不选原始波形的三个理由
第一个理由是音色信息集中在频谱包络上。语音由声门激励和声道滤波叠加而成,说话人个性主要落在共振峰位置与带宽上,也就是频谱包络。MFCC 的 Mel 滤波器组和 DCT 恰好把这段包络压缩保留,同时丢掉基频、相位这些对说话人识别无用的细节。
第二个理由是维度决定 GMM 能不能训得动。16 kHz 采样率下 25 ms 一帧就是 400 个采样点,全协方差 GMM 要估计 400×400 的矩阵;MFCC 把每帧压到 13 维,经过 DCT 后各维近似独立,这正是说话人识别能用对角协方差 GMM 的前提。
第三个理由是 Mel 刻度对噪声和信道失真的容错更好。人耳对频率的感知不是线性的,Mel 滤波器组在低频放更多滤波器、高频放更少,等于给频谱做了一次符合听觉特性的加权压缩,远比线性谱耐造。
提示:MFCC 是帧级统计特征。GMM 只吸收每帧特征的分布形态,不关心帧的顺序,所以 GMM 说话人识别不需要语音识别里那套时序结构。
2.2 在MATLAB里手写一套MFCC提取
下面的函数不依赖 Audio Toolbox,只需要 MATLAB 基础环境和 Signal Processing Toolbox 的 dct。完整链路是:预加重 → 分帧 → 加窗 → FFT → Mel 滤波器组 → log → DCT → 取第 2~14 维。
function feats = extract_mfcc(x, fs, cfg) % x: 单声道语音列向量; fs: 采样率; cfg: 配置结构体 if nargin < 3 || isempty(cfg), cfg = struct(); end if ~isfield(cfg, 'preemph'), cfg.preemph = 0.97; end % 预加重系数 if ~isfield(cfg, 'nFilters'), cfg.nFilters = 24; end % Mel通道数 if ~isfield(cfg, 'nCoeffs'), cfg.nCoeffs = 13; end % MFCC维数 frameLen = round(fs * 0.025); % 帧长25ms frameShift = round(fs * 0.010); % 帧移10ms x = filter([1 -cfg.preemph], 1, x); % 高通预加重 nFrames = floor((numel(x) - frameLen) / frameShift) + 1; frames = zeros(frameLen, nFrames); for i = 1:nFrames s = (i-1) * frameShift + 1; frames(:,i) = x(s : s + frameLen - 1); end % 汉明窗显式写法,等效hamming(),少一个工具箱依赖 win = (0.54 - 0.46 * cos(2*pi*(0:frameLen-1)/(frameLen-1))).'; nfft = 2^nextpow2(frameLen); H = mel_filter_bank(fs, cfg.nFilters, nfft); % 三角滤波器组 feats = zeros(nFrames, cfg.nCoeffs); for i = 1:nFrames spec = abs(fft(frames(:,i) .* win, nfft)).^2; % 功率谱 melSpec = H * spec(1:nfft/2+1); % 映射到Mel域 c = dct(log(melSpec + eps)); % DCT去相关 feats(i,:) = c(2:cfg.nCoeffs+1).'; % 丢弃C0能量项 end end逻辑说明和参数含义:
- 预加重系数 0.97 用于补偿语音频谱每倍频程约 6 dB 的衰减,把高频段的共振峰信息在后面的对数压缩里保住。
- 帧长 25 ms 能覆盖 2 个以上基音周期,帧移 10 ms 使相邻帧有 60% 重叠,切分不会丢掉端点信息。
- 取 c(2:14) 丢弃 C0,因为 C0 反映整帧能量,对麦克风距离和录音增益极其敏感,识别场景里砍掉它通常只有好处。
Mel 滤波器组单独实现:
function H = mel_filter_bank(fs, nFilters, nfft) hz2mel = @(f) 2595 * log10(1 + f/700); mel2hz = @(m) 700 * (10.^(m/2595) - 1); melPts = linspace(hz2mel(0), hz2mel(fs/2), nFilters+2); f = (0:nfft/2) * fs / nfft; m = hz2mel(f); H = zeros(nFilters, numel(f)); for k = 1:nFilters lo = melPts(k); mid = melPts(k+1); hi = melPts(k+2); tri = zeros(size(m)); tri(m >= lo & m <= mid) = (m(m >= lo & m <= mid) - lo) / (mid - lo); tri(m > mid & m <= hi) = (hi - m(m > mid & m <= hi)) / (hi - mid); H(k,:) = tri; end end这段代码先把 0 到 fs/2 的线性频率轴映射到 Mel 刻度上等距切分,再映射回 Hz 构造三角滤波器。每个三角的上升沿从上一滤波器的中心开始,到自己的中心结束,相邻滤波器平滑衔接。输出结果是低频桶在 Hz 轴上更窄、分辨率更高,高频桶更宽,对应人耳对低频变化更敏感的特性。
2.3 MFCC的工程替换与一阶差分
如果机器装了 Audio Toolbox,上面的全流程可以压缩成三行,适合快速验证想法:
afe = audioFeatureExtractor(SampleRate=fs, mfcc=true, mfccCoeffs=13); feats = extract(afe, x); plot(feats(:, 1:5)); % 目视对比手写版前5维趋势audioFeatureExtractor 内部自带分帧和 DCT,但默认不做预加重,C0 的去留逻辑也和手写版不一样。替换前拿一条 3 秒语音和手写版各跑一遍,确认两组特征趋势一致再上。
说话人识别里帧间动态信息同样重要,常见做法是在 MFCC 后面拼一阶差分:
deltaFeat = gradient(feats); % 按时间方向求差分 feats = [feats, deltaFeat]; % 13维扩到26维差分特征相当于给 GMM 补充了共振峰的移动方向和速度,对语调和声调变化的分辨效果明显。是否再拼二阶差分到 39 维,要看注册语音量:训练帧数少于 1000 时,39 维对角协方差的方差估计压力会明显变大,不划算。
表 2.1 MFCC 参数速查
| 参数 | 典型值 | 说明 |
|---|---|---|
| 预加重系数 | 0.97 | 补偿声门激励的频谱衰减 |
| 帧长 / 帧移 | 25 ms / 10 ms | 帧重叠 60%,覆盖基音周期 |
| Mel 通道数 | 24 | 常用 20~40,太多对高频无收益 |
| MFCC 维数 | 13(去掉 C0) | 加一阶差分后常用 26 维 |
| 窗函数 | Hamming | 旁瓣衰减约 43 dB |
3. GMM参数估计:用EM算法训练每个说话人的模型
3.1 GMM的数学形式与参数规模
GMM 的概率密度函数写作:
p(x | λ) = Σ_{k=1}^{K} w_k · N(x; μ_k, Σ_k),且 Σ w_k = 1
参数集合 λ 包含每个分量的权重 w_k、均值 μ_k 和协方差 Σ_k。K 是混合分量数,说话人识别里常见 16~64。“训练一个 GMM”,就是给定该说话人的 MFCC 特征矩阵 X(N 帧 × D 维),估计出这套 λ。
先算一笔参数账,D=13、K=32 时:
- 对角协方差:每个分量需要 D 个均值 + D 个方差 + 1 个权重,共 27 个参数,32 个分量合计 864 个;
- 全协方差:每个分量需要 D + D(D+1)/2 + 1 = 105 个参数,合计 3360 个。
注册语音通常只有 10~30 秒,约 1000~3000 帧特征。全协方差在这种数据量下很容易遇到矩阵奇异,所以说话人识别系统默认用对角协方差。这不算精度妥协:MFCC 已经经过 DCT 去相关,假设各维在高斯分量内独立本来就站得住。
3.2 EM算法在做什么
GMM 训练本质上是一个带隐变量的最大似然估计,隐变量是“每个特征帧 x_n 属于哪个高斯分量”。EM 就在这个不清楚归属的约束下迭代:
E 步(Expectation)用当前参数算出每个分量对每个特征帧的后验概率 γ_nk,等于该分量的密度乘权重后对所有分量归一化。
M 步(Maximization)把这些后验概率当软计数更新参数:
γ_nk ∝ w_k · N(x_n; μ_k, Σ_k)
w_k = Σ_n γ_nk / N
μ_k = Σ_n γ_nk x_n / Σ_n γ_nk
Σ_k = Σ_n γ_nk (x_n − μ_k)(x_n − μ_k)^T / Σ_n γ_nk
收敛判据看对数似然 ll = Σ_n log Σ_k w_k N(x_n; μ_k, Σ_k)。连续几轮 ll 变化小于 1e-3 可以认为收敛,工程里更常见的做法是直接设 MaxIter 上限,每 10 轮打印一次 ll 观察趋势。
3.3 MATLAB手写EM训练代码
function [w, mu, Sigma] = train_gmm_em(feats, K, maxIter) % feats: NxD 特征矩阵; K: 混合分量数; maxIter: 最大迭代轮数 [N, D] = size(feats); % kmeans初始化:比随机初始化更容易避开局部最优 [~, idx] = kmeans(feats, K, 'MaxIter', 100, 'Replicates', 2); for k = 1:K members = feats(idx == k, :); if size(members, 1) < 2 members = feats(randperm(N, 2), :); % 空簇兜底 end mu(k,:) = mean(members, 1); Sigma(:,:,k) = cov(members) + 1e-4 * eye(D); % 对角正则 w(k) = size(members, 1) / N; end for iter = 1:maxIter % E步:未归一化的分量密度 comp = zeros(N, K); for k = 1:K comp(:,k) = w(k) * mvnpdf(feats, mu(k,:), Sigma(:,:,k)); end ll = sum(log(sum(comp, 2) + eps)); % 监控收敛用的对数似然 gamma = comp ./ sum(comp, 2); % 归一化得到后验 % M步:软计数更新 nk = sum(gamma, 1); % 各分量等效样本数 w = nk / N; mu = (gamma' * feats) ./ nk'; % 加权均值 for k = 1:K diff = feats - mu(k,:); Sigma(:,:,k) = (diff .* gamma(:,k))' * diff / nk(k); Sigma(:,:,k) = Sigma(:,:,k) + 1e-5 * eye(D); % 协方差下限 end if iter == 1 || mod(iter, 10) == 0 fprintf('iter %3d, logLik = %.3f\n', iter, ll); end end end代码里三个值得注意的点:
第一,初始化用 kmeans 而不是随机打散。后验概率的初始划分一旦接近合理区域,后续 EM 基本只会做局部修正;Replicates 设 2 是性价比最高的选择,再往上就是纯耗时间。
第二,加了两次对角正则:初始化的 1e-4 和每轮迭代的 1e-5。它们都是加在协方差矩阵对角线上,防止某个分量的方差收缩到零,避免 mvnpdf 报错或 ll 变成 NaN。
第三,收敛监控用的是 E 步里未归一化的 comp,而不是归一化后的 gamma,两者分开算逻辑更清楚。想改成早停,只需记录上一轮 ll,当差值小于 1e-3 时 break。
3.4 用fitgmdist做工程落地
手写 EM 是为了看清参数怎么动。真实项目里,装了 Statistics and Machine Learning Toolbox 就直接用 fitgmdist:
gm = fitgmdist(feats, 32, ... 'CovarianceType', 'diagonal', ... 'RegularizationValue', 1e-5, ... 'MaxIter', 200, ... 'Replicates', 3); % gm是gmdistribution对象,后续用pdf(gm, x)打分fitgmdist 内部默认就是 k-means 初始化加 EM 迭代,和手写版等价,但在协方差奇异处理、收敛判据、并行计算上都做了工程化。返回的 gm 自带 ComponentProportion、mu、Sigma 三个属性,既可以直接打分,也能转成数组接回手写代码。
表 3.1 fitgmdist 关键参数
| 参数 | 建议取值 | 作用与坑 |
|---|---|---|
| CovarianceType | 'diagonal' | 默认 full;特征维数高时 full 极易报 ill-conditioned |
| RegularizationValue | 1e-5 ~ 1e-3 | 加在协方差对角线;越大模型越平滑也越钝 |
| MaxIter | 200 | 默认 100;报“未收敛”时先调这个 |
| Replicates | 2~3 | 多次随机初始化取最优;超过 5 收益很小 |
| Start | k-means 结果 | 数据量大时比默认初始化更快更稳 |
注意:fitgmdist 要求 feats 行数大于列数且每列不全为零。报 “The number of rows” 相关错误时先查特征矩阵是否被转置;报 ill-conditioned covariance 时优先加大 RegularizationValue,而不是把 K 调大。
4. 注册、打分与判决:把GMM组装成可用的说话人识别系统
4.1 三段式流程与注册代码
完整的 GMM 说话人识别系统分三段:
- 注册(enrollment):每个说话人录 2~5 条语音,提取 MFCC 后拼成一个特征池,训练专属 GMM,存成 .mat。
- 识别(identification):对未知语音提取 MFCC,往所有说话人模型上打分,取对数似然最高的模型。
- 确认(verification):识别多一个阈值判断,得分超过该说话人阈值的才接受身份声明。
注册代码:
function model = enroll_speaker(wavDir, cfg) % wavDir: 某个说话人的注册语音目录; cfg: 与第2章共用同一套参数 files = dir(fullfile(wavDir, '*.wav')); featPool = []; for f = 1:numel(files) [x, fs] = audioread(fullfile(wavDir, files(f).name)); feats = extract_mfcc(x, fs, cfg); featPool = [featPool; feats]; % 多条语音按帧拼接 end gm = fitgmdist(featPool, cfg.K, ... 'CovarianceType', 'diagonal', ... 'RegularizationValue', 1e-5); model.gm = gm; model.fs = fs; model.cfg = cfg; endfeatPool 把不同时长、不同内容的语音拼在一个矩阵里完全合法,因为 GMM 只关心帧的分布,不关心帧顺序。拼接前最好对每条语音先做一轮静音检测(VAD),如果录音开头挂着 2 秒静音,那批低能量帧会把模型均值往底噪方向拉。
4.2 识别与确认:打分代码和两个易错点
识别打分:
function [speakerId, scores] = identify_speaker(wavFile, modelSet, cfg) [x, fs] = audioread(wavFile); feats = extract_mfcc(x, fs, cfg); speakers = fieldnames(modelSet); scores = zeros(1, numel(speakers)); for s = 1:numel(speakers) gm = modelSet.(speakers{s}).gm; logPdf = log(pdf(gm, feats) + eps); % 每帧对数概率 scores(s) = mean(logPdf); % 平均而不是求和 end [~, idx] = max(scores); speakerId = speakers{idx}; end两个易错点:
第一,用 mean 而不是 sum。同一段测试语音对所有人打分时,argmax 结果不受影响;但一旦涉及多段语音投票、或者用得分去和阈值比,sum 会天然偏向帧数多的长语音,必须换成平均对数似然。
第二,log(pdf + eps) 的 eps 不是可有可无。某一帧落在高斯分量尾部时概率可能下溢到 0,log(0) 直接变 -Inf,整条语音的得分就被这一帧毁了。
实时录音只需把 audioread 换成三行:
rec = audiorecorder(fs, 16, 1); recordblocking(rec, 3); % 阻塞录音3秒 x = getaudiosignal(rec);4.3 阈值怎么定:EER与开发集
确认场景必须有判决门限。工程做法是准备一个开发集,构造两类打分数对:同一说话人的注册模型与测试语音算 target,不同说话人组合算 impostor,全部跑一遍打分,得到两组分数分布后扫阈值。
function [eer, bestTh] = calibrate_threshold(targetScores, impostorScores) % targetScores: 本人语音得分; impostorScores: 他人语音得分 cand = sort([targetScores, impostorScores]); far = zeros(size(cand)); frr = zeros(size(cand)); for i = 1:numel(cand) t = cand(i); far(i) = sum(impostorScores >= t) / numel(impostorScores); frr(i) = sum(targetScores < t) / numel(targetScores); end [~, idx] = min(abs(far - frr)); % 等错误率点 eer = (far(idx) + frr(idx)) / 2; bestTh = cand(idx); endfar 是误接受率:把他人语音当作本人,阈值越低越严重;frr 是误拒绝率:把本人语音拒掉,阈值越高越严重。EER 是两者相等的点,不依赖人为设定的业务优先级,适合作为系统报告的基线数字。生产环境更常按业务成本取 DCF,优先压低误接受或优先压低误拒绝。
提示:构造 trials 时,注册语音和测试语音绝不能来自同一条录音的同一段,否则得分虚高、EER 虚低。实际里不少人留出注册语音又拿它当测试,本地 EER 1%,换到现场立刻崩。
表 4.1 两种任务与判决规则
| 任务类型 | 输出 | 判决规则 | 评价指标 |
|---|---|---|---|
| 闭集识别 | 说话人 ID | 对所有模型打分取 argmax | Top-1 准确率 |
| 开集确认 | 接受 / 拒绝 | 得分超过阈值则接受 | EER、DCF |
5. 用MAP自适应与分数规整让GMM系统扛住短语音
5.1 通用背景模型 + MAP自适应短语音
注册语音只有十几秒是常态,直接对每人做 EM 训练,模型会各自过拟合,且不同说话人模型的分数可比性差。标准做法是 GMM-UBM:把几十个非目标说话人的特征池混在一起训一个大 GMM(K=64 或 128)作为通用背景模型,再用 MAP 自适应把 UBM 均值向量向目标说话人的统计量收缩:
μ̂_k = α_k · E_k[x] + (1 − α_k) · μ_k^UBM,其中 α_k = n_k / (n_k + τ)
n_k 是对齐到第 k 个分量的软计数,τ 是相关性因子,典型值 8~16。注册语音越长,α_k 越接近 1,模型越向说话人数据靠拢;语音短则靠近 UBM,不会因为样本不足而畸形。MATLAB 里最常用的是只自适应均值:
function [muA, wA] = map_adapt_mean(ubm, feats, tau) % ubm: 通用背景模型(gmdistribution对象); feats: 注册语音特征 K = ubm.NumComponents; comp = zeros(size(feats,1), K); for k = 1:K comp(:,k) = ubm.ComponentProportion(k) * ... mvnpdf(feats, ubm.mu(k,:), squeeze(ubm.Sigma(:,:,k))); end gamma = comp ./ sum(comp, 2); % 后验概率 nk = sum(gamma, 1); % 软计数 alpha = nk ./ (nk + tau); % 自适应系数 E = (gamma' * feats) ./ nk'; % 分量内的加权帧均值 muA = alpha' .* E + (1 - alpha)' .* ubm.mu; wA = ubm.ComponentProportion; % 权重通常不动 end只自适应均值不是偷懒。注册语音短,协方差和权重的估计方差比均值大得多,动它们反而容易带偏模型。tau 越大模型越保守,建议从 10 开始,在开发集上扫一遍 EER 再定。
5.2 用Z-norm统一分数量纲
不同说话人模型的得分绝对值差异很大,直接共用一个阈值不成立。常见做法是让每个模型在非目标背景集上打一遍分,记录该模型的均值 μ_z 和标准差 σ_z,之后所有得分都规整成 (score − μ_z) / σ_z。规整后的分数跨说话人可比,阈值也能共用,不会出现“A 的模型天然分高、B 的模型天然分低”的现象。
最后验证系统状态盯两个数字:注册语音在本人模型上的对数似然应明显高于测试语音,差距过大说明过拟合;开发集 EER 和现场实测 EER 的差距应控制在一倍以内,差距过大先查录音条件差异,而不是急着换模型结构。这两个数字比任何单条语音的识别结果都更能说明模型状态。
本文还有配套的精品资源,点击获取