识别出文字只是开始

模型把话听对了,离一份能读的稿子还差好几步。

语音识别的原始结果为什么不能直接给用户看?

识别器输出的是一串字,标点零散、没有分段、句子还可能在音频切块的边界被拦腰截断。要变成能读的稿子,得重新决定在哪里断句、在哪里分段,标点也要整段重排一遍。

  • 早期按固定 30 秒硬切音频,句子经常被从中间切开
  • 现在切分点只落在真实的静音处,优先选停顿超过 0.3 秒的位置
  • 标点先整段剥掉,再用模型重新加一遍,不然会和识别器自带的标点叠在一起
  • 实时转写的断句阈值调过三次,才在切得太碎和积成巨块之间找到位置

固定时长切块是错的

导入一段长音频做转写,不能一次性喂给模型,得切成块。最早的做法是按固定 30 秒硬切。

这个做法的问题在真实录音里很明显:切点落在哪儿完全看运气。一句话说到第 29 秒还没说完,剩下的半句就进了下一块。两块各自识别,中间那个词往往两边都不对,读起来是一句话断在半空,下一段又从半个词开始。

现在的做法是先用 VAD 扫一遍整个文件,拿到所有的语音区间和它们之间的静音间隙,再把语音区间聚合成不超过 28 秒的解码窗口,窗口的分界点只允许落在静音间隙上,优先挑停顿超过 0.3 秒的位置。如果碰到极端情况,一段音频里连续说了很久没有一个像样的停顿,才退而求其次,在窗口里找那个最长的间隙切下去。

切点从此由内容决定,不再由时间决定。代价是要多跑一遍 VAD 全文件扫描,换来的是句子不会被拦腰截断。

标点要先剥掉,再重新加一遍

SenseVoice 的输出自带标点,但那个标点是逐窗口独立产生的,窗口之间没有上下文。把十几个窗口的结果拼起来,标点密度和风格并不一致。

所以标点这一步要重做,用的是 CT-Transformer,一个专门做标点恢复的离线模型,int8 量化后大约 70MB,和 App 一起打包。它的输入契约是不带标点的原文。

我第一次接的时候没注意这一点,直接把识别结果喂了进去,结果输出里出现了「。,」「,,」这样的组合,模型加的标点和识别器自带的标点叠在了一起。修法是在送进模型之前先用一个函数把所有标点剥干净,只留下汉字、字母、数字,空白折叠成单个空格,然后再恢复。

这个坑值得记一笔,因为它不会报错。产出是一份看起来正常、细看到处是重复标点的稿子,如果测试用例里没有恰好跨窗口的长句子,很容易漏过去。

模型不在的时候怎么办

标点模型有可能加载不了,资产包没下完、文件损坏、磁盘满,都有可能。这时候不能让整个转写失败,用户要的是文字,标点漂不漂亮是第二位的。

现在标点服务在模型不可用时返回空,由上层走降级路径:保留识别器自带的标点,再用规则做一遍修补。出来的稿子标点会比模型版本粗糙一些,但内容是完整的,用户能读、能编辑、能导出。

实时断句的三个数字

实时转写要一边说一边出字,就得决定什么时候认为一句话说完了、可以定稿上屏。这件事由三条规则控制,各自管一种情况。

第一条管的是完全没出字的静音,超过 2.4 秒就认为这一轮结束。第二条管已经出了字之后的尾部停顿,超过 0.7 秒就切。第三条是兜底,一块连续说了 15 秒还没遇到任何停顿,硬切。

这三个数里有两个是改过的。sherpa-onnx 的默认值是尾部停顿 1.2 秒、硬切上限 30 秒,直接用下来发现不对:中文说话的句间停顿常常只有零点几秒,1.2 秒这个门槛基本抓不到,结果就是一直不切,一直攒,最后攒够 30 秒硬切一次,屏幕上突然蹦出一大块字。用户看到的是转写卡了半分钟然后一次性刷出来。

调到 0.7 秒和 15 秒之后,正常带停顿的说话大概每 2 到 6 秒切一块,实时感回来了。中间还怀疑过是不是自适应增益把停顿填平了导致检测不到,专门验了一次,不是,停顿能正常检出。