#我给 AMD 显卡做了一套本地语音输入器:从 ROCm 适配到实时文本注入
我最近一直在迭代一个叫 LocalVoiceInput 的 Windows 本地语音输入器。它不是“录一段音频,再打开网页上传”的转写工具,而是一个真正面向日常输入的桌面程序:按住快捷键或点击悬浮麦克风说话,识别结果会直接出现在当前聊天框、编辑器或文档里;实时结果先跟随语音增长,最终模型完成校正后,再原位替换成本轮最终文本。
目前项目迭代到了 v0.9.9。这一版已经把我最初在意的几件事串成了一条完整链路:
- 不依赖系统 Python,双击 EXE 即可进入启动器;
- 启动器自动检测 CPU、Intel、AMD 和 NVIDIA 计算设备,并给出推荐;
- AMD Radeon 可以通过 Windows ROCm 运行本地 Qwen3-ASR;
- Sherpa-ONNX 提供低延迟实时结果,Qwen3-ASR 负责最终识别;
- 快捷键和桌面悬浮麦克风都能实时输入,并用最终文本安全替换本轮草稿;
- 支持自动停顿结束、个人词汇表、文本整理、数字格式和句末标点策略;
- 支持托盘、开机自启动、全屏自动隐藏悬浮按钮,以及退出时释放模型和显存;
- 每个版本都能生成一个不带运行环境和个人数据的便携原始包,复制到另一台电脑后再一键准备环境。
这篇文章不只是功能介绍。我更想完整记录它背后的工程思路:AMD ROCm 是怎么适配的,模型如何加载和预热,实时文字为什么不能简单地反复粘贴,以及我在 Windows 文本注入上踩过哪些坑。
#一、我为什么要重新做一个本地语音输入器
很多语音识别演示只解决了“把音频变成文字”,但一个真正能每天使用的语音输入器,还要同时解决另外几类问题:
- 延迟问题:大模型最终识别更准确,但不能让用户说完前一直看不到任何文字。
- 输入问题:结果不应只显示在自己的窗口里,而要可靠地写进当前应用。
- 修正问题:实时识别会不断变化,最终识别也可能与实时草稿不同,替换时不能误删原文。
- 硬件问题:同一个 Windows 程序要面对 CPU、NVIDIA CUDA、AMD ROCm 和 Intel XPU。
- 分发问题:不能要求普通用户打开终端、安装 Python、配置依赖和记住启动命令。
- 生命周期问题:关闭 GUI 后,后台模型不能继续占着显存。
因此,这个项目从一开始就不是单独选一个 ASR 模型,而是在做一个完整的桌面输入系统。
#二、整体架构:快模型负责“跟手”,大模型负责“定稿”
我最终采用了两阶段识别架构:
flowchart LR
A["麦克风 16 kHz 音频"] --> B["Sherpa-ONNX 流式识别"]
B --> C["实时结果与增量输入"]
A --> D["完整音频缓存"]
D --> E["Qwen3-ASR 最终识别"]
E --> F["规则或本地 LLM 文本整理"]
F --> G["数字与句末标点策略"]
G --> H["安全替换本轮实时草稿"]
H --> I["当前前台应用"]
G --> J["可选复制到剪贴板"]
K["个人词汇表"] --> B
K --> E
Sherpa-ONNX 的任务是低延迟地产生 partial result,让用户说话时马上看到文字。录音结束后,缓存下来的完整音频再交给 Qwen3-ASR,得到更稳定的最终结果。最终文本还会经过数字、标点、口头语和风格策略,然后只替换本轮已经注入的实时草稿。
这种设计比“只跑一个大模型”多了一些状态管理,但换来了两点非常重要的体验:
- 第一段文字出现得更快,用户知道程序确实在工作;
- 最终仍能享受更强模型在完整上下文上的识别能力。
#三、把脚本项目变成真正能双击的便携 EXE
项目早期需要输入命令启动,这对开发者没有问题,但不适合作为一个日常工具。我后来增加了一个很轻量的原生 C# 启动器,并把它编译成 LocalVoiceInput.exe。
启动器只承担几件关键职责:
- 检查项目内的活动 Python 运行时是否存在;
- 环境已准备好时,用
pythonw.exe无控制台启动 GUI; - 环境缺失时显示“一键准备环境”页面;
- 实时读取安装脚本输出,把四个安装阶段映射到进度条和日志;
- 给子进程注入项目内缓存、临时目录和 MIOpen 缓存位置。
它并不把 Python、模型和几 GB 的依赖硬塞进 EXE。相反,便携原始包只包含启动器、源码、配置和环境准备脚本。用户把整个目录复制到另一台电脑后,第一次双击 EXE,再根据自动检测结果准备本机环境即可。
这样做的好处是:安装过程可见、失败信息可诊断、不同硬件可以安装不同推理栈,而且不会修改系统 Python。
为了避免升级 AMD 环境时破坏原有环境,我还引入了一个很小但实用的机制:用 active-python.txt 记录当前有效的项目内运行时目录。新环境只有在依赖安装、GPU 可用性验证和模型准备都成功后,才会被写成活动运行时。
#四、AMD ROCm 适配:最关键的不是安装,而是正确识别语义
#4.1 先识别真实显卡,再做推荐
GUI 和安装器都会读取 Windows 显示适配器注册信息,过滤远程桌面、虚拟显示器和镜像驱动,然后按实际显卡推荐计算后端:
- NVIDIA:CUDA;
- AMD Radeon:Windows ROCm;
- Intel Arc、Iris、UHD 或 Graphics:PyTorch XPU;
- 无可用 GPU:CPU 通用模式。
这里“计算设备”不再只是一个让用户猜的下拉框。界面会先告诉用户检测到了什么、推荐什么,以及准备环境后会如何验证。
#4.2 AMD 使用独立的 Python 3.12 运行时
当前采用的 AMD Windows ROCm 轮子对应 CPython 3.12,因此 AMD 环境不复用普通的 Python 3.11 运行时,而是建立独立的 Python 3.12 目录。安装器使用官方 embeddable Python,并显式启用项目内的 site-packages。
核心逻辑可以概括为:
$pythonFolder = if ($accelerator -eq "amd-rocm") {
"python312"
} else {
"python"
}
$pythonVersion = if ($accelerator -eq "amd-rocm") {
"3.12.10"
} else {
"3.11.9"
}
随后在这个隔离环境中安装 ROCm SDK 7.2.1,以及与其匹配的 PyTorch 2.9.1、TorchVision 和 TorchAudio。环境变量 PYTHONNOUSERSITE=1 会阻止用户目录里的包意外混入项目环境,pip 缓存、临时文件和模型也都留在项目目录体系内。
#4.3 ROCm 版 PyTorch 仍然叫 torch.cuda
这是整个适配过程中最容易误判的一点。
在 PyTorch ROCm 中,AMD GPU 仍通过 torch.cuda 命名空间暴露。如果代码看到 torch.cuda.is_available() 为真就直接写“检测到 NVIDIA”,那么 AMD 显卡会被错误分类。
正确判断不仅要检查设备是否可用,还要看 PyTorch 是否带 HIP:
is_amd_rocm = (
torch.cuda.is_available()
and bool(torch.version.hip)
)
device_name = torch.cuda.get_device_name(0) if is_amd_rocm else "unavailable"
相应地,NVIDIA 的判断要增加 not bool(torch.version.hip)。Intel 则走独立的 torch.xpu.is_available()。
模型后端的自动设备选择也因此写成:先检查 XPU,再检查 CUDA 命名空间,最后回落 CPU。这里选择到的 cuda 对 AMD ROCm 来说并不是“偷偷用了 NVIDIA”,而是 PyTorch 的兼容接口设计。
if hasattr(torch, "xpu") and torch.xpu.is_available():
device = "xpu"
elif torch.cuda.is_available():
device = "cuda" # CUDA 或 ROCm 都可能走这里
else:
device = "cpu"
#4.4 自动精度与模型装载
Qwen3-ASR 后端在 CPU 上默认使用 float32,在 XPU 或 CUDA/ROCm 路径上默认使用 float16。模型以单批次推理为目标,直接把解析后的设备交给 device_map:
dtype = torch.float16 if device in {"xpu", "cuda"} else torch.float32
model = Qwen3ASRModel.from_pretrained(
model_dir,
dtype=dtype,
device_map=device,
max_inference_batch_size=1,
max_new_tokens=256,
)
当前项目已经在 AMD Radeon RX 9070 XT 上实际完成过 Windows ROCm 运行时、GPU 设备识别和 Qwen3-ASR 本地转写验证。不过这不意味着所有 Radeon 型号和驱动组合都天然兼容,所以安装器不会仅凭“包装好了”就宣布成功。
#4.5 安装完成必须做真实可用性验证
AMD 依赖安装结束后,安装脚本会启动项目自己的 Python,验证三件事:
torch.cuda.is_available()为真;torch.version.hip非空;- 能读出实际 GPU 名称。
如果验证失败,安装器会报错,而且不会把刚才的目录切换为活动运行时。这里的原则是:装上轮子不等于 GPU 推理可用,能被当前运行时真实调用才算完成。
#4.6 把 MIOpen 缓存放进项目内
AMD 第一次执行某些算子时可能需要建立 MIOpen 数据库和内核缓存。启动器和模型后端都会为它设置项目内缓存目录:
os.environ.setdefault("MIOPEN_USER_DB_PATH", miopen_db)
os.environ.setdefault("MIOPEN_CUSTOM_CACHE_DIR", miopen_kernel_cache)
这既避免缓存散落到不可控位置,也让“第一次慢、后续快”的原因更容易观察和解释。
#五、Qwen3-ASR 的第一次推理为什么慢,以及如何预热
第一次使用大模型时,慢的不只是模型文件读取。它还可能包括权重搬运、GPU 上下文建立、算子初始化和 MIOpen 内核缓存。若用户第一次按下快捷键后才触发这些工作,界面就会像“没有反应”。
我最初提出过“启动时先喂一个随机短 prompt”的想法,但真正接入源码后发现,ASR 模型的核心输入是音频,不是聊天模型的纯文本 prompt。只发送一段文字无法完整覆盖语音推理链路。
最终实现是在模型加载后,用 100 ms、16 kHz 的全零静音音频执行一次真实 transcribe,结果直接丢弃:
warm_audio = np.zeros(1600, dtype=np.float32)
model.transcribe(
audio=(warm_audio, 16000),
context="预热",
language=None,
return_time_stamps=False,
)
预热放在后台线程中,GUI 会显示“正在后台预热”和“预热完成”,预热期间暂时不开始新的录音,以免第一次真实识别与预热同时争抢模型锁和显存。
在当前测试机器上,这次首次预热曾耗时约 101 秒,之后模型保持在显存中,后续识别不再重复完整初始化。这个数字只代表那次具体的驱动、硬件、模型和缓存状态,不应被当作所有 AMD 设备的固定指标。
模型停止或 GUI 真正退出时,后端会解除模型引用、执行垃圾回收,并调用相应设备的 empty_cache()。GUI 退出路径还会结束剩余后台推理进程,避免窗口没了、显存却仍被占用。
#六、实时输入不是“每次都粘贴”:两种入口要有两套策略
这个项目最难修的部分其实不是 ASR,而是如何把不断变化的 partial result 安全映射到别人的文本框。
#6.1 悬浮按钮:可以追踪并替换实时修订
点击悬浮麦克风录音时,用户没有按住 Alt 或 Ctrl。实时结果发生变化后,可以删除本轮已经注入的草稿,再输入新的 partial result。因此它既能追加,也能跟随实时模型的小范围修订。
例如:
第一帧:我想做一个
第二帧:我想做一个本地语音输入器
第三帧:我想做一个本地的语音输入器
悬浮按钮路径可以把本轮草稿从第二帧替换为第三帧,但只会删除本轮自己输入的字符,不会触碰光标前原来就存在的内容。
#6.2 快捷键:按住修饰键时不能做破坏性回删
快捷键路径更棘手。假设按住 Alt+Q 说话,此时如果程序为了修订实时文字而模拟 Backspace,Windows 或当前应用可能把它解释为 Alt+Backspace;Ctrl+Backspace 还可能一次删除整个词。结果就是文本顺序错乱、旧内容消失,甚至触发应用快捷操作。
因此快捷键路径采用保守策略:
- 第一段非空实时结果立即输入;
- 后续结果如果以前一段为前缀,只追加增长出来的差值;
- 按键仍按住时,不对发生分叉的 partial result 做回删;
- 用户释放快捷键、最终识别完成后,再统一安全替换成本轮最终文本。
简化后的状态逻辑如下:
if not live_injected:
type_text(partial)
live_injected = partial
elif shortcut_is_held and partial.startswith(live_injected):
delta = partial[len(live_injected):]
type_text(delta)
live_injected = partial
elif not shortcut_is_held:
replace_recent_text(live_injected, partial)
live_injected = partial
这就是为什么同样是“实时输入”,悬浮图标和快捷键不能共用一个粗暴的替换算法。
#七、从剪贴板粘贴改成 Win32 Unicode SendInput
早期版本曾使用“写剪贴板—发送 Ctrl+V—恢复剪贴板”的方式输入文字。这个方案看起来简单,却会制造典型的竞争条件:最终线程、实时线程和用户自己的剪贴板操作可能互相覆盖,表现为偶尔输入上一轮缓存、重复旧句子,或者把当前文本替换成某段历史识别结果。
现在普通文本注入完全改用 Win32 SendInput 的 Unicode 键盘事件。只有用户显式开启“最终结果自动复制到剪贴板”时,程序才会写剪贴板。
实现中有一个非常隐蔽的 ABI 坑:Windows 的 INPUT 是一个包含鼠标、键盘和硬件输入的联合体。若 Python ctypes 结构只定义 KEYBDINPUT,它在 Win64 下只有 32 字节,而系统要求完整的 40 字节结构。SendInput 会直接拒绝整批事件,表面现象就是“识别完全正常,但一个字也不输入”。
因此结构必须完整声明,并在导入时检查大小:
class INPUT_UNION(ctypes.Union):
_fields_ = [
("mi", MOUSEINPUT),
("ki", KEYBDINPUT),
("hi", HARDWAREINPUT),
]
expected = 40 if ctypes.sizeof(ctypes.c_void_p) == 8 else 28
assert ctypes.sizeof(INPUT) == expected
文本按 UTF-16LE 编码成扫描码,每个字符发送按下和抬起事件,并分批提交。这样中文、英文和大部分 Unicode 文本都不需要经过剪贴板。
#八、如何避免“上一轮结果覆盖下一轮输入”
实时识别和最终识别分别运行在不同线程中。如果录音停止后,最终线程继续读取引擎对象上会变化的 live_injected、目标窗口和后端,那么用户开始下一轮录音后,上一轮线程就可能拿到下一轮状态。
这正是“为什么它偶尔会输入某段以前说过的话”这类问题的根源之一。
现在每次停止录音时,程序会为这一轮建立不可变会话快照:
session = {
"live_injected": current_live_text,
"target_hwnd": current_target_window,
"cfg": deepcopy(current_config),
"preview_backend": preview_backend,
"final_backend": final_backend,
}
最终线程只使用自己的快照。同时,在上一段语音仍在生成最终结果时,引擎不会开始下一轮录音。最终注入前还会再次核对前台窗口句柄:如果焦点已经切到另一个程序,结果仍显示在 GUI 中,但不会自动打进错误窗口。
这个设计牺牲了一点并发,但换来的是输入安全。对一个可能操作任何编辑器、聊天框和文档的桌面输入器来说,写错位置比慢一点严重得多。
#九、停顿结束、文本整理和个人词汇表
#9.1 自动停顿结束
录音线程会对每个音频块计算 RMS。只有累计语音超过最短时长后,静音计时才会生效;当持续停顿达到用户设定值时,程序自动停止录音并进入最终识别。默认配置为约 2 秒,界面可在 1~8 秒之间调整。
这避免了环境底噪一开始就触发结束,也让悬浮按钮既可以“再点一次停止”,也可以“说完停顿后自动提交”。
#9.2 最终文本的三层处理
最终 ASR 文本可以依次经过:
- 保守规则:合并异常重复、处理部分口头填充和自我修正;
- 可选本地 LLM:按日常、简洁或科研风格整理;
- 确定性输出策略:数字格式和句末标点。
数字可以选择中文形式或阿拉伯数字形式;型号、版本号等字母数字组合则尽量保持原样。日常聊天模式可以关闭句末句号,科研模式强制保留规范句末标点。
数字和标点被放在最后一层还有一个重要原因:即使本地文本整理模型没有安装、运行失败或超时,用户选择的输出策略仍然会应用到 ASR 原文上,而不是跟着一起失效。
#9.3 一个词汇表,同时影响快模型和最终模型
用户可以粘贴、导入、导出或手动维护常用术语。词汇表会以两种形式进入识别链路:
- 对 Sherpa-ONNX,把中文词拆成 cjkchar 热词格式,并启用
modified_beam_search; - 对 Qwen3-ASR,把高优先级术语整理进
context,提示可能出现的专业词、产品名和专有名词。
程序还会观察最终识别结果。一个候选词达到设定出现次数后,可以自动晋升为启用词条;手动词条始终拥有更高优先级。这样词汇表不是一个孤立的收藏夹,而是同时约束实时和最终两条识别路径。
#十、GUI 不是外壳,它也参与运行时治理
这一版 GUI 采用白色、扁平的 Material 风格,并专门处理了高 DPI 和小窗口下的滚动布局。除了模型、麦克风、快捷键和文本策略,它还承担了不少运行时职责:
- 启动、停止和检查语音服务状态;
- 显示环境准备进度和安装日志;
- 显示 Qwen3-ASR 正在预热、就绪、识别中或最终文本已输入;
- 把快捷键、悬浮按钮位置和各种选择持久化到配置;
- 最小化到托盘,单击托盘图标在显示与隐藏之间切换;
- 支持从托盘真正退出,并在退出时清理模型;
- 悬浮麦克风可拖动,位置跨重启保留;
- 检测当前前台应用是否真正全屏,并自动隐藏悬浮按钮;
- 支持 Windows 登录后自动启动。
悬浮麦克风使用固定的 48×48 逻辑尺寸和矢量绘制,而不是放大一张位图。这样在不同缩放比例下仍能保持圆形,也不会再出现文字裁切或图标变成方块的问题。
#十一、便携发布:每个版本都保留一份“干净起点”
项目的发布脚本会为每个版本创建一个新的只增不覆盖目录。便携包包含启动器、源码、配置、诊断和环境准备脚本,但刻意不包含:
- Python 运行时;
- 已下载模型;
- pip、MIOpen 和临时缓存;
- 用户个人词汇表;
- 当前电脑的麦克风设备编号。
打包时麦克风设备会重置为自动选择。目标版本目录如果已经存在,脚本会拒绝覆盖,避免一次误操作破坏之前可用的发布包。
这种“原始启动器 + 本机一键准备”的模式比打一个巨大压缩包更适合多硬件环境:同一个源包到 AMD 机器上准备 ROCm,到 Intel 机器上准备 XPU,到 NVIDIA 机器上准备 CUDA,不需要为每个设备维护一套完整源码。
#十二、我现在如何验收一个版本
这类工具不能只看“模型返回了字符串”。每个版本至少要分层验证:
- Python 源码能完整编译;
- 快捷键保存后重启仍然生效;
- Win32
INPUT结构尺寸和 Unicode 输入事件正确; - 实时草稿只增量追加或替换本轮内容;
- 最终结果能安全替换实时草稿,不破坏原有文本;
- 前台窗口变化时拒绝误输入;
- 词汇表能正确生成 Sherpa 热词与 Qwen context;
- 数字和标点策略在规则、LLM 失败和关闭整理三种情况下都一致;
- 全屏检测、托盘切换、悬浮位置记忆和高 DPI 布局正常;
- Qwen 预热确实执行真实音频推理,并显示状态;
- AMD 环境能读出 HIP 和实际设备名,而不只是成功安装 Python 包;
- 退出 GUI 后,后台推理进程和 GPU 占用随之释放;
- 新生成的便携包不携带当前机器环境和个人数据。
有些问题只有真实操作才能暴露。例如单元测试能证明替换函数只删除指定长度,却无法完全代替在聊天软件、浏览器和编辑器中按住 Alt+Q 说话的实际体验。因此自动测试、启动器 smoke test 和人工端到端输入测试缺一不可。
#十三、这次开发让我印象最深的几个结论
第一,硬件后端的 API 名称不等于硬件厂商。ROCm 复用 torch.cuda 是现实,代码必须依据 HIP 信息判断,不能依据函数名猜设备。
第二,第一次慢应该变成一个可观察的状态,而不是让用户怀疑程序死了。真实预热、后台执行和明确状态提示,比单纯在文档里写“首次启动较慢”有用得多。
第三,语音识别的最后一公里是文本编辑问题。partial result 会分叉、快捷键修饰符仍可能按住、焦点会变化、上一轮线程可能延迟结束。只有把这些状态建模清楚,识别结果才真正“可输入”。
第四,剪贴板很方便,但不适合承担高频实时注入总线。直接 Unicode 输入再配合目标窗口检查,虽然底层实现更麻烦,却能避开大量历史缓存和竞争问题。
第五,便携不是把所有东西塞进一个 EXE。对包含多个 GPU 推理栈和大模型的程序来说,可迁移的启动器、隔离运行时、可见安装过程和真实设备验证,往往比一个黑盒安装包更可靠。
#十四、接下来还想继续做什么
LocalVoiceInput 现在已经能承担我的日常中文语音输入,但它还远没有到“做完了”的程度。后面我还想继续优化:
- 缩短 AMD 首次内核初始化和模型预热的等待时间;
- 增加更细的端到端延迟与显存监控;
- 继续改善复杂编辑器、远程桌面和特殊输入控件的兼容性;
- 让专业词汇的自动学习更准确,减少普通短语误晋升;
- 为不同设备维护更清晰的模型大小、速度和精度推荐;
- 进一步收紧便携包的自动验收和跨机器回归测试。
#结语
从最初的命令行脚本,到现在可以双击启动、自动检测硬件、在 AMD ROCm 上运行 Qwen3-ASR,并把实时和最终文本安全写入任意前台应用,这个项目真正困难的地方始终不是某一个模型,而是把模型、Windows、GPU、GUI 和人的输入习惯连接成一套稳定的系统。
这也是我想发布 LocalVoiceInput 的原因:它既是一个已经可以使用的本地语音输入工具,也是一份关于“怎样把语音大模型做成桌面生产力软件”的工程记录。
LocalVoiceInput v0.9.9,继续迭代中。
评论