1.08 秒能打断语音 agent,抢回麦克风要两道锁

Andee 是一个装在自己 Android 手机上的 agent。他没有主界面,身体是一个前台服务:一颗球浮在别的 App 上面,靠无障碍服务读屏幕上的控件树,自己把点击、滑动、打字发出去。你也可以直接跟他说话——点一下球,麦克风开;他答完,声音从同一个喇叭出来。因为他住在设备里,那只麦克风既是他的,也是来电、导航、你刚点开的短视频的。

11 月 3 日,量子位实测了一个叫 Orilo 的语音原生 agent。被夸得最多的一项能力是:被你打断之后重新开口只要 1.08 秒,在 Full-Duplex-Bench 里是最快的,对比 GPT Realtime 1.50 秒、Gemini Live 2.62 秒。评测里还有一句更要紧的话——你中途打断、派他去后台建日程,嘴上不停,顺着刚才的话题继续往下说,不会掉进那段两分钟的死寂。

这篇讲那个 1.08 秒量不到的另一半:在设备上,"被打断"不是一个延迟指标,是一次端口所有权的交接。我们为他写了两把方向相反的锁、留下过一条疤,还有一件事到现在也没做到。

一、1.08 秒量的是"多快能重新开口"

先说它在测什么。

Orilo 是两层架构:前端一个超低延迟的语音模型负责听、接话、处理打断,后端一个慢思考的 agent 在幕后读邮件、查日历、调接口。执行的那十几秒里前端还能陪你聊,所以没有空白。1.08 秒量的是前端那一层:你说完最后一个字,另一头多久能接上话。

这个指标很硬,也确实解决了一个真问题——过去两年语音 agent 几乎都死在同一处:答应一声"好的",然后安静两分钟。但这个数字是模型侧的。它没有回答另一个问题:在你开口的那一刻,凭什么轮到那个 agent 听?

在一台真手机上,这个问题有物理形态。

二、打断的第一件事,是让喇叭闭嘴

AsrController.kt 里有一句注释,写的是我们请求音频焦点的理由,原文很朴素:被压低的声音仍然从喇叭里出来,而喇叭离我们即将打开的麦克风大约一英寸。

一英寸。这就是整件事的起点。

所以"被打断"在设备上不是"我该怎么回应你",而是先让喇叭停下来。他正在说话,你开口,这两个动作在 Android 的音频策略里抢的是同一个资源:音频焦点。谁拿到,谁出声;没拿到的那一方必须闭嘴。

Android 只提供了一个机制来表达"我要用一下音频设备":AudioManager.requestAudioFocus。我们把它包成了一个 145 行的类,叫 AudioFocusGate,并且在两个方向上各开了一道。

两道锁的差别只在一个参数上,但那个参数决定了用户体验的方向。

说话时,请求 AUDIOFOCUS_GAIN_TRANSIENT。 这是有礼貌的那种:一个守规矩的播放器可以选择把音量压低(duck)而不是完全停,我们只要盖过它就行——反正在录音的不是我们。

收音时,请求 AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE。 这个不是讲究排场。代码注释写得很直白:普通的那种会让一个"听话"的播放器选择 duck 而不是停,而被压低的音乐仍然从喇叭出来、仍然进麦克风。对着一个正在放视频的平板做语音识别,不加这一条是没救的。

同一件事,两个方向,对"礼貌"的要求正好相反。这是最容易写错的一处:给语音链路配一把锁,最后要么打断不了,要么听不清。

三、半句回答不是回答

还有一处更细的取舍。

系统给的焦点丢失通知有三种:LOSS(有人彻底接管)、LOSS_TRANSIENT(来电这类临时占用)、LOSS_TRANSIENT_CAN_DUCK(“你要不要自己小点声”)。第三种最微妙:它不是在抢,是在商量。

对一个正在说话的 agent,我们把这个商量当成了终止。代码里的理由是一句话:半句回答、四分之一的音量,不是回答。 所以三种丢失一起处理,并且在构造请求时打开了 setWillPauseWhenDucked(true)——意思是"别替我决定要不要降音量,让系统来问我"。

规则没问题,问题出在同时用在了另一侧。

四、一个疤:任务跑到一半,点球,他立刻不听了

AsrController 里有一个私有函数叫 lossEndsTurn,注释开头是一句自我批评:

他以前总是结束这一轮——那是说话侧需要的规则,被套用到了不出声的那一侧上。

代价长这样:任务跑到一半,你点一下球想补一句"等等,改成明天"。麦克风开了,然后立刻关了。因为你被驱动的那个 App 正在前台,它自动播放的视频、刚收到消息的提示音、上一次点击打开的那条语音,每一个都是一次音频焦点请求。而你刚说完半句话。

0.7.0 修的就是这个。修法不是加判断,是换了一种问法:让听的那一侧有权拒绝。

AudioFocusGate 因此有两个 acquire 重载。第二个多收一个 decide: (change: Int) -> Boolean:系统通知丢了焦点时先问一句「这一轮算不算完」,返回 false 就继续听,回调保持武装,等下一次。

lossEndsTurn 于是变成三条分支:

  1. 只要求 duck 的,直接不理。 麦克风没有音量可以 duck,这个请求对它没有意义。
  2. 来电,结束这一轮。 判据是 CallState.isActive,或者音频模式不是 MODE_NORMAL——后者在响铃那一刻就变,比通知还早。
  3. 其他抢走焦点的,讨回来,但一个回合最多两次。 常量 MAX_REGRABS = 2。理由是:不加这个上限,两个 App 会互相抢到天亮;超过两次就不再讨,宁可丢掉焦点也不丢掉你正在说的话。

这条修复在 CHANGELOG 里留了一句很少见的自我说明:“Reasoned from the code, not from a capture: the Huawei test phone keeps no app logs and its voice key is invalid, so this is not yet verified on a device.” 意思是,它是从代码推出来的,没在真机上验证过。这里现在不行——这条路径到今天仍然是"应该对",不是"测过对"。

五、为什么不能边听边说:10.2 秒

到这里有一个自然会问的问题:既然两边抢得这么难看,为什么不干脆同时开?一边说一边听,用户随时插话,像人一样。

因为我们试过,代价是 10.2 秒。

AudioIO.kt 在 startStreaming 上面有一段注释,标题是「故意不挂 AcousticEchoCanceler / NoiseSuppressor」。它们本来是我们给"播放器选择 duck 而不是停"准备的第二道防线。挂上去之后,小米平板 5 上实测:

  • 只挂降噪:第一次 read() 10.2 秒后才返回
  • 两个都挂:20.4 秒
  • 都不挂:159 毫秒

十秒钟没有声音的麦克风不是"体验下降",那是整个回合。火山那边一个会话八秒收不到包就断,用户点球、对着空气说完一句话,收到一条"网络超时"——而网络从来不是问题。

于是我们做了取舍:不追求边听边说,只保证"你说停,我立刻停,然后把麦克风还给你"。

这也是为什么在这套实现里,打断必然有先后顺序:不是"多快能接话",而是"先让喇叭闭嘴,再开麦"。所以 1.08 秒式的指标在我们这儿不是慢一点的问题,是根本没在做同一件事。一句话说清:他能被打断,但他不能同时听和说。

六、判据为什么不在他手上

最后一个容易忽略的点:这一整套判断的输入,来自系统,不来自模型。

CallState 只有 59 行,只管一件事:现在是不是有一通电话。它用的是 ConcurrentHashMap.newKeySet<String>(),装的是 StatusBarNotification.key——不是布尔值。注释里给了两个必须用集合的理由:

  • 并发来电:WhatsApp 的未接来电横幅还在,拨号器的响铃通知又来了。用布尔值,第一条 onNotificationRemoved 一到就把状态清了,通话中途麦克风就开了。
  • 通知更新:同一通电话从"正在呼叫…“变成"通话中 00:12”,系统会重新 post 同一个 key。按键去重,才不会重复计数。

而"为什么不用 getMode()"那段写得更长:MODE_IN_CALL 在某些 OEM 上要电话权限;MODE_IN_COMMUNICATION 是App 开了麦克风之后才置位的——太晚了,唤醒词已经撞上去了;而且它完全覆盖不到响铃那一段。

唤醒词那一侧更直白,startWakeWord 里那个 gate 是与的关系,七项全真才拿麦克风:没退出、允许听、ASR 空闲、TTS 停着、没在录音、没有来电、提示音没在响。少一项他就会听见自己说话——注释里那句是"否则 Andee 会听到自己说出一个接近唤醒词的东西,然后被自己的声音叫醒"。

判据每 100 毫秒看一次(POLL_MS),任一项变假就松手。

七、现在不行的地方

三处:

  1. 没有回声消除,所以没有全双工。 不是没做,是做了会把麦克风冻住 10.2 秒。这条取舍的代价就是你没法在他说话的中途插一句"不对,换个方向",你只能先按停。
  2. 上面那条 0.7.0 的修复没在真机验证过。 测试机不保留 App 日志、语音 key 又失效,这一条至今是推理正确而不是实测正确。
  3. 中断只有本地那两层是保证的。 stopEverything 的注释写得很清楚:三层里,本地两层一定停,已经派发给这台设备的工具请求会跑完。云端那一层只是"请取消"。

回到开头那个 1.08 秒。它是个好数字,量的是模型多快能开口。而在一台设备上,开口之前还有一步:把喇叭关掉,把麦克风拿回来,确认抢它的不是一通电话。这一步没有 benchmark,只有一英寸和两次讨价还价。

Logo

更多推荐