实时转写为什么会丢字
一场 5.6 分钟的录音,丢了 71% 的内容。
边录边转的时候,字是从哪一步丢的?
至少有三个地方。一是音频排队排不过来被主动丢弃,二是识别器对着有声音的片段输出了空串、上层当成静音跳过了,三是修完前两个之后,环境噪声被送进识别器产生了幻觉短词。每一处都要单独查、单独修。
- 排队上限从 10 秒放宽到 180 秒,积压音频只占每秒 64KB
- 重放测试里丢失率从 83% 降到 0
- 有语音能量却输出空串的片段,换一个引擎能解出完整句子
- 光靠能量门槛拦不住噪声幻觉,还得加一道文本层的门槛
第一处:排队排不过来,就把音频丢了
实时转写的结构是麦克风持续产出音频块,识别器在另一条线程上消费。产得比消得快的时候,队列会积压,得有个策略。我当时设的上限是积压超过 10 秒就开始丢新来的块。
取证的时候拿一场 5.6 分钟的真实录音跑,最后统计丢了 71% 的内容。10 秒这个数看着不小,但识别一块的耗时会随着设备发热、其他应用抢 CPU 而波动,一旦某几秒卡住,后面就雪崩式地丢。
算了一下积压的实际成本:音频是每秒 64KB,积压 180 秒也就 11MB 出头,对手机来说不算什么。于是把上限从 10 秒放宽到 180 秒,宁可排队也不轻易丢语音。
光放宽还不够,还做了三件事。冲灌音频时加了节流,积压超过 4 秒就先让解码追上来再继续喂;后台精修任务按积压情况分步让路,单次占用不超过 5 秒;丢弃的秒数记进数据库,线上到底丢没丢、丢了多少,可以直接查。
最后写了一套重放测试,把真实录音喂进整条链路复现问题。修之前重放丢 83%,修完是 0。
第二处:识别器说这里没有声音,但其实有
丢音频修完之后,还是有用户说实时转录会漏句子,而且重新转录一遍能找回来。
取证抓到一段 18.7 秒的录音,问题出在第 2.7 到 5.3 秒。这一段的音频能量是 0.0202,明显不是静音,但流式识别器对它输出了空字符串。我的上层逻辑看到空串就认为这块没内容,不建块,音频也跟着丢掉了。同一段音频交给离线引擎,能解出完整的一句。
所以「识别器返回空」和「这里真的没人说话」是两件事,我之前把它们当成了一件。
修法是给有语音能量的空块加一条救援路径,送到离线引擎再解一次。门槛定在时长至少 1 秒且能量至少 0.01。这个数是从取证数据里挑的:真实语音的能量在 0.018 到 0.035,尾部噪声在 0.006 到 0.008,中间有一段干净的空隙。不足 1 秒的能量尖峰不救,那些通常是碰撞声。
救回来的内容要过跨语种护栏和纯标点过滤,然后按时间顺序插回原位,直接标成精稿状态,翻译照常跟进。
第三处:修好上一个,冒出来一个新的
救援上线之后,真机实拍发现了新问题:安静环境里放着不动,屏幕上每两三秒蹦出一个词,「的。」「我。」轮流刷。
原因是环境噪声也能达到 0.01 的能量门槛,于是被当成空块救援,送进离线引擎。引擎对着一段纯噪声,产生了 The、I 这类短词幻觉,再被翻译成中文,就刷起屏来。
能量门槛在有环境噪声的场合是拦不住的。所以又加了一道文本层的门槛:救援出来的内容,拉丁文至少 2 个词、中文至少 2 个字,才允许建新块。这道门只管没有粗稿证据的新建块路径,已经存在的块做更新时不走它,因为块存在本身就说明那里有语音。
这里有个明确的取舍:宁可丢掉边界处的孤词,也不放幻觉进稿。测试样本里有一句话的句首是个单独的 I,加了门槛之后它不再被补回来。我认为这个交换是值的,用户看到少一个词,和看到满屏「的。我。的。」,感受完全不是一个量级。
顺带把一个参数扫了一遍
实时转写有个硬切上限,一块连续说了多久还没遇到停顿就强制切断。原来是 15 秒。想压低它让定稿更快,但担心切碎了影响准确率,所以做了一轮扫参。
拿一段 55.7 分钟的真实会议,从 5 秒到 15 秒扫了 8 档。结果是:粗稿的错误率对这个参数完全不敏感,全档一样;精修后的错误率在 10 秒及以上是平的,9 秒开始单调恶化,压到 6 秒会比基线差 41%。
恶化的机制也看清楚了:硬切会把词从中间剁开,切点越密错误越多,每多一个硬切点大约多 0.4 个词的错误。不是护栏误拦,护栏的拦截率全档持平。
所以 10 秒是个免费的档位,压到这儿准确率不掉,而定稿等待时间从平均 5.84 秒降到 4.67 秒,p90 从 12 秒降到 9.2 秒,代价只是块数多了 13.5%。想再快就不能继续压这个参数了,得改成按自然语义边界切句。
这轮扫参也顺手否决了一个原本写在设计文档里的设想,那个设想认为压到 6 秒左右是安全的。数据不支持。
不是模型不够好,是喂给它的太长了
英文识别一直不如中文,很自然的想法是再加一个专做英文的小模型。评估了四个候选,全部劣于现役模型:现役在真实英文会议音频上的词错误率是 42.1%,两个 whisper 小模型分别是 54.4% 和 64.9%,两个 moonshine 更差,到了 98% 和 121%。唯一确实更好的是个 3GB 的大模型,体积上根本不可能装进手机。
顺带说一句,用简单的英文对照音频测的时候,四个候选全都是 0% 错误率,完全区分不出好坏。测试素材不够难,就测不出差距。
真正的原因是输入长度。同一个模型、同一段音频,整段 35 秒解码的词错误率是 56%,切成 10 秒窗口再解是 42%。中文也是同向的:45 秒档的字错误率 5.4%,15 到 20 秒档是 3.1% 到 3.4%。中英文的共同最优区间在 15 到 20 秒,而当时段级精修的输入上限是 45 秒,长段反而比按块处理还差。
所以这个问题的解法是 0MB 的:把精修的解码窗口切到 20 秒以内就行,不用加任何模型。
改的时候还带出一个跨语种护栏的漏洞。含混不清的英文段落,自动语种检测有时会滑向粤语,输出「系冇好多睇。」这种东西。原来的护栏只拦日文假名和韩文谚文,粤语用汉字书写,直接穿透。补上汉字方向的拦截之后又发现一个反作用:如果不要求「必须有明确的英文证据」才启用这个方向,那些空粗稿救援出来的中文内容会被全盘拒绝,因为它们本来就没有粗稿可比对。这个条件现在被测试锁着。