
1. 先说清楚声纹识别不是语音识别很多人从第一步就学歪了我当年第一次接触声纹识别是给一个智能音箱项目做区分家庭成员的功能。团队里没人做过声纹大家的第一反应都是去翻语音识别的资料拿 ASR 的声学模型改改。折腾了两个礼拜效果惨不忍睹后来才明白声纹识别和语音识别虽然都吃音频但解决的问题完全不同。语音识别要的是你说的内容是什么它会把音频转成文本关注的是音素的序列声纹识别要的是说话的人是谁它不管内容只关心声音里那些不太受内容影响的、属于某个人的独特印记。一个是内容识别一个是身份识别本质上就是两条技术路线。学声纹识别我建议你从一开始就把脑子里的语音识别思维清空。你不该想着怎么去对齐音素、怎么解码文本你要想的是怎么能从一段声音里抽出一个代表这个人的向量而且这个向量对换了句话换了环境足够鲁棒。这篇文章我不会给你堆一大堆论文公式而是结合我自己从零跑通一个说话人识别系统的完整过程把声纹识别初学阶段该知道的东西讲清楚。你会看到特征怎么提、模型怎么搭、训练踩了哪些坑、以及最后怎么用一段新的音频判断这是不是注册过的那个人。如果你是学生、刚转行的算法工程师或者产品经理想了解技术实现这篇文章都适用。文里的代码我都简化过了只保留主干拿公开的语音数据集就能跑不需要特殊硬件。2. 声纹特征的本质为什么 MFCC 和 fbank 能代表一个人2.1 声纹特征不是玄学是声道形状的签名人跟人声音不同根源在生理结构。声带的长短厚薄、咽腔的大小、口腔和鼻腔的形状决定了每个人发声时的共振峰位置、基频范围、谐波能量分布都不一样。这些特征在信号层面表现为频谱包络的形状差异。MFCC 和 fbank梅尔滤波器组特征做的就是一件事把一段音频的频谱包络提取出来压缩成几十维的向量序列。它们不是直接拿来比大小的而是作为模型的输入让模型自己学会哪些维度、哪些时间片段组合起来最能区分说话人。所以初学阶段你不需要纠结哪个特征更高级fbank 是现在的主流选择信息保留比 MFCC 多——MFCC 做 DCT 去相关会丢掉一些细节而 fbank 保留原始能量分布喂给神经网络时往往效果更好。2.2 预处理这一步决定你后面的路好不好走特征提取前的预处理往往被新手忽略但我实测下来预处理的坑比模型结构的坑多得多。标准流程是这样的import librosa import numpy as np def load_and_preprocess(wav_path, target_sr16000): # 1. 读取音频统一重采样到16kHz # 声纹领域的通用标准是16k采样率不统一会造成特征分布偏移 audio, sr librosa.load(wav_path, srtarget_sr) # 2. 去除静音段这一步非常关键尤其是录音开头结尾的静音 # 静音段没有说话人信息但会拉低有效帧的比例甚至让模型学到静音模式 audio trim_silence(audio, sr) # 3. 提取fbank特征80维帧长25ms帧移10ms这是主流配置 # 25ms的窗长能兼顾频率分辨率和时间分辨率 fbank librosa.feature.melspectrogram( yaudio, srsr, n_fft512, hop_length160, # 10ms win_length400, # 25ms n_mels80, fmin0, fmax8000 ) # librosa返回的是功率谱转成对数域 log_fbank librosa.power_to_db(fbank, refnp.max) return log_fbank.T # 返回 [时间帧数, 特征维度] 的矩阵静音检测我用的是最稳妥的 energy-based 方法——计算短时能量低于阈值的帧裁掉。网上有现成的 webrtcvad 也可以用但对音乐、噪声环境不太友好。注意切静音不是必须的但强烈建议做。我后面会专门讲一个因为静音没处理好导致的惨案。2.3 特征长度的处理固定时长还是动态长度一段录音可能是 3 秒可能是 30 秒。神经网络需要固定输入所以有两个思路固定时长裁剪训练时随机从长音频里裁出 3 秒左右的片段喂给模型。这同时是一种数据增强——同一句话每次随机裁的位置不一样模型见过的形态更丰富。变长输入 池化模型支持任意长度输入最后通过统计池化层把变长特征压成固定维度。初学阶段我强烈建议用第一种简单、稳定、好调试。等你对模型结构熟悉了再上统计池化不迟。我个人的标准配置是训练时随机裁 3 秒测试时用整段如果不够 3 秒就重复填充。3. 我用的模型结构thin-ResNet 加 AM-Softmax新手也能在单卡上跑起来3.1 为什么一开始别碰 ECAPA-TDNN你搜索声纹识别大概率会看到 ECAPA-TDNN 和 WeSpeaker。ECAPA 是目前说话人验证的主流 SOTA 结构效果确实好但我不建议新手一上来就复现它。原因很简单它太复杂了——密集连接的 SE-Res2Block、多尺度特征融合、注意力统计池化任何一个模块理解不到位调试的时候都是灾难。而且它的计算量不小没有好显卡的话训练周期会被拖得很长新手很容易在等训练结果的过程中失去耐心。先跑通一个简单的、你能完全把握住每个模块的模型建立对训练-验证-推理全流程的体感再往上加复杂度这是更务实的路径。3.2 thin-ResNet 的结构和理由我用的模型是一个缩小版的 ResNet在说话人验证任务上效果不差显存占用还小。核心思路把 80 维 fbank 当图像处理——时间维是宽特征维是高用二维卷积提取局部模式最后通过全局池化得到一个固定长度的说话人向量embedding。import torch import torch.nn as nn class ThinResNet(nn.Module): def __init__(self, num_speakers100, embed_dim192): super().__init__() # 输入是 [B, 80, T]80维fbankT是时间帧数 # 这里将80维特征看作图像的高度T看作宽度 # 用1x1卷积先做通道变换相当于特征维度的线性组合 self.frontend nn.Sequential( nn.Conv2d(1, 16, kernel_size3, padding1), nn.BatchNorm2d(16), nn.ReLU(), ) # 三个残差stage逐步下采样时间维同时增加通道数 self.stage1 self._make_stage(16, 32, num_blocks2, stride(2, 2)) self.stage2 self._make_stage(32, 64, num_blocks2, stride(2, 2)) self.stage3 self._make_stage(64, 128, num_blocks2, stride(2, 2)) # 全局均值池化把最后一层的特征图压成向量 # 注意这里用的是均值池化没有用注意力池化后者效果更好但先简单点 self.pool nn.AdaptiveAvgPool2d((1, 1)) self.embedding nn.Linear(128, embed_dim) def _make_stage(self, in_ch, out_ch, num_blocks, stride): layers [] layers.append(BasicBlock(in_ch, out_ch, stride)) for _ in range(1, num_blocks): layers.append(BasicBlock(out_ch, out_ch, 1)) return nn.Sequential(*layers) def forward(self, x): # x: [B, 1, 80, T] x self.frontend(x) x self.stage1(x) x self.stage2(x) x self.stage3(x) x self.pool(x) # [B, 128, 1, 1] x x.view(x.size(0), -1) # [B, 128] emb self.embedding(x) # [B, 192] # 对embedding做L2归一化让后续相似度计算更方便 emb nn.functional.normalize(emb, p2, dim1) return emb为什么用 192 维经验值。太短比如 64 维会损失区分度太长比如 512 维存储和计算成本高192 是精度和效率比较均衡的点。实际项目中 256 也很常见没有本质区别。3.3 AM-Softmax 损失函数让不同说话人之间拉开距离分类模型最后接 softmax 交叉熵是最自然的想法但对声纹任务来说有个致命问题softmax 只要求能正确分类不要求不同说话人的特征向量之间有足够远的距离。真正的声纹系统是 open-set 的——训练时见过的人测试时基本不会出现你要判断的是这个新声音像不像注册的那个人而不是它是训练集里的哪个 ID。AM-Softmax 加了一个 margin间隔让模型不仅分类正确而且同类特征聚得更拢异类特征隔得更开。通俗理解softmax 追求的及格线是 60 分AM-Softmax 要求 90 分以上。class AMSoftmaxLoss(nn.Module): def __init__(self, embed_dim, num_speakers, scale30.0, margin0.35): super().__init__() self.scale scale # 特征向量的模长放大倍数 self.margin margin # 角度间隔 # 这个权重矩阵的每一行可以理解为每个说话人的中心向量 self.weight nn.Parameter(torch.randn(num_speakers, embed_dim)) nn.init.xavier_normal_(self.weight) def forward(self, embedding, label): # embedding: [B, 192]已经做过L2归一化 # 对权重也做L2归一化这样内积就是cosine相似度 w nn.functional.normalize(self.weight, p2, dim1) # [num_speakers, 192] cos_theta torch.matmul(embedding, w.t()) # [B, num_speakers] # 构造one-hot矩阵取出对应类别的cos值减去margin one_hot torch.zeros_like(cos_theta) one_hot.scatter_(1, label.view(-1, 1), 1.0) cos_theta_m cos_theta - one_hot * self.margin # 注意这里和原始AM-Softmax略有差异我们去掉了对cos值下限的截断 # 实际使用中发现加了截断反而容易训不稳边界样本的梯度会乱跳 output self.scale * cos_theta_m loss nn.functional.cross_entropy(output, label) return lossscale30, margin0.35是比较稳的起点。scale 太小模型收敛慢太大容易造成梯度爆炸margin 太大模型难以收敛太小区分度不够。你可以在这个基础上调观察验证集 EER 的变化。4. 从零跑通训练数据准备、DataLoader 和完整训练流程4.1 数据集选型初学阶段别一上来就啃 VoxCelebVoxCeleb 是声纹领域最常用的数据集但几十万条视频提取的音频、还有各种噪声环境新手拿去直接训练容易被数据的各种问题劝退。我建议用COLA 数据集——它是从 LibriSpeech 里提取的、格式非常规整的说话人验证数据集包含了训练、验证、测试的划分说话人严格不重叠非常适合做第一个实验。如果你英语发音的数据不感兴趣也可以用国内的 AISHELL-1 或 aidatatang_200zh按说话人 ID 划分即可。关键是保证训练集和验证集的说话人完全没有交集——这是整个项目里最基础也最容易翻车的关卡我后面会讲我在这上面摔的跟头。4.2 DataLoader 里最容易出错的几个细节写 DataLoader 的时候新手常犯的错误是把一个人读的一句话当成一个样本其实声纹训练需要的是裁剪后的片段。我的做法是import torch from torch.utils.data import Dataset class SpeakerDataset(Dataset): def __init__(self, wav_paths, labels, sr16000, num_frames300): self.wav_paths wav_paths self.labels labels self.sr sr # num_frames300 对应 3 秒帧移10ms self.num_frames num_frames def __len__(self): return len(self.wav_paths) def __getitem__(self, idx): audio, _ librosa.load(self.wav_paths[idx], srself.sr) audio trim_silence(audio, self.sr) # 随机裁剪3秒如果音频不够长先重复填充 if len(audio) self.num_frames * 160: reps int(np.ceil(self.num_frames * 160 / len(audio))) audio np.tile(audio, reps) start np.random.randint(0, len(audio) - self.num_frames * 160) audio audio[start:start self.num_frames * 160] # 提取fbank fbank librosa.feature.melspectrogram( yaudio, srself.sr, n_fft512, hop_length160, win_length400, n_mels80 ) log_fbank librosa.power_to_db(fbank, refnp.max) # 归一化对每个样本减去全局均值等于做一个简单的特征增强 log_fbank (log_fbank - log_fbank.mean()) / (log_fbank.std() 1e-5) feature torch.tensor(log_fbank, dtypetorch.float32).unsqueeze(0) # [1, 80, T] label torch.tensor(self.labels[idx], dtypetorch.long) return feature, label这个 DataLoader 里有一个很关键的设计每次取样本时重新做随机裁剪。这意味着同一个音频文件在每一个 epoch 里被裁剪的位置不同模型天然获得了平移增强效果比你单独做 SpecAugment 还明显。4.3 训练配置和循环照着抄就能跑通的参数我用单张 3090 训练 100 个说话人、每人 30 条语音batch size 128大概 2 个小时就能收敛到可用的水平。具体配置如下优化器AdamW学习率 1e-3权重衰减 0.01学习率调度线性 warmup 5 个 epoch然后 cosine 衰减到 1e-5训练轮数30 个 epoch数据增强可选SpecAugment时间 mask 最大 30 帧频率 mask 最大 4 个 binfor epoch in range(epochs): model.train() for batch in train_loader: features, labels batch features features.to(device) labels labels.to(device) embeddings model(features) loss criterion(embeddings, labels) optimizer.zero_grad() loss.backward() # 梯度裁剪防止fbank离群值导致loss飞升 torch.nn.utils.clip_grad_norm_(model.parameters(), 3.0) optimizer.step() scheduler.step()注意梯度裁剪这个细节我几乎每次训练都会加因为 fbank 偶尔会出现极端值不做裁剪 loss 容易突然飙到 NaN。4.4 验证指标EER 怎么看声纹领域最常用的指标是 EER等错误率——当误拒率FRR等于误识率FAR时的值。EER 越低越好表示系统在该放行的人不放行和不该放行的人放进来了之间取得的最佳平衡点。初学阶段你不需要跑完整的验证集只要在训练中每隔几个 epoch 在小型验证集上算一下 EER观察它是否在下降就行。计算 EER 的代码很简单from sklearn.metrics import roc_curve from scipy.optimize import brentq from scipy.interpolate import interp1d def compute_eer(same_scores, diff_scores): labels np.concatenate([np.ones(len(same_scores)), np.zeros(len(diff_scores))]) scores np.concatenate([same_scores, diff_scores]) fpr, tpr, _ roc_curve(labels, scores) fnr 1 - tpr # EER是FPR和FNR相等的位置 eer_thresh brentq(lambda x: 1. - x - interp1d(fpr, tpr)(x), 0., 1.) eer interp1d(fpr, tpr)(eer_thresh) return 1 - eer5. 训练踩坑实录三个让我浪费一周的问题5.1 训练集和验证集说话人重叠EER 虚低到 0.5%最讽刺的坑。我第一次跑验证脚本EER 低到 0.3%激动得以为自己要发论文了。结果仔细一查数据划分时偷懒用了按文件随机划分而不是按说话人划分——同一个人的录音一部分进了训练集一部分进了验证集。模型直接从训练集里记住了这个人验证自然满分。这个问题的本质是信息泄漏。声纹模型要解决的是没见过的人也能区分的问题如果验证集里的人模型已经见过了你测的就不是泛化能力而是记忆能力。我后来定了一个规矩划分数据集时必须按 speaker_id 分组一组数据要么全在训练集要么全在验证集绝不允许交叉。这也直接导致了我的 EER 从虚低的 0.5%变成了真实的 4%但这才是模型真正的水平。5.2 静音段没切除导致同一个人两次录音得分剧烈波动有一次我在测试一个已经训好的模型发现同一个人的两条录音相似度得分一次是 0.85一次是 0.31完全不像同一个人。排查了很久最后把两条音频的波形画出来一看得分低的那条前面有 0.5 秒的静音静音段的 fbank 特征能量很低等同于给模型喂了一段灰屏。原因很清晰模型在训练时见过大量静音段它学到的模式是静音段特征没有判别力应该被忽略但测试时如果不过滤静音段依然会参与统计池化把 embedding 向量往中心拉从而降低相似度。修好之后同类得分立刻稳定在 0.8 以上。从此我所有推理流程都要先过 VAD 或能量检测这已经成了我的固定习惯。5.3 SpecAugment 参数太大模型训到最后都不收敛SpecAugment 本来是为了增强鲁棒性但我第一次用的时候直接把 frequency mask 设成 16——也就是把 80 维 fbank 里 20% 的频段随机抹掉。结果模型收敛速度肉眼可见地变慢loss 在 10 个 epoch 后还抖个不停。原因是声纹特征的信息高度集中在低频段主要是 0-4kHz你把低频段大面积遮掉模型等于瞎了一只眼。后来我把 frequency mask 调成 4time mask 保持 30 帧收敛速度和最终 EER 都比完全不增强要好。增强不是越狠越好关键是别把主要信息通道堵死。6. 推理阶段注册、打分和阈值——声纹系统的完整落地姿势6.1 说话人注册如何生成一份声纹档案训练好模型之后你手里的其实只是一个特征提取器——它能把任意一段语音变成 192 维的向量。真实的使用流程是注册让用户说几句固定文本比如芝麻开门系统将这些录音通过模型转换成多个 embedding取平均得到该用户的声纹中心向量存进数据库。验证用户再次说话系统提取得到一个新的 embedding计算它与档案向量之间的余弦相似度。判定如果相似度超过阈值判定为同一人否则拒绝。这里有个细节注册的时候建议用 3-5 条不同环境下的录音做平均而不是只录一条。只录一条的话环境噪声、录音设备的变化都会让中心向量偏移导致后续验证得分偏低。def enroll_speaker(model, wav_paths, device): 用多条录音生成说话人档案 model.eval() embeddings [] with torch.no_grad(): for wav_path in wav_paths: feature extract_feature(wav_path, device) # [1, 80, T] emb model(feature) # [1, 192] embeddings.append(emb) # 对多个embedding做平均再归一化 centroid torch.mean(torch.stack(embeddings), dim0) centroid nn.functional.normalize(centroid, p2, dim1) return centroid6.2 千万别用 softmax 概率做判定有些新手训完分类模型会想当然地拿 softmax 的输出概率当置信度把概率最高的那个人作为识别结果。这在闭集场景测试的人一定在训练集里勉强能用但真实场景是开集的——来的人你根本没见过softmax 给它输出的概率分布没有任何意义。正确的做法是上面的流程提取 embedding和所有已注册用户的中心向量算余弦相似度然后拿相似度和一个阈值比较。相似度高的就是熟脸全部低于阈值就判为陌生人。6.3 阈值怎么定阈值不是拍脑袋定的要在开发集上扫出来。通常做法是准备一批同一人的录音对正样本对和不同人的录音对负样本对对每个候选阈值计算误拒率FRR和误识率FAR选择一个业务上可接受的平衡点。比如门禁场景宁可多拒几次也别放陌生人进来那就把阈值调高、容忍较高的 FRR智能音箱唤醒场景则相反阈值调低一点减少用户喊半天没反应的糟糕体验。下表是不同业务场景的常见阈值调节方向场景阈值方向原因支付/门禁调高如0.75安全优先误识的代价高智能音箱/陪伴机器人调低如0.5-0.6体验优先偶尔误判可接受电话客服身份确认中等0.6-0.7需要在安全和体验之间找平衡6.4 完全可复现的验证脚本最后给你一个完整的验证脚本可以直接拿它测试你训好的模型def verify(model, enroll_wavs, test_wav_path, device, threshold0.6): model.eval() with torch.no_grad(): centroid enroll_speaker(model, enroll_wavs, device) test_feat extract_feature(test_wav_path, device) test_emb model(test_feat) cos_sim torch.cosine_similarity(centroid, test_emb).item() return cos_sim, cos_sim threshold注意测试时输入的语音不要随机裁剪要用整段音频计算 embedding。随机裁剪在训练时是增强手段但测试时会造成得分抖动——你今天裁到的一段恰好没说话得分就偏低。推理阶段一切操作都要可复现这是我吃了好几次亏之后的准则。7. 给初学者的最后叮嘱先跑通再优化先复现再创造声纹识别这个领域看着门槛高但其实现在的开源生态已经很友好了。除了我上面从零写的这套流程你还可以直接用 WeSpeaker 或 SpeechBrain 的现成方案跑通基线。我的建议是至少要自己从头写一遍训练和推理的流程哪怕代码跑得慢一点、效果差一点。因为只有自己写过一遍你才会真正理解 embedding 是什么、margin 在干什么、EER 是怎么算的。以后无论换什么模型结构、什么数据集你都有能力快速迁移。我个人的体感是声纹项目的难点从来不在模型结构而在于数据质量、训练细节和评测流程。同样的模型数据处理好、训练参数合理、评测严谨EER 可能相差一个数量级。所以如果你在跑实验时发现问题优先检查数据其次是损失函数和训练配置最后才轮到模型结构。顺带一提声纹属于生物特征上线前务必考虑安全和隐私问题录音是否有被录音重放攻击的风险要不要加活体检测用户的声纹特征如何加密存储、能不能撤回这些虽然是产品层面的事但作为开发者从一开始就要有这根弦。技术能做出来是一回事能安全可靠地落地是另一回事。这篇就写到这里。如果你在跑第一个声纹模型时遇到了什么问题欢迎带着具体现象来交流——一起把坑填平后面的路就好走了。