
静默是承重的
GeekBye v1 的最后两次发布,讲的是同一个令人不安的真相:真实网络之上的实时转录并非无损,而诚实的一步是别再假装它是。v1.8.20 在一次重连中丢弃每一个音频块之前,先在磁盘上留了一份它的副本,并开始把转录里的缺口大声标出来。v1.9.0 为省带宽而停止发送静默——却发现那静默正是转录引擎用来判断一句话是否说完的那个信号。两次发布,都关乎把东西扔掉的代价。
GeekBye v1 的最后两次发布——v1.8.20 和 v1.9.0,正好在第二版里程碑之前的那两次——共享一个只有把它们放在一起读才会变得明显的主题。两者都关乎这个 app 正在悄悄扔掉的东西,而两者都抵达同一个结论:你为效率而丢掉的东西,做的活比你以为的要多。一次发布丢的是网络重连期间的音频。另一次丢的是为省带宽的静默。两种情形里,被丢掉的那个东西,到头来都是承重的。
实时转录并非无损,而 v1.8.20 承认了这点
关于在一条 WebSocket 上流式做语音转文字,有一个容易别过脸去的事实:当连接断掉又重连时——或者当 app 从一个转录提供方倒换到另一个、从 Deepgram 到 ElevenLabs 时——客户端会有意丢弃那段缺口里捕获的音频块。它不得不这么做。一个刚接上的提供方,若突然收到一大堆积压的陈旧音频,会被噎住,或更糟,把它的语音活动检测器锁到错的东西上。所以客户端把那些块扔掉。
问题不在于丢弃。问题在于丢弃从前_意味_着什么:那些话就是从没出现在转录里,而且没有任何东西告诉你它们缺了。录制看上去是完整的。它不是。
v1.8.20 从两条战线上攻打这件事,而其中哪一条都不是「让重连永不丢音频」——因为你做不到,在实时里做不到。相反,它把这份损失变得可恢复且诚实。
可恢复的那一半,是一个持久的 PCM tee。在主进程的捕获处理器里,捕获到的每一个块如今都会在它触及重连与宽限期的丢弃闸门_之前_,被写到磁盘上的一个文件里:
every captured chunk is written to
<userData>/sessions/session-<ts>.wavbefore realtime drop gates so reconnect-lost audio is preserved for a future batch-STT reconciliation pass
那个机理低技术得让人舒服:teeChunkToDisk 打开一个 WriteStream,先预留一个 44 字节的 WAV 头,把原始的 16 位单声道采样随到随追加,并在停止时用最终的尺寸回补那个头(fs.openSync(..., 'r+'),在偏移 0 处写 44 字节)。结果是一个_完整_音频的可播放 WAV——包括实时转录从没见过的那些部分。录制结束时,它发出一个带着文件路径和字节数的 session-audio-saved 事件。
这里有两条诚实的备注要紧,因为这件事很容易被夸大。第一:在这几次发布里,tee 并不填上那个缺口。它把原始音频保留下来,_为_日后的一趟对账——一次把文件与实时转录对齐的批量重转录——而这是这些发布埋下的一个钩子,不是它们出货的一个功能。跟进的那条 commit 甚至给 session-audio-saved 加了一个指向对账相关 issue 的 TODO 注记,好让没人把它错当成死代码。第二:这要花磁盘。团队估算每 30 分钟录制大约 86 MB,所以同一次发布加了一个 StartupCleanupService,去修剪掉早于 7 天的已保存会话。一张无限生长的安全网是一处泄漏;正是那次清理让 tee 变得可以出货。
诚实的那一半更简单。在第一次重连尝试时,客户端往转录里写下一条真实、可见的条目:
[reconnecting — audio may be missing]
那一行字,是这次发布整个哲学的一个缩影。app 无法保证它听见了一切,所以它不再暗示自己听见了。一个你看得见的缺口,是一个你能绕过去的缺口;一个你看不见的缺口,是一份正等着发生的 bug 报告。
v1.9.0:你为优化而抹掉的那份静默,正是那个信号
v1.9.0 是最后一个 v1 发布,也是后端把一组实时转录的职责下放给客户端的地方——丢弃静默、依据一条本地语音活动时间线把每一行转录归到麦克风或系统音频、拥有那条重连循环、强制录制上限。其中大部分是第二版架构的地基工程,且已在别处讲过。但其中有一条线索,是这两次发布里最好的那个小故事,因为它是一个不得不去撤销一次「孤立来看完全正确」的优化所造成的伤害的修复。
它从一个显然是好的改动开始:别再发送静默。 客户端开始在把那些「主导来源是静默」的块送上 socket 之前,先把它们滤掉——更少带宽,更少让后端去处理的无用音频,而且它遵守了一条明说「别发送静默」的后端客户端契约。做这件事的每一条理由都是站得住脚的。
而它彻底破坏了断句终结。修好它的那条 commit,以不寻常的坦率把这条因果链摆了出来:
After the client began filtering silence chunks … ElevenLabs Scribe's VAD-based commit strategy no longer fires — EL needs ~1s of silence in the audio stream to commit a partial, and that silence never reaches it. Result: partials accumulate forever, UI shows trailing "...", utterances never finalize.
这是「漏洞百出的抽象反咬一口」的理型。转录引擎的语音活动检测器并没有一个单独的「说话人说完了」的输入——它是从听到音频里大约一秒的静默来_推断_一句话的结束。静默不是线上的噪声;它是一条消息。为省带宽而把它滤掉,客户端就无意中停下了那个唯一告诉转录引擎「一句话已经说完」的信号。于是每一段 partial 转录都只是不停地长,尾巴拖成一个永不了结的 ...。
你本可以把静默放回去来修这件事——但那你就把优化撤销了。真正的修复更好:发送静默的意义,而不发送静默本身。 客户端如今在本地跟踪音频已经静默了多久,而当那越过 SILENCE_COMMIT_THRESHOLD_MS = 1000——有意地与后端自己的 VAD 静默阈值对齐——时,它就带外触发一个显式的 commit 信号:sendMixedChunk 注意到那份持续的静默,经由 IPC 发出一个 realtime-audio:commit,主进程在 socket 上转发一个 { type: 'commit' },后端便告诉 ElevenLabs 去终结那个 partial。两小片状态,silenceStartedAt 和 silenceCommitFired,让它不至于反复触发。带宽上的胜利活了下来;终结又能工作了;末尾的 ... 了结了。
这次发布还有一个小小的、诚实的脚注:v1.9.0 引入的那套无界重连逻辑,最初是在_渲染进程_里作为一个 WsReconnectController 造出来的,随后在同一次发布之内,一旦真正的实现落到它本该待的主进程处理器里,就被删掉了。重连故事本身——无界退避、每五次失败轮换提供方——在它自己的章节里讲;v1.9.0 添上的,是那套架构诞生的地方,也是客户端第一次接手它的地方。
这些发布教会的三件事
- 当你无法阻止损失时,让它可恢复,并让它可见。 v1.8.20 并不阻止重连丢弃音频——那在实时里物理上办不到。它为日后留了一份原始音频的副本,并给转录里的缺口贴上标签。哪一样都不光鲜;两者合起来,把无声的数据损失变成一种已知的、有界的、可恢复的状况。
- 一张安全网需要它自己的边界。 那个持久的 tee 若放着不管,会悄悄把磁盘填满——一个会变成慢动作 bug 的修复。同一次发布里出货的 7 天清理,不是事后的补想;它正是那个让 tee 根本上能安全出货的东西。如果你加了一个会累积的机制,就在同一口气里把修剪它的机制也加上。
- 你为效率而丢掉的东西,可能是一个信号。 这两次发布里最锋利的那一课:把静默滤掉,在你通常会检查的每一根轴上都是正确的——带宽、负载、契约合规——它却仍然弄坏了产品,因为一个下游系统正在从那份静默里读出意义。当你为效率而移除数据时,问一问是什么正在从那份数据里_推断_着某件事。有时你不得不显式地把意义发出去,恰恰因为你停下了发送那个它从前赖以被推断出来的东西。
而那就是第一版故事的终点——重写之前的最后一次加固。上一章,见让 Web Audio 保持存活的三个动词(v1.8.16 + v2.0.2);这一切都在奔向的地方,见一个真正的第二版需要付出什么:206 次提交(v2.0.0);而整段弧线,见把软件打磨到完美地发布出去的解剖学。