我给终端里的 Pi Agent 装上了"语音输入法"

一个朴素的想法

事情的起因很简单:我每天都在终端里用 pi(一个 AI 编程助手)写代码,打字打累了,突然冒出一个念头——能不能按住一个键,直接跟它说话?

就像微信的"按住说话"一样,说完松手,文字自动出现在输入框里,检查一眼,回车发送。

听起来不复杂,但 pi 是一个纯终端应用,没有 GUI、没有麦克风按钮、没有任何语音相关的内置功能。不过它有一套 TypeScript 扩展机制,可以自己注册命令、监听事件、甚至起 HTTP 服务。这就够了——剩下的活儿,自己干。

这篇文章记录从想法到跑通的完整过程,包括方案选型、架构设计,以及中间踩过的每一个坑。

先想清楚:三条路

动手之前,我把可能的方案列了出来:

方案一:macOS 自带听写。 系统级功能,任何输入框都能用,双击 Fn 键开始说话。优点是零配置,缺点是完全不受控——它只是一个"语音键盘",没法做"按住说话、松手发送"这种交互,识别质量也一般。

方案二:本地 ASR 服务 + pi 扩展。 自己跑一个语音识别服务,写一个 pi 扩展负责录音和调用,再搞一个全局热键触发。完全可控,但要写代码。

方案三:全双工语音对话。 边说边识别、AI 回复用 TTS 朗读、支持打断。体验最科幻,复杂度也最高——VAD、流式识别、打断处理,每一个都是坑。

我选了方案二。理由很朴素:先把"按住说话"这一个场景做扎实,比什么都重要。 全双工是后来的事,地基不稳,楼盖不高。

技术选型:为什么是 FunASR

语音识别引擎的选择,我考虑过 Whisper,最终用了阿里达摩院开源的 FunASR,具体模型是 SenseVoiceSmall。原因:

  • 中文效果好,中英混说也能识别(对程序员很重要,毕竟我们会说"帮我跑一下 npm install")
  • 自带标点和 ITN(逆文本正则化),说"百分之五十"直接输出"50%"
  • 。非自回归架构,实测 9.5 秒的音频转写只要 0.6 秒
  • 完全本地,不要 API key,不花钱,录音不出本机

模型从 ModelScope 下载,约 1GB,首次下载一分钟以内。

架构:三个零件,各司其职

整体设计拆成三个独立组件,通过本机 HTTP 通信:

图表

voice-asr 服务(Python):常驻进程,启动时预加载模型,暴露两个接口:/health/transcribe。只干一件事——收 WAV 音频,返回文字。用 uv 管理独立的 Python 3.11 环境,不污染全局。

pi 扩展(TypeScript):住在 pi 进程里,负责录音(调 ffmpeg)、调 ASR 服务、把文字填进输入框。同时自己在 127.0.0.1:9878 起了一个 tiny HTTP 服务,用来接收热键事件。还注册了一个 /voice 命令作为备用触发方式。

hotkey.py(Python):全局键盘监听,检测"长按 Option ≥0.2 秒",向 pi 扩展发 HTTP 请求。

为什么录音放在 pi 扩展里而不是 ASR 服务里?因为 ffmpeg 录音天然是子进程模型,Node 的 spawn 管起来很顺手;而 Python 那边保持无状态,挂了重启也不丢东西。职责单一,出了问题知道去哪修。

踩坑实录

过程当然不是一帆风顺,一共踩了五个坑,都不大,挑三个有代表性的说一嘴:

  • 新版 FunASR 把 torch 改成了可选依赖,装完一跑直接报错,补上才算完;
  • FastAPI 一个 422 查了半天,根因是 from __future__ import annotations 和函数内 import 的"组合拳"——这类坑教程里永远没有,只在真实开发里出现;
  • ffmpeg 枚举录音设备时发现 :0 是 iPhone 的麦克风(Continuity Camera 的锅),MacBook 自带麦克风排在 :1,差点把音录到不知道放在哪的手机上。

全是"问题不大、但需要现场勘查"的类型。这也是自己做工具和调现成 API 的区别:坑都很具体,解决一个少一个。

细节设计:防误触是刚需

全局监听 Option 键,最大的风险是误触。做了三层防护:

  1. 0.2 秒阈值:Option 按下后启动计时器,超过 0.2 秒才判定为"长按"。正常打字中 Option 都是作为修饰键快速点按,不会触发。

  2. 组合键取消:Option 按下后的 0.2 秒窗口内,如果按了任何其它键(比如 Option+方向键跳词、Option+Delete 删词),立即取消长按判定。这两个操作是程序员肌肉记忆的重灾区,必须保住。

  3. 前台应用检查:只有终端(Ghostty)在前台时才触发。切到浏览器查文档时,手指搭在 Option 上也不会突然开始录音。

另外一个产品决策:识别完先填入编辑器,人工确认后回车发送,而不是直接发送。语音识别再好也有出错的时候,把"发送"这个动作留给人,成本是多看一眼,收益是避免把识别错误的指令直接丢给 AI 执行。扩展里留了 autoSend 开关,喜欢全自动的可以打开。

跑起来的样子

全部装好后,体验是这样的:

长按 Option(状态栏出现 ● 录音中…)
  ↓ 说话:"帮我把今天的改动提交,commit message 写得详细一点"
松开 Option(状态栏变成 ⏳ 转写中…)
  ↓ 约 1 秒后
输入框出现文字,检查,回车

实测性能(M 系列芯片,CPU 推理):

指标 数值
模型加载(仅启动时一次) 4~8 秒
9.5 秒音频转写耗时 0.6 秒
RTF(实时率) ≈ 0.065
短指令(2~3 秒)端到端延迟 约 1 秒

模型常驻内存,第二次开始就是纯推理时间。这个延迟对"按住说话"场景完全无感。

彩蛋:这套系统,是 AI 给"自己"装的

最后补一个有点 meta 的事实:上面这套语音输入系统——从架构设计、三个组件的全部代码,到五个坑的当场排查——都是 K3 模型通过 pi 一次完成的,零返工

"零返工"这三个字的含金量,体验过的人才懂。FastAPI 那个 422,它看到错误响应体的下一秒就定位到了 from __future__ import annotations 加函数内 import 的组合拳;iPhone 抢占麦克风那个坑,它在枚举设备列表时就提前拆了雷,我根本没机会踩。五个坑,四个是它自己写完代码顺手埋了又当场拆掉的,一个是提前预警。整个过程里人类(也就是我)只做了两件事:确认方案,和说"开搞"。

当然,缺点也得照实说:K3 的额度不太经用,烧起来有点快。好马费草,好模型费 token——一次做对的代价,是每一次都不便宜。希望后面额度能大方一点。毕竟模型用顺手之后,回不去的是人。

最后

整套东西拆开来,没有任何高深技术:一个 ASR 模型、一个 HTTP 服务、一个扩展、一个键盘监听。但把它们严丝合缝地拼起来,要处理权限、竞态、设备枚举、防误触这些"脏活累活"。

做工具的体感就是这样:20% 的时间写功能,80% 的时间处理边界情况。 但当你按住 Option 说出第一句话、看到文字准确出现在输入框里的那一刻,会觉得这些坑都踩得值。

后续的优化方向也想好了:流式识别(边说边出字)、TTS 朗读回复(全双工)、识别历史记录。不过那是下一个迭代的事了。

如果你也在终端里用 AI 编程助手,不妨也试试给它装个"耳朵"。


技术栈:pi + FunASR (SenseVoiceSmall) + ffmpeg + pynput + FastAPI,全部本地运行,零 API 成本。

END