こんにちは、皆さん。
会話音声を聞いていると、誰が話しているかは自然に追えます。
しかし、プログラムで同じことをしようとすると、
発話の有無だけでなく「ここで話者が変わった」という境界も見つけなければなりません。
さて、今日は Pyannote Segmentation 3.0 の ONNX モデルを使い、2 人の会話音声から話者交代を検出して、発話ごとの WAV ファイルへ分割します。
今回検証する内容
検証では、14 秒ほどの会話を FFmpeg で 16 kHz、mono の波形へ変換し、Pyannote の segmentation モデルと単純な後処理で、時間的に連続した話者区間を作ります。
確認するのは次の点です。
- 2 人が交互に話す 6 発話を、それぞれ別の区間へ分割できるか
- 検出した話者インデックスが音声全体で一貫するか
- ONNX Runtime の CPU 実行だけで実時間より高速に処理できるか
- 各区間を個別の WAV ファイルとして保存できるか
完全なコードと再現可能な環境は、kiarina/labs の pyannote-scd lab で公開しています。
なお、今回行うのは segmentation モデルが出力する話者インデックスを使った区間化です。話者埋め込みの照合や clustering は行わないため、長い音声を通して人物を識別する完全な話者ダイアライゼーションではありません。
検証環境を準備する
実行には、次のツールが必要です。
次のコマンドで lab だけを取得し、共有テスト音声をダウンロードして実行できます。
git clone --depth 1 --filter=blob:none --sparse \
https://github.com/kiarina/labs.git
cd labs
git sparse-checkout set .gitignore .mise/tasks Makefile mise.toml \
2026/07/04/pyannote-scd
mise -C 2026/07/04/pyannote-scd run
初回実行時には、Hugging Face の onnx-community/pyannote-segmentation-3.0 から通常精度の onnx/model.onnx をダウンロードします。その後、uv が Python の依存パッケージを用意し、検出処理を実行します。
モデルのライセンス
今回使用した onnx-community/pyannote-segmentation-3.0 は、配布元で MIT License と表示されています。利用や再配布を行う場合は、配布元で最新のライセンス条件を確認してください。
どのように話者区間を検出するか
入力には、共有テストアセットの次のファイルを使いました。
tests/assets/mp3/conversation_2speaker_14s_16k.mp3
音声のシナリオは次のとおりです。
Speaker 1: もしもし、もう駅に着いた?
Speaker 2: うん。今、改札出たところ。そっちは?
Speaker 1: こっちはまだ電車。あと5分くらいかな。
Speaker 2: 了解。じゃあ、カフェの前で待ってるね。
Speaker 1: 助かる。今日は結構寒いね。
Speaker 2: ほんと、マフラー持ってきて正解だった。
FFmpeg で MP3 を 16 kHz、mono の波形へデコードし、10 秒ずつモデルへ入力します。最後のチャンクは 10 秒になるまでゼロで埋め、推論後に元の音声長を超えるフレームを取り除きます。
検出設定は次のとおりです。
| 項目 | 値 |
|---|---|
| sample rate | 16 kHz |
| inference window | 10 秒 |
| model speakers | 3 |
| maximum simultaneous speakers | 2 |
| active speaker threshold | 0.5 |
| overlap margin | 0.1 |
| minimum speaker change | 100 ms |
| minimum speech segment | 100 ms |
| execution provider | CPUExecutionProvider |
モデルは各フレームについて、無音、単独話者、2 人の組み合わせからなる 7 クラスの log probability を出力します。この powerset 表現を 3 つの話者確率へ変換し、確率 0.5 以上の話者をアクティブと判定します。
2 人以上がアクティブで、上位 2 話者の確率差が 0.1 以下なら overlap とします。ただし、これは確率が拮抗したフレームを表す規則であり、実際の重複発話を直接保証するものではありません。
話者確率をそのまま区間へ変換すると、無音や短い揺れによって細かな断片が生じます。そこで、無音フレームを前後の話者へ割り当て、100 ms 未満の短い話者状態を隣接区間へ統合します。今回のモデル出力では 1 フレームが約 16.978 ms なので、100 ms はおよそ 6 フレームです。
最後に、各区間を PCM 16-bit、16 kHz、mono の WAV として output/ に保存します。既存の出力は実行のたびに削除して作り直します。
実行結果
Mac Studio で実行した結果、14.171 秒の入力から 6 個の話者区間が検出されました。
audio duration: 14.171s
SCD elapsed: 0.019s
SCD real-time factor: 0.001x
frame duration: 16.978ms
segments: 6
001: 0.000s - 1.851s ( 1.851s) speaker_2
002: 1.851s - 4.737s ( 2.886s) speaker_1
003: 4.737s - 7.317s ( 2.581s) speaker_2
004: 7.317s - 9.677s ( 2.360s) speaker_1
005: 9.677s - 11.834s ( 2.156s) speaker_2
006: 11.834s - 14.171s ( 2.337s) speaker_1
6 区間は隙間や重複なく、入力の 14.171 秒全体を覆っています。生成された 6 ファイルの合計は 226,736 samples で、すべて PCM 16-bit、16 kHz、mono でした。
各ファイルを聴いて台本と照合した結果は次のとおりです。
| file | model output | speech |
|---|---|---|
speaker_2_001.wav |
speaker_2 |
もしもし、もう駅に着いた? |
speaker_1_002.wav |
speaker_1 |
うん。今、改札出たところ。そっちは? |
speaker_2_003.wav |
speaker_2 |
こっちはまだ電車。あと5分くらいかな。 |
speaker_1_004.wav |
speaker_1 |
了解。じゃあ、カフェの前で待ってるね。 |
speaker_2_005.wav |
speaker_2 |
助かる。今日は結構寒いね。 |
speaker_1_006.wav |
speaker_1 |
ほんと、マフラー持ってきて正解だった。 |
6 ファイルはそれぞれ台本上の 1 発話に対応し、発話途中で不自然に分割されたファイルも、隣の話者が混ざったファイルもありませんでした。モデル内の speaker_2 は Speaker 1、speaker_1 は Speaker 2 に対応し、全区間で交互に切り替わりました。
実行環境は次のとおりです。
- machine: Mac Studio
- chip: Apple M4 Max
- memory: 128 GB
- OS: macOS 26.5.1(25F80)、arm64
- Python: 3.12.11
- ONNX Runtime: 1.27.0
- execution provider: CPUExecutionProvider
SCD elapsed は、ONNX 推論と話者区間判定だけにかかった時間です。モデルの初期化、FFmpeg による入力デコード、WAV の生成は含みません。ウォームアップなしの 1 回の測定なので厳密なベンチマークではありませんが、入力の長さに対する real-time factor は 0.001x でした。
結果を考える
今回の音声では、台本上の 6 発話と検出された 6 区間が一致しました。発話間の無音を別ファイルにせず前後へ分配したため、入力全体を欠けなく保持しながら、話者交代を境界に分割できています。文字起こしの前処理として使うなら、各区間に一人の発話だけが入る形は扱いやすそうです。
CPU だけで 14.171 秒を 0.019 秒で判定した点も良好です。ただし、これは推論と区間判定に限った時間で、単発の参考値です。ファイルの読み書きを含む処理全体の速度や、別のマシンでの速度を示すものではありません。
一方で、speaker_1 や speaker_2 は人物の永続的な ID ではありません。今回の実装は音声を 10 秒窓に分けて独立に推論しており、話者埋め込みで窓同士を照合していません。この入力では 10 秒の境界を越えてもインデックスが一貫しましたが、別の音声でも同じになる保証はありません。
評価対象も、交互に話す 2 人の短い音声 1 本だけです。重複発話、3 人以上の会話、雑音、長時間音声は試しておらず、時刻単位の正解ラベルによる定量評価も行っていません。今回の結果から確認できるのは、この音声と設定の組み合わせで 6 発話を正確に分離できたことまでです。
試してみた感想
Pyannote の segmentation モデルは、VAD より一段踏み込んで「誰かが話している」だけでなく「話者が変わった」を扱えるのが面白いところでした。単純な閾値判定と平滑化だけでも、この短い会話では発話単位に綺麗に分けられました。CPU 上の ONNX Runtime で十分に速い点も、ローカル処理へ組み込みやすい印象です。
ただ、綺麗な 6 ファイルが出ると、つい話者ダイアライゼーションまで完成したように見えます。実際には、窓をまたぐ話者 ID の照合が抜けています。この違いは長時間の録音を扱ったときに効いてきそうです。
次は重複発話や 3 人以上の会話で境界を評価し、話者埋め込みと clustering を加えて、長い音声でも同じ人物へ一貫した ID を付けられるところまで試したいです。