最近豆包输入法的语音输入很火。
我是在开放后下载下来用了一下,确实很好用,对比之前用的 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 小工具:
用的是 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
| /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。