问题定义:字幕轴要的是断点,不是全文
把语音转成文字早就不是难点,难点在文字之外那一维:时间。
字幕轴需要两类信息,一是每条 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) |
|---|---|---|
| 今天 | 160 | 400 |
| 下午 | 400 | 680 |
| 3 | 680 | 840 |
| …… | …… | …… |
| 会 | 2480 | 2640 |
| 记得 | 2920 | 3200 |
| 报表 | 4160 | 4440 |
关键观察在间隔上:多数相邻词的间隔是 0,"会"到"记得"之间是 2640→2920,空了 280 毫秒。这 280ms 对应原文那个逗号处的自然停顿。
也就是说,句级给的是"这段话讲了多久",词级给的是"每个字落在哪",而词与词之间的空隙才是切分能用的信号。
三层要分开消费,不能混用。全文适合做校对与检索索引,句级适合做章节打点,词级才是 cue 边界的唯一依据。混用的典型后果是把句段当字幕条:本轮一句话素材的句级只有 1 段,三句话素材同样只返回 1 段,一条 cue 会横跨整段语音,时长远超可读范围。
还有一个容易被忽略的点:本轮返回的毫秒值都是 20 的整数倍。这是观察到的现象,不构成对接口最小时间粒度的断言,我没有为此做验证;写换算逻辑时按毫秒处理即可,不要假设它是任意精度。
机制二:静音间隔作为切分信号
标点不在词级输出里——16 个词拼起来是"今天下午3点在第二会议室开选题会记得带上周的数据报表",逗号消失了。按标点切条这条路在数据层面就不成立。
但停顿没有被抹掉,它以时间差的形式留在词级时间戳里。于是切分规则可以完全建立在时间上:
- 按顺序遍历词级数组,计算相邻词的
next.start - cur.end - 间隔大于阈值 → 在此处断开,前一段末词的
end作为 cue 结束时间,后一段首词的start作为 cue 开始时间 - 间隔小于等于阈值 → 并入当前 cue
- 每段 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 合成,干净、单说话人。真实录音的底噪、重叠话、麦克风差异会改变间隔分布,阈值与切分表现需在自己的素材上重跑验证。
落地时的三个接口约定
把这套东西接进管线,需要固定的其实只有三件事:
- 输入约定:音频与
--language,本地文件直接给路径 - 中间产物约定:词级数组是唯一可信的切分依据,句级只做粗标记,全文只做校对
- 输出约定:cue 的起止毫秒直接来自词级字段,SRT 时间格式
HH:MM:SS,mmm由毫秒换算,不做四舍五入到秒
三条定下来,切分逻辑就是一个纯函数:输入词级数组与阈值,输出 cue 列表。它可以脱离具体接口单独测试,也方便针对不同素材类型配不同阈值。
你们做字幕或语音卡点的时候,cue 边界是从哪一层拿的?如果也在用词级间隔切分,评论区聊聊你们的阈值取值区间和统计方法——我想知道 250ms 这个数在真实录音上偏大还是偏小。