Skip to content
Tutorials2 min read

标注 ASR 转写稿:一个完整实例

从一个 Whisper 输出目录一路走到带标签的说话人话轮:怎么选标注单元、怎么处理说话人分离、怎么写配置、怎么把任务跑起来,以及怎么在导出时保住时间对齐。

Potato Team

这篇文章把一个项目从头走到尾:40 段录下来的研究访谈,已经用 Whisper 转写过,需要按主题编码,还要标出谁说了什么。它是那两篇参考指南的具体版本,每个决定都真的做了,而不是描述了一遍。

如果你要的是逐格式的细节,那部分在转写格式里。

从一个 ASR 输出目录到带标签的话轮,四步走检查你保留了什么、决定谁来标说话人、写好配置、带着时间戳导出

第 0 步:先看看你手里到底有什么

别的都先放一边,先弄清楚这个目录里是什么。这一步花十秒钟,能省你一天:

bash
potato transcripts ./whisper_out --dry-run
text
Scanned 40 file(s):
  interview_01.json      Whisper JSON      42 turns    891.4s  undiarized
  interview_02.json      Whisper JSON      51 turns   1120.8s  undiarized
  interview_03.txt       plain text         1 turns      0.0s  undiarized
  ...

40 item(s), 1683 turn(s).

从里面能读出两件事。所有文件都没做说话人分离,所以这份语料里没有任何东西知道说话的是谁。另外 interview_03.txt 被当成了一个时长为零的话轮,因为 Whisper 出来的 .txt 里只有文字,别的什么都没有。里面根本没有时间信息可以找回来。

第三个文件要么得把对应的 .json 找出来,要么把音频重跑一遍。下游没有任何环节能补救它。

第 1 步:先定标注单元,再定标签

单元这个问题对你的一致性数字的影响,比标签集大得多。

Whisper 的分段大致是话语级的,它在停顿处断开,而不是在任何语法边界上断开。对访谈编码来说这通常就是对的单元:受访者的一次回答会分成好几段进来,把每段单独编码,得到的记录比把整段回答一起编码要细。

会出问题的是视频平台的自动字幕,那里的字幕行边界落在字幕框填满的地方。让标注者在这样的字幕行上给“每个句子”打分,产生的分歧是关于句子边界在哪,而不是关于你想测的那个东西。如果这就是你的输入,请看如何标注 YouTube 字幕

这里的分段可以直接用,所以单元就是话轮。

第 2 步:决定由谁来分配说话人

Whisper 不做说话人分离。有三个选项,而且这是一个真正的决定,不是走过场:

WhisperX 重跑一遍。 自动、快,但在重叠说话上错得够频繁,反正总得有人来核一遍。

bash
whisperx interview_01.mp3 --model medium --diarize --output_format json

用一个开了说话人分离的云端 API。 Deepgram 加 diarize=true、AssemblyAI 加 speaker_labels、AWS Transcribe,或者 Rev.ai。这四种 Potato 都能原生读。

让标注者一边听一边分配说话人。 对 40 段双人访谈来说,我们会选这个。两个角色分明的说话人对人来说是最容易的情形,对模型却不一定,而且标注者本来就在听音频。

我们选第三个。没做说话人分离的话轮会显示为 Unassigned 并带一个选择器,分配结果和标注一起保存。

带灰色斜纹背景的转写话轮,每个都标着 Unassigned 并带有下拉框未做说话人分离的话轮以 Unassigned 到达,每条上都有一个选择器

第 3 步:造数据文件

bash
potato transcripts ./whisper_out --media-dir ./audio -o data/interviews.json

转写稿按文件名主干和音频配对,所以 interview_01.json 会找到 interview_01.mp3。Whisper 那种重复的 interview_01.mp3.json 命名也处理了,条目 id 出来就是 interview_01

结果是:

json
{
  "id": "interview_01",
  "conversation": {
    "audio": "audio/interview_01.mp3",
    "turns": [
      {"turn_id": "t0", "speaker": null, "start": 0.0, "end": 6.5,
       "text": "So I want to start with how the team was structured."}
    ]
  }
}

如果你更愿意把转写稿保持成文件,这一步完全可以跳过。数据文件可以直接指向它们,Potato 会在渲染时读取并归一化:

json
{"id": "interview_01", "conversation": {"audio": "audio/interview_01.mp3",
                                        "transcript": "whisper_out/interview_01.json"}}

第 4 步:写配置

yaml
annotation_task_name: "Interview Coding"
task_dir: .
data_files:
  - data/interviews.json
 
item_properties:
  id_key: id
  text_key: conversation
 
instance_display:
  fields:
    - key: conversation
      type: audio_dialogue
      label: "Interview"
      span_target: true
      display_options:
        show_timestamps: true
        scroll_height: 520px
        allow_speaker_assignment: auto
        speakers:
          - id: interviewer
            name: "Interviewer"
            color: "#7c3aed"
            side: left
          - id: participant
            name: "Participant"
            color: "#059669"
            side: right
 
annotation_schemes:
  - annotation_type: radio
    name: turn_topic
    description: "What is this turn about?"
    labels: [structure, workload, tooling, morale, other]
 
  - annotation_type: span
    name: quotes
    description: "Highlight anything quotable in the writeup"
    labels:
      - name: quotable
        key_value: "q"

这里有三处在干活。

说话人名册在还没有人分配过任何一个话轮之前,就给这两个角色定好了稳定的名字、颜色和左右位置。没有它,说话人也还是会拿到颜色,但那是每份转写稿各自确定性地分配的,不是全语料一致的。

turn_level: true 配上 turn_binding,把主题问题挂到每一个话轮上,而不是挂到整场访谈上。这才是让话轮在实际操作中成为标注单元的东西。

span_target: true 加上 span 方案,让一段高亮可以跨越话轮边界,当值得引用的段落横跨一个问题和它的回答时,这一点很重要。说话人被重新分配时,偏移量保持稳定。

跑起来:

bash
python potato/flask_server.py start config.yaml -p 8000

转写话轮以彩色说话人气泡呈现,每个话轮带播放按钮,旁边挂着一个标注问题每个话轮都有一个只播放这一轮的播放按钮,以及它自己的标注问题

每个气泡上都有一个播放按钮,只播这一个话轮然后停下。实际用起来,这是标注者会主动提起的功能:拿某一句去核对音频,不再是一场拖进度条的苦差事。

第 5 步:在对的东西上看一致性

每场访谈两位标注者,于是你有两种一致性要看。

主题标签是话轮层面上普通的分类一致性。因为话轮 id 是确定性的,同一个文件永远产生同样的 id,两位标注者的标签不需要任何对齐步骤就能对上。

说话人分配值得单独看。如果两位标注者在 15% 的话轮上对“谁在说话”有分歧,那是关于音频本身的一个信号,也意味着自动说话人分离至少会以同样的频率出错,而且不会告诉你。

一致性指标见标注者间一致性,高亮部分见跨度与结构化输出的一致性,后者需要不同的处理方式,因为标注者除了选标签还要选边界。

第 6 步:导出

标准的 JSON、JSONL 和 CSV 照常可用。如果你想让时间对齐一路活到语音分析工具里,就把分层标注导出成 ELAN 或 Praat 格式:

bash
python -m potato.export --config config.yaml --format eaf --output ./out/
python -m potato.export --config config.yaml --format textgrid --output ./out/

两者都能双向往返,因为 Potato 也能把 EAF 和 TextGrid 当输入读。在这里标注,在 ELAN 里精修,再把结果读回来。

会出问题的四件事

有人留的是 .txt 没有时间信息,找不回来,只能重跑。--dry-run 会在你在它上面搭起任何东西之前就抓住它。

时间差了 1000 倍。 上游某个环节把秒和毫秒混了。Whisper 和 Deepgram 输出的是浮点秒;AssemblyAI、whisper.cpp 的偏移量,以及 Whisper 的 TSV 输出的是整数毫秒。

在字幕行输入上套句子级方案。 上面讲过了,也是这四件里代价最高的一件,因为你要等到算一致性的时候才会发现。

信任了没人核过的说话人分离。 一处分离错误会传染到挂在那个话轮上的每一个标签,而当你回头找原因时,它看起来像是标注者分歧。

延伸阅读