在 Linux 上没有可用的商业中文输入法——豆包、讯飞、千问、微信都没有 Linux 版,能用的只有 IBus 或 fcitx 这些原生框架。ibus-voice-ime 是我为这事自造的方案:一个 IBus 自定义语音输入法,本质上是个胶水层,缝合了 Rime 输入内核、雾凇拼音词库、千问 ASR 和一些自制的记忆/映射层。代码在 git.ruochongliang.top/lrc/ibus-voice-ime。
这篇不讲安装用法(README 已有),只复盘这套输入法在关键节点上做了哪些非显然的工程取舍。目标读者是同行:如果有人要做类似的事,以下这些判断可以直接复用。
vendor 一整套 librime,而不是用系统的 ibus-rime
系统自带的 ibus-rime 会读写 ~/.config/ibus/rime/ 这类全局共享目录,一个原型项目直接复用会和用户既有的 Rime 安装互相污染——schema、用户词库、build 产物互相覆盖,调试无法隔离。
所以这个项目 vendor 一整套 Rime 运行时:用 ldd 抓出系统 librime.so.1 的全部传递依赖,连同 rime-data、build 目录一起拷进 vendor/rime/,再通过 ctypes 直接声明 RimeApi 调用,不写 C 扩展、零编译。代价是磁盘占用和跨发行版可移植性下降;换来的是这个输入法的 Rime 行为和系统完全互不干扰、版本固定、调试可重现。
一处边界判断值得记下:glibc 被特意排除。私有化 glibc 会触发内核/加载器版本不匹配,运行时崩在加载阶段最难排查——可以隔离业务库,但不能隔离 C 运行时。隔离优于复用,这是整个项目反复出现的一条偏好。
ASR:多后端、默认千问,以及一个显存升降级状态机
语音识别支持七个后端(Qwen3-ASR、MiMo 本地/云端、火山豆包、自定义命令、faster-whisper、vosk),按开关优先级 dispatch,第一个命中的胜出。多后端不是花哨,而是不同场景最优解不同:本地千问省 key 钱、云端豆包准、faster-whisper 只作诊断兜底。默认是本地 Qwen3-ASR 1.7B,理由是尺寸和准确率的平衡——但真正有意思的是它配套的那个状态机。
问题在于:固定加载 1.7B 会常占约 5GB VRAM,ComfyUI 一占显存就 CUDA OOM,听写直接挂。需要”显存够就用大模型、不够自动降级、别的任务让出后升回去”。三种驻留策略的权衡:
| 策略 | 代价 | 为什么没选 |
|---|---|---|
| 全留 GPU | 1.7B 常占 ~5GB,ComfyUI 跑不动 | 与其它 GPU 任务互斥 |
| 全靠磁盘重载 | 每次听写 disk→VRAM 约 10s | 听写体验不可接受 |
| 常驻 RAM,GPU↔RAM 切换 | 占一份 RAM,但 model.to(device) 约 1s | 平衡点 ✓ |
选中第三种。ModelManager 在每次推理前按空闲显存求值升降级。两个配套把延迟藏了起来:录音开始时就异步 POST /warm 把模型搬到 GPU,用户说完时近乎缓存命中;idle 5s 后 watchdog 把模型搬回 RAM 释放显存——宁可激进释放让 ComfyUI 随时能用,也不囤积。可靠性上,watchdog 的 except 永不退出,模型管理的故障不传染到听写请求本身。
后处理:为什么 LLM 重排被焊死关掉
转写出来是糙的,常见思路是再接一层 LLM 润色。这个项目里,这层代码写好了、实现完整,但被强制关闭——而且是双层防御:run-engine.sh 用裸赋值(不是 ${VAR:-0})把三个 LLM 开关钉成 0,哪怕用户配置里写了 =1 也被覆盖;进程内 enabled() 硬编码 return False。
为什么从”默认关”升级到”焊死关”?因为小模型(Qwen3.5-0.8B)做听写后处理时不稳定:会润色、改主语、丢信息。对一个每次按键都影响光标输出的输入法,偶发的不可控改写是致命的——用户根本不知道提交出去的是什么。我的判断是重编排要 300B 以上的大模型才靠谱,小模型的”不稳定”比”错误”更难接受:错误可预期,不稳定不可预期。
代码没删,只是开关焊死,未来模型够好时翻 enabled() 就行——保留 optionality,但默认不暴露。这条决策背后是一以贯之的偏好:确定性优于概率性。规则清理(标点、简繁、口令映射的正则)可预测、可调试;LLM 是黑盒,可以用,但不放在影响光标输出的最终路径。
Rime 之上为什么还要一层候选记忆
Rime 自己有用户词频学习,但它学的只是”在 Rime 候选里选哪个”,覆盖不到”我这次根本没走 Rime”的场景——比如输 opencode 直接按 Enter 透传,Rime 完全不参与,这次偏好对它不可见。自建的记忆层就是补这个缺口。
这层叠加有两个克制设计避免它污染 Rime:记忆候选只在第一页注入(否则高频 token 会占住每页同一槽位);并且做拼音合法性门控——输入码不是合法拼音(如 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 权限)进入默认体验。这是对现实不一致性的承认:不假设网页都规范支持输入法,而是准备分层降级。
热词三列:软加权 vs 硬替换
ASR 有两类性质不同的错误:发音对但不敢输出(罕见词,训练语料里没有);发音相近听错了(如 Rime→Rim,解码已结束)。单一热词只能做软加权,对第二类无能为力。所以 voice-dictionary.txt 分三列 标准词 | 别名 | 常见误识别:前两列喂 ASR prompt biasing(软,解决第一类),第三列做本地确定性整词替换(硬,解决第二类)。
整词边界是关键纪律:CJK 字符算合法边界,但 ASCII 字母/数字不算——所以 Rims、XRim 里的 “Rim” 子串不会被误伤。宁可漏修,也不误伤合法词。
工程偏好收尾
这套输入法的工程判断有清晰的一致性:确定性优于概率性(LLM 焊死关、热词用正则、记忆用频率排序);隔离优于复用(vendor librime、独立 venv、独立词库目录);宁可焊死,不留不稳定开关(LLM 双层硬关);默认值保守,激进要显式 opt-in(降噪、自动标点、模糊音默认全关);对真实世界的悲观主义 + 分层降级(三层粘贴、ASR 多后端、永不传染的故障兜底)。
这套输入法本质上是妥协的产物——没有大厂愿意给 Linux 做输入法,只能自己把需求满足掉。它不优雅,也还有”用着用着词库加载不到”这类没去修的 bug。但在这些约束下,每个设计决策都是可解释的。