Steven
Steven1 分钟阅读

耳机引发的 bug:当对方从我们的转录里消失了

双声道转录在笔记本扬声器和头戴式耳机上都完美运行。后来有人插上了入耳式耳机,于是他正在交谈的那个人就这样从转录里消失了 — 因为对方一开口,Chromium 就悄悄把我们的音频静音了。十一次修复都失败了。第十二次,改变了整套架构。

工程
音频
转录
GeekBye 发布
耳机引发的 bug:当对方从我们的转录里消失了

GeekBye 转录一段对话的两侧:一个声道是你的麦克风,另一个声道是对方的音频(也就是你的电脑正在播放的声音)。它在笔记本扬声器上运行得天衣无缝。它在头戴式耳机上也行。后来有个用户插上了入耳式耳机,于是他正在交谈的那个人从转录里消失了。不是含糊。不是归错人。是没了。

这是 GeekBye v1.6.12 的故事,一个花了十一次失败修复的 bug,以及我们意识到自己一直在跟浏览器对着干、而不是顺着它来的那一刻。

那条活着却空无一物的轨道

症状透着诡异。你开始一场会议,一切看起来都正常,你说了一个字 — 从那一刻起,对方的音频声道就陷入了纯粹的数字静音。 我们量过:系统音频信号本来处在一个正常的能量水平(RMS 大约在 0.03–0.06),而就在麦克风采到你的声音的那一刻,它掉到 0.0000,然后在这一整场会话里一直停在那儿。

下面这部分让我们搭进去好几天。每一个浏览器 API 都坚称这条音频轨道健康得很。 readyState: liveenabled: truemuted: false。按平台暴露的每一个状态标志来看,这条流都没问题。死掉的是那些 采样数据 — 一根活着的管子,里面流的却只有零。我们花了很长时间不敢相信自己的能量表,因为官方说法坚持这条轨道在正常工作。

触发条件专属于入耳式耳机,而原因是物理性的:一个入耳式耳机是同一个设备,它既是你的麦克风输入,又是你的音频输出。 笔记本扬声器和头戴式耳机把这两者分开了 — 输出去一个地方,麦克风在别处。但当输入和输出是同一个设备时,你按定义就有了一条反馈通路。我们强烈推断(这藏在浏览器的内脏里,所以我们没法引经据典)Chromium 检测到了这种“麦克风加扬声器同处一个设备”的形态,判定这是一条马上就要啸叫的反馈回路,于是为了自保把采集静音了 — 悄无声息,不通过任何一个应用能读到的标志告诉应用。

十一次修复,好几次还更糟

我们先用最显而易见的办法去攻它,并把每一次尝试都记了下来。在两条流上都关掉回声消除、噪声抑制和自动增益。把两条流依次错开一点时间启动,而不是一起启动。给每条流各自的采样率。加一个静音的增益节点。把一切都走同一个共享的音频上下文。改用 AudioWorklet。一路降到最底层的原始 WebCodecs 轨道处理器。

十一种办法。它们不仅失败了 — 好几次还让事情更糟。 有一次引入了 30 秒的延迟。另一次让你麦克风的转录被标成了 对方 说的。我们深陷泥潭,逐个症状去治,而这个 bug 一直在挪位置。

突破来自一次重新框定:到目前为止的每一次修复,都在设法让两条独立的流保持健康。可两条独立的流,恰恰 就是 Chromium 的反馈启发式能看见并静音的那个东西。要是根本没有可供比较的两条呢?

修复:给浏览器一条流,而不是两条

v1.6.12 不再硬碰硬,改了架构。它不再把麦克风和系统音频当作两条独立的流发出去,而是在任何东西离开应用之前,就在客户端把它们混成一条 mono 流。 当两个来源都流经同一条管线时,两条轨道 就没法各自死掉 了 — 再也没有一个双设备的反馈形态供浏览器去检测和静音。触发条件干脆就不复存在。

还有一个令人愉快的额外收获。两条流意味着两条从客户端到后端的 WebSocket,以及两条从后端到供应商的连接;一条混合流把这些坍缩成了一条对一条。修复这个耳机 bug 的一个副作用,是把我们的转录成本砍掉了一半。

但单声道混流有一个明显的问题:如果你把两个人都揉进一条声道里,你还怎么知道谁说了什么? 你已经把那个告诉你答案的分离本身给扔掉了。

从能量里重建“谁在说话”

答案是在混流 之前 就量出每个来源的响度,然后给每一块音频附上一个提示,说明是哪个来源占了主导 — 麦克风、系统,还是静音。更响的来源胜出。在扬声器上,这够简单了。

入耳式耳机还需要多一层,而这正是回声重新登场的地方 — 它指向的方向跟你猜的正好相反。在入耳式耳机上,对方 的声音从耳塞里漏出来,又回灌进麦克风。所以一不小心,他们的回声,从你的麦克风声道进来,就被归到了头上。修复办法是一组懂回声的阈值:当系统音频的播放高过一个小小的底线时,你的麦克风要算作“你在说话”所需跨过的那道门槛就猛地抬高 — 从正常的 0.015 提到 0.1。换句话说:在对方说话的时候,你的麦克风必须清清楚楚、毫不含糊地比他们的回声更响,我们才会把这些话算到你头上。 模棱两可的帧默认归为“系统”,假定它们是回声。这是一个刻意的偏向,它恰恰在过去总会出错的那种情形里,把归属做对了。

那个概念验证 — 单声道混流加上基于能量、懂回声的语音检测 — 是 GeekBye 今天所运行的归属系统的祖先。那套脚手架在证明了自己之后,就在同一个版本里被拆掉了,而那套能量计算后来固化成了一个经过妥善测试的模块。但 v1.6.12 才是这个想法诞生的地方 — 在重压之下,由一位戴着一副入耳式耳机的用户催生。

这个耳机 bug 教给我们的三件事

  1. 一个健康的句柄可以承载死掉的数据。 整场调查的转折,在于相信我们的能量表,而不是平台的状态标志 — liveenabledunmuted,全都在撒谎,而采样数据是零。盯着负载,别盯着 API 的自我报告。如果一条流 声称 自己没问题,去量一量它是不是真没问题。
  2. 当平台跟你的架构对着干时,改架构 — 别去改平台。 我们没法在辩论上赢过 Chromium 的反馈保护,而每一次绕着边缘去关掉它的尝试要么失败要么反噬。移除那个 可被观测的条件 — 两条流变成一条 — 让整个问题烟消云散。移除触发条件,几乎总是比跟浏览器打一场胜仗更省事。
  3. 当你为了稳健而坍缩声道时,你必须把你摧毁的东西重建回来。 混成 mono 杀死了那个承载“谁在说话”的声道分离。那份信息必须从混流前的能量和懂回声的阈值里重新推导出来。这套补偿逻辑不是修复之外的马后炮 — 它 就是 修复的一半。

这是那个终将成为 GeekBye v2 的可靠性故事的第四章。上一章,见 一次掉线不该拖垮整个应用(v1.6.8);这项音频工作所依托的那次管线重写,见 我们删掉了 5,000 行音频代码(v1.6.0);而整条弧线,见 把软件打磨到完美地发布的解剖学