豆包语音输入助手(doubao-voice-bridge)
把「切输入法、双击 Option」压缩成一个 Fn 键的 macOS 语音输入桥,从 Hammerspoon 脚本重写为原生菜单栏应用。
为什么做
我日常重度使用豆包输入法的语音输入。识别效果很好,但启动路径长:先把系统输入法切到豆包,双击 Option 开始说话;说完还要把输入法切回常用的那个。动作不复杂,烦在频次——每天几十次,每次都打断手上的节奏。
这个工具把整套动作压缩成一个键:按一下 Fn,切到豆包输入法并模拟双击左 Option,语音输入开始;再按一下,停止输入并恢复最近一次使用的非豆包输入源。工具常驻菜单栏,没有主窗口,全部日常交互就是那一个 Fn 键。
它是什么
第一版是 Hammerspoon(用 Lua 写 macOS 自动化的工具)脚本,很快跑通主流程。日用一段时间后决定用 Swift 重写为原生菜单栏应用:脚本方案的事件处理稳定性和启动依赖都不可控,一个打算长期常驻的工具,值得原生化。
两个不起眼但关键的行为参数:Fn 键按下时系统产生的是 keycode 179 的 keyDown 事件,要在事件流里把它识别为 Fn 本身;Fn 触发后延迟 0.2 秒再模拟 Option 双击——立刻双击也能启动语音输入,但识别准确度明显下降,推测是语音引擎尚未就绪。
技术要点与设计决策
重写过程踩了三类坑,都在 macOS 系统编程的深水区。
第一个坑:CGEventTap 回调被系统静默禁用。症状是闲置一段时间后第一次按 Fn 没反应,再按恢复正常,无崩溃无日志。根因是 tap 的保护机制——回调约 1 秒不返回,系统直接禁用这个 tap,且不给任何通知。我的回调里恰好有阻塞调用(用 TIS 接口跨进程读当前输入法),偶尔一慢就触线;闲置后更易复现,与 App Nap 降频有关。解法四条一起上:EventTap 挪到独立线程;watchdog 定时检查 tap 状态、发现被禁用就重建;进程禁用 App Nap;回调内不做任何阻塞调用,重活全部异步抛出。前三条降低超时概率,watchdog 兜底。
第二个坑:输入法「假切换」。部分 Electron 应用里,TIS 切换输入法后菜单栏图标变了,实际生效的还是旧输入法。解法是对前台应用做一次焦点刷新,但刷新有可见闪动,所以做成配置白名单,只对已知有问题的应用启用。这又牵出「怎么可靠判断前台应用」:NSWorkspace.frontmostApplication 在 Raycast 这类浮窗弹出时会误判,最终用三层顺序——AX 焦点应用 → 最上层可见窗口反查 → frontmostApplication 兜底。
第三个坑:模拟修饰键的状态残留。合成 Option 双击要用 .hidSystemState 事件源,让合成事件的修饰键状态与系统实际状态一致;释放时只移除对应的 flag 位,图省事清零全部 flags 会把用户真实按着的其他修饰键一起清掉;流程结束后检查残留位,有修饰键停在按下状态就补发抬起事件。这一节还有一个「不修」的结论:录屏软件的按键可视化一直显示合成的 Option 处于按住状态,逐项核对后确认事件收发正确,是录屏软件对合成事件的显示偏差——把真 bug 和观测工具的假象区分开,也是排障的一部分。
回头看,真正花功夫的部分几乎都与功能无关:独立线程、watchdog、状态残留检查、白名单降级。这类常驻工具的验收标准是「几个月无感常驻」,任何一次「按了没反应」都在消耗信任。
当前状态
日常常驻使用中,仓库未公开。三个坑的完整排障记录写成了博客文章。