上一篇 里,我写到,Hey Jarvis 的第一个 Pipeline(串行语音处理流程)其实很快就实现了最初的需求。

我说一句 “Hey Jarvis”,它录下问题,交给 AI 回答,再把答案读出来。

如果只看流程图,这个语音助手已经完成了。

但真正用起来以后,我发现语音交互里最困难的部分,往往不在“识别了什么”或者“回答了什么”,而在一些更基础的问题:它什么时候开始听?什么时候停止听?它能不能分清我的声音和自己的声音?一段对话结束以后,麦克风到底回到了谁手里?

这些问题没有解决时,模型再聪明,使用者也会很快失去耐心。

Pipeline 的第一个问题:它会听见自己 链接到标题

为了让我知道唤醒成功,Hey Jarvis 会先播放一句简短的确认音。

最早是“在呢”,后来实时语音会话(Realtime)版本使用的是“嗯,我在,请说”。它听起来只是一个很小的体验细节,却引出了整个项目最早、也最反复的一类问题。

Mac 的扬声器播放确认音时,旁边的麦克风也会收到这段声音。如果程序在确认音结束后才开始读取麦克风,那么音频缓冲区里可能还留着刚才的回声。对程序来说,这些数据和用户刚刚说出的话并没有天然区别。

结果可能是这样的:

我说 “Hey Jarvis”
→ Jarvis 回答“在呢”
→ 麦克风重新收到“在呢”
→ Jarvis 以为用户已经开口
→ 录下一段错误的问题

最直接的办法,是播放确认音时完全忽略麦克风。

但这又制造了相反的问题。我听见“在呢”以后,可能会很自然地立刻开始说话,甚至在确认音的最后一个字还没有完全结束时就已经开口。如果这段时间的音频全部被丢掉,我的问题开头也会一起消失。

比如我说“明天新加坡会下雨吗”,系统收到的可能只剩“新加坡会下雨吗”。这次看起来还能理解;换一个问题,少掉前几个字就可能完全改变意思。

所以最终的处理不是简单地“听”或者“不听”,而是把确认音前后分成几个不同区域:

  • 确认音播放期间持续读取麦克风,避免旧声音堆积在缓冲区;
  • 明显属于扬声器回声、削波或溢出的片段不能成为“用户已经开口”的证据;
  • 靠近播放结束的一小段安全音频暂时保留,作为问题开头的候选;
  • 只有确认音结束后仍然观察到真实说话的迹象,候选音频才会进入正式录音;
  • 如果后面没有人继续说话,就安静地回到唤醒状态,不请求 AI。

这套逻辑还需要估计环境底噪,并在最近的一组音频片段里确认持续的人声,而不是因为某一个突然变大的声音就开始录音。开始录音时,它还会把前面大约半秒的音频一起带上,尽量不丢掉第一个字。

一个原本只有两个状态的问题——“在听”或“没在听”——最后变成了对回声、底噪、连续语音和录音前缓冲的共同判断。

“我在”应该是一句承诺 链接到标题

Pipeline 里的确认音主要表示唤醒词已经被识别。

到了实时语音模式,它的含义必须更加严格。

唤醒之后,Hey Jarvis 需要释放本地唤醒使用的麦克风,建立网络连接,完成实时对话配置,准备好播放远端声音,再开放麦克风输入。如果确认音放得太早,我听见“我在”就开始说话,但实时对话可能还没准备好,我说出的内容会直接消失。

所以我后来给这句确认音定义了一个产品层面的含义:

当“嗯,我在,请说”播放完,Hey Jarvis 才应该真正准备好听我说话。

实现这句话,需要等待两个条件:

实时对话已经配置完成
确认音已经播放完成
开放麦克风输入

这两个过程可以同时进行,但任何一个没有完成,用户输入都不能开放。

我试过几种不同的确认方式。

最初的本地确认音很短,启动很快,但它与后面 Realtime 回答的声音和语气不太一致。后来我让 Realtime 模型直接生成确认语,听起来更自然,却把生成时间和网络延迟放进了每一次唤醒。

最后采用的是一个折中方案:先用 Realtime 生成并挑选一段自然的固定确认音,之后把它作为本地资源随应用一起提供。每次唤醒后,浏览器开始播放已经缓存的确认音,同时在后台建立实时对话。这样既保留同一种声音的连贯感,也不需要每次重新生成确认语。

在一次真实 Mac 测试中,确认音在唤醒后约 411 毫秒开始播放,整个系统在约 3.4 秒时开放输入。这里面包含一段约 2.4 秒的完整确认语,所以我优化的并不只是一个越小越好的延迟数字,而是用户听见了什么,与系统实际上能做什么,必须保持一致

允许打断,也意味着允许回声进入系统 链接到标题

连续对话带来的另一个关键体验是打断。

如果 Hey Jarvis 正在回答一个很长的问题,我应该可以直接说“等一下”,它停下来听我,而不是强迫我等待整段语音结束。

这意味着播放答案时,麦克风不能简单关闭。系统必须一边通过扬声器播放回答,一边继续从麦克风判断我是否开口。这就是全双工语音最麻烦的地方:它越像自然对话,输入和输出就越需要同时存在。

在耳机里,输入和输出比较容易隔离。但我最初的使用场景是在房间里直接对着 Mac 说话,声音会从内置扬声器回到内置麦克风。

如果回声处理太弱,Jarvis 会把自己的回答当成我的插话,自己打断自己;如果过滤太强,又可能把我真正的插话一起抹掉。

我最后组合了几层处理:

  • 请求浏览器使用设备支持的回声消除;
  • 对笔记本内置麦克风使用远场降噪;
  • 保留服务端的人声检测和打断能力;
  • 单独记录远端声音什么时候真正开始和停止播放,而不是只看模型什么时候生成完答案;
  • 在真实的 Mac 内置扬声器和麦克风上,分别测试正常回答和人为插话。

这里没有一个只靠单元测试就能证明正确的参数。

自动化测试可以验证事件顺序、状态转换和清理逻辑,却不能替我判断房间里的声音是否自然,也不能证明真实的扬声器回声不会触发错误打断。因此这个项目后来一直把两类证据分开:离线测试证明程序遵守协议,真实设备测试证明人实际听见和说出的行为成立。

麦克风同一时间只能有一个主人 链接到标题

Hey Jarvis 里实际存在两套音频世界。

唤醒前,Python 在本地读取麦克风,只寻找 “Hey Jarvis”。唤醒以后,WKWebView 通过 WebRTC 负责实时对话。会话结束后,浏览器必须先释放麦克风,Python 才能重新开始本地唤醒。

整个过程最重要的约束是:

任意时刻,麦克风只能有一个明确的主人。

本地唤醒拥有麦克风
→ 关闭本地输入
→ WebRTC 获得麦克风
→ 连续对话
→ 关闭 WebRTC 媒体
→ 本地唤醒重新获得麦克风

如果交接顺序错了,轻则某一边拿不到声音,重则两个进程都以为自己正在工作。

我曾经遇到过一个非常直观的现象:一个旧的 Chrome Realtime 页面没有关闭,新的页面又启动了。结果确认音和回答出现了两份。问题并不在模型,而在系统里同时存在了两个媒体拥有者。

从那以后,“只能有一个主人”不再只是实现约定,而成为了整个架构的核心规则。每一次开始、结束、超时和错误恢复,都必须回到同一个问题:现在谁拥有麦克风?前一个拥有者是否已经真的释放?

对话结束也必须是一条完整路径 链接到标题

开始听很重要,停止听同样重要。

当我说“再见”时,Hey Jarvis 不只是停止生成回答。它要先阻止新的输入,播放简短的告别语,关闭远端音频、麦克风轨道、数据通道和实时连接,最后才把麦克风交还给本地唤醒。

任何一步失败,都要在有限时间内进入安全清理,不能永远卡在“正在停止”,也不能在旧会话仍未结束时重新打开本地麦克风。

在一次真实测试中,告别语播放结束后,本地唤醒曾在约 83 毫秒内恢复。这个数字本身不是一个永远成立的性能承诺,也不能代表所有版本的告别流程,但它验证了一件更重要的事:一次对话结束以后,系统确实可以重新回到下一次 “Hey Jarvis”。

为了验证这条循环,我不只测试一次成功的对话,还把它拆成了几个重复场景:唤醒与交接、连续两轮对话、回答中插话、结束后恢复,以及再次唤醒一个全新的会话。

语音助手不是完成一次回答就结束。它必须能够反复回到起点。

最难的不是听懂,而是建立信任 链接到标题

回头看这些问题,我发现它们都指向同一件事:语音界面缺少屏幕和键盘提供的确定感。

输入框里有没有文字,我一眼就能看见;按钮有没有按下,界面可以立即反馈。但面对一个语音助手,我只能根据它的一句话、一个声音或者一次停顿,判断它现在究竟在做什么。

因此,“嗯,我在,请说”不只是一段音频。

它是一份很小但很具体的承诺:我已经被唤醒,我没有把刚才自己的声音当成你,我已经准备好接收接下来的话。

而“再见”也不只是一句礼貌用语。它意味着这次实时连接已经结束,麦克风已经释放,本地隐私边界重新生效,下一次唤醒可以重新开始。

模型决定语音助手能不能给出一个聪明的回答。

这些看不见的时序和边界,决定人会不会相信它真的在听。

下一篇,我会继续写另一类困难:当这套语音能力离开终端和浏览器,变成一个真正的 Mac App 时,权限、安全、睡眠、Settings、进程退出和打包又带来了什么问题。


Building Hey Jarvis 系列:

  1. 我做了一个 Hey Jarvis:从睡前的一个问题开始
  2. Hey Jarvis 的困难 I:让语音交互真正成立
  3. Hey Jarvis 的困难 II:从 Demo 到 Mac 产品
  4. Hey Jarvis 的未来:当 AI 需要自己的入口