音声認識(ASR)の文字起こしをアノテーションする:実例で見る手順
Whisperの出力が入ったフォルダから、ラベル付きの話者ターンまでを通しでたどります。アノテーション単位の選び方、話者分離の扱い、設定の書き方、タスクの実行、そして時間のアライメントを保ったままのエクスポートまで。
ここでは1つのプロジェクトを最初から最後までたどります。すでにWhisperで文字起こし済みの、録音された研究インタビュー40件を、トピックと発話者でコーディングするという案件です。2本のリファレンスガイドを具体化したもので、判断を説明するのではなく、実際にすべて決めていきます。
形式ごとの詳細のほうが知りたい場合は、文字起こし形式にあります。
何を残したか確認し、話者を誰がラベル付けするか決め、設定を組み立て、時間情報つきでエクスポートする
ステップ0:実際に手元にあるものを見る
まず何より、そのフォルダに何が入っているかを確かめます。10秒で終わって、1日を節約できます。
potato transcripts ./whisper_out --dry-runScanned 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).
ここから読み取ることが2つあります。どのファイルも話者分離されていないので、このコーパスには誰が話しているかの情報がありません。そして interview_03.txt は長さ0の1ターンとして入ってきています。Whisperの .txt にはテキストしか入っていないからです。取り戻せる時間情報がそこにはありません。
この3つ目のファイルは、対応する .json を探すか、音声にモデルを再実行する必要があります。下流の処理で直ることはありません。
ステップ1:ラベルより先にアノテーション単位を決める
一致度の数字は、ラベルの集合よりも単位の決め方に左右されます。
Whisperのセグメントはだいたい発話くらいの大きさで、文法的な切れ目ではなく無音のところで区切られます。インタビューのコーディングでは、たいていこれが適切な単位です。回答者の答えは複数のセグメントとして届き、それぞれを個別にコーディングすれば、答え全体をまとめて1つ付けるより細かい記録が残ります。
これがうまくいかないのは、動画プラットフォームの自動生成字幕の場合です。キューの境界は字幕ボックスが埋まったところに落ちています。そういうキューに対して「1文ずつ」評価してくれと頼むと、測りたかったものではなく、文がどこで切れるかについての不一致が生まれます。それが入力なら、YouTube の字幕をアノテーションする方法を見てください。
今回はセグメントをそのまま使えるので、単位はターンにします。
ステップ2:誰が話者を割り当てるかを決める
Whisperは話者分離をしません。選択肢は3つあり、これは形式的な確認ではなく本当の判断です。
WhisperXで走らせ直す。 自動で速く、そして発話が重なるところでは十分な頻度で間違えるので、結局誰かが確認することになります。
whisperx interview_01.mp3 --model medium --diarize --output_format json話者分離を有効にしたクラウドAPIを使う。 diarize=true のDeepgram、speaker_labels のAssemblyAI、AWS Transcribe、Rev.aiなど。Potatoは4つともそのまま読めます。
アノテーターが聴きながら話者を割り当てる。 2人で話す40件のインタビューなら、私たちならこれを選びます。役割のはっきり違う2人の話者は、人にとっては簡単なケースですが、モデルにとって必ずしも簡単ではありませんし、どのみちアノテーターは音声を聴いています。
ここでは3つ目を採ります。話者分離されていないターンはピッカー付きのUnassignedとして表示され、割り当てはアノテーションと一緒に保存されます。
話者分離されていないターンは、それぞれにピッカーが付いたUnassignedとして届く
ステップ3:データファイルを作る
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 になります。
結果はこうなります。
{
"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は表示時に読み込んで正規化します。
{"id": "interview_01", "conversation": {"audio": "audio/interview_01.mp3",
"transcript": "whisper_out/interview_01.json"}}ステップ4:設定を書く
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"ここで働いているものが3つあります。
話者の名簿は、まだ誰も1ターンも割り当てていない段階で、2つの役割に安定した名前、色、配置を与えます。これがなくても話者に色は付きますが、コーパス全体で一貫するのではなく、文字起こしごとに決定的に割り当てられます。
turn_level: true と turn_binding は、トピックの質問をインタビュー全体ではなく各ターンに紐づけます。実務上これが、ターンをアノテーション単位にしている仕掛けです。
span_target: true と span スキーマの組み合わせで、ハイライトをターンの境界をまたいで引けます。引用したい箇所が質問とその答えにまたがるときに効いてきます。話者が割り当て直されても、オフセットは安定したままです。
実行します。
python potato/flask_server.py start config.yaml -p 8000
各ターンには、そのターンだけを再生するボタンと、そのターン用のラベル質問が付く
各バブルには、そのターンだけを再生して止まるボタンがあります。実際にアノテーターが感想を口にするのはこの機能です。特定の1行を音声と突き合わせる作業が、シークバーとの格闘ではなくなります。
ステップ5:正しい対象について一致度を確認する
インタビュー1件につきアノテーターは2人。ここで見るべき一致度は2種類になります。
トピックのラベルは、ターン単位の普通のカテゴリカルな一致度です。ターンIDは決定的なので、同じファイルからは常に同じIDが生成され、2人のラベルはアライメントの処理なしに突き合わせられます。
話者の割り当ては、別に確認する価値があります。2人のアノテーターがターンの15%で誰が話しているかについて食い違うなら、それは音声についてのシグナルであり、自動の話者分離なら少なくとも同じ頻度で、しかも黙って間違えていたということです。
指標についてはアノテーター間一致度の解説を、ハイライトについてはスパンと構造化出力における一致度を見てください。ハイライトはアノテーターがラベルだけでなく境界も選ぶので、別の扱いが必要です。
ステップ6:エクスポートする
標準のJSON、JSONL、CSVはいつもどおり使えます。時間のアライメントを音声解析ツールまで持っていきたいときは、階層アノテーションをELANかPraatにエクスポートします。
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で仕上げて、その結果をまた読み込めます。
うまくいかなくなる4つのこと
誰かが .txt を残していた。 時間情報がなく、復元もできず、再実行が必要です。--dry-run なら、その上に何かを組み立てる前に気づけます。
時間が1000倍ずれている。 上流のどこかで秒とミリ秒が混ざりました。WhisperとDeepgramは浮動小数点の秒を、AssemblyAI、whisper.cppのオフセット、WhisperのTSVは整数のミリ秒を出します。
キュー単位の入力に、文単位のスキーマを当てる。 上で触れたとおりで、4つの中でいちばん高くつきます。一致度を計算するまで気づけないからです。
誰も確認していない話者分離を信じる。 話者分離の誤りは、そのターンに紐づくすべてのラベルに伝播しますし、原因を探しにいったときにはアノテーター間の不一致のように見えます。