> 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/):

  • AudioCapturecapture.py)——从麦克风录音,存成 WAV bytes。
  • ASRClientclient.py)——把 WAV 字节流 POST 到云 API,返回转写文本。
  • OverlayWindowoverlay.py)——一个 GTK 悬浮窗,显示录音状态 + 转写结果。

这三个模块不直接互相调用——ASRHandlerhandler.py)是协调者。

ASRHandler:状态机

ASRState 枚举

Python
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 不抢焦点

Python
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() 把"音频准备好了"事件投递回主线程。

录音线程 → 主线程

Python
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 接口

Python
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 配置

Toml
[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_endpointapi_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 如果有内容,先强制 commitmode.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 协议让这一切几乎零成本。

最后修改: 2026年7月1日

作者

评论

发表评论

您的邮箱地址不会被公开。