环境:Arch Linux + KDE Plasma(Wayland)+ PipeWire 1.6.8 | Ryzen 5 7500F | RTX 4070 SUPER | ALC897 模拟麦
目标:按住热键说话 → 本地识别 → 文本直接落到光标处(复刻 macOS 上 VoxCode 的工作流)
结果:voxtype 1.0.1(Whisper large-v3-turbo + Vulkan)+ PipeWire 110Hz 高通降噪 + DeepSeek 后处理,中文可用,口述到上屏 1 秒内
1. 起因:VoxCode 只有 macOS 版
想要的东西很具体:选中代码 → 按住热键口述 → 松开后把「文件#行号 + 转录文本」粘到光标处,给 Claude Code / Codex 这类终端 agent 用。
找到的工具是 VoxCode(作者是 WunderGraph 的 CEO),思路很讨喜——不做 IDE 插件,只做两件事:本地语音转文字 + ripgrep 式代码定位,粘贴 src/foo.rs#L49-57 这样的引用。但翻源码后发现它是 macOS 独占:
hotkey.rs/paste.rs是纯 CoreGraphics FFI,没有任何cfg降级路径;window_ext.rs用 objc2 + NSSound,editor_context.rs走 Accessibility API;notify开了macos_fsevent,ONNX Runtime 只加载.dylib;- 没有 GitHub Release、没有 AUR/Flathub/npm 包,最后提交停在 2026-04。
MULTIPLATFORM.md 里作者自己写了移植指南,并把 Wayland 标为「hard」——全局热键、按键注入、窗口管理都没有标准 API。也就是说,想在 Linux 上用,等于自己写一份平台层。
于是换思路:用 Linux 原生的等价物。选型落到 voxtype(1.4k★,Rust,AUR 由作者本人维护):同样是「按住说话 → 本地识别 → 注入到光标」,支持 7 种引擎(Whisper / Parakeet / SenseVoice / Paraformer / Dolphin / Omnilingual / Cohere)、Vulkan/CUDA 加速、Wayland/X11 都能用。
2. 安装:AUR 预编译 + GPU 变体
# voxtype 预编译包(含 avx2/avx512/vulkan/onnx-cuda/migraphx 多种变体)
yay -S voxtype-bin
# 注入后端:dotool 用于「type 模式」,ydotool 用于「paste 模式」的粘贴按键
yay -S dotool && sudo pacman -S ydotool gtk4-layer-shell alsa-utils
# 切到 Vulkan 变体(NVIDIA/AMD/Intel 通用)
sudo voxtype setup gpu --enable
# 下载多语言模型(1.6G)
voxtype setup --download --model large-v3-turbo
装完确认 GPU 生效:
ggml_vulkan: 0 = NVIDIA GeForce RTX 4070 SUPER | fp16: 1 | matrix cores: KHR_coopmat
whisper_model_load: Vulkan0 total size = 1623.92 MB
实测速度:5.5 秒中文语音 0.39s 出结果,JFK 11 秒英文样本算上模型加载 2.86s。
3. KDE Wayland 上的四个坑
这部分是真正花时间的,逐条记下来。
3.1 evdev 热键读不到输入设备(系统服务里)
voxtype 的默认方案是 evdev 内核级热键(不依赖合成器,任何场景都能触发)。现象是:配置里 [hotkey] enabled = true、key = "SCROLLLOCK",但按下去毫无反应。
定位:
pid=$(systemctl --user show voxtype -p MainPID --value)
awk '/^Groups/{print $2}' /proc/$pid/status # 只有 958
id -G # 当前 shell 里有 994(input)
原因:systemd --user 管理器是登录时启动的,早于我把自己加进 input 组,所以它带着旧组集合,服务进程自然读不了 /dev/input/event*(权限 root:input 660)。
一般做法是「重新登录」让用户管理器拿到新组。但有个更快的办法——给活动会话用户直接发 ACL:
# /etc/udev/rules.d/60-input-uaccess.rules
SUBSYSTEM=="input", TAG+="uaccess"
sudo udevadm control --reload && sudo udevadm trigger --subsystem-match=input --action=change
getfacl -p /dev/input/event6 | grep user:chen # 出现 user:chen:rw- 即生效
这也是 Fedora 等发行版对输入设备的默认处理方式,改完立即生效,不用注销。
3.2 注入的按键被输入法吞掉 / dotool 打不出中文
KDE 上 wtype 基本没用(KWin 不实现虚拟键盘协议),社区推荐 dotool(uinput 注入)或 ydotool。但实测有两个叠加问题:
- 我平时开着 fcitx5(双拼)。
fcitx5-remote返回2(激活态)时,注入的 ASCII 会被输入法收进候选窗,应用一个字都收不到——这就是为什么最初几次「注入测试」全部失败。 dotool是按布局映射键码的,天生打不出中文:
$ printf 'type 你好世界\n' | dotool
dotool: WARNING: impossible character for layout: 你
结论:中文口述不要用 type 模式,用 paste 模式——先把转录写进剪贴板,再模拟 Ctrl+V:
[output]
mode = "paste"
driver_order = ["dotool", "clipboard"]
注意 1.0.1 的粘贴按键硬编码 Ctrl+V(paste_keys 是 1.1 才有的配置项)。所以往终端(Konsole 等)里口述时它不会自动粘贴——终端的粘贴是 Ctrl+Shift+V。两个选择:要么把模式切成 clipboard(转录进剪贴板,自己按 Ctrl+Shift+V),要么在 Konsole 里把 Paste 快捷键改成 Ctrl+V。
3.3 voxtype 会主动忽略「自家」虚拟键盘
调试时我用 dotool 注入热键来测通路,日志里一直刷:
DEBUG Skipping virtual injection keyboard: Some("dotool keyboard")
这是防自触发的设计——voxtype 不监听名字含 dotool keyboard 的注入设备,免得自己打出来的字又触发录音。知道这点后,测试时给虚拟键盘换个名字即可:
{ printf 'keydown k:100\n'; sleep 2; printf 'keyup k:100\n'; } | \
DOTOOL_KEYBOARD_NAME="External Test Keyboard" dotool
3.4 采集源不是麦克风,而是 HDMI 监视器
最诡异的一个:录音总是「有声音但内容是静音」,Whisper 于是开始幻觉,输出 Thank you. 这类短句。
查当时所有采集流:
pactl list source-outputs | awk '/Source: [0-9]+/{s=$2} /application.name =/{print s, $3}'
# 结果:voxtype 挂在 2218 —— 那是 HDMI 输出的 monitor,不是麦克风
根因是 PipeWire 的按应用路由残留(module-stream-restore 记住了这个客户端之前的目标设备)。修法:给 voxtype 一个专属的 ALSA PCM,让它以新的客户端名出现,并在配置里显式指定:
# ~/.asoundrc
pcm.vxclean {
type pipewire
capture_node "clean_mic" # 指向下面第 5 节的降噪虚拟源
hint { show on description "Voxtype 降噪输入" }
}
voxtype config set audio.device vxclean
4. 底噪诊断:85% 的能量在 50Hz 以下
之前隐约觉得「底噪大」,但一直没量化。这次录了 16 秒环境噪声做频谱分析,结果很反直觉:
| 频段 | 能量占比 |
|---|---|
| 0–50 Hz | 85.5% |
| 50–120 Hz | 10.8% |
| 120–300 Hz | 2.8% |
| 300–1000 Hz | 0.6% |
| 1000–3000 Hz | 0.2% |
| 3000–8000 Hz | 0.1% |
最强频点在 48–51 Hz(工频嗡声 + 次声轰鸣)。也就是说,所谓的「底噪」根本不是嘶嘶声,而是低频能量。
更要命的是电平:环境噪声整体 RMS −15.3 dBFS,峰值 −0.3 dBFS——采集链几乎在削顶。查 ALSA 增益:
$ amixer -c 1 sget "Capture" | tail -2
Front Left: Capture 63 [100%] [30.00dB] [on]
$ amixer -c 1 sget "Rear Mic Boost" | tail -2
Front Left: [100%] [30.00dB]
+30dB 采集增益叠加 +30dB Mic Boost,总共 +60 dB。低频轰鸣把 ADC 的动态余量吃干净了,语音(300–3400Hz)只剩很小的一点。
5. 降噪:RNNoise 实测翻车,改用高通
第一反应是上神经网络降噪,noise-suppression-for-voice(RNNoise)在官方源里,装起来也简单。但在这套 PipeWire 1.6.8 上,它会连人声一起削掉:
| 配置 | JFK 语音转写结果 | 输出电平 |
|---|---|---|
| 仅高通 110Hz(无 RNNoise) | ✅ 正确 | −20.7 dBFS |
| 高通 + RNNoise(VAD 0%) | ❌ um |
−62.1 dBFS |
| 高通 + RNNoise(VAD 25%) | ❌ yep |
−53.2 dBFS |
| 高通 + RNNoise(VAD 50%) | ❌ oh |
−56.4 dBFS |
LADSPA 和 LV2(https://github.com/werman/noise-suppression-for-voice#mono)两种插件都试了,阈值怎么调都是把人声当噪声处理。既然噪声能量 96% 在 120Hz 以下,而语音基本在 300Hz 以上,那用滤波器就够了——PipeWire 内置的 bq_highpass 就够使:
# ~/.config/pipewire/pipewire.conf.d/99-clean-mic.conf
context.modules = [
{ name = libpipewire-module-filter-chain
args = {
node.description = "降噪麦克风 (高通 110Hz)"
filter.graph = {
nodes = [
{
type = builtin
name = hp
label = bq_highpass
control = { "Freq" = 110.0 "Q" = 0.707 }
}
]
}
capture.props = {
node.name = "capture.clean_mic"
node.passive = true
target.object = "alsa_input.pci-0000_0d_00.6.analog-stereo"
}
playback.props = {
node.name = "clean_mic"
node.description = "降噪麦克风 (高通 110Hz)"
media.class = "Audio/Source"
}
}
}
]
改完 systemctl --user restart pipewire pipewire-pulse,实测对比:
| 指标 | 原始麦克风 | 经高通 |
|---|---|---|
| 整体 RMS | −16.6 dBFS | −33.3 dBFS |
| 峰值 | −0.0 dBFS(削顶) | −13.1 dBFS(健康) |
| <50 Hz | 基准 | −24.6 dB |
| 50–120 Hz | 基准 | −14.7 dB |
| 300–3400 Hz(语音带) | 基准 | −5.6 dB(这段本来也几乎全是低频尾巴) |
关键是峰值回来了:从「贴着 0 dBFS」回到 −13 dBFS,语音终于有了动态余量。而语音保真没问题——同一段 JFK 样本经这条链转写依然完全正确。
6. 别影响打游戏:路由隔离
折腾完最容易忽略的是「别的应用怎么办」。我把系统默认输入改回了原始麦克风,只让 voxtype 走降噪源:
| 使用方 | 采集源 | 说明 |
|---|---|---|
| 游戏语音 / 微信 / Discord / 浏览器 | 原始麦克风 | 与装 voxtype 之前完全一致 |
| voxtype 口述 | clean_mic(高通虚拟源) |
只有这条路径降噪 |
实测并发:voxtype 录音(源 43)的同时,另一个客户端从原始麦克风(源 69)采集同样正常拿到数据。空闲时两个节点都是 SUSPENDED——滤波链是 node.passive,只有 voxtype 采集时才跑,一个二阶 biquad 的 CPU 开销可以忽略。
延迟方面:音频图周期 1024 帧 ≈ 21ms,滤波节点自报额外延迟为 0(同一图周期内处理)。
7. 后处理:用 DeepSeek 收尾(关掉思考模式)
Whisper 的原始转录经常带着口头禅、缺标点,专有名词也容易错。voxtype 支持把转录文本管道给外部命令:
[output.post_process]
command = "~/.local/bin/voxtype-postprocess" # 文本走 stdin → stdout
timeout_ms = 22000
两种落实方式:本地跑 ollama,或者调在线 API。我选在线(DeepSeek),并且显式关掉思考模式——校对这种任务不需要推理链,关掉后延迟从几秒降到 1 秒内:
curl https://api.deepseek.com/chat/completions \
-H "Authorization: Bearer $DEEPSEEK_API_KEY" -H "content-type: application/json" \
-d '{"model":"deepseek-flash",
"reasoning_effort":"none", # 关键:关闭思考
"temperature":0,
"messages":[{"role":"user","content":"..."}]}'
实测 deepseek-flash + reasoning_effort: "none":三次调用 reasoning_content 长度均为 0,输出稳定,中文用例 0.77s、英文用例 1.32s。
脚本用标准库就好(urllib),要点是任何失败都原样返回原文,绝不吞字:
def main():
text = sys.stdin.read().strip()
if not text:
return
try:
out = call_deepseek(text) # 超时、网络错、返回空都抛/空
except Exception:
out = ""
print(out if out else text) # 兜底:原文
效果对比:
输入: 嗯 那个 帮我用 vox type 把 那个 阿里云 das 的 api 文档 更新一下 然后 就是 那个 codex 也得 加进去
输出: 帮我用 VoxType 把阿里云 DAS 的 API 文档更新一下,然后 Codex 也得加进去。
输入: So uh I think we should use the Wunder Graph super graph instead of the old BFF and um ship it on Friday
输出: So I think we should use the WunderGraph supergraph instead of the old BFF and ship it on Friday.
守护进程日志确认整条链路:
INFO Post-processing enabled: command="…/voxtype-postprocess", timeout=22000ms
INFO Transcribed: "…the old BFF and um ship it on Friday."
INFO Post-processing, has_context: false
DEBUG Post-processed (97 -> 97 chars) # 耗时 0.78s
一个诚实的局限:它不认识你的私有术语。实测 ASR 把 WunderGraph 听成 WonderGraph,DeepSeek 不会自动纠正(它不知道这是品牌名)。两个补法:把词表写进后处理脚本的 system prompt,或者用 voxtype 自己的硬替换:
[text]
replacements = { "wondergraph" = "WunderGraph", "vox type" = "voxtype" } # 大小写不敏感
[whisper]
initial_prompt = "Voxtype, WunderGraph, Parakeet TDT, ONNX Runtime" # 识别期偏置
initial_prompt 是「软偏置」(提高首次识别正确率,实测能把 VoxType/WonderGraph 纠正成 Voxtype/WunderGraff),replacements 是「硬兜底」(出现即替换)。两者配合最稳。
8. 配置速查
# ~/.config/voxtype/config.toml 摘要
[hotkey]
key = "RIGHTALT" # 按住右 Alt 说话,松开转写
enabled = true
[audio]
device = "vxhush" # 指向 §9 的 HUSH 虚拟麦克风(不占 GPU 的降级链见 §5)
[whisper]
model = "large-v3-turbo"
language = "auto"
flash_attention = true
[output]
mode = "paste" # 剪贴板 + Ctrl+V(终端要 Ctrl+Shift+V)
driver_order = ["dotool", "clipboard"]
[output.post_process]
command = "~/.local/bin/voxtype-postprocess"
timeout_ms = 22000
[vad]
enabled = true # 纯静音/噪声不送识别,防幻觉文本
另外几个值得知道但没开的:[meeting] 会议模式自带 GTCRN 神经增强(去背景噪 + 去扬声器串音 + 说话人分离),[audio] pause_media 可在录音时自动暂停播放器,[profiles.*] 可按场景套不同后处理(voxtype record start --profile slack)。
热键也可以不用 evdev,改成 KDE 自定义快捷键调 voxtype record toggle(此时把 [hotkey] enabled 设为 false)。对打游戏的人来说这点重要:右 Alt 在很多游戏里有功能,误按会开始录音、松开还会把文本 Ctrl+V 到前台窗口——要么换键,要么玩之前 systemctl --user stop voxtype。
9. 最终方案:把 NVIDIA Maxine 跑起来(HUSH)
第 4、5 节走到最后会卡在一个地方:剩下的嘶声是空气传播的环境噪声——把麦克风用手罩住,300 Hz–8 kHz 立刻掉 8–17 dB;而它就落在语音带里,任何 EQ 都会连人声一起削。NVIDIA Broadcast / RTX Voice 官方没有 Linux 版,但社区把 Maxine 的 Audio Effects SDK 包成了 HUSH,直接把清理后的信号注册成一个叫 HUSH 的虚拟麦克风。
安装
yay -S hush-mic-bin # 预编译版
两个坑:
**
libxdo.so.3**:预编译的 GUI 按 3.x 的 soname 链接,而 Arch 早就是libxdo.so.4。给自己目录做个兼容 shim(不要动系统库):mkdir -p ~/.local/lib/hush-compat ln -sf /usr/lib/libxdo.so.4 ~/.local/lib/hush-compat/libxdo.so.3运行时约 3.6 GB:
hushd依赖 TensorRT 10 + CUDA 12 + AFX 库,这些不在包里,由 GUI 首次启动时自动下载(实测压缩包870 MB,解到 `/.local/share/hush/sdk/2.1.0/,含libnvinfer、libnv_audiofx、denoiser` 模型)。所以第一次必须让 GUI 跑一次。
让引擎脱离 GUI 自启
GUI 拉起 hushd 后会传一个 LD_LIBRARY_PATH(里面就是上面那个 sdk 目录)。把它抄成 systemd 用户服务,之后就不再依赖 GUI:
# ~/.config/systemd/user/hushd.service
[Unit]
Description=NVIDIA Maxine AI 降噪引擎 (HUSH)
After=pipewire.service
[Service]
Type=simple
Environment=LD_LIBRARY_PATH=%h/.local/share/hush/sdk/2.1.0/nvafx/lib:%h/.local/share/hush/sdk/2.1.0/external/cuda/lib:%h/.local/share/hush/sdk/2.1.0/features/denoiser/lib:%h/.local/lib/hush-compat
ExecStart=/usr/bin/hushd
Restart=on-failure
[Install]
WantedBy=graphical-session.target
systemctl --user enable --now hushd
pactl list short sources | grep HUSH # 出现名为 HUSH 的虚拟输入源
实测效果
静音 8 秒(同一时刻分别录原始麦克风与 HUSH 输出):
| 指标 | 原始麦克风 | 经 HUSH | 降噪量 |
|---|---|---|---|
| RMS | −18.5 dBFS | −75.9 dBFS | −57.5 dB |
| 峰值 | 0.0 dBFS | −57.2 dBFS | — |
| 20–120 Hz | 69.6 | 8.3 | −61.3 dB |
| 300 Hz–1 kHz | 55.6 | 5.1 | −50.5 dB |
| 3–8 kHz(嘶声) | 46.2 | 1.7 | −44.4 dB |
也就是说:不说话的时候,电平表真的归零了(之前那套滤波只能压掉低频,中高频的环境噪声一直在)。
语音保真的一组对照:用扬声器放英文语音、同时本人说中文,原始麦克风把两者一起录进去(转写出来是混合的乱码),HUSH 的输出里音箱内容被压掉、只留下本人的说话,正常转写为中文——近场人声保留、远场/混响抑制,符合它对”语音增强”的定义。
代价:引擎常驻占 808 MiB 显存(TensorRT 模型),空闲时 GPU 利用率个位数。不想要 AI 或想省显存时,直接停服务即可(配了个 hush-ctl toggle 小脚本挂进应用菜单)。
接线:谁用降噪
pactl set-default-source HUSH # 让所有应用都用 AI 降噪
pactl set-default-source alsa_input.pci-0000_0d_00.6.analog-stereo # 切回硬件麦克风
- 我可以按需切换:会议/录音/游戏语音选
HUSH,其余场景保持硬件麦克风; - 第 5 节那条”高通 ×2 + 工频陷波”的链我保留着,作为不占 GPU 的降级方案(
clean_mic); - 语音输入(voxtype)固定接
HUSH,不受系统默认输入切换影响。
一点反思
从”装个语音输入”一路折腾到”在 Linux 上跑起 Maxine”,真正解决问题的不是更复杂的算法,而是先把问题量化:
- 频谱分析 → 发现 85% 能量在 50 Hz 以下(工频+结构传导轰鸣)→ 用高通解决;
- 增益扫描 → 发现采集一直在削顶(静音时 19.2 万采样里 8.5 万个贴顶)→ 降 16 dB 增益解决;
- 捂住麦克风 → 把剩下的噪声定性为空气噪声 → 才轮到 AI 降噪登场。
顺序反过来(一上来就上神经网络降噪)不仅贵,还可能像 RNNoise 那样直接把人声一起削掉——第 5 节的四组对照就是这么翻车的。
10. 小结:这次踩的坑
- macOS 独占工具在 Linux 上没有捷径:VoxCode 的核心是 CGEventTap + Accessibility,Wayland 下等于重写平台层,直接换等价工具更快。
- 系统服务的组权限会过期:
systemd --user在你加组之前启动,服务进程拿不到新组;udev的TAG+="uaccess"比重新登录更快。 - 中文场景别用按键注入:fcitx5 会吞掉注入的 ASCII,dotool 也打不出中文——剪贴板粘贴是唯一稳的路径。
- 先量化再优化:以为的「底噪」其实是次声+工频,削顶比噪声更致命;不测频谱就会在错的方向上折腾。
- 神经降噪不总是更好:RNNoise 在这种链路上直接把人声削没了,简单的高通反而解决问题。
- 隔离路由:把降噪源做成独立虚拟输入,系统默认保持原样,其它应用(游戏语音)零影响。
折腾到这里,口述到上屏不到一秒、中文识别可用、私有术语能兜底、游戏语音不受影响——够用了。下一步大概是把术语表接进后处理 prompt,以及试试 SenseVoice/Paraformer 这两个中文向的 ONNX 引擎。