本記事はディップ Advent Calendar 2025の4日目の記事です。
空いていた枠をお借りして投稿しています。
TL;DR
- ステレオ音声のL/Rチャンネルが話者ごとに分かれている場合、ffmpegの音量解析だけで話者分離が可能
- 機械学習モデル不要で高速・シンプル・高精度
- 1対1の会話限定だが、電話録音などでは十分実用的
はじめに
音声の文字起こしで「誰が話しているか」を識別するために話者分離は重要です。
一般的にはpyannote-audioなどの機械学習ベースのツールが使われますが、今回は「ステレオ音声のL/Rチャンネルが話者ごとに分かれている」という特性を活かしたシンプルなアプローチを紹介します。
前提条件
- ステレオ録音で、L/Rチャンネルが話者ごとに分離されていること
- 1対1の会話であること(片方のチャンネルに複数人いる場合は識別不可)
処理フロー
- 音声ファイルをffmpegでステレオとモノラルに変換
- Whisperでモノラル音声を文字起こし
- 各発話区間についてステレオ音声のL/R音量を比較し話者を判定
- 判定結果をラベルとして付与
アプローチの特徴
- シンプル
- 機械学習モデル不要、ffmpegの音量解析だけで実現
- 高速
- 追加の推論処理がないため、Whisperの文字起こし速度を維持可能
- 高精度
- チャンネル分離が明確なら、高い精度で話者識別可能
実装
1. 音声ファイルの準備
ステレオ音声(解析用)とモノラル音声(Whisper用)の2つを生成
Whisperは16kHzモノラル推奨のため、文字起こしにはモノラル音声を使用
# ステレオ音声(音量解析用)
ffmpeg -y -v error -i "$INPUT_FILE" -ar 16000 -ac 2 -c:a pcm_s16le stereo.wav
# モノラル音声(文字起こし用)
ffmpeg -y -v error -i "$INPUT_FILE" -ar 16000 -ac 1 -c:a pcm_s16le mono.wav
2. Whisperで文字起こし
whisper.cppでJSON形式を指定し出力し、タイムスタンプを話者検出に利用する
whisper-cli -m ggml-large-v3-turbo.bin \
-f mono.wav \
-l ja \
-oj \ # JSON出力
-of output_prefix
出力されるJSONの構造
{
"transcription": [
{
"timestamps": { "from": "00:00:00,000", "to": "00:00:05,000" },
"offsets": { "from": 0, "to": 5000 },
"text": "お電話ありがとうございます。株式会社山田食品でございます。"
},
{
"timestamps": { "from": "00:00:05,000", "to": "00:00:08,000" },
"offsets": { "from": 5000, "to": 8000 },
"text": "お世話になっております。私、サンプル株式会社の田中と申します。"
}
]
}
3. 話者検出(Pythonスクリプト)
各発話区間についてステレオ音声のL/Rチャンネルの音量を比較し、どちらのチャンネルで発話されたかを判定
3-1. L/Rチャンネルの音量取得
ffmpegのvolumedetectフィルタで指定区間のL/Rそれぞれの音量を取得
import subprocess
import re
def get_channel_metrics(audio_file, start_time, end_time):
"""指定区間のL/Rチャンネルの音量を取得"""
PADDING = 0.1
adjusted_start = max(0, start_time - PADDING)
adjusted_end = end_time + PADDING
# 短い発話は解析区間を広げる
if (adjusted_end - adjusted_start) < 0.3:
mid_point = (start_time + end_time) / 2
adjusted_start = max(0, mid_point - 0.15)
adjusted_end = mid_point + 0.15
duration = adjusted_end - adjusted_start
def build_cmd(channel_map):
return [
'ffmpeg', '-ss', str(adjusted_start), '-t', str(duration),
'-i', audio_file,
'-filter_complex',
f'[0:a]channelsplit=channel_layout=stereo:channels={channel_map},volumedetect',
'-f', 'null', '-'
]
try:
r_l = subprocess.run(build_cmd('FL'), capture_output=True, text=True)
r_r = subprocess.run(build_cmd('FR'), capture_output=True, text=True)
out_l = r_l.stdout + r_l.stderr
out_r = r_r.stdout + r_r.stderr
def parse(o):
mean = re.search(r'mean_volume:\s*([-\d.]+)', o)
maxv = re.search(r'max_volume:\s*([-\d.]+)', o)
return (
float(mean.group(1)) if mean else -100.0,
float(maxv.group(1)) if maxv else -100.0
)
return parse(out_l), parse(out_r)
except:
return (-100.0, -100.0), (-100.0, -100.0)
ポイント:
-
channelsplitでステレオをL/Rに分離 -
volumedetectで平均音量(mean_volume)と最大音量(max_volume)を取得 -
PADDINGでWhisperのタイムスタンプ誤差を吸収
3-2. 話者判定ロジック
L/Rの音量差から話者を判定
def detect_speaker(l_metrics, r_metrics, duration):
"""L/Rの音量差から話者を判定"""
THRESHOLD = 1.5 # 通常時の閾値 (dB)
SHORT_THRESHOLD = 2.0 # 短い発話時の閾値 (dB)
l_mean, l_max = l_metrics
r_mean, r_max = r_metrics
# 短い発話は最大音量で比較
if duration < 1.0:
diff = l_max - r_max
if abs(diff) < SHORT_THRESHOLD:
return "不明"
return "Lチャンネル" if diff > 0 else "Rチャンネル"
else:
diff = l_mean - r_mean
if abs(diff) < THRESHOLD:
return "不明"
return "Lチャンネル" if diff > 0 else "Rチャンネル"
ポイント:
- 1秒以上の発話 → 平均音量で比較(安定した値が取れる)
- 1秒未満の発話 → 最大音量で比較(「はい」など短い返事に対応)
- 閾値未満の差は「不明」として誤判定を防止
3-3. メイン処理
WhisperのJSON出力を読み込み、各発話に話者ラベルを付与。
import json
import sys
def main(json_file, stereo_audio):
with open(json_file, 'r', encoding='utf-8') as f:
data = json.load(f)
for utt in data.get('transcription', []):
# タイムスタンプ取得(ミリ秒→秒)
start = utt['offsets']['from'] / 1000.0
end = utt['offsets']['to'] / 1000.0
# L/Rチャンネルの音量取得
l, r = get_channel_metrics(stereo_audio, start, end)
# 話者判定
speaker = detect_speaker(l, r, end - start)
# ラベル付与
utt['speaker'] = speaker
utt['volumes'] = {
'left': round(l[0], 1), # mean volume
'right': round(r[0], 1)
}
if not utt['text'].startswith('['):
utt['text'] = f"[{speaker}] {utt['text']}"
print(json.dumps(data, ensure_ascii=False, indent=2))
if __name__ == '__main__':
main(sys.argv[1], sys.argv[2])
完全なコード
上記をまとめた detect_speakers.py の全体:
detect_speakers.py
import json
import subprocess
import re
import sys
def get_channel_metrics(audio_file, start_time, end_time):
"""指定区間のL/Rチャンネルの音量を取得"""
PADDING = 0.1
adjusted_start = max(0, start_time - PADDING)
adjusted_end = end_time + PADDING
if (adjusted_end - adjusted_start) < 0.3:
mid_point = (start_time + end_time) / 2
adjusted_start = max(0, mid_point - 0.15)
adjusted_end = mid_point + 0.15
duration = adjusted_end - adjusted_start
def build_cmd(channel_map):
return [
'ffmpeg', '-ss', str(adjusted_start), '-t', str(duration),
'-i', audio_file,
'-filter_complex',
f'[0:a]channelsplit=channel_layout=stereo:channels={channel_map},volumedetect',
'-f', 'null', '-'
]
try:
r_l = subprocess.run(build_cmd('FL'), capture_output=True, text=True)
r_r = subprocess.run(build_cmd('FR'), capture_output=True, text=True)
out_l = r_l.stdout + r_l.stderr
out_r = r_r.stdout + r_r.stderr
def parse(o):
mean = re.search(r'mean_volume:\s*([-\d.]+)', o)
maxv = re.search(r'max_volume:\s*([-\d.]+)', o)
return (
float(mean.group(1)) if mean else -100.0,
float(maxv.group(1)) if maxv else -100.0
)
return parse(out_l), parse(out_r)
except:
return (-100.0, -100.0), (-100.0, -100.0)
def detect_speaker(l_metrics, r_metrics, duration):
"""L/Rの音量差から話者を判定"""
THRESHOLD = 1.5
SHORT_THRESHOLD = 2.0
l_mean, l_max = l_metrics
r_mean, r_max = r_metrics
if duration < 1.0:
diff = l_max - r_max
if abs(diff) < SHORT_THRESHOLD:
return "不明"
return "Lチャンネル" if diff > 0 else "Rチャンネル"
else:
diff = l_mean - r_mean
if abs(diff) < THRESHOLD:
return "不明"
return "Lチャンネル" if diff > 0 else "Rチャンネル"
def main(json_file, stereo_audio):
with open(json_file, 'r', encoding='utf-8') as f:
data = json.load(f)
for utt in data.get('transcription', []):
start = utt['offsets']['from'] / 1000.0
end = utt['offsets']['to'] / 1000.0
l, r = get_channel_metrics(stereo_audio, start, end)
speaker = detect_speaker(l, r, end - start)
utt['speaker'] = speaker
utt['volumes'] = {
'left': round(l[0], 1),
'right': round(r[0], 1)
}
if not utt['text'].startswith('['):
utt['text'] = f"[{speaker}] {utt['text']}"
print(json.dumps(data, ensure_ascii=False, indent=2))
if __name__ == '__main__':
main(sys.argv[1], sys.argv[2])
4. 実行
python detect_speakers.py output_prefix.json stereo.wav > labeled.json
# テキスト抽出
jq -r '.transcription[].text' labeled.json > output.txt
出力例
JSON(labeled.json)
{
"transcription": [
{
"timestamps": { "from": "00:00:00,000", "to": "00:00:05,000" },
"offsets": { "from": 0, "to": 5000 },
"text": "[Rチャンネル] お電話ありがとうございます。株式会社山田食品でございます。",
"speaker": "Rチャンネル",
"volumes": { "left": -91.0, "right": -33.9 }
},
{
"timestamps": { "from": "00:00:05,000", "to": "00:00:08,000" },
"offsets": { "from": 5000, "to": 8000 },
"text": "[Lチャンネル] お世話になっております。私、サンプル株式会社の田中と申します。",
"speaker": "Lチャンネル",
"volumes": { "left": -31.8, "right": -40.7 }
},
{
"timestamps": { "from": "00:00:12,000", "to": "00:00:18,000" },
"offsets": { "from": 12000, "to": 18000 },
"text": "[Lチャンネル] お世話になりますが、人事部の山本様はいらっしゃいますでしょうか?",
"speaker": "Lチャンネル",
"volumes": { "left": -27.5, "right": -44.6 }
}
]
}
テキスト出力(output.txt)
[Rチャンネル] お電話ありがとうございます。株式会社山田食品でございます。
[Lチャンネル] お世話になっております。私、サンプル株式会社の田中と申します。
[Lチャンネル] お世話になりますが、人事部の山本様はいらっしゃいますでしょうか?
[Rチャンネル] サンプルの田中様ですね。いつもお世話になっております。
[Rチャンネル] あいにく山本はただいま席を外しております。
[Lチャンネル] さようでございますか。今日何日をお戻りになられるご予定でしょうか?
[Rチャンネル] 申し訳ございません。本日は終日外出しておりまして戻らない予定となっております。
[Lチャンネル] 承知いたしました。では明日改めてお電話をいただきます。忙しいところありがとうございました。
[Rチャンネル] よろしくお願いいたします。失礼いたします。
[Lチャンネル] 失礼いたします。
まとめ
ステレオ音声のL/Rチャンネル分離を活用した話者識別は、1対1の会話では非常に効果的
機械学習モデル不要でffmpegの音量解析だけで実現できるため、導入コストが低く処理も高速
補足
本記事の文字起こしのテキストについて
例の文字起こしはテスト用の音声で、会社名・人物名は架空のものです。
Whisperの文字起こし結果を転記しているため一部誤字・脱字があります。
処理時間について
AWS EC2(GPU搭載)での参考データ
1時間程度の録音
| 処理 | 話者分離あり | 話者分離なし |
|---|---|---|
| 音声形式変換 | 3秒 | 1秒 |
| 文字起こし | 363秒 | 354秒 |
| 話者分離 | 22秒 | - |
| 合計 | 393秒 | 359秒 |
1分程度の録音
| 処理 | 話者分離あり | 話者分離なし |
|---|---|---|
| 音声形式変換 | 0秒 | 0秒 |
| 文字起こし | 5秒 | 5秒 |
| 話者分離 | 1秒 | - |
| 合計 | 10秒 | 8秒 |
別アプローチと不採用の理由
-
pyannote.audioによる話者分離 → 精度向上は見込めるものの、GPUメモリ使用量の増大と処理時間の観点から採用を見送り
-
チャンネルごとの文字起こし後に統合 → 統合時に発話順序が入れ替わることがあるため採用を見送り