简介:Python实现的说话人识别(声纹识别)算法源码,属于完成度较高的课程设计个人项目,面向正在学习语音处理、模式识别或需要完成同类大作业的高校学生与开发者,既可满足课程设计提交要求,也可作为动手实践时的代码参照。整个项目曾获98分评审成绩,所有代码均经过严格调试,确保解压后即可在实际环境中运行,能够有效避开环境配置与核心算法复现中的常见问题。压缩包采用zip格式,大小约761KB,内容以Python源码为核心,结构紧凑,便于快速定位预处理、特征提取、模型匹配等关键环节。目前已有327人学习参考,常用于毕业设计、课堂项目以及声纹识别入门实践。借助这份源码,读者可以直观理解语音信号到声纹特征的转换流程、相似度判定逻辑以及完整工程组织方式,既能作为课程设计直接提交的素材,也能作为二次开发与算法对比实验的基线项目。
1. 说话人识别不是语音识别:这份 Python 声纹识别课程设计源码到底能跑出什么
用 Python 实现说话人识别,也就是声纹识别,最开始容易搞混的一点是:它跟语音识别是两回事。语音识别回答“你说了什么”,声纹识别只回答“说话的人是谁”。这份课程设计源码做的是后者——给一段两三秒的录音,判断它来自哪个注册说话人,或者判断它是不是某个指定的人。音频经过预加重、分帧、MFCC 特征提取,再用高斯混合模型给每个说话人建模,最后通过对数似然打分得出结论。评审分能到 98 分,说明代码的可读性、注释和流程完整度都经得起现场验收。适合正在做课程设计、毕业设计,或者想从零搞懂声学特征加传统机器学习建模的人。想一次把原理、参数和坑都摸清的,这套源码值得下下来对照着改。
2. 声纹识别的技术路线:为什么课程设计首选 MFCC 加 GMM 而不是深度学习
2.1 从音频到特征:MFCC 为什么是声纹任务的默认起点
声纹识别整条链路里,特征提取是第一道关,也是最容易糊弄过去的一步。MFCC 全称梅尔频率倒谱系数,核心思想是模拟人耳:听觉对频率的感知不是线性的,低频分辨细、高频分辨粗,所以先把频率轴映射到梅尔刻度,再经过滤波器组和倒谱变换,把一帧语音压缩成 13 维左右的向量。这一段几乎没有任何“智能”成分,但它的稳定性经过了二十年实际系统验证,直到今天很多深度学习声纹模型的前端仍然用 filter bank 特征,只是换了个取 log 的方式。
为什么课程设计不直接上深度学习?原因很实在:x-vector、ECAPA-TDNN 这类模型要训出可用效果,至少需要几小时到几十小时的语音数据,还得有 GPU;而课程设计的数据量往往是每人十几条、每条几秒,扔给神经网络一跑就是过拟合。MFCC 加 GMM 是传统声纹识别的标准基线,特征维度低、训练快、在几十秒注册样本下反而更稳。更重要的是答辩好讲:每一帧怎么变成向量、模型怎么拟合分布,都可以在黑板上推出来,比甩一个黑匣子模型加分得多。
MFCC 提取涉及七个步骤,每一步都有明确的参数约定,先列一份我常用的默认配置:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 采样率 sr | 16000 Hz | 麦克风录音常用 16k,电话语音用 8k |
| 帧长 frame_len | 25 ms | 对应 n_fft=400 @16kHz |
| 帧移 frame_shift | 10 ms | 相邻帧重叠 15ms,保证平滑 |
| MFCC 维数 | 13 | 加一阶差分可扩到 26 |
| 梅尔滤波器数量 | 40 | 覆盖 0-8kHz,人声主要能量区 |
| DCT 保留系数 | 前 13 个 | 高阶系数对声道个性信息贡献小 |
这组数值不是玄学,而是语音信号处理里反复验证过的标准配置。改采样率会影响帧长对应的点数,改帧移会影响时间分辨率,只要动了其中一个,后面所有环节都要跟着变。
2.2 预处理链路实现:预加重、分帧、加窗的代码与参数
拿到原始波形之后,第一件事不是直接算 MFCC,而是做预加重。语音的高频段能量天然比低频低,而声纹信息很大程度藏在高频共振峰里,需要用一阶高通滤波器把它抬起来。实现非常简单:
import numpy as np def pre_emphasis(signal, alpha=0.97): # signal 是一维 float 数组,取值范围约在 [-1, 1] return np.append(signal[0], signal[1:] - alpha * signal[:-1])alpha 取 0.95 到 0.97 都常见,作用是让当前采样点减去前一个采样点的 0.97 倍。这个操作会把低频压下去、把高频相对抬起来,代价是整体 SNR 稍微变化,但对说话人个性特征提取利大于弊。代码里signal[1:] - alpha * signal[:-1]必须用 numpy 数组,Python 原生 list 不支持这种切片减法。
分帧是把连续波形切成 25ms 的小段,因为 FFT 假设信号是平稳的,而语音在 10-30ms 尺度上可以近似看成平稳。实现时注意边界处理:
def framing(signal, frame_len=0.025, frame_shift=0.010, sr=16000): frame_samples = int(round(frame_len * sr)) # 400 个采样点 shift_samples = int(round(frame_shift * sr)) # 160 个采样点 n_frames = 1 + (len(signal) - frame_samples) // shift_samples frames = np.zeros((n_frames, frame_samples)) for i in range(n_frames): start = i * shift_samples frames[i] = signal[start:start + frame_samples] return frames这段代码的逻辑是按帧移在波形上滑动截取固定长度片段。n_frames 的计算方法保证最后一帧不越界,而不是简单用整除,否则尾部音频会被丢掉。3 秒的 16k 音频约 48000 点,分帧后大约得到 298 帧,这个数量级对 GMM 训练完全够用。
分帧之后要加窗,一般用汉明窗:
def apply_hamming(frames): window = np.hamming(frames.shape[1]) return frames * window汉明窗的作用是压低帧边缘的幅度,避免把一帧截断成矩形时在频域引入旁瓣泄漏。矩形窗在频域会出现很宽的拖尾,会让相邻频带互相污染,而汉明窗旁瓣衰减更干净。课程设计里如果发现两个说话人区分度很差,先别怀疑模型,检查一下加窗那步是不是被跳过了。
2.3 用 librosa 一句话拿到 MFCC,但要知道它背后是什么
实际写工程时一般不会手搓 FFT、梅尔滤波器组和 DCT,直接用 librosa 一行就能取到 MFCC:
import librosa def extract_mfcc(wav_path, sr=16000, n_mfcc=13, n_fft=400, hop_length=160, n_mels=40): y, _ = librosa.load(wav_path, sr=sr, mono=True) mfcc = librosa.feature.mfcc( y=y, sr=sr, n_mfcc=n_mfcc, n_fft=n_fft, hop_length=hop_length, n_mels=n_mels ) return mfcc.T # 转成 (帧数, 13) 的形状这里最关键的是理解参数对应关系:n_fft=400 就是 25ms 窗在 16k 采样率下的采样点数,hop_length=160 就是 10ms 帧移,n_mels=40 与默认配置一致。mfcc.T转置很重要,librosa 默认返回形状是 (特征维数, 帧数),而 sklearn 的模型输入要求特征是列、样本是行,转置后变成 (帧数, 13),训练时直接丢给 GaussianMixture 就行。
很多老课程设计源码用的是 python_speech_features 这个库,API 长得不一样,但算出来的东西和 librosa 基本一致,不要看到 import 不同就以为代码有问题。如果你拿到手的源码是 python_speech_features 版本,只需要注意它默认的 winlen=0.025、winstep=0.01,两个库混用前先用一个 wav 文件各算一遍,比对数值差异。
还差一步就完整了:声纹识别通常要拼上一阶差分,因为单帧 MFCC 只刻画静态频谱包络,而说话人个性大量体现在频谱随时间的变化上。常见做法是在 13 维基础上拼 delta 变成 26 维,librosa 也给了现成函数:
mfcc_delta = librosa.feature.delta(mfcc, width=9) feature = np.hstack([mfcc, mfcc_delta])width 参数控制差分窗口宽度,课程设计用默认 9 就够了,太大会把快速变化抹平,太小噪声敏感。
2.4 GMM 建模:为什么高斯混合模型能当“声纹指纹”
MFCC 特征拿到之后,问题变成:怎么把一个人的几百帧特征浓缩成一个“模型”。高斯混合模型的做法是假设一个人的特征分布可以由若干高斯分量叠加而成,每个分量对应一种发音状态或声道形态。说“每个人一个 GMM”就是这个意思——训练时只喂这个人的特征,模型参数就成了他的声纹指纹。
scikit-learn 提供了现成的 GaussianMixture,训练代码非常短:
from sklearn.mixture import GaussianMixture def train_gmm(features, n_components=8): gmm = GaussianMixture( n_components=n_components, covariance_type='diag', max_iter=200, random_state=42 ) gmm.fit(features) return gmm参数说明:n_components 是混合分量数,课程设计规模下 8 到 16 足够,分量越多拟合越细但越容易在小数据上过拟合;covariance_type 用 diag 表示每个高斯分量用对角协方差,参数数量少、训练快,对声纹任务效果不比 full 差太多,full 在数据量不足时反而容易估计出病态矩阵;max_iter 200 是 EM 算法的最大迭代次数,一般几十轮就收敛,设大点只是保险。
训练时喂进去的是 (帧数, 13) 或 (帧数, 26) 的矩阵,sklearn 会自动做 EM 迭代。这里有一个常见的理解偏差:GMM 不是把音频压成一个向量,而是保留了一个概率分布,识别时用测试帧去计算“这个分布生成这些帧的可能性”,这就是下一章打分模块的基础。
3. 源码拆解:训练、注册、识别三个模块怎么串成一条完整的身份验证链路
3.1 目录结构与核心文件职责
拿到声纹识别课程设计源码,我一般先不急着跑,先把 zip 里的目录结构画出来。这类项目通常按“特征提取、模型训练、识别打分”三段组织,解压后常见结构是下面这样:
| 路径 | 职责 | 关键点 |
|---|---|---|
| data/train/ | 每个说话人一个子目录,存放训练 wav | 目录名就是说话人标签 |
| data/test/ | 测试音频,不与训练同源 | 答辩最容易在这里翻车 |
| features.py | MFCC 提取与预处理封装 | 采样率、帧长、帧移的全局常量 |
| train_model.py | 遍历训练集,训练每人一个 GMM | 输出 pickle 模型文件 |
| recognize.py | 加载模型,对测试音频打分 | 包含阈值判断逻辑 |
| requirements.txt | 依赖清单 | numpy、scipy、librosa、soundfile、scikit-learn |
这个结构的核心设计思想是让三个模块尽量解耦:features.py 只负责“波形变成特征”,train_model.py 不关心音频怎么读,recognize.py 不关心模型怎么训。我见过不少课程设计把训练和识别写在一个脚本里,跑倒是能跑,但答辩时老师问“怎么单独加一个说话人”,代码就会乱成一团。
3.2 训练模块:把每个人的音频变成模型文件
训练模块的输入是一个目录,目录下每个子目录代表一个说话人,子目录里是若干条 wav。流程是先遍历所有说话人,对每人提取全部帧的 MFCC,堆叠成一个矩阵,然后训练一个 GMM 并存入字典:
import os import pickle import numpy as np from features import extract_mfcc from sklearn.mixture import GaussianMixture def build_models(train_dir, n_components=8): models = {} for speaker in os.listdir(train_dir): speaker_dir = os.path.join(train_dir, speaker) if not os.path.isdir(speaker_dir): continue frame_list = [] for wav in os.listdir(speaker_dir): if not wav.endswith('.wav'): continue mfcc = extract_mfcc(os.path.join(speaker_dir, wav)) frame_list.append(mfcc) all_frames = np.vstack(frame_list) # 该说话人的全部帧 gmm = GaussianMixture( n_components=n_components, covariance_type='diag', max_iter=200, random_state=42 ) gmm.fit(all_frames) models[speaker] = gmm print(f'{speaker}: {all_frames.shape[0]} frames') with open('models.pkl', 'wb') as f: pickle.dump(models, f) return models逻辑说明:np.vstack(frame_list)把每条音频的 (帧数, 13) 矩阵纵向拼接,形成这个人全部音频的总帧矩阵。这么做的理由是一个人内部有多种发音状态,单条音频的帧数太少,GMM 容易欠拟合,多条音频拼起来能让分布估计更稳定。models[speaker] = gmm的字典键就是目录名,识别阶段直接用这个名字作为输出标签。
参数说明:speaker 目录名建议只用数字或英文,不要带中文和空格,否则 pickle 跨平台加载时可能出现编码问题;n_components 是全局参数,如果发现某个人音频特别长,帧数上万,可以适当调到 16,但课程设计规模 8 就够。训练结束后把 models 字典 pickle 序列化,识别阶段直接读文件,不用每次重新训练。
3.3 识别打分:log 似然和阈值是怎么决定“是否本人”的
识别模块做的事情本质上是一件事:拿测试音频的每一帧,去每个 GMM 下计算对数似然,然后决定它属于谁或是否被拒绝。核心代码如下:
import pickle import numpy as np from features import extract_mfcc def recognize(models_path, test_wav, threshold=None): with open(models_path, 'rb') as f: models = pickle.load(f) mfcc = extract_mfcc(test_wav) scores = {} for name, gmm in models.items(): # gmm.score 返回所有帧的 log 似然之和 avg_log_likelihood = gmm.score(mfcc) / len(mfcc) scores[name] = avg_log_likelihood best_name = max(scores, key=scores.get) best_score = scores[best_name] if threshold is not None and best_score < threshold: return 'unknown', best_score return best_name, best_score逻辑说明:gmm.score(mfcc)返回的是该模型下全部测试帧的对数似然总和,直接用它做比较会出问题——音频越长,帧数越多,总和天然越大。所以必须除以len(mfcc)得到每帧平均对数似然,把时长因素消除,这样 1 秒的测试音频和 5 秒的测试音频才能公平比较。这是新手最容易漏的一步,漏掉的直接后果是长音频永远得高分。
参数说明:threshold 是拒识阈值,只有传了它,系统才具备“不是注册人就不放行”的能力。所有注册人的分数都低于阈值时返回 unknown。阈值通常是个负数,数值范围取决于特征维度和 GMM 配置,不能拍脑袋写一个 0 或 1 进去,后面第 5 章会专门讲怎么确定它。
3.4 1:1 验证与 1:N 辨认:两个场景的调用差异
声纹识别落地时分成两个场景,调用方式完全不同。1:1 验证是“你说你是谁,我就只拿你的模型来对”,比如门锁里录入过张三,张三说“开门”,系统只跟张三的模型比。1:N 辨认是“不管你是谁,我拿所有人模型都过一遍,取分数最高的人”,比如考勤打卡时系统不知道当前是谁。
1:1 场景必须设阈值,否则陌生人只要声音足够像某个人也会被放进来;1:N 场景可以只取最大值,但如果有陌生人乱入,强取一个名字照样是错的,所以最稳妥的做法是 1:N 选出最佳人之后再用阈值兜底。上面的 recognize 函数把 threshold 留成可选参数,正是为了同时兼容这两个场景。答辩演示时建议两种都跑一遍,向老师说明阈值在这里起的是“兜底拒识”作用,这是声纹系统与普通分类器最大的区别。
4. 跑通全流程:Python 环境配置、录音数据集准备与参数调整
4.1 环境配置:依赖库和 Python 版本怎么选
课程设计源码跑不起来的首要原因,八成是依赖库版本打架。声纹识别项目涉及的库很集中,我一般按下面这条命令装:
pip install numpy scipy scikit-learn librosa soundfile逻辑说明:numpy 和 scipy 是底层数值计算依赖,scikit-learn 提供 GaussianMixture 和后续评估用的工具,librosa 负责 MFCC 提取,soundfile 是 librosa 读取 wav 的后端库,这个组合覆盖了整个训练和识别链路。如果源码里用的是 python_speech_features,再补一条:
pip install python_speech_features参数说明:Python 版本建议 3.8 到 3.10。Python 3.12 下个别 numpy 版本会和 scikit-learn 出现二进制兼容告警,一旦遇到“module compiled against API version”之类报错,不要硬解,直接建一个 Python 3.9 的虚拟环境重装,十分钟能解决别人折腾两小时的问题。
4.2 录音数据集准备:5 个人、每人 20 条,怎么录才不被答辩挑毛病
声纹识别的数据是自己录的,这既是好事也是坑。好处是可以完全控制内容、时长和录制环境,坑在于录音质量直接决定识别率,而且录坏了很难补。我建议第一轮就按“5 个人、每人 20 条、每条 2 到 3 秒”的规模准备,其中 15 条训练、5 条测试。测试音频必须在训练录音之后过十分钟再录一遍,内容换个说法,这样才叫真正的开卷有备、数据不泄漏。
可以用一段简单的录音脚本,sounddevice 直接采集麦克风:
import sounddevice as sd import soundfile as sf def record_once(save_path, duration=3.0, sr=16000): audio = sd.rec(int(duration * sr), samplerate=sr, channels=1, dtype='float32') sd.wait() sf.write(save_path, audio, sr) print(f'saved: {save_path}')逻辑说明:sd.rec是阻塞式采集,录够 duration 秒后返回,sd.wait()等录音完成,channels=1保证单声道,避免双声道数据在后续处理里平白多一倍计算量。保存时用 float32 格式,与 librosa 默认的数值范围一致,省去类型转换。
参数说明:duration 取 2-3 秒,太短帧数不足,GMM 估计不准;太长录音过程容易引入环境噪声变化。录音时人和麦克风保持 30 厘米左右,环境安静,同一句话不要读第二遍来凑数——每一条都换内容,模型才能学到发音变化而不是背文本。这一步省了,后面识别效果不稳定时真的没有后悔药。
4.3 训练与识别命令:从零跑到出结果只需要三个文件
数据准备好之后,整个流程就是两步:先训练,再识别。命令长这样:
python train_model.py --data_dir ./data/train --model_path ./models.pkl --n_components 12 python recognize.py --model_path ./models.pkl --audio ./data/test/speaker_01_test.wav第一行的含义是告诉训练脚本去哪里找训练数据、把模型存到哪、每个 GMM 用几个高斯分量。第二行加载模型文件,对一条测试音频打分并打印出最可能的说话人和分数。两份脚本都只需要把路径参数改对就能跑,不需要改任何业务代码,这也是解耦设计的好处。
参数组合的经验值:数据量是每人 15 条、每条 3 秒的情况下,n_components 取 8 到 12;如果每人数据量减半,就降到 6,防止过拟合。测试阶段第一次跑建议不传 threshold,先看分数输出,确认同人分数和异人分数大致在什么区间,再决定阈值怎么设。
4.4 快速验收:同人分数和异人分数有没有拉开
不要上来就调阈值,先做一个最直观的验收实验:选一个注册说话人的测试音频和另一个注册说话人的测试音频,分别去同一个模型打分,看两个分数是否明显分开。比如 speaker_01 的模型,对 speaker_01 自己的测试音频打 -28.5,对 speaker_02 的测试音频打 -41.3,那这个模型就是可用的,中间差了 13 个点,阈值随便设在 -35 附近都有余量。
如果两个分数只差两三个点,甚至同人分数比异人分数还低,问题几乎一定出在数据或特征环节:录音串号了、采样率不统一、静音太长、训练和测试用了同一条音频,按优先级排查。这组比对的代码就是反复调用第 3 章的 recognize 函数,不需要新逻辑,但这一步值得每次改完参数都做一遍,它会告诉你分数分布的长相。
5. 避坑:课程设计与答辩里最常翻车的 5 个声纹识别问题
5.1 采样率不统一,特征维度对不上
现象:训练阶段一切正常,测试阶段 librosa.load 偶尔报错,识别分数整体偏低,同一个人的测试音频得分比陌生人还不可靠。
原因:录音设备输出了 44100 Hz 的 wav,而训练时统一用的 16000 Hz。特征提取脚本里 load 时没有强制指定 sr,导致有些音频被 resample、有些没有,GMM 学到的分布和测试时的特征分布根本对不上。声纹任务里这是最隐蔽的坑,因为报错不一定出现,两套采样率都能跑,只是结果不可信。
解决:所有 load 调用统一强制 16k,已经读进内存的数组用 librosa.resample 修正:
import librosa # 统一方案:load 时就指定 y, sr = librosa.load(wav_path, sr=16000, mono=True) # 如果已经拿到的数组是 44.1k,先记住原采样率再重采样 y_resampled = librosa.resample(y, orig_sr=44100, target_sr=16000)从那以后我拿到任何 wav 都先打印 sr 字段确认,绝不信任后缀名。
5.2 静音段没处理,陌生人也能打高分
现象:测试时录一段空白或者纯环境噪声,系统将其判成某个注册用户,而且分数还挺高。
原因:静音帧的频谱包络趋同,MFCC 计算出来的向量在不同人之间差异极小。GMM 对能量极低的帧照样计算似然,一段 3 秒音频里如果前 1 秒是静音,这 1 秒的帧会把整体分数往同一个方向拉,弱化真实语音段的作用。
解决:提取 MFCC 前先做静音裁剪,能量低于阈值的帧直接丢弃:
def trim_silence(y, sr=16000, top_db=30): y_trimmed, _ = librosa.effects.trim(y, top_db=top_db) if len(y_trimmed) < sr * 0.3: return y # 整段几乎都是噪声时保留原信号 return y_trimmedtop_db=30 表示低于峰值 30dB 的段视为静音,0.3 秒的下限是防止录音太差导致裁剪后只剩几个帧、连特征都提不出来。引入裁剪之后,同人分数会明显更集中。
5.3 soundfile 后端缺失,读音频报各种看不懂的错
现象:Windows 上跑 librosa.load 报 “File format not recognised”,或者 soundfile 报 “Error opening file”,但同一个 wav 用别的播放器能正常打开。
原因:librosa 解码音频默认依赖 libsndfile,Python 端就是 soundfile 库。环境里没装 soundfile 时,librosa 会退回 audioread 走 ffmpeg 兜底,而 ffmpeg 路径没配好就会给出上述报错。这是环境问题,不是代码问题。
解决:明确装 soundfile,并且所有音频读取都走 librosa.load 单一入口,不要一部分用 scipy.io.wavfile 读、一部分用 librosa 读,两套读法的数值范围和位深处理不一致,混用会导致特征数值漂移。如果源码里已经有 scipy.io.wavfile 读取的音频,先转成 float32 再进入特征提取,否则 int16 的数值范围是 -32768 到 32767,和 librosa 的 -1 到 1 相差三万倍,GMM 训练直接崩。
5.4 阈值拍脑袋设,拒真和误识全乱套
现象:threshold 设成 0,陌生人全被放进来;设成 -20,本人也频繁被拒;设成 -50,陌生人又全过了。
原因:每帧平均对数似然是负数且带有模型特定的量纲,不同特征维度、不同 n_components 下分数范围完全不同。这个值没法靠直觉定,必须从数据里估。
解决:把验证集同人分数和异人分数分别收集起来,取两个分布的中间点作为初始阈值:
import numpy as np genuine = [] # 同人打分结果 impostor = [] # 异人打分结果 # 填充两组分数后,线性近似取分界 threshold = (min(genuine) + max(impostor)) / 2 print('genuine range:', min(genuine), max(genuine)) print('impostor range:', min(impostor), max(impostor)) print('threshold:', threshold)这个线性近似不是最优解,但课程设计答辩足够讲清楚。更严谨的做法是下一章的 EER 计算。核心原则是:阈值永远来自分数分布,不来自想象。
5.5 训练测试同源,识别率再高也是假象
现象:自己测的时候识别率 98%,答辩现场老师随便录一句话,系统直接认错人,场面一度很尴尬。
原因:训练和测试用了同一条 wav,或者用了同一次录音的前后半段。MFCC 帧之间高度相关,GMM 相当于把同一段声音的分布背下来了,换一个人、换一次录音,分布立刻漂移。这是声纹识别和图像分类最大的不同:图片多看几次不会变,声音每次说都不一样。
解决:严格按“会话”切分数据。第一轮录音全部进训练集,隔十分钟再录第二轮全部进测试集,内容不能重复,情绪和语速可以有变化。如果你拿到手的源码自带数据集,先检查训练目录和测试目录是否来自同一批录音文件,是的话整包可信度都要打个问号。能拿 98 分的作业和答辩翻车的作业,差距往往就藏在数据这一层。
6. 验证声纹模型真能用的两个手段:EER 曲线与余弦相似度模板
6.1 用同人/异人分数算等错误率 EER
阈值调完了,怎么向老师证明这套模型不是碰巧能用?等错误率 EER 是最简洁的指标。它描述的是误识率和拒真率相等时的数值,越低说明系统区分能力越强。实现不复杂:
def compute_eer(genuine_scores, impostor_scores): thresholds = np.linspace( min(genuine_scores + impostor_scores), max(genuine_scores + impostor_scores), 200 ) best_diff = 1.0 eer = 1.0 for thr in thresholds: far = np.mean([s >= thr for s in impostor_scores]) frr = np.mean([s < thr for s in genuine_scores]) diff = abs(far - frr) if diff < best_diff: best_diff = diff eer = (far + frr) / 2 return eer逻辑说明:FAR 是异人分数越过阈值的比例,FRR 是同人分数低于阈值的比例。理想系统存在一个阈值让两者都接近 0,实际系统只能在两个错误之间权衡,EER 就是两者的平衡点。运行前先保证 genuine 和 impostor 两个列表来自独立录音,否则算出来的 EER 再低都是自欺欺人。课程设计规模下 EER 能到 10% 以内就算可演示,超过 20% 先回去查特征提取,别急着改阈值。
6.2 轻量方案:MFCC 均值向量做声纹模板
如果不做 GMM,还有一种更直观的实现:把一个人所有帧的 MFCC 求平均得到一个模板向量,测试时把测试音频的 MFCC 均值向量跟模板算余弦相似度。代码很短:
def build_template(frames): return frames.mean(axis=0) def cosine_similarity(vec_a, vec_b): return float(np.dot(vec_a, vec_b) / (np.linalg.norm(vec_a) * np.linalg.norm(vec_b) + 1e-8))这个方案适合在答辩 PPT 上画一张“特征空间相似度”的图,解释起来比 GMM 直观,但它丢掉了帧与时序信息,同一个人换情绪、换语速时分数波动很大,阈值一般取 0.7 到 0.85 之间也要靠验证集微调。我的建议是把它当作可视化辅助和快速原型,正式打分仍然用 GMM 的对数似然,两条路都跑通,你对这套声纹系统的理解才算完整。
我第一次做声纹课程设计时就是栽在训练测试同源上,训练集识别率 98%,答辩现场老师现场录一句话,系统直接认成另一个人。从那以后我每次跑完训练都强制把录音会话分开重录一轮测试,再算一次 EER,高于 15% 就回头查特征和录音质量,绝不靠调阈值自欺欺人。希望帮到你。
本文还有配套的精品资源,点击获取