2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ステレオ音声のL/Rチャンネルを活用した話者分離

Posted at

本記事はディップ Advent Calendar 2025の4日目の記事です。
空いていた枠をお借りして投稿しています。

TL;DR

  • ステレオ音声のL/Rチャンネルが話者ごとに分かれている場合、ffmpegの音量解析だけで話者分離が可能
  • 機械学習モデル不要で高速・シンプル・高精度
  • 1対1の会話限定だが、電話録音などでは十分実用的

はじめに

音声の文字起こしで「誰が話しているか」を識別するために話者分離は重要です。

一般的にはpyannote-audioなどの機械学習ベースのツールが使われますが、今回は「ステレオ音声のL/Rチャンネルが話者ごとに分かれている」という特性を活かしたシンプルなアプローチを紹介します。

前提条件

  • ステレオ録音で、L/Rチャンネルが話者ごとに分離されていること
  • 1対1の会話であること(片方のチャンネルに複数人いる場合は識別不可)

処理フロー

  1. 音声ファイルをffmpegでステレオとモノラルに変換
  2. Whisperでモノラル音声を文字起こし
  3. 各発話区間についてステレオ音声のL/R音量を比較し話者を判定
  4. 判定結果をラベルとして付与

アプローチの特徴

  • シンプル
    • 機械学習モデル不要、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秒

別アプローチと不採用の理由

  1. pyannote.audioによる話者分離 → 精度向上は見込めるものの、GPUメモリ使用量の増大と処理時間の観点から採用を見送り

  2. チャンネルごとの文字起こし後に統合 → 統合時に発話順序が入れ替わることがあるため採用を見送り

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?