LOADING

加载过慢请开启缓存 浏览器默认开启

在 KDE Wayland 上折腾本地语音输入(voxtype):底噪诊断、RNNoise 翻车与 DeepSeek 后处理

环境: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 = truekey = "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。但实测有两个叠加问题:

  1. 我平时开着 fcitx5(双拼)fcitx5-remote 返回 2(激活态)时,注入的 ASCII 会被输入法收进候选窗,应用一个字都收不到——这就是为什么最初几次「注入测试」全部失败。
  2. 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+Vpaste_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        # 预编译版

两个坑:

  1. **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
    
  2. 运行时约 3.6 GBhushd 依赖 TensorRT 10 + CUDA 12 + AFX 库,这些不在包里,由 GUI 首次启动时自动下载(实测压缩包 870 MB,解到 `/.local/share/hush/sdk/2.1.0/,含 libnvinferlibnv_audiofxdenoiser` 模型)。所以第一次必须让 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”,真正解决问题的不是更复杂的算法,而是先把问题量化

  1. 频谱分析 → 发现 85% 能量在 50 Hz 以下(工频+结构传导轰鸣)→ 用高通解决;
  2. 增益扫描 → 发现采集一直在削顶(静音时 19.2 万采样里 8.5 万个贴顶)→ 降 16 dB 增益解决;
  3. 捂住麦克风 → 把剩下的噪声定性为空气噪声 → 才轮到 AI 降噪登场。

顺序反过来(一上来就上神经网络降噪)不仅贵,还可能像 RNNoise 那样直接把人声一起削掉——第 5 节的四组对照就是这么翻车的。

10. 小结:这次踩的坑

  1. macOS 独占工具在 Linux 上没有捷径:VoxCode 的核心是 CGEventTap + Accessibility,Wayland 下等于重写平台层,直接换等价工具更快。
  2. 系统服务的组权限会过期systemd --user 在你加组之前启动,服务进程拿不到新组;udevTAG+="uaccess" 比重新登录更快。
  3. 中文场景别用按键注入:fcitx5 会吞掉注入的 ASCII,dotool 也打不出中文——剪贴板粘贴是唯一稳的路径。
  4. 先量化再优化:以为的「底噪」其实是次声+工频,削顶比噪声更致命;不测频谱就会在错的方向上折腾。
  5. 神经降噪不总是更好:RNNoise 在这种链路上直接把人声削没了,简单的高通反而解决问题。
  6. 隔离路由:把降噪源做成独立虚拟输入,系统默认保持原样,其它应用(游戏语音)零影响。

折腾到这里,口述到上屏不到一秒、中文识别可用、私有术语能兜底、游戏语音不受影响——够用了。下一步大概是把术语表接进后处理 prompt,以及试试 SenseVoice/Paraformer 这两个中文向的 ONNX 引擎。