ASR 转写与字幕时间轴:强制对齐、CPS 约束与 SRT 工程化
字幕是视频的"第二叙事线",也是 AI 剪辑管线里牵一发动全身的数据源------口播稿由它生成、AI 分段按它切分、素材推荐靠它的文本做语义匹配。但一条"能用"的字幕和一条"好用"的字幕之间,隔着强制对齐、CPS 阅读速度约束、断句算法、SRT 兼容性四座山。本文拆解 ASR 到字幕的完整数据流。
一、从音频到带时间戳文本:两条技术路线
路线 A:ASR 直接出时间戳
主流语音识别引擎(Whisper 类、云端 ASR)原生输出词级/句级时间戳:
json
{
"text": "大家好今天我们聊时间线引擎",
"segments": [
{ "start": 0.0, "end": 2.4, "text": "大家好" },
{ "start": 2.4, "end": 5.1, "text": "今天我们聊时间线引擎" }
],
"words": [
{ "word": "大家", "start": 0.0, "end": 0.5 },
{ "word": "好", "start": 0.5, "end": 0.7 }
]
}
优点:一次推理全搞定。缺点:句级切分靠模型"感觉",经常出现一句 12 秒的长句(用户读不过来)或 0.5 秒的碎句。
路线 B:ASR 出纯文本 + 强制对齐(Forced Alignment)
先得到纯文本,再用对齐模型把已知文本"贴"回音频时间轴:
text
已知文本:"大家好,今天我们聊时间线引擎"
音频:0.00s ~ 5.10s
对齐输出:每 个 字/词 的精确 [start, end]
适用场景:口播稿是先写好的(TTS 场景的反向应用)、或 ASR 引擎不支持时间戳。对齐算法(CTC 分割、DTW)在已知文本条件下精度比 ASR 自由解码更高------因为搜索空间被文本约束死了。
工程选择:AI 剪辑管线通常两条都要------原素材走路线 A(转写 transcriptText),AI 生成口播稿合成 TTS 后走路线 B(精确对齐出字幕)。两路产出的字幕结构必须同一 schema:
ts
const subtitleCueSchema = z.object({
id: z.string().min(1),
start: z.number().nonnegative(),
duration: z.number().positive(),
text: z.string().min(1),
})
字段极简是刻意的:start + duration 而非 start + end(避免 end < start 的非法态);text 单条单行(断句在切分层完成,不在展示层兜底)。
二、CPS 约束:字幕可读性的硬指标
CPS(Characters Per Second,每秒字符数)是字幕行业的基础指标。中文 comfortable 区间是 4~8 字/秒,超过 9 字/秒普通观众就跟不上了(双语字幕读者还要加看外文的量,上限更低)。
切分算法要把 CPS 当成硬约束:
ts
const CPS_LIMIT = 8
const MIN_DURATION = 0.8 // 单条字幕最短 0.8s,闪一下的让人抓狂
const MAX_DURATION = 6.0
const MAX_CHARS = 20 // 单条最大字符数(中文)
function splitByCps(text: string, start: number, end: number): CueDraft[] {
const totalDuration = end - start
const charCount = text.length
const rawCps = charCount / totalDuration
if (rawCps <= CPS_LIMIT && totalDuration <= MAX_DURATION) {
return [{ start, end, text }] // 天然合规
}
// 超限:按比例切分成 N 条,每条时长 = 字符数反比分配
const needSegments = Math.ceil(rawCps / CPS_LIMIT)
const pieces = balancedSplit(text, needSegments) // 按标点/词边界均衡切
const totalChars = pieces.reduce((s, p) => s + p.length, 0)
let cursor = start
return pieces.map(p => {
const dur = (p.length / totalChars) * totalDuration
const cue = { start: cursor, end: cursor + dur, text: p }
cursor += dur
return cue
})
}
balancedSplit 的切分点优先级:句末标点 > 逗号 > 词边界 > 硬切。中文字幕有一条行规------切分点尽量落在语法边界,"今天我们聊/时间线引擎"可以,"今天我们/聊时间/线引擎"是灾难。用 jieba 类分词器找词边界,比按固定字数硬切好一个档次。
三、时间轴语义:无缝衔接还是留缝
相邻字幕的时间关系有两种流派:
- 无缝流派(Netflix 风格):上一条结束 = 下一条开始,节奏紧凑。
- 留缝流派 (传统影视):条间留 2
4 帧(约 80160ms)空隙,视觉上有"呼吸感",也让"字幕切换"这个事件可被感知。
工程上留缝的坑在于帧对齐 :字幕时间戳最终要按帧渲染,start: 3.427s 在 30fps 下对应第 102.81 帧------不是整帧。两条都各自取整后,可能产生 1 帧重叠(闪烁)或 3 帧缝(不均匀)。解决方案是在切分层就把时间戳对齐到帧网格:
ts
function snapToFrame(t: number, fps: number, mode: 'floor' | 'ceil'): number {
const frame = t * fps
const snapped = mode === 'floor' ? Math.floor(frame) : Math.ceil(frame)
return snapped / fps
}
// 相邻字幕:上一条 end 向下取整,下一条 start 向上取整,天然无重叠
cueA.end = snapToFrame(rawA.end, fps, 'floor')
cueB.start = Math.max(cueA.end + minGap, snapToFrame(rawB.start, fps, 'ceil'))
minGap 建议取 1 帧(1/fps),既保留呼吸感又不破坏紧凑。
四、SRT:一个"简单"格式的兼容性深坑
SRT 是事实标准,但它的"简单"是陷阱:
srt
1
00:00:01,500 --> 00:00:04,200
大家好,今天我们聊时间线引擎
2
00:00:04,300 --> 00:00:07,800
三层校验,缺一不可
导出 SRT 时的工程检查单:
- 毫秒用逗号 :
00:00:01,500不是00:00:01.500------大量老播放器只认逗号,但有些现代工具只认点号。标准答案是逗号(SRT 原始规范),导出可配置。 - 时间码溢出 :超过 1 小时是
01:00:00,000,不要写成1:00:00。padStart(2, '0')每个字段。 - 换行策略 :单条字幕超长时,SRT 支持
\n内部换行。中文单行 ≤ 16 字、双行总长 ≤ 32 字是常见标准。行内换行要平衡(两行长度接近),避免"一行 3 字一行 15 字"。 - 序号连续:从 1 递增,缺失或跳号会被部分播放器拒绝。
- BOM 与编码:UTF-8 无 BOM 是默认,但 Windows 记事本打开中文乱码的历史问题让一些用户需要 UTF-8 with BOM 选项------导出设置里给开关。
- HTML 标签 :
<i>、<b>、<font>在 SRT 里是"事实扩展",激进播放器支持、保守播放器显示原文。导出默认纯文本,样式标签可选。
导入 SRT 则要做宽容解析 :正则宽松匹配时间码(兼容点/逗号)、容忍序号缺失、容忍多余空行、--> 两侧空白任意。解析后每条 cue 都要过 schema 校验 + 时间轴重叠检测(与 document 层 superRefine 的"同轨不重叠"检查对齐)。
五、字幕与 AI 分段的联动
字幕表是 AI 分段的语义地基。分段引擎按字幕的时间连续性和文本相似度聚类:
ts
function groupSubtitlesIntoSegments(subs: SubtitleCue[]): AiSegmentDraft[] {
// 信号 1:句末标点(。!?)→ 强切分点
// 信号 2:间隔 > 0.8s 的静音 → 强切分点
// 信号 3:语义相似度骤降(embedding 相邻窗口余弦 < 阈值)→ 弱切分点
// 聚合成段,每段聚合出 scriptText(拼接 + 润色)与时间范围
}
这带来一个反向依赖:字幕修改要同步失效分段 。用户手工改了某条字幕文本,它所属分段的 scriptText 就过期了。工程处理:
- 字幕 update op 的补丁管线里,检测受影响的
aiSegments(通过subtitleIds外键反查)。 - 标记分段为
stale(UI 显示"内容已修改,建议重新生成口播稿"),而非自动重算------自动重算会覆盖用户已编辑的口播稿,违反最小惊讶原则。
这是典型的派生数据失效 问题:scriptText 是 subtitles 的派生物,但派生过程含 LLM 调用(贵、慢、不确定),所以不能像 computed 一样自动重算,只能显式失效 + 用户触发。数据流设计上要区分"便宜的派生"(自动)与"昂贵的派生"(失效标记 + 手动刷新)。
六、双语与样式:多轨字幕的扩展
进阶需求是双语字幕(中英对照)与样式变体。数据层扩展建议:
ts
// 双语:主文本 + 副文本,而不是两条独立 cue
const bilingualCueSchema = subtitleCueSchema.extend({
textSecondary: z.string().optional(),
})
不推荐"两条时间戳相同的 cue"方案:中文断句和英文断句的最优单位不同(中文按意群,英文按短语),强行时间戳对齐会导致两边都是坏断句。主副文本结构允许两边断句独立、渲染时同框。
七、小结
| 环节 | 关键点 |
|---|---|
| 转写 | ASR 自由解码出 transcript;已知文本用强制对齐提升精度 |
| Schema | start+duration 极简四字段;断句在切分层完成 |
| CPS | 4~8 字/秒舒适区;超限按标点/词边界均衡切分 |
| 帧对齐 | end 向下取整、start 向上取整,minGap 1 帧 |
| SRT | 逗号毫秒、序号连续、行平衡、BOM 可选;导入宽容解析 |
| 分段联动 | 字幕是分段地基;昂贵派生只失效不自动重算 |
| 双语 | 主副文本结构,断句独立 |
字幕系统的设计哲学与时间线一致:时间轴是一等公民,文本是挂在时间轴上的 payload。所有算法(CPS 切分、帧对齐、分段聚类)都围绕"时间不可协商、文本可以妥协"展开。这条原则守住了,字幕系统就能同时服务好人类观众和下游 AI。