Xiaomi-CocktailASR-1 做说话人归属、输出按时间轴对齐

文章导读
拿到 Xiaomi-CocktailASR-1 这类带说话人分离的识别结果后,先别急着拼字幕。这类输出通常是片段级的中间结构:每段带一个说话人标签和本段自己的时间,直接拼起来会出现时间轴对不上、标签串行的问题。要做说话人归属和按时间轴对齐,建议先把 speaker、start、end、text 这几个字段的实际含义确认清楚,再把片段级时间累加成全局时间表,最后用播放器抽查边界。字段名和是否自带 o
📋 目录
  1. 壹 从示例输出里摘出最小字段集
  2. 贰 把片段级时间戳累加为全局时间轴
  3. 叁 将 speaker 标签映射到字幕或纪要模板
  4. 肆 用音频播放器抽查三条时间戳边界
  5. 伍 处理 speaker 标签为空或时间戳倒挂的异常行
A A

拿到 Xiaomi-CocktailASR-1 这类带说话人分离的识别结果后,先别急着拼字幕。这类输出通常是片段级的中间结构:每段带一个说话人标签和本段自己的时间,直接拼起来会出现时间轴对不上、标签串行的问题。要做说话人归属和按时间轴对齐,建议先把 speaker、start、end、text 这几个字段的实际含义确认清楚,再把片段级时间累加成全局时间表,最后用播放器抽查边界。字段名和是否自带 offset,需要结合你实际拿到的输出确认,不要照搬下文的示例名。

把识别输出当中间结构处理:确认字段 → 逐段累加时间 → 映射说话人模板 → 播放器抽查 → 过滤异常行。适用于已有片段级 speaker 与时间戳、要生成字幕或会议纪要的场景;关键动作是保留原始时间戳并累加 offset;验证方式是用音频播放器比对开头、说话人切换点、结尾三处;边界是 speaker 为空、时间倒挂的行不能直接进最终稿,需要回听或标记。

从示例输出里摘出最小字段集

第一步不是写转换脚本,而是先看一眼真实输出里到底有哪些键。可以对一小段音频跑一次识别,把返回结果原样保存下来,再逐条检查。下面是一个脱敏后的片段示例,字段名以你实际输出为准,可能是 speaker_label、begin、end_ms 之类的变体:

{
  "segments": [
    {"speaker": "S1", "start": 0.00, "end": 2.35, "text": "先同步一下今天的排期"},
    {"speaker": "S2", "start": 2.35, "end": 5.10, "text": "接口那块我来跟"},
    {"speaker": "S1", "start": 5.10, "end": 7.80, "text": "好,那字段先定下来"}
  ]
}

需要确认的最小字段集只有四类:说话人字段、起始时间字段、结束时间字段、文本字段。要留意三点:时间是秒还是毫秒,起点是相对片段还是相对整段音频,说话人标签是 S1/S2 这类代号还是可读名字。如果输出里时间单位是毫秒而模板按秒换算,后面所有时间戳都会整体偏移。可以先写个两三行的脚本打印键名和值的类型,比凭印象假设可靠。

把片段级时间戳累加为全局时间轴

分段输出最常见的坑,是每段的时间都从本段起点算,或者每段之间还夹着一段静音。判断方法很直接:看第二段的 start 是否接近第一段的 end。如果每段都从接近 0 开始,就需要按顺序累加已消费的时长。下面是一段 Python 骨架,字段名按实际输出替换:

import json

rows = []
offset = 0.0        # 已消费掉的音频时长
GAP = 0.0           # 段间固定间隔,按实际填,没有就保持 0

for seg in segments:                 # segments 为逐段输出
    s = float(seg['start'])          # 字段名以实际输出为准
    e = float(seg['end'])
    rows.append({
        'speaker': seg.get('speaker'),
        'g_start': round(offset + s, 3),
        'g_end':   round(offset + e, 3),
        'text':    seg.get('text', ''),
    })
    offset += (e - s) + GAP

rows.sort(key=lambda r: r['g_start'])
with open('global_timeline.jsonl', 'w', encoding='utf-8') as f:
    for r in rows:
        f.write(json.dumps(r, ensure_ascii=False) + '\n')

如果输出本身就带绝对时间(每段 start 已经是整段音频的位置),就不要累加,只做排序和去重。判断依据可以取最后一段的 end 与音频总时长比对,差距大说明还需要累加,接近说明本身就是全局时间。

将 speaker 标签映射到字幕或纪要模板

全局时间表有了之后,说话人归属就是按标签取值映射成可读文本。字幕和纪要的差别在于,字幕更依赖原始时间戳,纪要更依赖按人归并。建议先保留一份带原始时间戳的中间文件,再分别套模板,避免转换后无法回查。字幕模板可以先写成这样:

[00:00:03.120 - 00:00:05.480] 说话人A:先同步一下今天的排期
[00:00:05.480 - 00:00:08.900] 说话人B:接口那块我来跟

如果要做纪要,可以在同一份 rows 上按 speaker 分组,组内仍按 g_start 排序,把时间戳保留在行首或行尾:

Xiaomi-CocktailASR-1 做说话人归属、输出按时间轴对齐
def fmt(t):
    m, s = divmod(t, 60)
    return f'{int(m):02d}:{s:06.3f}'

rows.sort(key=lambda r: (r['speaker'] or 'ZZ', r['g_start']))
for r in rows:
    print(f"{r['speaker'] or '未知'}  [{fmt(r['g_start'])}]  {r['text']}")

这里建议不要急着把 S1、S2 改成真实姓名,映射表单独维护一份,等抽查确认说话人一致后再替换。同一说话人如果标签在不同片段间跳变,先保留原标签,后面再统一。

用音频播放器抽查三条时间戳边界

时间轴是否可用,靠抽查比靠脚本自检更直接。用能显示播放位置的播放器打开原始音频,依次看三处:开头第一段的起点、中间一次说话人切换点、最后一段的结束点。每处记录播放器显示的位置、文本里的时间戳、以及你听到该句起止时的位置,把差值记下来。

  • 开头:确认第一句文本出现的位置与音频起始说话是否吻合,主要看整体是否偏前或偏后。
  • 中间切换点:找一处明显换人的位置,看标签变化是否与听感一致,这里最容易暴露累加 offset 算错。
  • 结尾:确认最后一段的 g_end 不超出音频时长,超出往往意味着 GAP 或单位换算有问题。

如果三处的差值方向一致、量级接近,说明是整体偏移,可以统一平移;如果差值忽大忽小,说明单位或累加逻辑有问题,需要回到第二小节重新检查。单个点对不上不必立刻改全局,先回听确认是不是识别本身切分就不准。

处理 speaker 标签为空或时间戳倒挂的异常行

最后一步是把脏数据挡在成稿之外。常见异常有两类:speaker 为空,以及 g_end 小于等于 g_start 的倒挂行。前者可能是重叠说话或短促应答,后者多半是累加时边界处理错了。可以先用简单规则过滤并打标:

clean, review = [], []
for r in rows:
    if r['g_end'] <= r['g_start']:
        r['flag'] = '时间倒挂'
        review.append(r)
        continue
    if not r['speaker']:
        r['flag'] = 'speaker为空'
        review.append(r)   # 保留但标记,是否入稿再定
        continue
    clean.append(r)

处理原则建议是:时间倒挂的行一般不直接进最终稿,需要回听确认是切分问题还是转换问题;speaker 为空但文本完整的行,可以先保留并标成待定,回听后再决定归到相邻说话人还是标为未知。整段音频都被标成同一个 speaker 时,也要回听抽查,这类情况可能是分离结果本身没生效,而不是你的转换脚本出错。