![]()
TL;DR:一个 Linux/X11 全局语音输入工具。按住快捷键说话、松手就把识别结果敲进当前光标处,中英混说、自动标点,全程本地 ASR、音频不出本机。难点不在「能识别」,而在三件事:在任意窗口截获按键、把录音无阻塞地交给识别线程、再把文字稳稳地注入到别的应用里。这篇就拆这三件事,外加 X11 文本注入的坑和 Wayland 那道墙。
为什么自己写一个
好用的桌面语音输入,要么绑死在某个输入法里,要么是云端服务——要联网、要上传音频、可能还要钱。我要的很简单:任何窗口,按住一个键说话,松手文字出现在光标处,而且我说的话不离开这台机器。 Linux/X11 上没有现成的,于是有了 Voice Input。
线程模型:这才是真正的难点
「按住录音、松手出字」听着简单,但全局热键监听、音频采集、模型推理、文本注入分别跑在不同线程上,谁都不能阻塞谁——尤其键盘监听线程一旦卡住,你整个系统的按键都会延迟。最终的线程划分是这样的:
┌─ 主/GUI 线程 (PySide6) ──────────────────────────┐
│ 托盘图标、设置窗口、音量条 HUD │
└───────────────▲───────────────────────────────────┘
│ on_level(peak) 回调刷新音量
┌─ PortAudio 回调线程 (sounddevice) ────────────────┐
│ 16kHz/单声道,每个 block 追加到 chunks,算 peak │
└───────────────▲───────────────────────────────────┘
│ self.audio_chunks(self.lock 保护)
┌─ 键盘监听线程 (pynput) ───────────────────────────┐
│ on_press → 起录音流;on_release → 另起 daemon 线程│
└───────────────┬───────────────────────────────────┘
▼ threading.Thread(daemon=True)
┌─ 识别 worker 线程(一次性,阻塞跑完即退)──────────┐
│ 拼接音频 → SenseVoice 推理 → 注入文字 │
└────────────────────────────────────────────────────┘
核心代码很能说明设计意图。按下键,在监听线程里直接起录音流(开销极小,瞬间返回);松开键,绝不在监听线程里做识别,而是甩给一个 daemon 线程:
def on_press(self, key):
if self.match_hotkey(key):
self.start_recording()
def on_release(self, key):
if self.match_hotkey(key):
threading.Thread(target=self.stop_recording, daemon=True).start()
音频的交接没有用队列或信号,而是共享实例状态 + 一把锁——worker 线程进来先抢锁,把状态切走、把 chunks 快照取走,监听线程那边就再也碰不到这批数据了:
def stop_recording(self):
with self.lock:
if not self.recording:
return
self.recording = False
self.processing = True
chunks = self.audio_chunks
self.audio_chunks = []
self._recognize_and_type(chunks) # 仍在 daemon 线程里,阻塞也无所谓
这是我最满意的一处取舍:识别要花几十到几百毫秒,但因为它跑在「用完就扔」的 daemon 线程里,监听线程早就回去听下一次按键了,系统按键零延迟。
录音:peak 振幅怎么驱动那根音量条
录音用 sounddevice 的 InputStream,16kHz、单声道、float32,靠回调把每个 block 收进缓冲:
def start(self) -> bool:
with self._lock:
if self._running:
return False
self._chunks = []
self._stream = sd.InputStream(
samplerate=self.sample_rate, channels=CHANNELS,
callback=self._callback, dtype="float32",
device=self.input_device,
)
def _callback(self, indata, frames, time_info, status):
if not self._running:
return
self._chunks.append(indata.copy())
if self.on_level is not None:
peak = float(np.max(np.abs(indata))) # 用峰值而非 RMS,更跟手
self.on_level(peak)
这里特意用 peak(np.max(np.abs))而不是 RMS——RMS 平滑、好看,但峰值对说话起音的反应更即时,那根 HUD 音量条「跟手」的手感主要靠它。注意 _callback 跑在 PortAudio 自己的线程上,所以 on_level 只做最轻的事:把数值丢给 GUI 去画。
识别:SenseVoice,懒加载 + 自动降级
识别引擎是阿里的 SenseVoice(经 FunASR 加载)。两个工程决定:
def resolve_device(device):
if device.startswith("cuda"):
if not torch.cuda.is_available():
logger.warning("cuda 不可用,回退 cpu")
return "cpu"
return device
def load(self) -> None:
if self.model is not None: # 懒加载:第一次真正识别时才载入
return
self.device = resolve_device(self.device)
from funasr import AutoModel
self.model = AutoModel(model=model_arg, device=self.device, ...)
懒加载意味着首次识别会多等一下(模型上 GB,载入要时间),但之后每次都是热的;CUDA 不可用就回退 CPU,没显卡也能跑、绝不崩。语言、热词、ITN(反向文本归一化,把「百分之二十」规整成数字标点)都按 preset 能力选择性传入,免得给不支持的模型塞参数报错:
def recognize(self, audio, language="zh", hotwords="", sample_rate=16000):
kwargs = dict(input=audio, cache={}, **p.gen_kwargs)
if p.accepts_hotword and hotwords.strip():
kwargs["hotword"] = hotwords.strip()
if p.accepts_language:
kwargs["language"] = language
if p.accepts_itn:
kwargs["use_itn"] = True
res = self.model.generate(**kwargs)
关于「RTF ≈ 0.02」这个数怎么来的
代码里 RTF 的口径很直白——单次识别的推理耗时 ÷ 这段音频的时长,模型已经加载好(热态)之后测:
duration = len(audio) / sample_rate # 音频本身有多长
elapsed = time.time() - t0 # 这次 generate 跑了多久
rtf = elapsed / max(duration, 1e-6)
所以它不含懒加载那一次性的模型载入开销,衡量的是稳态下的识别速度。RTX 5060 上 ≈ 0.02,即 5 秒语音约 0.1 秒识别完,体感就是「松手即出字」。纯 CPU 我没做严格基准,但量级上会差一两个数量级(RTF 到 0.x 甚至接近 1),短句仍可用、长句就明显能感到等待——功能照常,只是没了「即时」的爽感。
文本注入:怎么把字「打」进别人家的窗口(X11)
这是最折腾的一环,也是 Wayland 卡死我的地方。把识别结果送到光标处,我最终选了剪贴板 + 模拟粘贴为主、模拟逐字输入为辅。
先搞清楚「当前是什么窗口」——因为终端里 Ctrl+V 根本不是粘贴。检测窗口类用两步:主用 xdotool,失败回退 xprop:
# 主:一步到位
["xdotool", "getactivewindow", "getwindowclassname"]
# 回退:先拿窗口 id,再查 WM_CLASS
["xdotool", "getactivewindow"]
["xprop", "-id", wid, "WM_CLASS"]
(上一版草稿里我把这俩工具的职责写拧了,这里据源码纠正:是 xdotool 优先、xprop 兜底,不是二选一。)拿到窗口类后,按是不是终端选粘贴键,再用 xclip 写两个选区、xdotool 发键:
cls = detect_active_window_class()
in_term = bool(cls) and (cls in TERMINAL_CLASSES or "term" in cls)
chosen = terminal_paste_key if in_term else paste_key # 终端 Ctrl+Shift+V,否则 Ctrl+V
subprocess.run(["xdotool", "key", "--clearmodifiers", chosen], check=False)
别把用户的剪贴板搞脏:存档 → 粘贴 → 还原
用剪贴板注入有个绕不过去的工程坑:你把识别文字塞进剪贴板,就覆盖了用户原本复制的东西。我刚开始忽略了这点,结果用着用着发现「我刚复制的链接怎么没了」——被语音输入的文字顶掉了。所以完整流程其实是「先备份、再粘贴、最后还原」:
orig_clip = subprocess.run(
["xclip", "-selection", "clipboard", "-o"],
capture_output=True, timeout=1,
).stdout # 1) 先把用户原有剪贴板存下来
_xclip_set("clipboard", text.encode("utf-8"))
_xclip_set("primary", text.encode("utf-8"))
time.sleep(0.06) # 2) 等 xclip 真正抢到选区所有权,太快发键会粘到空
# …按上面的逻辑发 Ctrl+V / Ctrl+Shift+V…
time.sleep(0.25) # 3) 等目标应用把内容读走
_xclip_set("clipboard", orig_clip) # 4) 还原用户原来的剪贴板
那两个 sleep 不是拍脑袋加的,是对付时序竞态:X11 的剪贴板是「谁持有选区、被请求时才给数据」的惰性模型,xclip 写完到它真正 hold 住选区有个窗口期,0.06s 就是等这个;而粘贴键发出去后,目标应用是异步去读剪贴板的,0.25s 是确保它读完了我再还原,否则可能还没读就被我换回旧内容、粘出来是空的。这两个数是我反复试出来的折中——再小会偶发粘空,再大则手感发黏。
为什么不用「模拟逐字打字」当主力
兜底方案是逐字敲 xdotool type --delay 12 --clearmodifiers <text>。我一度想用它当主力——不碰剪贴板,多干净。但实测快速注入中文时它会丢字、串序:定位时我把同一句「今天天气不错适合写代码」连发十次,每次掉的字都不一样,纯随机。根因是 xdotool type 对非 ASCII 字符要走「临时重映射 keymap → 发键 → 还原」这条路,每个字都要和 X server 往返几次,节奏一快就和应用自己的输入处理抢拍子,丢的就是那些没来得及映射的。试过把 --delay 调到 40ms 能缓解,但一句话要敲两三秒,体验崩了。于是认栽:剪贴板粘贴当主路,type 只在系统没装 xclip 时救场。
为什么不支持 Wayland
整条注入链全踩在 X11 原语上:xdotool 模拟按键、查活动窗口,xprop 读 WM_CLASS,xclip 操作 clipboard/primary 两套选区。Wayland 出于安全把这些全封了——没有统一的 WM_CLASS、剪贴板语义不同、也没有「全局模拟按键 / 查任意窗口」的等价接口。这不是我偷懒,是 Wayland 的隔离哲学使然,所以现阶段 X11 是一等公民,Wayland 暂不支持。
踩过的两个坑
- 右 Ctrl 报不出名字:有的键盘右 Ctrl 在 pynput 里识别不出符号名,绑快捷键时怎么都匹配不上。最后给热键解析加了第三种写法——原始虚拟键码
vk:269025067,专治这种「叫不出名」的键。仓库里还留了个keytest.py就是当时为查键码写的。 - 第一次在终端里试,文字没粘上反而触发了一串怪操作:才反应过来终端的 Ctrl+V 是「字面输入」,得 Ctrl+Shift+V。窗口类检测那段逻辑就是这么逼出来的。
附:安装 & 配置(速查)
- 原生包:Linux
.deb/ Windows.exe/ macOS.dmg,下载即用。 - 源码:克隆后
./install.sh;NVIDIA 想要 GPU 加速可一键装 CUDA runtime(约 3GB)。 - 环境:Python 3.10+、PyTorch ≥ 2.7 + CUDA 12.8(CPU 亦可)、SenseVoice 模型约 1GB。
- 配置
~/.config/voice-input/config.json;历史~/.local/share/voice-input/history.json;日志按 5MB 滚动。
托盘三态图标、设置窗口、录音 HUD 的实际界面截图,见仓库 README(本文聚焦实现,界面图就不在这里重复贴了)。
小结
整个项目最值的一个决定是核心与 GUI 彻底分离:识别、注入、热键全是无界面依赖的模块,调逻辑时根本不用起窗口,tests/ 下一整套测试也才跑得起来。而剪贴板粘贴 > 模拟打字、识别甩进 daemon 线程这两个取舍,则是「在别人家窗口里稳稳打字、又不拖累系统按键」的关键。用熟之后写注释、回消息、记灵感直接开口就行——而且音频从不出本机,这是我最在意的。
欢迎试用、提 issue。我另外两个 X11 桌面小工具——GNOME 顶栏宠物 Notchi 和 折叠托盘图标的 Tray Collapse——同样是折腾 Linux 桌面的产物,感兴趣可以顺手看看。