最近豆包输入法的语音输入很火。

我是在开放后下载下来用了一下,确实很好用,对比之前用的 type less 对我而言有点修饰过头,微信输入法整体效果还行但是略逊于豆包。我设置的快捷键是 Command + Option ,按住说话,松开就上屏,体验很顺。

但是我现在主力打字输入法还是微信输入法。

原因也很简单,它有多端剪切板同步。

虽然这个同步率我感觉也就 90% 左右吧,不是每次都稳,但是有总比没有强。用久了之后就有点回不去了。手机上复制点什么,电脑这边能接着用,虽然偶尔抽风,但是整体还是方便的。

所以问题就来了。

我不想把主力输入法换成豆包输入法,但是我又想用豆包的语音输入。我想偷个懒。

我想到我的流程是,平时打字继续用微信输入法,需要语音的时候,按住 Command + Option 临时切到豆包语音,松开之后再回微信输入法。

那么理论可以全自动做这件事情。我把这个需求丢给了 macbook 上跑着的 Hermes,我坚信它以及它背后的 GPT-5.5 能做到。

最终思路

最后 Hermes 先搓了一个后台 agent,后来又把它收成了一个顶栏 mini app:

1
doubao-voice-wetype-agent

它干的事情不是破解豆包,也不是改输入法,而是在中间当一个代理。

更准确地说,它现在像一个很小的插件,常驻在 macOS 顶栏,没有 Dock 图标。平时显示 豆 OK,权限或者监听有问题的时候显示 豆 !,按住语音的时候会变成 豆 REC

这个小状态栏后来证明很重要。

因为这种系统级快捷键代理如果没有可视状态,真的太黑盒了。你不知道是权限没拿到,还是 event tap 被系统关了,还是输入法切过去了但豆包没接住。现在点开顶栏菜单,至少能看到权限、当前输入法、最近一次事件和监听状态。

大概流程是这样:

[text] 显示已折叠代码(33 行)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
当前输入法是微信输入法
  |
  | 按下 Command + Option
  v
agent 捕获到这个组合键
  |
  v
检查当前输入法不是豆包
  |
  v
切换到豆包输入法
  |
  v
重新发送一组干净的 Command + Option keyDown
  |
  v
豆包开始语音输入
  |
  | 我继续按住,说话
  v
松开 Command + Option
  |
  v
agent 捕获释放事件
  |
  v
重新发送 Command + Option keyUp
  |
  v
豆包结束语音输入
  |
  v
切回微信输入法

一句话就是:

我按的还是同一组快捷键,但是中间由 agent 临时切输入法,然后把快捷键事件补发给豆包。

这个设计听起来绕,但其实就是为了保证豆包收到完整的 keyDown -> keyUp

为什么不能只靠 macOS 切输入法

macOS 本来就能切输入法。

但是这个场景的问题在于,豆包必须在 keyDown 发生时就已经是当前输入法。

如果流程是:

1
2
我按下 Command + Option
  -> 系统切到豆包输入法

那对豆包来说,很可能已经错过了最关键的 keyDown

所以就会出现一种很烦的情况:

看起来输入法已经切到豆包了,但是豆包语音没有被唤起。

这不是因为豆包不能用,也不是快捷键错了,而是事件顺序不对。

豆包是在 keyDown 之后才被激活的,它没收到完整的按下过程。

所以必须重放事件:

1
2
3
真实 Command + Option 被 agent 捕获
agent 切到豆包
agent 再合成一组新的 Command + Option keyDown 给豆包

松开的时候同理,也要把 keyUp 给豆包补上。

但是还不够稳

后来实际用的时候又发现一个问题。

有时候按下 Command + Option 之后,输入法确实切到豆包了,但是豆包语音没有被唤起来。

这个就很烦。

大概率原因是这样:

1
2
3
4
5
系统已经开始切到豆包
  -> 但是豆包自己的 ASR shortcut monitor 还没完全 ready
  -> agent 太快发了 Command + Option down
  -> 豆包没接住这次 keyDown
  -> 最后表现就是只切过去了,但没唤起语音

也就是说,之前的逻辑虽然保证了事件顺序,但还没有保证豆包真的准备好了。

原来代码里是固定睡一会儿:

1
2
3
selectInput(doubao)
sleep 120ms
post Command + Option down

这个写法比较赌。

机器状态好、系统切得快的时候没问题。某一瞬间慢一点,就翻车。

所以我把源码又补了几层保护。

现在切到豆包后不再盲等固定 120ms,而是先轮询确认当前输入法真的已经变成豆包:

1
2
3
4
selectInput(doubao)
waitForInput(doubao, max 600ms)
settle 260ms
post Command + Option down

waitForInput 做的事情也很简单,每 20ms 检查一次:

1
currentInputID() == "com.bytedance.inputmethod.doubaoime.pinyin"

确认切过去了,再额外等 260ms,给豆包一点 settle 的时间。

这就比固定睡眠稳定很多。

另外还加了一个 syntheticDownPosted

这个是为了防一种边界情况:

1
2
3
我按得很快
  -> agent 还没成功发 synthetic Command + Option down
  -> 我就已经松手了

如果这时候照旧发 synthetic up,就可能把豆包的状态机搞乱。

所以现在只有确认成功发过 synthetic down,松开时才补 synthetic up。没发过 down,就不乱发 up。

恢复微信输入法也稍微稳了一点。

松开后的流程变成:

1
2
3
post Command + Option up
wait 150ms
selectAndSettleInput(wetype, 60ms)

总之就是别急。

这种跟输入法、系统事件、第三方快捷键监听混在一起的东西,最怕的不是逻辑不对,而是太快。你以为已经切过去了,实际上对面还没准备好。

后来又补了一个顶栏状态

还有一个后来才意识到的问题。

稳定性之外,可观测性也很重要。

一开始它只是一个 LaunchAgent 拉起来的后台进程。理论上能跑,但是一旦它在 Obsidian 里唤不起来,我完全不知道发生了什么。

权限没了?

event tap 没创建成功?

还是豆包没接住合成的 keyDown?

不知道。

这种感觉很烦,因为你面对的不是一个能报错的普通 App,而是一堆 macOS 权限、输入法、事件监听混在一起的东西。

所以最后又给它加了一个极小的顶栏 mini app。

现在顶栏会显示几个状态:

1
2
3
4
豆 OK  -> 权限和监听都正常
豆 !   -> 权限不足、监听失败,或者 event tap 异常
豆 REC -> 正在托管 Cmd+Option,理论上豆包正在听
豆 ... -> 启动或者切换中

点开之后还能看到:

1
2
3
4
5
辅助功能权限
输入监控权限
当前输入法
event tap 是否启用
最近一次触发事件

它还会写一个状态文件:

1
cat ~/.hermes/hermes-agent/doubao-voice-wetype-status.json

现在跑通后的状态大概是这样:

1
2
3
4
5
6
7
8
{
  "accessibilityOK": true,
  "inputMonitoringOK": true,
  "eventTapReady": true,
  "currentInputName": "微信输入法",
  "mode": "监听中",
  "lastEvent": "已恢复微信输入法"
}

这个体验就好多了。

至少它坏的时候,我不用再靠玄学猜。

切输入法:TIS API

macOS 输入法不是靠菜单栏上的名字切换的,而是靠输入源 ID。

这次用到的两个 ID 是:

1
2
3
4
5
微信输入法:
com.tencent.inputmethod.wetype.pinyin

豆包输入法:
com.bytedance.inputmethod.doubaoime.pinyin

这些 ID 可以从输入法 App 的 Info.plist 里找到:

1
2
/Library/Input Methods/WeType.app/Contents/Info.plist
/Library/Input Methods/DoubaoIme.app/Contents/Info.plist

里面会有类似这样的字段:

1
2
3
ComponentInputModeDict
  tsInputModeListKey
    TISInputSourceID

Hermes 写了一个 Swift 小工具:

1
~/.local/bin/im-switch

用的是 macOS Carbon / TIS API。

它支持查看当前输入法:

1
~/.local/bin/im-switch --current

切到微信输入法:

1
~/.local/bin/im-switch com.tencent.inputmethod.wetype.pinyin

切到豆包输入法:

1
~/.local/bin/im-switch com.bytedance.inputmethod.doubaoime.pinyin

这个比 UI 自动化靠谱多了。

不依赖菜单栏,不用模拟鼠标点击,也不会抢焦点。

监听和补发键盘事件:CGEvent

最开始还试过 AppleScript:

1
2
3
4
tell application "System Events"
  key down option
  key up option
end tell

但是这玩意儿不太稳,容易卡在权限弹窗或者 System Events 上。

最后换成 Swift + Quartz,也就是 CGEvent

agent 创建一个 event tap,监听修饰键变化:

1
2
3
4
5
6
CGEvent.tapCreate(
    tap: .cgSessionEventTap,
    place: .headInsertEventTap,
    options: .defaultTap,
    eventsOfInterest: flagsChanged
)

它监听的不是普通字符键,而是这些修饰键状态:

1
2
3
4
5
Command
Option
Control
Shift
Fn

当 flags 变成 Command + Option,就认为我开始按住语音快捷键。

当 flags 从 Command + Option 变成不是这个组合,就认为我松开了。

然后再用 CGEvent 合成按下和松开的事件:

1
2
CGEvent(keyboardEventSource: nil, virtualKey: keyCode, keyDown: true)
event.post(tap: .cghidEventTap)

这样豆包收到的是一组真实系统层面的键盘事件,而不是 AppleScript 那种隔了一层的东西。

防止 agent 自己触发自己

这里还有个很容易翻车的点。

agent 自己会合成 Command + Option 事件。

如果它监听到自己发出去的事件,然后又处理一遍,那就套娃了:

1
2
3
4
5
6
我按下 Command + Option
  -> agent 捕获
  -> agent 合成 Command + Option
  -> agent 又捕获自己合成的 Command + Option
  -> 再触发一次
  -> 状态乱套

所以合成事件时会打一个 marker:

1
event.setIntegerValueField(.eventSourceUserData, value: marker)

监听时如果看到这个 marker,就知道这是 agent 自己发出去的事件,直接跳过。

这个还挺关键的。

不然这种键盘事件代理很容易变成自己打自己。

当前已经是豆包时,不接管

还有一个坑。

如果当前输入法已经是豆包输入法,那豆包本来就能收到我真实按下的 Command + Option

这时候 agent 就不应该再插手。

否则会多发一组 down/up,豆包自己的状态机可能就乱了。

所以最终规则是:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
let cur = currentInputID()

if cur == doubaoID {
    // 当前已经是豆包输入法
    // 不接管,让豆包自己处理原始快捷键
    passThrough()
} else {
    // 当前不是豆包输入法
    // agent 接管:切豆包,重放快捷键
}

这条规则非常重要。

只有从微信输入法或者其他输入法进入豆包语音时,agent 才介入。

如果已经在豆包输入法,就完全放行。

最终状态机

整理一下,最终状态机大概是这样:

[text] 显示已折叠代码(33 行)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
Idle
 |
 | 用户按下 Command + Option
 v
Check current input method
 |
 |-- 当前是豆包 --> PassThrough
 |
 |-- 当前不是豆包
        |
        v
    ManagingHold
        |
        | 切换到豆包
        | 确认当前输入法已经是豆包
        | 额外 settle 一下
        | 合成 Command + Option keyDown
        |
        v
    WaitingForRelease
        |
        | 用户松开 Command + Option
        v
    如果已经发过 synthetic down
        |
        v
    合成 Command + Option keyUp
        |
        v
    等一下,再切回微信输入法并 settle
        |
        v
    Idle

再写得土一点:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
按下时:
  切豆包
  确认豆包已经切过去
  稍微等它 ready
  发送 Command + Option down

松开时:
  如果前面真的发过 down,就发送 Command + Option up
  等一下
  切回微信并确认稳定

可以,清晰很多。

文件备忘

我把脱敏后的源码也整理成了一个公开仓库:Coco422/doubao-voice-wetype-agent

最后留下来的文件主要是这些:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
~/.local/bin/doubao-voice-wetype-agent
~/.hermes/scripts/doubao-voice-wetype-agent.swift
~/.hermes/scripts/doubao-voice-wetype-core.swift
~/.hermes/scripts/doubao-voice-wetype-app.swift
~/.hermes/scripts/doubao-voice-wetype-events.swift

~/.local/bin/im-switch
~/.hermes/scripts/im-switch.swift

~/Library/LaunchAgents/com.ray.doubao-voice-wetype-agent.plist
~/.hermes/hermes-agent/doubao-voice-wetype-status.json

doubao-voice-wetype-agent 是顶栏 mini app 加监听器。

im-switch 是输入法切换工具。

LaunchAgent 用来登录后自动启动:

1
~/Library/LaunchAgents/com.ray.doubao-voice-wetype-agent.plist

这样不用每次手动开。

权限问题

这个方案需要 macOS 给权限。

路径是:

1
系统设置 -> 隐私与安全性

需要给这个二进制授权:

1
/Users/ray/.local/bin/doubao-voice-wetype-agent

通常需要:

1
2
辅助功能 / Accessibility
输入监控 / Input Monitoring

如果没给权限,日志里会看到:

1
failed to create event tap; grant Accessibility/Input Monitoring permission

这个不是代码逻辑错,是 macOS 不让这个进程监听键盘事件。

新版会更直观一点,权限不对的时候顶栏直接显示 豆 !

状态文件里也能看到:

1
2
3
4
5
6
{
  "mode": "需要权限",
  "accessibilityOK": false,
  "inputMonitoringOK": false,
  "eventTapReady": false
}

所以现在排查路径很简单。

先看顶栏,再看状态文件,最后才去翻日志。

最后跑通的日志

最终日志是这样的:

1
2
3
4
5
6
7
event tap installed
agent mini app started pid=57107
physical cmd+option down, current=com.tencent.inputmethod.wetype.pinyin
posted cmd+option down
physical cmd+option released; managing=true
posted cmd+option up
restored wetype

这说明完整链路已经通了:

1
2
3
4
5
6
微信输入法状态下按下 Command + Option
  -> agent 接管
  -> 切到豆包
  -> 给豆包发送按下事件
  -> 松开时给豆包发送释放事件
  -> 恢复微信输入法

这东西最终解决的其实是一个很小的需求。

我就是不想切输入法。

微信输入法继续负责日常打字和剪贴板同步,豆包输入法只在我按住快捷键的时候出来负责语音。

这个小需求绕了一圈之后,居然还挺有意思。macOS 输入法系统、TIS API、CGEvent、按住式快捷键状态机,全都串起来了,而我对这些一窍不通,所以我也不知道这是不是最优解。

但是姑且目前用下来很顺。

偷懒造福人类,yes。