← 返回博客

技术拆解

纯 Rust 推理落地:一套轻依赖的后融合说话人分离方案

OpenASR 的说话人分离以 WhisperX 风格的旁路阶段运行,不侵入 ASR 核心——项目首批纯 Rust 前向推理,逐一比对 ONNX 参考模型验证数值一致性。
纯 Rust 推理落地:一套轻依赖的后融合说话人分离方案

VAD 与说话人分离作为后融合旁路阶段围绕 ASR 核心运行,仅通过时间区间通信——项目首批纯 Rust 前向推理,逐一验证与 ONNX 参考模型的数值吻合度。

说话人分离要回答的问题看上去很直觉——“这段话是谁说的”——而最顺手的做法是钻进每个 ASR 模型内部,把说话人信息教给它。OpenASR 走了相反的路:分离模块是一组围绕 ASR 核心的旁路阶段,ASR 本身对它们的存在一无所知,最终只靠时间区间的重叠把结果缝合回转写文本。

后融合:不碰核心的旁路架构

整体思路是 WhisperX 风格的后融合(late fusion)。ASR executor 原封不动;语音活动检测(VAD)和说话人分离分别作为前置和后置阶段运行,与引擎其余部分的对话只有一种语言:时间范围。归属步骤——真正把”谁说了什么”落到每个词上——是一个纯粹的区间最大重叠函数,与模型零耦合。对每个词,它累计与所有重叠说话人片段的交集长度,取最长重叠者为赢家;没有任何重叠的词要么留空,要么按策略贴靠最近的片段。

因为这个拼接过程只是区间上的算术,同一份 assign_speakers 代码就能为 whisper、qwen、cohere、parakeet、moonshine 以及 wav2vec2 CTC 路径做说话人归属——无需按模型家族分叉。

为什么选后融合? 保持 ASR 核心不动是整个设计的出发点:引擎已有的准确率与一致性保证全部原样保留,说话人分离是一件”螺栓拧上去”的事,而不是对每个模型的重写。

许可证:默认干净

默认选型的原则是能装进一个 Apache-2.0 仓库,不引入 viral 或非商业限制。VAD 用的是 Silero v6(MIT)。说话人变化分段用的是 pyannote segmentation-3.0——MIT 许可,但取自非门控的 onnx-community 镜像,官方仓库有访问门控。说话人嵌入用的是 WeSpeaker 的 ResNet34(VoxCeleb)——CC-BY-4.0,允许商用,只需署名,与 Apache-2.0 的授权方式有别但兼容。聚类部分大约 150 行自研 Rust 代码,基于余弦距离做凝聚层次聚类,不含任何模型权重。

有几个知名选项是刻意排除的:TEN VAD 因其许可证中的竞业限制条款,开源仓库无法满足,不予采用;NVIDIA 的 Sortformer 设为 opt-in 拉取,永远不捆绑分发。

项目的第一批纯 Rust 前向推理

这是 OpenASR 项目中第一批纯 Rust 实现的前向推理,每一个都在被信任之前与其 ONNX 参考模型做过比对。Silero 的 VAD 概率值与上游模型的最大绝对误差在 1e-3 以内。WeSpeaker 前端与 torchaudio 的滤波器组最大误差为 1.3e-3,完整的音频到嵌入路径的余弦相似度达到 1.000000。pyannote PyanNet 前向在 293 帧上的最大绝对误差为 6e-5

这些数字要窄着读。它们是数值吻合度的度量——衡量 Rust 移植版在多大程度上忠实复现了参考网络的输出——仅此而已。它们不是分离准确率,不是 DER,也不是”说话人标签对了多少”的声明。它们说的是:数学是一样的。至于”谁说了什么”的实际效果如何,是另一个问题,本轮工作尚未回答。

余弦相似度 1.000000 意味着移植版复现了参考网络的输出——而不是说话人标签就是对的。

——摘自 VAD 与说话人分离设计文档

“纯 Rust”这件事也值得说清楚边界。这些小型 VAD 和分离模型是代码树中唯一的纯 Rust 推理。ASR 引擎本身仍然完全跑在 ggml 的 FFI 上——底下是 C,nn/* 模块是 ggml 的计算图构建器,不是 Rust 数学。去 C(shedding C)是长期方向,不是今天的现状。

隐私是默认状态

这里的说话人分离指的是区分说话人,而不是识别说话人。标签是匿名的、会话级的——SPEAKER_00SPEAKER_01——产生它们的嵌入在会话结束时即被丢弃。唯一的身份特性是一个默认关闭的”注册主用户”功能:一个本地质心,可以把某个聚类重新标记为”你”。在远程模式下,这一映射被设计为留在客户端;服务端只返回匿名标签,不接收注册的声纹——与项目其他部分遵循的”本地优先”隐私立场一致。

分离,不识别。 按设计,注册声纹始终留在设备上;在远程计算模式中,身份识别是客户端的后置步骤,叠加在服务端返回的匿名标签之上——而不是引擎默认提供的能力。

已接通的,和老实说还没接通的

整个方案是增量式的,受一致性门控约束。关闭说话人分离时,输出与当前版本逐字节一致——这是整个引擎所遵守的 golden-diff / WER-0 准则——开启时,分离模块只写入 speaker 字段,绝不碰文本或时间戳。批量路径(一个 BatchDiarizer,受按请求的 diarize 标志控制)已为所有模型接通,但目前每个 VAD 片段只输出一个说话人,凝聚聚类的合并阈值 0.35 是一个未调优的领域相关旋钮,不是一个经过调优的最优值。后融合也会压平重叠:它为每个词只分配一个说话人,因此两人同时说话时会变成单一标签。

所以诚实的定位是这样的:--diarize 是一个已发布的、opt-in 的功能,不只是引擎侧的基础设施——它通过纯 Rust 实现的 WeSpeaker 和 pyannote 能力包(按需拉取),为任意模型家族的转写结果归属匿名的 SPEAKER_NN 标签。Cohere 能力包还可以在此基础上额外输出内联的说话人 token。当前的工作是把同一条”谁说了什么”的路径在各处收紧,以参考模型的数值吻合度来衡量,而不是在验证之前就做宣传。

查看完整技术细节

中文版以中文读者更容易理解的方式整理了核心结论。代码、测试条件和完整数据请参考英文原文。

阅读英文原文 →
标签说话人分离架构