Steven
Steven2 分钟阅读

让 Web Audio 保持存活的三个动词

两次 GeekBye 小版本发布,相隔两个月、落在两个不同文件里,从相反的两端教给我们的音频代码同一个教训:别再把浏览器的 AudioContext 当成用完即弃的东西。一次发布学会了去 resume() 一个被 macOS 在录制中途悄悄 suspend 掉的 context;另一次学会了用 suspend() 而不是 close(),好让一场接一场的会话别再撞上 Chromium 那个大约六个 context 的天花板。resume、suspend、close——这就是全部剧情。

工程
Audio
桌面端
GeekBye 发布
让 Web Audio 保持存活的三个动词

多数时候,一个 Web Audio AudioContext 表现得像个普通对象:你构造一个,接上几个节点,然后就把它忘了。两次 GeekBye 发布,正是这个假设破裂时发生的事——两次,在这个 app 的两个不同部分,相隔两个月。哪一次都不是作为另一次的跟进而被谋划的。但把它们并排放,它们讲的是同一个故事:浏览器的 AudioContext 不是一个一次性对象。它是一份带着真实状态机、稀缺的、由操作系统拥有的资源,而那台状态机的三个动词——resumesuspendclose——就是两次发布的全部剧情。

GeekBye 有两条各自独立的音频管线,这正是同一个教训不得不学两遍的原因。一条是视频录制路径(ScreenRecordingService),它搭一张 Web Audio 图,把麦克风和系统音频混进一个被录制的视频文件。另一条是实时转录路径(RealtimeAudioCaptureService),它搭自己的图去喂语音转文字。每一条都有自己的 AudioContext。下面的每次发布,各修好其中一条。

v1.8.16:视频哑了,转录却没有

第一个 bug 是一种裂脑。你在录一场会议,你切到别的 app 待了一会儿,然后发生了一件微妙的事:视频文件丢了你的麦克风,而实时转录却像什么都没变一样继续滚动。转录发誓你一直在说话。视频是静的。那是一种让人抓狂的 bug,因为两件本该一致的事——「麦克风到底听没听见你」——竟然截然相左。

修好它的那条 commit 精确地为病因命了名:

AudioContext can be suspended by macOS when the app loses focus during screen recording. This silences the mic in the video file while the ScriptProcessorNode (STT path) keeps running on the JS main thread, causing a split where transcripts continue but the video has no mic.

机理是这样。ScreenRecordingService.setupMonoMixingPipeline() 搭起一个 AudioContext、一个会成为视频音轨的 MediaStreamDestination、一个用于静音的 GainNode,以及一对(通过 createScriptProcessor 造的)ScriptProcessorNode,把音频帧泵向 VAD/STT 逻辑。当 macOS 挂起这个 context 时——它会在一次屏幕录制期间 app 失焦时这么做——由音频硬件时钟驱动的那部分图停了下来:MediaStreamDestination 停止接收麦克风音频,于是被录制的视频归于安静。但一个 ScriptProcessorNodeonaudioprocess 回调跑在 JavaScript 主线程上,而那个线程还在滴答不停。于是 STT 那一侧从它最后拿到的东西继续产出转录条目,同一个麦克风的两个消费者就此分道扬镳。

修复就是察觉这次挂起并把它撤销:

audioContext.onstatechange = () => {
  if (audioContext.state === 'suspended' && this.status === 'recording') {
    logger.info('AudioContext suspended during recording, resuming')
    audioContext.resume()
  }
}

这就是全部的正确性改动:盯着 context 的 state,如果它在我们本该还在录制时掉到了 suspended,就调 resume()。这次发布还在麦克风的 MediaStreamTrack 上接了 onmuteonunmuteonended 监听器——不是为了改变行为,而是好让一次 OS 层面的静音或一次设备断连出现在日志里,而不是成为一个看不见的静默之因。当一个 bug 全部的难处就在「音频为什么停了」时,把音频的停止变得_可观测_,就是修复的一半。

几分钟后又落了一条跟进的 commit,去给那些新监听器收尾:cleanup() 现在会在拆掉这张图之前把 micTrack.onmute/onunmute/onended 置空,并设 audioContext.onstatechange = null。那条 commit 备注说它是在「匹配 RealtimeAudioCaptureService 的清理模式」——这个 app 的_另一个_音频服务。记住这个细节,因为两个月后,正是这个一模一样的模式,成了下一次发布不得不重写的东西。

v2.0.2:音频和转录在大约六场会话后死去

第二个 bug 住在另一条管线 RealtimeAudioCaptureService 里,而它有一个绝妙的形状:一切都完美运转,然后当你在一次 app 会话里开开停停录了那么几回之后,音频就……死了。先是系统音频哑掉,接着转录停下。重启 app 又好了——直到又是那么几场会话之后。

那条根因备注值得整段读,因为它是一个教科书式的「bug 背后的 bug」:

Root cause: setupMonoMixingPipeline created a new AudioContext on every start() and cleanup() closed it. Chromium hard-caps hardware AudioContexts at ~6 per page and closed-but-not-yet-GC'd contexts keep their slot, so ~6 back-to-back sessions exhaust the cap. The next context is dead → the silent-monitor passthrough can't play (no system audio) AND the mixer worklet can't capture (no transcript) — the reported 'after 6+ sessions audio dies, then transcript dies'.

陷阱在于,旧代码做的是那件「负责任」的事。每次 start() 都造一个全新的 AudioContext;每次 cleanup() 都对它调 close()。用完一份资源就把它关掉,正是你被教着去做的事。甚至还有一条内联注释在为这份来回折腾辩护——说每来一个新 context 就重新加载一遍 AudioWorklet 模块没关系,因为「每次 start() 一个全新 context,意味着我们总是只付这份代价一次」。

但 Chromium 限制一个页面能同时握住多少个硬件后端的 AudioContext——大约六个——而且,关键在于,close() 并不会在你调用它的那一瞬间就把名额还回来。一个已关闭的 context 会一直占着它的名额,直到它被垃圾回收,而那要等 JS 引擎什么时候腾出手来才发生。所以一套一丝不苟、每次都关的策略,_恰恰_就是把你一步步领进天花板的那个东西:六轮造/关之后,六个名额全被尚未被回收的尸体握着,第七个 new AudioContext() 返回来就是死的,你往它上面接的每一个节点都悄无声息地什么都不做。

修复就是别再把 context 当一次性的。造它一次,并在整个服务的生命周期里复用它:

  • setupMonoMixingPipeline 里,只有当还没有一个、或现有那个是 closed 时,才构造一个 new AudioContext(...)。如果它存在但处于 suspended,就 await audioContext.resume(),而不是造一个新的。
  • cleanup 里,把 audioContext.close() 换成 audioContext.suspend()(并加了守卫,绝不对一个已关闭的 context 去调它)。context 熬过了这场会话。
  • 因为 context 活了下来,那个已加载的 AudioWorklet 模块也随它一起活着——代码不再在清理时重置它那个「worklet 已加载」的标志,于是 audioWorklet.addModule(...) 在整个服务生命周期里只付一次,而非每场会话都付。

那些每场会话的零件——用于麦克风和系统音频的 createMediaStreamSource 节点、混音 worklet,以及那个让 Chromium 的系统音频回环抽头保持存活的静默直通——依然每一轮都搭起来又拆掉。只有 AudioContext 本身长存,所以任何时刻至多存在一个。天花板够不着,因为你从不把 context 一个个往上摞去顶它。

这条 commit 关于自己说了一句诚实的备注:这是手动验证的——「手动验证过:8 场以上接连的会话里系统音频一直听得见、转录一直在流(此前在第 7 场就死了)」——而作者坦白,Web Audio 的生命周期「在 jsdom 里若不新造一套庞大的 mock 装置就没法做单元测试」。那是对的那种诚实。这个失败只在跨越真实会话、面对一台真实音频设备时才现身;一套绿的单元测试本什么也证明不了。「大约六个」和「第 7 场就死」是团队对 Chromium 上限咬人之处的观察,不是一个有文档的常量——代码自己用了个波浪号来留有余地,任何复述它的人也该如此。

resume、suspend、close

把两次发布对齐,那份对称几乎太过工整。两者都关乎同一种对象上的同样三个方法,只是从相反的方向靠近:

  • v1.8.16 学会了 resume() context 被 OS 从脚下 suspended 掉了,而修复是逮住这件事并 resume——让一个本该在运行的 context,继续运行。
  • v2.0.2 学会了用 suspend() 而非 close() 旧代码太急着去销毁 context,而修复是把它挂起并复用——让一个重造起来代价高昂的 context,活着。

它们是同一条原则的两半:别再把 AudioContext 当废弃物。 一半说,别让它在不该死的时候死;另一半说,别在不必杀它的时候杀它。而时间线里有个小小的讽刺——v1.8.16 的清理被写成去「匹配 RealtimeAudioCaptureService 的清理模式」,然后 v2.0.2 恰恰在 RealtimeAudioCaptureService 里重写了那个模式,因为那个模式「每次都 close()」的习惯,结果正是那个 bug。这两个服务不断从彼此对音频生命周期的处理里学习,一次一个修复。

这些发布教会的三件事

  1. 一个 AudioContext 有一台状态机——把 state 当作承重的。 runningsuspendedclosed 不是琐碎小事。OS 能不问你就把一个 context 在它们之间挪动,而你那张图的不同部分反应各异(主线程上的 ScriptProcessorNode 照跑不误;被硬件时钟驱动的目的地则不然)。如果你不盯着 onstatechange,你就是在信任 context 会待在你留它的地方——而在一台真实的桌面上,在失焦与屏幕录制之下,它不会。
  2. close() 不是 free() 「资源要及时关」这条直觉总是对的——正是这条直觉造出了第二个 bug。当资源是一个由浏览器硬性设定、只在 GC 时才回收的池子里的一个名额时,激进地关,恰是你耗尽这个池子的办法。有时对的生命周期是「造一次、常挂起」,而非「每次用都造了再毁」。
  3. 有些 bug 只能穿过真实硬件与真实时间那道门才够得着。 「大约六场会话后死掉」和「你一切走就哑了」,是一个把音频设备 mock 掉的单元测试抓不住的——而团队直白地这么说了,没有拿一个假装通过的测试去糊弄。给你测试装置的极限起个名字;一个要八场真实会话才现身的 bug,值得一句诚实的「手动验证过」,而不是一个什么都不意味的绿勾。

关于上一章,见把一场通话和一个开着的 app 分辨开(v1.8.15–v1.8.19);整段弧线则见把软件打磨到完美地发布出去的解剖学

相关文章

静默是承重的
Steven
Steven2 分钟阅读

静默是承重的

GeekBye v1 的最后两次发布,讲的是同一个令人不安的真相:真实网络之上的实时转录并非无损,而诚实的一步是别再假装它是。v1.8.20 在一次重连中丢弃每一个音频块之前,先在磁盘上留了一份它的副本,并开始把转录里的缺口大声标出来。v1.9.0 为省带宽而停止发送静默——却发现那静默正是转录引擎用来判断一句话是否说完的那个信号。两次发布,都关乎把东西扔掉的代价。

工程
Audio
可靠性
把一场通话和一个开着的 app 分辨开
Steven
Steven2 分钟阅读

把一场通话和一个开着的 app 分辨开

GeekBye 能注意到你加入了一场视频会议,并提出替你一键录下它。检测原来是容易的那一半——一个每十秒读一遍窗口标题的 Swift 二进制。难的那一半是精确度:Zoom 只是开着时不发火,不为一场你已经在录的会议来提示你,也不去静音你真正身处的那场通话里的麦克风。三次发布,而每一次都是一个不得不学会不打败自己的守卫。

工程
macOS
桌面端
把后端从上传路径里拿掉
Steven
Steven3 分钟阅读

把后端从上传路径里拿掉

GeekBye 录下你的屏幕,并把视频存进你的 Google Drive。第一个版本在通往那里的路上,把每一段录制都经由 GeekBye 自己的服务器送过去;一次发布之后,文件从你的机器直奔 Drive,后端被降级成只握着一个指针。有意思的地方在于,那个「直接、可续传」的版本实际上包含的代码有多么少——因为可续传性来自删掉一个代理,而不是来自写一个。

工程
架构
桌面端