用纯 Node.js 做一个会议速记员:声纹认人、未知人员编号,以及四个不报错的坑

作者:澎涛 | 分类:科技 | 发布时间:2026-10-28 10:07:36 | 阅读:8 | 点赞:0 | 点踩:0
标签: 声纹识别


开会最烦的不是开会,是会后整理纪要。


尤其是那种五六个人轮流发言、中间还插话的会。录音在手,但你得反复拖动进度条,靠记忆判断"这句是谁说的"。市面上有些转写服务能做到,但要把整场会议的音频传到别人服务器上——这事在很多公司直接过不了合规。


所以我花了几天自己写了一个,跑在本机,不联网。要求很朴素:


提前录好每个人的声纹,开会时自动出「张三:说的话……」

有人同时说话,能标出来

临时来了个没录声纹的人,他的话也不能丢

最后一条比听起来重要得多。它是这个项目里改动最多的地方,也是踩坑最集中的地方。


一、先解决选型:Node 真的够用吗

原本的预设是要上 Python——pyannote、Whisper、SpeechBrain、WeSpeaker,这些都是 Python 生态的东西。


但我去翻了 sherpa-onnx 的 Node 绑定,发现它一个包就把整条链路覆盖了,而且用的都是官方导出的 ONNX 模型:


环节 用的模型

中文语音识别 SenseVoice-Small

语音端点检测(切句) Silero VAD

声纹(谁在说) 3D-Speaker CAM++,192 维

说话人分割 / 重叠检测 pyannote segmentation-3.0

声纹备选还有 ERes2Net-Base,改一行配置就能换。你点名的那些框架,一个都没浪费,只是换了个运行时。


没用 Whisper 是有原因的。 Whisper 是自回归解码,CPU 上比 SenseVoice 慢 5~10 倍,而且中文的标点和短句断句明显不如 SenseVoice。对这个场景(中文会议、要实时、要标点),SenseVoice 是更合适的选择。


结果就是整个后端只有一个 npm 依赖。HTTP 服务用 http 模块手写,实时推送用 SSE ,音频用裸二进制 body 传——没引入 express,也没引入 ws。


实测在这台笔记本上(RTX 4060,但全程 CPU 推理,20 核):


说话人分离实时率 0.07(56.9 秒音频 4 秒处理完)

整条链路实时率 0.09~0.18

也就是说边开会边处理,余量很大。


二、流水线长什么样

每一句话(VAD 切出来的一段连续发声)走三步:


音频流

 └─ Silero VAD 切句

    ├─ SenseVoice 识别 → 文字

    ├─ CAM++ 抽声纹 → 比对声纹库 → 姓名 / 「未知人员N」

    └─ pyannote 分割 → 这句话里有几个人?时间上重不重叠?

      ├─ 1 个人     → 一行:张三:xxx

      ├─ 多人但前后相接  → 拆成多行

      └─ 多人且时间重叠  → 一行「⚠ 同时说话」,逐人拆开分别识别

中间那个"多人但前后相接 → 拆成多行"是白捡的收益。


VAD 是按静音切的,换人的时候如果两个人之间没停顿,这一整段会被当成"一个人说的一句话"。pyannote 能看出里面有两个人,但这两个人的时间段是前后相接而不是重叠的——那就该拆成两行,各归各人。加上这一步之后,转写质量肉眼可见地上升。


而真正的重叠,才是需要标「⚠ 同时说话」的情况。


三、第一个坑:重叠时长不能当精确值用

我一开始的想法很直接:pyannote 输出的时间段如果相交,那就是重叠,相交多少秒就是重叠多少秒。


写完之后不放心,做了个注入式标定——取两个不同说话人的真实片段,把第二段按固定的偏移叠到第一段上,重叠量完全可控:


真实注入重叠 系统测出

0 秒(留 0.3 秒空隙) 0.00s ✅

0 秒(前后紧接) 0.00s ✅

1.0 秒 0.24s ✅

1.5 秒 0.91s ✅

2.0 秒 1.54s ✅

方向对,单调,但系统性低估,而且重叠小于大约 1 秒时基本测不出来。


原因不复杂:pyannote 的滑动窗口是 10 秒、步长 1 秒,时间分辨率本来就在 1 秒这个量级。你让一个分辨率 1 秒的东西去量 0.8 秒的重叠,它给不出可信的数字。


中途还遇到一个更迷惑的现象:注入的重叠越多,测出的反而越少——2.5 秒注入测出 0.00。查下来是重叠太大时,混音片段的声纹特征变成了"混合体",聚类 把它判成了一个陌生说话人,于是两个人的段变成一个人的段,重叠自然消失了。


所以最后的结论是:这个判据只用来回答"有没有",不宣称精确时长。


界面和导出里都只标「同时说话」,不显示秒数。显示一个看起来精确的假数字,比不显示更糟——用户会拿它去做判断。


判定"有没有"用的是双条件,两个都成立才算:


分割出的不同片段,声纹互不相似(余弦 < 0.35)—— 排除"同一个人被切成两段"

这些片段在时间轴上真的交叠(> 0.2 秒)—— 只是前后相接不算

第二条是必须的。少了它,上面说的"顺序发言被误判成同时说话"就会一直发生。


四、第二个坑:那个不报错的 bug

这个坑值得单独说,因为它差点蒙混过关。


做到接口联调 17 项断言全绿的时候,我随手看了一眼导出的文本:


张三:是夜有神。

音频里说的是「这是一个测试说话人日志的音频」。全绿,但内容是乱码。


查下来是这一行:


if (/float/i.test(contentType)) { /* 按 Float32 解析 */ }

浏览器传上来的格式声明是 f32,不匹配 /float/i,于是 Float32 数据被当成 Int16 解析。采样点数量翻倍,每个采样点的值全错。


最坏的地方在于它不报错。 注册声纹时音频被这样搞坏,开会时的音频也被同样搞坏,垃圾和垃圾互相匹配,声纹照样"认出了人",接口照样返回 200,界面照样一条条往外蹦记录。整条流水线"正常工作"。


如果不是多看了一眼导出文字,这个 bug 会一直待在里面。而且它不是"偶尔出错",是每次都在错,只是错得很安静。


修法有三层:


格式判断改成白名单精确匹配,未知值直接抛错,不再猜

收到音频后先做一次能量检查,幅度异常(peak > 1.5)就拒绝,而不是继续往下跑

断言从"流程通"改成落到内容上——识别出的文字必须包含音频里真实说过的关键词,记录时长必须和音频时长对得上

第三条是关键。前两条是修代码,第三条是修测试。只断言"接口返回 200 了"、“有结果了”,这类 bug 永远抓不到。


五、第三个坑:未知人员会散成一堆编号

"没录声纹的人也要记全"这个需求,最直接的实现是:认不出就编个号。


第一版用的是 说话人A / B / C,簇心取该簇第一段音频的特征。


结果是:同一个人第 1 句被编成「说话人A」,第 2 句语气一变,特征掉出阈值,被编成「说话人B」,第 5 句又变成一个号。一个人散成三四个号,会后完全没法核对——你根本不知道哪些话是同一个人说的。


改成用历次特征的均值当簇心之后,编号就稳了。这个改动很小,但它决定了这个功能有没有用。


还加了个簇数上限(默认 12)。到顶之后不再新建编号,把边缘的人并进最像的那一簇。否则一场嘈杂的会议会冒出「未知人员37」,同样是没法核对的结果。


顺便把标签改成了 未知人员1、未知人员2、未知人员3…,编号从 1 开始,同一场会议内固定。


验证这件事,不能只断言"有结果"。 我用的办法是:拿空声纹库跑官方那个 4 人中文样本(56.9 秒),以 numClusters=4 的整段分割当时间真值,然后检查:


真值里 4 个人 → 系统是不是恰好分出 4 个编号(不多不少)

每个编号,是不是只对应真值里的同一个人(不混人)

记录 9 条发言:未知人员1(12s)、未知人员4(8.9s)、未知人员2(6.6s)、未知人员3(5.9s)

✅ 真值 4 个人 → 分出 4 个编号

✅ 每个编号只对应真值里的同一个人

✅ 没有任何一段真实语音被整段漏掉

同一个人的两段相隔 34 秒,仍然是同一个号。


六、第四个坑:静默丢段

还有一个更隐蔽的问题:ASR 有时候一个字都识别不出来。


原来的代码遇到空文字直接 return,整段丢掉。


这是最糟的失败方式。屏幕上什么都不显示,看起来就像"这段时间没人说话"。你根本不知道漏了什么,也就不会去补。


改成留一行占位,标 [未识别],时间点是准的。会后可以按时间回听原始录音补记。门槛设在 0.6 秒,避免噪声也占一行。


顺带一提,"话有没有记全"这件事也不该靠感觉。我拿整段 4 人分割当时间真值,量记录行对真值时间轴的覆盖率:


基准语音总时长 27.9s

被记录行盖住  27.5s → 98.5%

完全没被记录的语音段:无


导出里原本有一段 0:40–0:48 看着像空白,也是这么确认的——真值里那段时间本来就没有人说话,不是丢段。


七、会后补一步:改名 + 补录声纹

未知人员编号只是临时的。真正的闭环在会后:


在历史记录里点说话人姓名(或者标签上的 ✎),把它改成真实姓名,整场的这个标签一次全改掉,时间轴和文字原样不动,可以反复改。


改名时会问一句:要不要顺便把 TA 在会议里的发言录成声纹?


选"是"的话,系统会去归档的原始录音里,取这个人真正在说话的那几段(长片段优先,总量封顶 45 秒,段间补 0.5 秒静音)抽特征入库。下次开会就能自动认出 TA。


这里有四个顺序和边界上的细节,错一个就白干:


必须先按旧标签取音频,再改名。反了之后旧标签已经不存在,找不到人。

只对新姓名 enroll。已经录过的姓名,不能拿嘈杂的会场音频去覆盖干净档案。

段间要补静音。硬拼成连续音频的话,注册时的 VAD 切不开,会把几段当成一段。

片段要按人挑,只取这个人说话的时间段,不能拿整段会议音频充数。

八、现在的效果和它的边界

官方 4 人中文样本上的输出:


张三:这是一个测试说话人日志的音频。

张三:下面我们来播放一些测试音频。

说话人A:非常累的时候,就会站在阳台上吹吹风,看远处的灯火明明暗暗。

说话人B:和你们一起走过很多很多的路,坚持到底。

张三:我们下面播放一条雷军的测试音频。

声纹认人一致率 7/7 = 100%,4 人全部正确切出。


测试入口三个,全部只做计算,不碰麦克风 、不播放声音:


npm run selftest — 全链路,6 项内容级断言

npm run test-overlap — 重叠语音专项,18 项

npm run test-api — HTTP 全流程(临时端口,跑完即关),40 项

但有几点必须说清楚,用之前得知道:


两人同时说话时,文字会互相干扰。 这是物理层面的事——两个人的声音在麦克风里叠加,被掩蔽的字就是没了。实测 2 秒重叠时,甲说的「测试音频」被识别成「摄像」,乙说的「和你们」被识别成「以经」。系统会把各人片段单独重识别尽量补救,但仍会缺字串字。重叠处建议人工核对。


重叠比重很大时会退化成一个人。 整句话从头到尾两人混说,混音片段的声纹是"混合体",会被判成一个陌生说话人。这个目前没有好的解法。


麦克风距离影响很大。 每人单独近距离注册效果最好;用一只远场会议麦收全场,认人准确率会明显下降。


未知人员编号只在同一场会议内通用。 同一个人下一场会重新编号,因为系统确实不知道他是谁。散会后改一次名 + 补录,之后就能认出来。


九、怎么跑

npm install

npm run download-models  # 约 350MB,从 GitHub Releases 拉

npm start         # → http://127.0.0.1:8787

浏览器建议 Chrome / Edge(系统声音采集依赖"共享音频",Safari 不支持)。


全程本机推理,不联网,音频不出机器。


一点感想

这个项目里真正花时间的不是"把模型跑起来"——那部分反而是最顺的,选对框架之后半天就能出第一版结果。


花时间的是那些不报错的地方:


格式解析错了,流水线照样跑,只是结果全是垃圾

重叠时长低估,数字看着合理,但差了三四倍

未知人员散成多个编号,每条记录都"有说话人",只是对不上

ASR 空结果被丢掉,界面上"没有这一行",看起来像没人说话

这四个问题的共同点是:系统都说自己成功了。


所以我后来给自己定了个规矩:断言必须落到内容上。不是"接口返回 200",而是"导出的文本里必须出现音频里真实说过的那句话";不是"有说话人标签",而是"4 个人必须是 4 个编号,且每个编号只对应一个人"。


这类断言写起来麻烦一点,但它是唯一能抓住"安静地错"的东西。


技术栈:Node.js + sherpa-onnx-node(SenseVoice / Silero VAD / CAM++ / pyannote-segmentation-3.0),无 Python,无 Web 框架,无前端构建步骤。

————————————————

版权声明:本文为CSDN博主「weipt」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。

原文链接:https://blog.csdn.net/weipt/article/details/169341410


登录后可以点赞或点踩


评论 (0)

登录 后发表评论

暂无评论