开会最烦的不是开会,是会后整理纪要。
尤其是那种五六个人轮流发言、中间还插话的会。录音在手,但你得反复拖动进度条,靠记忆判断"这句是谁说的"。市面上有些转写服务能做到,但要把整场会议的音频传到别人服务器上——这事在很多公司直接过不了合规。
所以我花了几天自己写了一个,跑在本机,不联网。要求很朴素:
提前录好每个人的声纹,开会时自动出「张三:说的话……」
有人同时说话,能标出来
临时来了个没录声纹的人,他的话也不能丢
最后一条比听起来重要得多。它是这个项目里改动最多的地方,也是踩坑最集中的地方。
一、先解决选型: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