三层时间结构与静音间隔:把 ASR 词级毫秒时间戳变成 SRT 字幕轴

4 阅读8分钟

问题定义:字幕轴要的是断点,不是全文

把语音转成文字早就不是难点,难点在文字之外那一维:时间。

字幕轴需要两类信息,一是每条 cue 的起止时刻,二是断点位置——在哪里切一刀,才既不把语义切断,又不让一条字幕长到读不完。多数转写接口只稳定提供第一类,第二类要么不给,要么给的是句级粒度,一句话十几秒,做字幕还得人工再拆。

这轮我用 bl speech synthesize + bl speech recognize 做了一次 TTS→ASR 往返,目的不是测转写质量,而是把返回里的时间结构摸清楚:它到底给几层、每层什么粒度、哪一层能直接支撑切分。

环境

一台配好 bl 的终端,Key 在控制台密钥管理页创建;命令签名与音色清单见阿里云百炼 CLI(bl)。

往返链路:两条命令

合成一段单说话人素材:

bl speech synthesize --text "今天下午三点在第二会议室开选题会,记得带上周的数据报表。" --voice longcheng_v3 --out 通知.wav

转写回来,拿结构化 JSON:

bl speech recognize --url 通知.wav --language zh --out 通知.json

用 TTS 造素材的好处是原文已知,往返差异可以逐字对齐,不必猜哪一处是识别错误、哪一处是输出约定。

机制一:三层时间结构,粒度差一个数量级

返回里的时间信息不是一层,是三层,各自服务不同用途:

层级本轮实际返回能直接支撑的事
全文整段转写文本校对、检索、纪要正文
句级1 段,160–4440ms粗粒度章节标记、进度条打点
词级16 个词,每词带起止毫秒字幕 cue 边界、卡点、逐词高亮

词级的形态是这样(节选):

词起(ms)止(ms)
今天160400
下午400680
3680840
………………
会24802640
记得29203200
报表41604440

关键观察在间隔上:多数相邻词的间隔是 0,"会"到"记得"之间是 2640→2920,空了 280 毫秒。这 280ms 对应原文那个逗号处的自然停顿。

也就是说,句级给的是"这段话讲了多久",词级给的是"每个字落在哪",而词与词之间的空隙才是切分能用的信号。

三层要分开消费,不能混用。全文适合做校对与检索索引,句级适合做章节打点,词级才是 cue 边界的唯一依据。混用的典型后果是把句段当字幕条:本轮一句话素材的句级只有 1 段,三句话素材同样只返回 1 段,一条 cue 会横跨整段语音,时长远超可读范围。

还有一个容易被忽略的点:本轮返回的毫秒值都是 20 的整数倍。这是观察到的现象,不构成对接口最小时间粒度的断言,我没有为此做验证;写换算逻辑时按毫秒处理即可,不要假设它是任意精度。

机制二:静音间隔作为切分信号

标点不在词级输出里——16 个词拼起来是"今天下午3点在第二会议室开选题会记得带上周的数据报表",逗号消失了。按标点切条这条路在数据层面就不成立。

但停顿没有被抹掉,它以时间差的形式留在词级时间戳里。于是切分规则可以完全建立在时间上:

  1. 按顺序遍历词级数组,计算相邻词的 next.start - cur.end
  2. 间隔大于阈值 → 在此处断开,前一段末词的 end 作为 cue 结束时间,后一段首词的 start 作为 cue 开始时间
  3. 间隔小于等于阈值 → 并入当前 cue
  4. 每段 cue 的文本由该段词文本拼接得到

阈值本轮取 250ms,低于实测的 280ms 停顿、高于其余 0ms 间隔,因此断点唯一且落在逗号处。产物:

1
00:00:00,160 --> 00:00:02,640
今天下午3点在第二会议室开选题会

2
00:00:02,920 --> 00:00:04,440
记得带上周的数据报表

两条 cue,起止时间直接来自词级毫秒值,不需要人工对表。这是跑出来的真实文件内容,不是示意。

阈值为什么不能写死

250ms 是对这段素材校出来的经验值,不是常量。它的上下界由素材的停顿分布决定:

素材特征阈值方向原因
语速快、停顿短调小真实停顿可能低于 250ms,阈值偏大会漏切
停顿多的口语、念稿调大句内换气也超过 250ms,阈值偏小会碎切
单句短素材先统计间隔分布再定样本少,凭直觉取值容易切错

工程上的做法是先跑一遍统计:把全部相邻间隔排序,看是否存在明显的双峰(句内 0 附近、句间几百毫秒)。有双峰就把阈值取在谷底,没有双峰说明素材本身不适合靠静音切分。

机制三:数字规范化是输出契约,不是识别错误

往返对照只有一处差异:原文"三点",转写"3点"。

这是 ASR 的输出约定——数字在时间、金额、编号这类语境里被规范化成阿拉伯数字。把它当 bug 去"修"是错的方向,正确做法是当成一个已知契约,在下游按业务需要处理:

下游用途处理策略
字幕文案要求口语写法写一条规范化逆映射(阿拉伯数字→汉字),并限定只在时间/数量语境生效
纪要、检索、结构化抽取保留阿拉伯数字,反而更好解析
需要与原文逐字比对的校验环节比对前先对两侧做同一套规范化,否则差异统计会被这类转换污染

同一批实验里还观察到标点也会被规范化:三句素材转写回来后,"复盘。第二"变成"复盘,第二"。这意味着转写文本不能当作原文的可靠副本,凡是需要精确标点的下游环节,都要么用原文,要么接受规范化结果并显式记录规则。

机制四:句段不是原句,别拿它当字幕条

第二轮素材是三句话:

bl speech synthesize --text "第一个议题是季度复盘。第二个议题,下月选题排期。散会前记得提交周报。" --voice longcheng_v3 --out 议题.wav

转写结果里,三句被并成了一个句段。也就是说句级数组的长度取决于 ASR 的断句策略,与说话人实际说了几句没有稳定对应关系。

这直接影响两个常见写法:

写法为什么不行
一个句段 = 一条字幕并段后一条 cue 会横跨三句,时长远超可读范围
按句级标点二次切分标点已被规范化,句号可能变成逗号,切点不可靠
按词级静音间隔切分可用:停顿是物理量,不依赖断句策略

结论是:句级适合做章节标记,cue 边界应该由词级时间戳推导。

边界

  • 数字规范化:三→3 属于 ASR 输出约定,不是错误;下游写法由业务后处理决定
  • 词级无标点:按标点切条不成立;静音间隔切分是本文方法,阈值需按素材自校
  • 句段会并段:多句素材的句段数不等于原句数,句段不能直接当字幕条
  • 说话人分离未实测:--diarization 本轮未跑(素材为单说话人),多人场景下"谁说了什么"的效果不在本文结论内

另有一条前提:本轮素材由 TTS 合成,干净、单说话人。真实录音的底噪、重叠话、麦克风差异会改变间隔分布,阈值与切分表现需在自己的素材上重跑验证。

落地时的三个接口约定

把这套东西接进管线,需要固定的其实只有三件事:

  1. 输入约定:音频与 --language,本地文件直接给路径
  2. 中间产物约定:词级数组是唯一可信的切分依据,句级只做粗标记,全文只做校对
  3. 输出约定:cue 的起止毫秒直接来自词级字段,SRT 时间格式 HH:MM:SS,mmm 由毫秒换算,不做四舍五入到秒

三条定下来,切分逻辑就是一个纯函数:输入词级数组与阈值,输出 cue 列表。它可以脱离具体接口单独测试,也方便针对不同素材类型配不同阈值。

你们做字幕或语音卡点的时候,cue 边界是从哪一层拿的?如果也在用词级间隔切分,评论区聊聊你们的阈值取值区间和统计方法——我想知道 250ms 这个数在真实录音上偏大还是偏小。