Windows 桌面端的语音唤醒:从「识别出文字」到「识别出唤醒词」

Windows 桌面端的语音唤醒:从「识别出文字」到「识别出唤醒词」

一次从 Vosk ASR 迁移到 sherpa-onnx KWS 的实践复盘。包含踩过的坑、实测数据,以及最后仍然没解决的问题。

做 Windows 桌面语音助手的时候,语音唤醒是最先撞上的那堵墙。理想状态下,用户对着屏幕说一句话,应用就醒了。但「醒来」这一步该怎么实现,决定了后面整条链路的形态。

我们这个项目的形态是:Electron 桌面端 + 数字人形象,用户不用手点麦克风,说一句唤醒词就能开始对话。这篇文章复盘我们换过一次唤醒方案的过程 ------ 不是因为旧方案不能用,而是因为它的代价结构是错的。


一、之前用的方案:Vosk + Grammar 模式

最早我们用的是 Vosk。思路很直接,也很容易想到:

Vosk 是一个流式 ASR 引擎,能把麦克风音频实时转成文字。那唤醒词检测不就等于「看识别出来的文字里有没有我的唤醒词」?

具体做法是启用 Vosk 的 Grammar 模式 (有限词表识别)。它的作用是把解码范围限制在一个指定的词表内。我们把用户设置的唤醒词拆成单字,加上一个特殊的 [unk] token 作为词表:

复制代码

这样识别器就只会吐出这几个字,不会去猜别的东西。然后在渲染侧订阅 partial-result 事件,拿识别文本做字符串包含匹配:

  • 完整包含 → 唤醒
  • 反向包含(识别到的是唤醒词的一部分,且长度占比 ≥ 75%)→ 在「高灵敏度」档位下也唤醒

唤醒之后调 resetVosk() 清空识别缓冲区,重新开始计静音时间,静音 1.5 秒认为用户说完了,把这一段送去后端识别。

这套东西是能跑的。问题是它跑起来的代价,几乎全都落在了错误的那一边。

缺陷 1:体积是数量级的差别

我们最初打包的是 vosk-model-cn-0.22 完整模型,带 RNNLM 重打分。这一套下来是 2044 MiB ------ 一个桌面应用的安装包里塞了两个多 G 的纯语音模型。

后来实在扛不住,砍成了 vosk-model-small-cn-0.22。模型本身降到 60 MiB 左右,但加上原生依赖 ------ libvosk.dll(25 MiB)、libstdc++-6.dll(25 MiB)、libvosk.lib(15 MiB)、libwinpthread-1.dll ------ 整套仍然要 132 MiB。而且这些 DLL 是通过 Koffi 这个 FFI 库从 Node 侧调用的,跨平台打包要单独处理一堆原生依赖的坑。

缺陷 2:误唤醒是架构性的,不是调参能解决的

Grammar 模式挡不住「别人说话」。原理很简单:词表里虽然只有唤醒词那几个字,但任何中文语音都可能被解码成这几个字里的某个组合。旁边有人聊「小爱同学的新品今天到货了」,只要解码出的小字序列凑巧包含「小爱同」三个字,就唤醒了。

我们靠「子串匹配」这种文本层面的启发式去补救,本质上是在拿字符串比较冒充声学判断 ------ 越想提高灵敏度,误唤醒越多;越想压住误唤醒,越要牺牲召回。

实测下来这套方案很难同时满足两个要求:该醒的时候醒、不该醒的时候不醒。

缺陷 3:灵敏度档位其实是「文本匹配策略」

上面提到的子串匹配只在 high 档启用。也就是说我们对外宣称的三档灵敏度,实际改的是文本匹配算法的宽容度,不是声学层面的置信度门槛。

这带来一个很别扭的后果:用户调到「最灵敏」,得到的其实是「最容易把旁人说话当成唤醒词」。语义和直觉是反的。

缺陷 4:缺少 VAD,「说完没有」只能靠猜

Grammar ASR 方案里没有语音活动检测。「用户说完了」这个判断是靠「多久没有新的 partial-result 了」来近似。

问题在于 ASR 的出词节奏和说话节奏并不是一回事 ------ 停顿、思考、词语间的间隙都会让 partial-result 停下来。真实场景里这句话经常被切成两半,或者反过来,把下一句无关的话也吞进来。

缺陷 5:唤醒词本身听不见

还有一个隐蔽但影响很大的问题:唤醒词是在 resetVosk() 之前说完的。识别器把整段音频都送去解码了,唤醒词本身也参与了识别;但我们一旦唤醒就立刻清空缓冲区、开始录音,唤醒词的发音通常落在录音起点之外或者被削掉一截。

结果是用户说「小爱同学,帮我查一下明天的天气」,送进后端的话里,「小爱同学」这四个字是缺的。

这几个缺陷加在一起,让我意识到一件事:用「通用语音识别」去做「唤醒」这件事,方向就是拧的。 唤醒需要的是一个声学层面的、只对特定词组敏感的判别器;而 ASR 提供的是一个「把所有话都转成文字」的通用能力,我们只能在这个通用输出上做字符串匹配 ------ 中间隔着一层,损失的全都在这一层里。


二、为什么换成 sherpa-onnx

换成 sherpa-onnx 之后,问题的性质变了。

sherpa-onnx 提供了两个正好对应我们需求的组件:

  • KeywordSpotter(KWS) :专用的关键词检测模型。输入音频,输出「命中了哪个关键词」或「没命中」。它不做完整转写,只判断特定 token 序列是否出现。
  • Silero VAD:语音活动检测。判断「当前这段音频里有没有人在说话」,以及「这一段话说完了没有」。

这两件事拼起来,正好就是我们需要的唤醒语义:

复制代码

关键的变化是:唤醒判定从「文本包含」变成了「声学匹配」。KWS 要求 token 序列精确匹配,旁人说话不会命中。这不是靠调参调出来的,是模型结构决定的。

现在的架构

复制代码

采集只有一路。唤醒流和录音缓冲共用同一个 AudioContext 和同一个 worklet 节点 ------ 之前为了省事给两者各建一个,等于把同一份麦克风信号降混两遍。

模型选型

项 选择
KWS 模型 sherpa-onnx-kws-zipformer-zh-en-3M-2025-12-20,chunk-16,fp32 全套
VAD 模型 snakers4 官方 Silero VAD v6.2.1(16k,windowSize=512)
KWS 阈值(灵敏度) low 0.30 / medium 0.20 / high 0.10,越小越灵敏
VAD 阈值 0.6
唤醒后等待语音 5 秒超时
唤醒宽限期 400 ms(丢弃唤醒词尾音)

唤醒词在运行时用 pinyin-pro 转成拼音 token 序列,写进 userData/wake-keywords.txt:

复制代码

KWS 模型是 3M 参数的 zipformer。体积上,encoder 11.4 MiB + decoder 0.7 MiB + joiner 0.3 MiB,加 VAD 模型与词典文件,整个 models/ 目录 17.9 MiB。相对 Vosk 的 132 MiB(更别提最初的 2044 MiB),这是两个数量级的差别 ------ 而且这次是「模型本身就小」,不是「砍功能砍小」的。

fp32 还是 int8?我们两个都试了:官方验证过的三条中文音频叠加白噪声和 babble,共 78 次试验,int8 命中 73.1%、fp32 78.2%。每格分母只有 15,差异统计上不显著,只能读作「方向上不劣于 int8」。体积成本是 0(两套模型本来都在仓库里,改路径不影响安装包),延迟也不构成理由(RTF 0.0048 vs 0.0054),所以直接选了 fp32。

现在方案的优点

1. 误唤醒从「架构问题」变成「可以不管的问题」

我们把 KWS 阈值从 0.15 一路扫到 0.50,命中始终是 3/3,误唤醒始终是 0 ------ 只注册中文唤醒词,然后喂六条他人的说话语音、白噪声和 babble 混音,一次都没误触发。

这个结论一开始让我挺意外的。我原本以为调阈值是必做功课,做完才明白:KWS 要求 token 序列精确匹配,旁人说话本来就碰不到。真正会把旁人说话当成输入的不是 KWS,是 VAD ------ 这个后面单独说。

所以现在灵敏度档位的语义终于正常了:high 就是「更容易被唤醒」,low 就是「更钝、更省心」。降阈值主要收益在弱口令、远场命中率,别指望它换来「更抗误唤醒」。

2. 「说完没有」有了确定判据

Silero VAD 直接给「这段语音结束了」。不需要再用「多久没出新词」去猜,也就不会把一句话切成两半。

3. 有了「用户到底说过话没有」的判据

这是意外收获。手动录音时,如果没有 VAD,我们就无法区分「用户说了一句话」和「用户点错了麦克风然后沉默了」。以前只能发一个空文件上去让后端报错;现在 VAD 会明确告诉我们「没检测到语音」,前端可以给一句准确的提示。

4. 唤醒词本身可以被完整保留

我们加了一个「预卷」机制:录音缓冲是一个环形窗口,持续保存最近 N 毫秒的音频(默认 2000 ms,可配置到 10000 ms)。唤醒链路的录音一开始,就把窗口里的预卷音频接到文件头部 ------ 唤醒词就完整回来了。

用户还能自己调这个时长:唤醒词被切掉开头就调大,不想要环境音就设为 0。

5. 崩溃能被感知

音频从渲染进程到 worker 是单向的 IPC 通道(ipcMain.on,无返回值、无从感知失败)。所以 worker 死了,唤醒能力会静默 死亡。现在主进程监听了 worker 的 error / exit,非预期退出时广播 sherpa-worker-lost,渲染侧收到后明确提示用户重新进入页面。


三、现在的方案有哪些坑

这一节可能是整篇文章最有用的部分。

坑 1:VAD 治不了旁人的说话声

我们最怕的失败模式是「环境噪音被判成语音」。换 Silero v6.2.1 确实改善了这类场景(官方数据 ESC-50 从 v4 的 0.51 提到 0.87)。

但换模型治不了核心风险:展馆里最大的干扰是旁人的说话声,那是真语音。任何 VAD 都会(正确地)判成语音。v6 的 87% 对此完全无效。

具体症状有两个:

  • 手动录音链路里 speechDetected 恒为真 ------ 用户一言不发,环境里别人说话,程序照样认为「检测到语音」,把环境音当指令发出去
  • 唤醒后进入 awaiting-speech,旁边人一开口就触发 speech-start,随后的 speech-end 把旁人那段话当指令发了出去

这个只能靠架构补:KWS 唤醒词已经是意图入口了,再加一层 ASR 文本过滤或声纹校验(sherpa-onnx 自带 speaker-recognition 模型)。这是一块需要单独立项的工作,目前还没做。

坑 2:ONNX Runtime 的默认线程策略在「小模型 + 高频调用」下是反效果

这个坑排查了很久。

现象:在 Arrow Lake 265K 上,语音唤醒空闲时 CPU 占用能到 2.9 核;但同一份代码在 9800X3D 上完全正常。

一开始怀疑是模型量化的差异,但两台机器的日志比对显示 chunks / 样本数 / 解码次数 完全一致 ------ 也就是说输入量和解码次数一模一样,不是工作量差异。钉 P 核 affinity 也没用。

真正的元凶是 ONNX Runtime 的 intra-op 线程池。我们的解码频率只有每秒 3 次左右,每次的计算量很小,线程池在两次解码之间持续自旋等待工作,而不是睡下去。单次计算量小 + 调用极密集 = 线程池空转。

解法是把 numThreads 从 2 降到 1:intra_op_num_threads=1 时 ORT 干脆不建线程池,在调用线程内联执行,自旋的载体就没有了。RTF 从 0.005 变成约 0.01 ------ 仍有百倍实时余量,调大没有收益还会和主进程抢 CPU。

教训:推理框架的默认线程策略不是为「小模型高频调用」设计的,这种场景下要先想一遍线程池的收益模型。

坑 3:宽限期不能按单点数据调

KWS 有 lookahead 延迟,唤醒词命中时,它的尾音还留在音频流里。如果立刻把这部分喂给 VAD,尾音会被判成「用户开始说话了」,指令的开头就被吃掉了。所以唤醒后要丢 400 ms。

但这个 400 ms 的端到端测量是非单调的 ------ 实测 150 ms 甚至比 0 ms 更快。原因是 VAD 的 512 样本窗口对齐带来了 ±0.2 s 的抖动,所以端到端延迟读出来是抖的,只能读趋势不能读单点。

结论是保留 400 ms,要动的话往 200 ms 减是安全方向,但别指望某个点上能测出「快了 100 ms」。

坑 4:唤醒被拒绝时,5 秒后的报错会掩盖真实原因

唤醒词命中之后,渲染侧要判断「现在能不能录音」------ 数字人连上了吗?附件就绪了吗?

如果这时候拒绝唤醒,worker 已经切到 awaiting-speech 状态了,接下来 5 秒里 KWS 完全不响应。用户马上再喊一次,会得到「没反应」。

更糟的是 5 秒后 worker 会发 speech-timeout,渲染侧弹一条「唤醒后未检测到语音输入」------ 把「数字人没连上」说成了「你没说话」。用户会很困惑:明明我刚说了话。

所以拒绝唤醒时必须做两件事:给真实 的原因提示(而不是写死一句「未连接」------ 附件还在上传时,用户该做的动作完全不同),以及让 worker 立刻回 idle。

坑 5:命令发出 ≠ 服务可用

手动录音链路里,渲染侧发 start-manual-vad 给 worker,然后等 manual-vad-ready 回执。

一开始我们是 postMessage 之后直接 resolve ------ 结果 VAD 实际不可用时,录音照跑、speech-start 永不到来,最后用户看到的是「未检测到语音输入」,完全看不出真正的原因。

现在改成:invoke 挂在 worker 的 ready 回执上(5 秒超时兜底),收到 ready 才置位 。worker 侧还要保证 VAD 可用 ------ 用户从没配过唤醒词时 worker 收不到 start 命令、VAD 是空的,所以要现建,否则手动录音每次都失败。

还有一条相关纪律:不要把「唤醒词专属的错误」和「worker 通用错误」混在同一个错误频道。我们专门拆开了 ------ 排查「模型路径没配」的时候,人总是会先怀疑错的那个。

坑 6:一条录音会被多条路径收尾

录音的终止路径有五条:手动停止、取消、60 秒自动停止、设备切换、对话切换、组件卸载、还有唤醒链路的自然结束。

它们会重叠。连点麦克风、60 秒自动停止和手动点击撞车、设备切换和 stop 同帧、取消和结束几乎同时按下、组件卸载和 speech-end 同帧 ------ 每一条都是真实发生过的。

如果每条路径各自抄一份收尾清单,漏项是必然的。我们踩过的具体 bug:

  • dispose 漏了清 isAwake → 之后点麦克风静默早退,且 5 秒后的 speech-timeout 绕过守卫弹出一条假错误
  • 「已经成功发送」之后又弹一条「未检测到语音输入」------ 因为 worker 在手动结束后还会补发一次 speech-end
  • 设备切换时 UI 计时器一直走 ------ 丢弃录音时忘了发「录音结束」通知(设备切换不卸载组件,卸载时的清理兜不住它)

最终的解法是两个机制配合:

  • 单飞 :stop() 和 flush() 都把并发调用收敛到同一个在途 Promise。不然第二个调用会看到「已经在录了」这个标志已是 false,直接绕过所有守卫弹错误
  • 代际号:每次开始或终止会话都自增号,只有最后认领者能处理结果,先认领者静默退场

半边都不行:只做单飞 → 两条路径各发一次文件;只做归属 → 第二条拿不到文件而误报。

坑 7:KWS 会话重建需要幂等和代际号

渲染侧有多个入口会先后触发 KWS 启动:进入 human 视图、保存设置、唤醒词变化、灵敏度变化。

worker 每次收到 start 都会重建一次 ONNX 会话。缺少去重的话,就会接连构建两三次,前一次的结果立刻被丢弃 ------ 白白浪费初始化时间。

还有一个更隐蔽的时序变体:stop 插在在途 start 的 await 期间时,worker 会按序先 start 后 stop(会话已经拆了),但 ready 随后才到,applyKeywords 会把「当前生效配置」又写回去 ------ 于是下一轮「配置相同」被误判为「不需要重建」,永远不再监听。

解法是给 stop / close 加一个自增的代际号,applyKeywords 在 await 之后校验代际没变才落值。

坑 8:官方测试集里有陷阱

test_wavs/ 目录里有 zh_0 ~ zh_6 七个中文音频,但官方验证过的只有 zh_3 / zh_4 / zh_5。

其余三条跟这份 keywords.txt 根本不匹配 ------ 把阈值降到 0.05 也检不出来。

评估唤醒率必须只用官方验证过的音频,否则会把测试集问题误判成模型问题,然后开始调模型、调阈值、调一切 ------ 方向从第一步就错了。

坑 9:窗口不可见时要不要继续听

我们最终决定:页面不可见(最小化、隐藏、被完全遮挡)时,不响应语音唤醒。

判据用的是 document.visibilityState 而不是「失焦」------ 后者会连带停掉「可见但没焦点」的情况(双屏、点了别的窗口但本窗口还露着)。Windows 上 Chromium 的原生窗口遮挡检测会落成 hidden,macOS 原生也反映遮挡。

而且停止喂音频的条件要加上「当前没有会话在进行」------ 唤醒词只可能在 worker 空闲期命中,所以这条规则等价于「不响应语音唤醒」,又不会掐断进行中的唤醒会话和手动录音。


四、几点体会

回过头看,这次迁移最大的收获不是「误唤醒变少了」,而是唤醒判定的层次变了。

用通用 ASR 做唤醒,我们被迫在「识别出的文本」这一层上做文章 ------ 所有的灵敏度、误判、截断问题都出在这一层,因为这一层离「声音到底是什么」太远了。换成专用 KWS 之后,判定回到了声学这一层,很多问题就不存在了。

另一个体会是:换模型不改变问题,只改变问题的位置。 从 k2-fsa 版的 Silero v4(629 KB)换成官方 v6.2.1(2.2 MB),噪音拒识的指标跨了两代;但换完之后,「旁人说话」这个最大干扰源照样存在 ------ 因为它压根不是噪音问题,是「真语音被当成了我们的输入」。识别出问题的本质,比识别出问题的症状重要。

最后是一些看起来琐碎但很影响体感的细节:唤醒词本身要能被完整保留(预卷机制)、没说话要有明确提示而不是发个空文件上去、worker 死了要能被感知而不是静默失效、崩溃后要能自己恢复。这些都不是技术难题,但用户感知到的「这个产品靠不靠谱」,往往就是由这些决定的。


附:几个可以参考的数字

指标 Vosk + Grammar sherpa-onnx KWS + VAD
语音相关模型 + 原生依赖体积 132 MiB(最初 2044 MiB) 17.9 MiB
唤醒判定依据 识别文本的字符串匹配 声学 token 序列精确匹配
误唤醒 结构性存在,靠启发式缓解 阈值 0.15~0.50 全程 0 次
灵敏度档位语义 文本匹配策略(与直觉相反) 声学阈值(high = 更容易醒)
「说完了」判据 靠 ASR 出词节奏近似 VAD 语音段结束
「用户说过话」判据 无 VAD isDetected()
唤醒词完整性 录音起点处常被切掉 预卷环形缓冲完整保留
唤醒后语音超时 2.5 s(计时器) 5 s(worker 状态机)

文中数据均来自项目实际提交记录与本机实测(Windows 11 + Electron 41 + sherpa-onnx-node 1.13.4)。

相关推荐
Suprman1 天前
从 B/S 到 Electron:平面排版套料系统桌面版开发实战
javascript·平面·electron·排序算法
不可能片场3 天前
工程实践 一个缓存把选片从十分钟压到一分钟
前端·electron
lemon_sjdk4 天前
从一段真实的 Electron IM 前端代码,看高级前端的工程化修养
前端·electron·状态模式·im
优选资源分享5 天前
PicGo v3.0.3 新版 | 开源免费图床工具
javascript·笔记·electron
不可能片场5 天前
Electron 发布核验 别把没查到当成没发出去
前端·electron
flash俊杰5 天前
桌宠点击反应:把一坨 if 拆成「归一化 → 门控查表 → 执行 → 广播」
electron
日耗10亿Token被老板赶出门7 天前
REA 火了:一个 Electron 按钮的调用链,它能追到哪一步?
javascript·electron
一铭111997 天前
海豹办公 1.9.15 跨端办公软件实践 文档兼容 Access 数据库与移动 WebView 排错
android·数据库·electron·开源软件·access
不可能片场7 天前
工程实践 原子替换改了 ctime 怎么修
前端·electron