Voice Input:给 Linux 写一个全程本地、按住就说的全局语音输入
⚡ 效率提升 开源项目 python 语音输入 ubuntu 8

Voice Input 图标

TL;DR:一个 Linux/X11 全局语音输入工具。按住快捷键说话、松手就把识别结果敲进当前光标处,中英混说、自动标点,全程本地 ASR、音频不出本机。难点不在「能识别」,而在三件事:在任意窗口截获按键、把录音无阻塞地交给识别线程、再把文字稳稳地注入到别的应用里。这篇就拆这三件事,外加 X11 文本注入的坑和 Wayland 那道墙。

为什么自己写一个

好用的桌面语音输入,要么绑死在某个输入法里,要么是云端服务——要联网、要上传音频、可能还要钱。我要的很简单:任何窗口,按住一个键说话,松手文字出现在光标处,而且我说的话不离开这台机器。 Linux/X11 上没有现成的,于是有了 Voice Input

项目地址:https://github.com/fnidore/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 振幅怎么驱动那根音量条

录音用 sounddeviceInputStream,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 模拟按键、查活动窗口,xpropWM_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 桌面的产物,感兴趣可以顺手看看。

Voice Input:给 Linux 写一个全程本地、按住就说的全局语音输入
http://localhost:8090/archives/voice-input
作者
fnidore
发布于
更新于
许可