在 Linux 上没有能用的商业中文输入法。豆包、讯飞、千问、微信都没有 Linux 版,能用的只有 IBus 或 fcitx 这些原生框架。ibus-voice-ime 就是我为这件事自己做出来的方案:一个 IBus 自定义语音输入法,说白了是个胶水层,把 Rime 输入内核、雾凇拼音词库、千问 ASR,再加上我自己写的记忆层和映射层缝在一起。代码在 git.ruochongliang.top/lrc/ibus-voice-ime。
安装和用法 README 里已经写了,这篇文章不讲那些。我想复盘的是:做这个输入法的过程里,有几个不那么容易想到的决定,我为什么这么定。如果你也在做类似的东西,下面这些判断可以直接拿去用。
Rime 内核:我为什么自己带一整套,而不用系统的 ibus-rime
系统自带的 ibus-rime 会读写 ~/.config/ibus/rime/ 这种全局共享目录。一个原型项目直接复用它,就会和用户已经装好的 Rime 互相污染:schema、用户词库、build 产物互相覆盖,出了问题也没法隔离调试。
所以我把一整套 Rime 运行时都搬进了项目里。做法是用 ldd 抓出系统 librime.so.1 的全部依赖,连 rime-data 和 build 目录一起拷进 vendor/rime/,然后用 ctypes 直接调用 RimeApi。这样不用写 C 扩展,也不需要编译。
代价是磁盘占用变大了,跨发行版的可移植性也差了一些。换来的是:这个输入法的 Rime 行为和系统完全互不干扰,版本固定,调试能重现。
这里还有一个边界判断值得记一笔:glibc 是我特意排除在外的。把 glibc 也私有化,会触发内核和加载器的版本不匹配,然后崩在运行时加载阶段,那是最难排查的一类问题。所以结论是:业务库可以隔离,C 运行时不能隔离。这也是整个项目里反复出现的一条偏好:能隔离就隔离,不要图省事去复用。
ASR:七个后端、默认千问,和一个跟着显存走的升降级状态机
语音识别这块我做了七个后端:Qwen3-ASR、MiMo 本地和云端、火山豆包、自定义命令、faster-whisper、vosk。它们各自挂在一个开关上,按优先级排序,取第一个命中的。
做这么多后端不是为了显得功能多,而是不同场景的最优解确实不一样:本地千问省 API 的钱,云端豆包更准,faster-whisper 我只拿它当诊断兜底。默认用的是本地 Qwen3-ASR 1.7B,理由是它尺寸和准确率的平衡还不错。不过我觉得这里真正有意思的不是选哪个模型,而是它配套的那个状态机。
问题出在显存上。如果固定加载 1.7B,它会常占 5GB 左右的显存,ComfyUI 一进来抢显存就 CUDA OOM,听写直接挂掉。所以我需要的是:显存够就用大模型,不够就自动降级,别的任务把显存让出来之后再升回去。三种驻留策略我都权衡过:
| 策略 | 代价 | 为什么没选 |
|---|---|---|
| 全留 GPU | 1.7B 常占 ~5GB,ComfyUI 跑不动 | 与其它 GPU 任务互斥 |
| 全靠磁盘重载 | 每次听写 disk→VRAM 约 10s | 听写体验不可接受 |
| 常驻 RAM,GPU↔RAM 切换 | 占一份 RAM,但 model.to(device) 约 1s | 平衡点 ✓ |
最后选的是第三种。ModelManager 在每次推理之前,按当前空闲显存求值,决定往上升还是往下降。
另外有两个配套动作,把延迟藏了起来。一个是录音开始的时候就异步 POST /warm,先把模型搬进显存,等我说完一句话,识别几乎是缓存命中的速度。另一个是闲置 5 秒之后,由 watchdog 把模型搬回内存、释放显存——我宁可释放得激进一点,让 ComfyUI 随时能用上显卡,也不想囤着。可靠性上,watchdog 的 except 永不退出,模型管理这边出故障,不会传染到听写请求本身。
后处理:我为什么把这层 LLM 重排关死
语音转写出来是糙的,常见的思路是再接一层 LLM 润色。这个项目里,这层代码我写好了、功能也是完整的,但被我强制关掉,而且是两层防御:run-engine.sh 用裸赋值(不是 ${VAR:-0})把三个 LLM 开关钉成 0,哪怕你在自己的配置里写了 =1 也会被覆盖;进程内的 enabled() 则直接硬编码 return False。
为什么从「默认关」升级到「彻底关死」?因为小模型(Qwen3.5-0.8B)做听写后处理的时候不稳定:它会润色、会改主语、会丢信息。对一个每次按键都直接影响光标输出的输入法来说,偶发的不可控改写是致命的——你根本不知道最后提交出去的是什么。我的判断是:这种重编排至少要 300B 以上的大模型才靠得住。而且小模型的「不稳定」比「错误」更难接受:错误是可预期的,不稳定是不可预期的。
代码并没有删,只是开关关死了,将来模型够好的时候,把 enabled() 翻过来就行,也就是留着以后能改回来的余地,但默认不暴露给用户。这条决定的背后是一以贯之的偏好:确定性优先于概率性。规则清理(标点、简繁、口令映射的正则)可预测、可调试;LLM 是个黑盒,可以用,但不放在影响光标输出的最终路径上。
Rime 之上,为什么还要再叠一层候选记忆
Rime 自己会学用户词频,但它学的只是「在 Rime 给出的候选里挑哪一个」,覆盖不到「这次我根本没走 Rime」的场景。比如我输 opencode 直接按 Enter 透传,Rime 完全不参与,这次的选择对它就是不可见的。我自建的记忆层,补的就是这个缺口。
这层叠加有两个地方我故意收着做,避免它污染 Rime。一是记忆候选只在第一页注入,否则一个高频词会占住每一页的同一个位置。二是做了拼音合法性门控:输入码不是合法拼音(比如 opencode)的时候,记忆候选排到前面;输入码是合法拼音(比如 bang)的时候,让 Rime 领先。不然的话,我记过的短词「帮」会盖住 Rime 更想给的「帮我」。这一层很清楚自己该有多薄。
/ 透传:输入法和命令行的冲突
这条来自一个很现代的使用观察:Rime 默认把 / 映射成顿号,但在中文模式下,/new、/help、URL、pi 这类斜杠命令的使用密度非常高,一旦被吃掉变成顿号,这些命令就废了。
我的解法是让 / 的透传看上下文,而不是一刀切。_should_forward_ascii_slash 在四个条件全都满足时才透传 /:总开关开着、按的确实是 /、没有修饰键、当前没有正在输入的候选词。第四条是关键——输拼音的时候 / 必须走 Rime,只有编辑区是空的时候才透传。这样既不破坏正常的中文输入,又照顾了高频的命令场景。
粘贴:三层兜底
Ctrl+Alt+P 的粘贴我做了三层递进,原因是 Web 应用对输入法的支持极不一致——有的吃 IBus 的 commit_text,有的只认真实的 paste 事件,有的连 paste 都 preventDefault 掉。只走一种方式,必然在某些网站上失效。
| 层 | 机制 | 解决前一层的什么失败 |
|---|---|---|
| L1 IBus commit | 走和语音结果同款的 commit_text 路径 | 最体面。但某些网页忽略 commit,或 commit 后 IME 状态被搞坏 |
| L2 状态恢复 | 提交后延迟切英文输入源再切回 | 修复 L1 副作用——切换输入源能「重启」上下文 |
| L3 uinput 虚拟键盘 | 建 /dev/uinput 虚拟键盘逐字符模拟真实按键 | L1/L2 全失效时的兜底。不走 paste 事件、不依赖 commit,网页分不清这是粘贴还是人手敲字 |
L1 是我故意复用语音提交路径的,这样粘贴就自动享有语音链路的全部焦点和时序健壮性。默认走 L1 加 L2,L3 留着手动兜底,不让它的重代价(逐字符 4ms 一个键、还需要 uinput 权限)进入默认体验。这就是我对现实的态度:不假设每个网页都规规矩矩地支持输入法,而是准备好一层一层的退路。
热词三列:软加权和硬替换
ASR 有两类性质完全不同的错误。一类是发音对但不敢输出:罕见词,训练语料里没有。另一类是发音相近听错了,比如把 Rime 听成 Rim,这时候解码都已经结束了。单一的热词表只能做软加权,对第二类无能为力。
所以 voice-dictionary.txt 我分成了三列:标准词 | 别名 | 常见误识别。前两列喂给 ASR 做 prompt biasing,属于软手段,解决第一类;第三列做本地确定性的整词替换,属于硬手段,解决第二类。
整词边界是这里的关键纪律:CJK 字符算合法边界,但 ASCII 字母和数字不算。所以 Rims、XRim 里面的「Rim」子串不会被误伤。宁可漏修,也不要误伤合法词。
收尾:这套东西背后的几条偏好
回头看,这套输入法的工程判断其实有一致性:确定性优先于概率性(LLM 关死、热词用正则、记忆用频率排序);能隔离就隔离,不图省事复用(自带 librime、独立 venv、独立词库目录);宁可关死,也不留不稳定的开关(LLM 双层硬关);默认值保守,激进的要显式打开(降噪、自动标点、模糊音默认全关);对真实世界保持悲观,然后分层降级(三层粘贴、ASR 多后端、故障永不传染的兜底)。
说到底,这套输入法是妥协的产物——没有大厂愿意给 Linux 做输入法,我只能自己把需求满足掉。它不优雅,也还有「用着用着词库加载不到」这类我没去修的 bug。但在这些约束下,每个设计决定都是能解释的。