> AudioCapture / ASRClient / OverlayWindow 三模块如何协同工作。
本篇是 《TUX IM 开发日志:从零到 0.1》 系列第 6/8 篇。
第 6 篇 · 让中文输入"会说话"——ASR 语音输入子系统
第 6 篇 · 让中文输入"会说话"——ASR 语音输入子系统
拼音、五笔、混打都是键盘输入。语音输入是另一种范式:用户按住一个键,说一句话,引擎把语音转成文字上屏。这篇讲 TUX IM 的 ASR 子系统怎么把这套流程集成到 IBus 引擎里。
为什么靠云 API,不做本地
先说一个非技术决定:TUX IM 不做本地语音识别。
- 本地 ASR 需要训练好的声学模型(几百 MB),还得跑 inference。Python 写本地 ASR 推理慢、效果差。
- 云 API(OpenAI Whisper、Google Speech、Azure)效果好、成本低。
- TUX IM 的定位是输入法引擎,不是 ASR 系统——应该让 ASR provider 可插拔。
> 设计原则:ASR 子系统是一个独立的、可插拔的子系统,核心引擎(engine.py)不直接调 ASR,而是通过 ASRHandler 这个协调者。
三个模块怎么协同
ASR 子系统分三个模块(tux_im/asr/):
- AudioCapture(
capture.py)——从麦克风录音,存成 WAV bytes。 - ASRClient(
client.py)——把 WAV 字节流 POST 到云 API,返回转写文本。 - OverlayWindow(
overlay.py)——一个 GTK 悬浮窗,显示录音状态 + 转写结果。
这三个模块不直接互相调用——ASRHandler(handler.py)是协调者。
ASRHandler:状态机
ASRState 枚举
class ASRState(StrEnum):
IDLE = "idle"
RECORDING = "recording"
PROCESSING = "processing"
RESULT = "result"
状态流转:
- 用户按
Ctrl+``(start_asr` shortcut)→ ASRHandler.start() - IDLE → RECORDING:启动 AudioCapture,显示 overlay("🔴 录音中…")
- 用户再按 `Ctrl+“ → ASRHandler.stop()
- RECORDING → PROCESSING:停止录音,调用 ASRClient 转写,overlay 变成"⏳ 转写中…"
- PROCESSING → RESULT:收到 API 响应,overlay 显示转写文本。
- 用户按 Enter / Ctrl+` → ASRHandler.commit():通过 callback 把文本上屏到焦点应用。
overlay 的"不抢焦点"设计
ASR overlay 是个 GTK 窗口,浮在屏幕中间显示当前录音状态。它必须做到:
- 显示出来,但不抢焦点(用户焦点还在编辑器上)。
- 用户继续打字时,overlay 不能挡住输入框。
- 转写完成后,能让用户预览后再决定是否上屏。
关键代码(简化):
overlay 不抢焦点
class OverlayWindow:
def __init__(self):
self._window = Gtk.Window(type=Gtk.WindowType.POPUP)
# POPUP 窗口:不接受焦点,不在任务栏
self._window.set_accept_focus(False)
self._window.set_focus_on_map(False)
self._window.set_skip_taskbar_hint(True)
> GTK 里 Gtk.WindowType.POPUP 是关键——它告诉窗口管理器"我只是个浮窗,别给我焦点、别让我进 alt-tab 列表"。配合 set_accept_focus(False),焦点永远留在用户原来的应用上。
从麦克风到 API:异步流
AudioCapture 不能阻塞主线程(主线程是 GLib 主循环),否则整个 IBus 引擎会卡住。
方案:AudioCapture 用 sounddevice(PortAudio 绑定)开一个录音线程,回调把音频帧写到 bytes 缓冲区。ASRHandler 通过 GLib.idle_add() 把"音频准备好了"事件投递回主线程。
录音线程 → 主线程
class AudioCapture:
def __init__(self, ..., on_silence):
self._on_silence = on_silence # callable
# sounddevice 的 InputStream,开新线程跑
self._stream = sd.InputStream(
samplerate=16000,
channels=1,
callback=self._audio_callback)
def _audio_callback(self, indata, frames, time, status):
self._buffer.extend(indata.tobytes())
# GLib 主线程跑 on_silence
GLib.idle_add(self._on_silence, self._buffer)ASR provider 可插拔
ASRClient 接收 endpoint / api_key / model / language / timeout 5 个参数,跟 OpenAI Whisper API 兼容的 endpoint 都能用:
ASRClient 接口
class ASRClient:
def __init__(self, endpoint, api_key, model, language, timeout):
self._endpoint = endpoint
# 兼容 OpenAI /v1/audio/transcriptions 协议
def transcribe(self, audio_bytes: bytes) -> str:
# multipart/form-data POST
# 返回转写文本配置里:
config.toml 中的 ASR 配置
[asr]
provider = "openai"
api_endpoint = "https://api.openai.com/v1/audio/transcriptions"
api_key = "sk-..."
model = "whisper-1"
language = "zh"
sample_rate = 16000
channels = 1要换 Azure / Google,只要改 api_endpoint 和 api_key。
ASR 失败怎么处理
网络挂了、API key 错了、麦克风没权限——任何环节都可能挂。处理策略:
- 网络/API 错误:overlay 显示 ❌ 错误信息,不上屏任何东西。
- 麦克风没权限:启动时检测,提示用户授权。
- 用户主动取消:按 Escape → discard 所有录音,overlay 关闭。
> AGENTS.md 里专门强调:ASR failures: show error in overlay, do not commit anything。宁可什么都不输入,也不要把半截识别结果上屏到用户编辑器里——那会污染用户正在写的内容。
ASR 跟拼音引擎怎么互动
ASR 跟拼音模式是并列的,不是叠加的。用户按下 `Ctrl+“ 时:
- 拼音 buffer 如果有内容,先强制 commit(
mode.commit())。 - 然后 ASRHandler.start() 启动录音。
- ASR 转写完成后,文本通过 callback 调 engine.commit_text(),上屏到应用。
- ASR 不会污染拼音 buffer(拼音引擎完全不知道 ASR 的存在)。
这种"互不感知"的设计来自 ASRHandler 的设计——它通过回调跟 engine 通信,而不是直接持有 engine 引用。
本篇小结
- 云 API 是务实的选择——本地 ASR 的 Python 推理既慢效果又差。
- 三个模块 + 一个协调者:AudioCapture / ASRClient / OverlayWindow 通过 ASRHandler 协作。
- overlay 不抢焦点:Gtk.WindowType.POPUP + set_accept_focus(False)。
- ASR 失败不上屏:宁可什么都不输入,也不要污染用户编辑器。
- provider 可插拔:兼容 OpenAI Whisper API 协议的 endpoint 都能用。
> 下一篇:从 Emoji 到 Google 拼音:InputMode 协议的胜利。讲讲怎么用 ctypes 绑 Google Pinyin C 库,以及为什么 InputMode 协议让这一切几乎零成本。
评论