Steven
Steven2 分钟阅读

我们删掉了 5,000 行音频代码 — 然后转录开始重复出现

GeekBye v1.6 拆掉了两个设备端 Swift 转录器,用一条统一的管线替换 — 净删除超过 5,000 行代码,而且是在人们正开着会用这个应用的时候完成的。接着转录开始重复出现,因为 bug 迁移到了那个仍以为存在两个引擎的层。

工程
转录
架构
GeekBye 发布
我们删掉了 5,000 行音频代码 — 然后转录开始重复出现

重构此刻正在运行的、服务于正在开会的人的代码,有一种特别的恐怖。你没法把功能下线维护 — 有人正指望着它转录一段无法重来的对话。GeekBye v1.6 恰恰对应用里最关键的那条路径做了这件事: 音频管线。这是关于我们删掉了什么、什么坏了,以及从中得出的两条可靠性教训的故事。

两个引擎,其中一个用 Swift 写的

在 v1.6 之前,GeekBye 的语音转文字在设备端跑在 Swift 二进制文件里。一共有两个: 一个包裹着 Apple 的 Speech 框架,另一个更大(超过 1,500 行),它通过 ScreenCaptureKit 采集双路音频 — 你的麦克风加上系统音频 — 并打开它自己的 WebSocket 直连我们的后端,把数据流送往 Deepgram。两个转录器、两条采集路径、两种和网络对话的方式,全都在一门编译型语言里,这意味着每一次改动都要重新编译一个二进制文件。

v1.6.0 把这一切收拢成一条管线。那一个统一性的提交删掉了 Apple Speech 转录器(约 1,145 行)、Deepgram 的 Swift 采集器(约 1,523 行)、两个 TypeScript 桥、旧的编排器,以及双路音频的 IPC 处理器 — 净删除超过 5,200 行。 取而代之的是: 渲染进程通过标准 Web APIs 采集音频,以 PCM 形式经 IPC 送出,而 Electron 主进程持有一条通往后端的单一 WebSocket 路径,具体用哪个服务商由后端配置决定。一条采集路径,一个套接字持有者,一处可供推理的地方。

删掉五千行代码的感觉很棒。棒了大约一天。

bug 迁移到了那个仍以为存在两个的层

接着转录开始重复出现。一句说出口的话会被保存成两条一模一样的条目 — 有时更糟。

根本原因是这整次发布里最有教益的东西,因为它是一条关于统一的普遍规律。我们在主进程里统一了服务商 — 一个套接字,一个处理器。但 React 层没收到通知: 它仍然同时挂载着两个特定于服务商的事件钩子,一个为 Deepgram 而建,一个为 ElevenLabs 而建。两个都在监听。两个都在保存。每一条转录都被当时还活着的钩子持久化了 — 而它们俩都活着。

在这之下还有第二重、更隐蔽的重复: ElevenLabs 会把每一行终稿发送两次 — 一次作为 committed 事件,再一次作为 committed_with_timestamps。所以即便只有一个钩子,一句话也可能作为两个事件到达。

修复(在 v1.6 这一支里)是一个干净利落、恰好对应两个成因的两部分方案: 把每个事件钩子挡在一个 enabled 标志之后,这个标志由后端的活跃服务商配置驱动,于是任何时候只有一个监听者是活的; 再加上按来源做的文本去重(为麦克风和系统音频各自记住上一条保存的行),以吞掉 ElevenLabs 的双发。

这个教训比这个 bug 本身更大: 当你把两个后端统一到一个接口之后,重复并不会消失 — 它会迁移到那个仍然假设存在两个的层。 我们在主进程里收拢了管路,而漏点在往上两层的地方冒了出来,就在 UI 的事件接线里。统一一条管线,不是在生产者被统一时就完成了; 而是在每一个消费者都不再相信旧形态时才算完成。

第一版重连: "试五次就放弃"

v1.6.0 还交付了 GeekBye 有史以来第一版带指数退避的 WebSocket 自动重连 — 它值得拿出来展示,恰恰因为它是我们今天所运行的那个坚不可摧版本的原始祖先

最初那版是有限的。五次尝试,延迟为 1s、2s、4s、8s、16s — 然后它投降了,发出一个致命错误: "连接已丢失 — 请重启转录。" 在当时那感觉很负责任: 别永无止境地重试,诚实地告诉用户。实际上它是一个伪装起来的 UX bug。十六秒的网络闪断 — 一次 Wi-Fi 切换、一次 VPN 重连、一条隧道 — 是短暂的,但有限重试把它当成了终结性的。用户什么错都没犯,却被告知去重启一个正在进行的会议。

那正是当前设计所消除的失败。今天 GeekBye 以无上限退避重连,甚至会轮换服务商,只在真正致命的原因下才停下 — 认证、配额、账单。从"试五次就放弃"到"永不因短暂错误而放弃"的整段旅程,都讲在为什么你的 AI 会议记录工具在糟糕的 Wi-Fi 下会停里。v1.6 是这条路开始的地方,带着那个不得不先存在的幼稚版本。

陈旧的连接会一直缠着你,除非它们有名字

还有一条可靠性教训落在了 v1.6.3,修复了一类微妙的 bug,任何在有状态后端上做重连的人最终都会遇到它。

当你重连时 — 或者在会话中途切换转录语言,这在底层也会重连 — 旧的连接并不总是安静地死去。它临终时的消息(CONNECTION_LOSTdisconnected)可能在新连接已经健康之后才到达,并拆掉那个完好无损的替代者。修复给每一次连接尝试一个身份 — 一个盖在 WebSocket URL 上的、按连接分配的会话 ID — 外加重连之后一个短暂的宽限窗口,在这段窗口里,来自先前尝试的陈旧断连消息会被忽略。它还会在关闭一个套接字之前发送一条明确的 stop 消息,让后端能干净地拆掉旧会话。

原则是: 任何在有状态后端上的重连逻辑,都需要给每次尝试打上一个身份标签,并把来自先前尝试的迟到消息当成噪声 — 否则一次成功的重连会被它前任的临终一口气杀死。

v1.6 教给我们的三件事

  1. 统一会重新安置 bug; 它并不删除 bug。 把两个引擎收拢成一个,然后审查每一个仍以为存在两个的消费者。我们的那个是 UI,在管路已经合并之后还挂着两个事件钩子。
  2. 你的第一版重连会是有限的,而有限对实时流是错的。 "试 N 次就放弃"把一次短暂闪断变成一个用户被怪罪的终结性失败。从第一个版本起就把致命的(认证、配额)、短暂的(掉线的套接字)区分开来。
  3. 重连需要身份。 给每次尝试打标签; 忽略来自已死尝试的迟到消息。一个没有名字的连接,可能被它自己的鬼魂谋杀。

这是最终成为 GeekBye v2 的那个可靠性故事的第二章。第一章请看那次花了四天六次发布才让应用变得杀不死的自动更新传奇(v1.5.x); 重连工作最终抵达的地方,请看为什么你的 AI 会议记录工具在糟糕的 Wi-Fi 下会停; 至于整条弧线,请看把软件打磨到完美的解剖