Skip to content
Tutorials2 min read

音声認識(ASR)の文字起こしをアノテーションする:実例で見る手順

Whisperの出力が入ったフォルダから、ラベル付きの話者ターンまでを通しでたどります。アノテーション単位の選び方、話者分離の扱い、設定の書き方、タスクの実行、そして時間のアライメントを保ったままのエクスポートまで。

Potato Team

ここでは1つのプロジェクトを最初から最後までたどります。すでにWhisperで文字起こし済みの、録音された研究インタビュー40件を、トピックと発話者でコーディングするという案件です。2本のリファレンスガイドを具体化したもので、判断を説明するのではなく、実際にすべて決めていきます。

形式ごとの詳細のほうが知りたい場合は、文字起こし形式にあります。

音声認識の出力が入ったフォルダから、ラベル付きのターンまでを4ステップで何を残したか確認し、話者を誰がラベル付けするか決め、設定を組み立て、時間情報つきでエクスポートする

ステップ0:実際に手元にあるものを見る

まず何より、そのフォルダに何が入っているかを確かめます。10秒で終わって、1日を節約できます。

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).

ここから読み取ることが2つあります。どのファイルも話者分離されていないので、このコーパスには誰が話しているかの情報がありません。そして interview_03.txt は長さ0の1ターンとして入ってきています。Whisperの .txt にはテキストしか入っていないからです。取り戻せる時間情報がそこにはありません。

この3つ目のファイルは、対応する .json を探すか、音声にモデルを再実行する必要があります。下流の処理で直ることはありません。

ステップ1:ラベルより先にアノテーション単位を決める

一致度の数字は、ラベルの集合よりも単位の決め方に左右されます。

Whisperのセグメントはだいたい発話くらいの大きさで、文法的な切れ目ではなく無音のところで区切られます。インタビューのコーディングでは、たいていこれが適切な単位です。回答者の答えは複数のセグメントとして届き、それぞれを個別にコーディングすれば、答え全体をまとめて1つ付けるより細かい記録が残ります。

これがうまくいかないのは、動画プラットフォームの自動生成字幕の場合です。キューの境界は字幕ボックスが埋まったところに落ちています。そういうキューに対して「1文ずつ」評価してくれと頼むと、測りたかったものではなく、文がどこで切れるかについての不一致が生まれます。それが入力なら、YouTube の字幕をアノテーションする方法を見てください。

今回はセグメントをそのまま使えるので、単位はターンにします。

ステップ2:誰が話者を割り当てるかを決める

Whisperは話者分離をしません。選択肢は3つあり、これは形式的な確認ではなく本当の判断です。

WhisperXで走らせ直す。 自動で速く、そして発話が重なるところでは十分な頻度で間違えるので、結局誰かが確認することになります。

bash
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と表示されている話者分離されていないターンは、それぞれにピッカーが付いたUnassignedとして届く

ステップ3:データファイルを作る

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

文字起こしはベース名で音声に対応付けられるので、interview_01.jsoninterview_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"

ここで働いているものが3つあります。

話者の名簿は、まだ誰も1ターンも割り当てていない段階で、2つの役割に安定した名前、色、配置を与えます。これがなくても話者に色は付きますが、コーパス全体で一貫するのではなく、文字起こしごとに決定的に割り当てられます。

turn_level: trueturn_binding は、トピックの質問をインタビュー全体ではなく各ターンに紐づけます。実務上これが、ターンをアノテーション単位にしている仕掛けです。

span_target: truespan スキーマの組み合わせで、ハイライトをターンの境界をまたいで引けます。引用したい箇所が質問とその答えにまたがるときに効いてきます。話者が割り当て直されても、オフセットは安定したままです。

実行します。

bash
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にエクスポートします。

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で仕上げて、その結果をまた読み込めます。

うまくいかなくなる4つのこと

誰かが .txt を残していた。 時間情報がなく、復元もできず、再実行が必要です。--dry-run なら、その上に何かを組み立てる前に気づけます。

時間が1000倍ずれている。 上流のどこかで秒とミリ秒が混ざりました。WhisperとDeepgramは浮動小数点の秒を、AssemblyAI、whisper.cppのオフセット、WhisperのTSVは整数のミリ秒を出します。

キュー単位の入力に、文単位のスキーマを当てる。 上で触れたとおりで、4つの中でいちばん高くつきます。一致度を計算するまで気づけないからです。

誰も確認していない話者分離を信じる。 話者分離の誤りは、そのターンに紐づくすべてのラベルに伝播しますし、原因を探しにいったときにはアノテーター間の不一致のように見えます。

参考資料