Nemotron 3.5 Transcribe 出文本、时间戳对齐交给后处理

文章导读
Nemotron 3.5 Transcribe 这类转写模型通常只对「文本」负责,时间戳多半落在分段级,甚至只到词或字一级,句子边界要靠后处理补。判断顺序建议是:先打印一条完整输出确认字段与时间单位,再决定时间戳在并句环节取首尾,还是交给强制对齐工具重算;字幕不对齐时不要先怀疑模型,先看自己的合并规则。
📋 目录
  1. 一 先打印一条完整输出,看清字段结构
  2. 二 把碎段合并成可读句子
  3. 三 句子级时间戳的两种补法
  4. 四 导出 SRT 时容易踩的格式坑
  5. 五 随机抽十句回听核对对齐
A A

Nemotron 3.5 Transcribe 这类转写模型通常只对「文本」负责,时间戳多半落在分段级,甚至只到词或字一级,句子边界要靠后处理补。判断顺序建议是:先打印一条完整输出确认字段与时间单位,再决定时间戳在并句环节取首尾,还是交给强制对齐工具重算;字幕不对齐时不要先怀疑模型,先看自己的合并规则。

把转写结果当成「文本 + 分段边界」,不要指望它直接产出句子级字幕。先用日志打印一条完整输出,确认 start/end 是秒还是毫秒、层级在哪一层;再用标点与停顿阈值并句,时间戳取首段起点、末段终点;需要严格句界时再接强制对齐。验收靠随机抽十句回听,把整体偏移和逐句漂移分开判断,不要只看第一句。

先打印一条完整输出,看清字段结构

不要凭记忆写解析代码。把一次请求的原始返回落盘成文件,再用 python -m json.tool 或 jq 展开看一遍,确认三件事:文本在哪个字段、时间戳挂在哪一层、分段是按什么切的。下面只是一个常见形态的样例,字段名和层级要以你本地实际运行结果为准,不要照抄。

{
  "text": "整段转写的完整文本",
  "segments": [
    {
      "start": 0.0,
      "end": 2.36,
      "text": "我们把这一段先切出来",
      "tokens": [
        {"text": "我们", "start": 0.0, "end": 0.42}
      ]
    }
  ]
}
  • text:整段纯文本,不含时间信息,只能用来做全文比对或搜索,不能直接切字幕。
  • segments:分段数组。粒度由模型按停顿切,可能一句一段,也可能半句一段,这是碎段的来源。
  • start / end:该段在音频中的起止。重点确认单位:秒的浮点数还是毫秒整数。若数值都是整百整千量级,基本可以判断是毫秒。
  • tokens:字或词级时间戳。有这一层就有条件做近似句子级对齐;没有这一层,句子级时间戳只能靠分段边界推算。

验证方式:取第一条 start 与最后一条 end,跟音频实际时长比对。起点明显大于 0,说明开头静音被吃掉;终点明显小于音频长度,说明尾部有内容没被识别出来。这两种情况都会让后续字幕整体错位,先修输入再谈对齐。

把碎段合并成可读句子

碎段的典型表现是:字幕一行只有三五个字,闪一下就走,读起来跟不上。合并逻辑建议按顺序累积相邻分段,满足任一条件就断句:累积文本以句末标点结尾、相邻两段的间隔超过停顿阈值、累积字符数达到上限。停顿阈值从 0.5 秒到 0.8 秒起调,按素材语速调整,不要写死在业务代码里。

Nemotron 3.5 Transcribe 出文本、时间戳对齐交给后处理
END_PUNCT = '。!?!?;;'

def flush(buf):
    return {
        'start': buf[0]['start'],        # 取第一段起点
        'end':   buf[-1]['end'],        # 取最后一段终点
        'text':  ''.join(s['text'] for s in buf).strip(),
    }

def merge(segments, pause=0.6, max_len=32):
    out, buf = [], []
    for seg in segments:
        if buf and seg['start'] - buf[-1]['end'] >= pause:
            out.append(flush(buf)); buf = []
        buf.append(seg)
        text = ''.join(s['text'] for s in buf).strip()
        if (text and text[-1] in END_PUNCT) or len(text) >= max_len:
            out.append(flush(buf)); buf = []
    if buf:
        out.append(flush(buf))
    return out

合并后时间戳的规则要写死并注释清楚:起始取被合并的第一段 start,结束取最后一段 end,不在字之间做插值,也不取平均。风险边界在于:如果两句之间夹着一段长静音,按首尾取时就会让字幕长时间停留,观感很差。稳妥做法是先按停顿断句,再做二次合并,或者把超过阈值的长静音从句子内部切出去。

句子级时间戳的两种补法

近似路线:用分段边界推算。句子由若干分段拼成,直接沿用首尾时间戳。适用条件是模型分段粒度已经接近句子、语速比较均匀、用途只是普通观看字幕。代价是句内起止并不严格对应第一个字和最后一个字,遇到快语速或句首轻声时偏差会更明显。这条路线不需要额外算力,改配置和脚本就能跑,建议先做。

精确路线:用强制对齐工具补。把原始音频和已经确认过的转写文本一起送进对齐器,拿到字或词级边界,再按分词结果聚合成句子。适用条件是必须有原始音频、文本与音频严格一致、对句界要求高(例如逐句高亮或教学场景)。代价和对齐风险要说清:文本被改写、漏字、多字都会让对齐失败或错位;中文还要处理分词边界,标点不能参与对齐;另外对齐步骤会明显增加处理耗时,需要在流水线里单独排队。判断顺序建议是先近似,抽检不过再上精确。

Nemotron 3.5 Transcribe 出文本、时间戳对齐交给后处理

导出 SRT 时容易踩的格式坑

SRT 的失败大多不在内容,而在格式:时间用点号而不是逗号、毫秒没补三位、序号不连续、块与块之间少一个空行,播放器就直接整段不认。下面是最小可用的生成骨架,fmt 负责补零与逗号,写文件时统一 UTF-8 编码,并保证结束时间严格大于开始时间。

def fmt(t):
    ms = int(round(t * 1000))
    h, ms = divmod(ms, 3600000)
    m, ms = divmod(ms, 60000)
    s, ms = divmod(ms, 1000)
    return f'{h:02d}:{m:02d}:{s:02d},{ms:03d}'

def to_srt(items):
    lines = []
    for i, it in enumerate(items, 1):
        start = it['start']
        end = max(it['end'], start + 0.001)   # 防止零长度
        lines.append(str(i))
        lines.append(f'{fmt(start)} --> {fmt(end)}')
        lines.append(it['text'].strip())
        lines.append('')
    return '\n'.join(lines)

一条输出样例,可以直接对照检查:

Nemotron 3.5 Transcribe 出文本、时间戳对齐交给后处理
1
00:00:00,000 --> 00:00:02,360
我们把这一段先切出来

2
00:00:02,400 --> 00:00:05,120
再决定时间戳补在哪一环

另外两个细节:句子内部的换行要保留,但不要出现连续两个空行;文件末尾留一个换行即可,多余的空白块会让部分播放器把最后一条当成非法条目。导出后用播放器打开一次,能正常显示再进入抽检环节。

随机抽十句回听核对对齐

抽检的目的不是证明「全部正确」,而是判断偏差是整体偏移还是逐句漂移。抽查清单建议这样定:随机取十句,覆盖开头、中段、结尾各至少两句;其中包含一句带数字或专有名词的、一句语速明显偏快的、一句句首有停顿的。回听时人工判断语音实际起止,与字幕起止相减,正值表示字幕晚于语音。

句子(首几字)起始偏差结束偏差备注
待填待记录待记录开头段
待填待记录待记录含数字
待填待记录待记录语速快

读表方法:十句偏差方向一致、幅度接近,基本是整体偏移,优先查音频起点、采样偏移、前置处理有没有裁静音;偏差方向随机、幅度忽大忽小,说明分段边界本身不准,回到并句阈值调整,或者改用强制对齐;如果起始偏差小而结束偏差大,句子被拉长了,多半是尾部静音被并进同一句。把这份记录留档,下次换素材时可以直接对比,比凭感觉说「好像对上了」可靠。