0
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?

【話者分離】業界標準のPyannoteと同水準の自作アルゴリズムを実装した話

0
Posted at

前回の記事はこちら→第二弾

1.はじめに:なぜPyannoteから脱却したかったのか

音声文字起こしにおける話者分離では「Pyannote.audio」が事実上の業界標準であり、他に有用な選択肢がほとんどないのが実情です。しかし、ライセンスの制約や重い依存関係から導入のハードルとなることもまた少なくありません

そこで、独自のグリッド設計による自作アルゴリズムを検証し、重いモデルに頼らずとも同水準のパフォーマンスを発揮するクリーンなパイプラインを構築しました。本記事ではその全容を解説します

2. 自作アルゴリズムのアーキテクチャとロジック

今回のアーキテクチャでは、制約の厳しいPyannoteのセグメンテーションモデルを排除し、軽量なオープンソース部品を組み合わせたカスタム・スライディングウィンドウ方式で設計しています

コアスタック

  • 話者埋め込み抽出: sherpa-onnx + WeSpeaker (voxceleb_resnet34.onnx)

  • 音声処理: librosa(16kHzリサンプリング)

  • クラスタリング: scikit-learn(AgglomerativeClustering / コサイン距離+Average linkage)

  • 時間的平滑化: scipy(medfilt メディアンフィルタ)

  • 文字起こし・同期: OpenAI Whisper (mediumモデル)

アルゴリズムのコアロジック

  • スライディングウィンドウによるベクトル抽出

  • コサイン類似度による全体クラスタリング

  • メディアンフィルタによるチャタリング防止

  • Whisperタイムスタンプとの最大オーバーラップ判定

3. 実装例、コード紹介

構築したスライディングウィンドウとクラスタリング、平滑化処理のコアロジックの実装例です。

# モデルの事前準備(ダウンロード)
# 処理に必要なONNXモデルを直接取得するシンプルなスクリプト

import os
import urllib.request

os.makedirs("models/wespeaker", exist_ok=True)
url = "https://github.com/k2-fsa/sherpa-onnx/releases/download/speaker-recongition-models/3dspeaker_speech_eres2net_base_sv_zh-cn_3dspeaker_16k.onnx"
path = "models/wespeaker/voxceleb_resnet34.onnx"

req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
with urllib.request.urlopen(req) as res, open(path, 'wb') as f:
    f.write(res.read())
# 話者分離 & Whisper統合パイプライン
# スライディングウィンドウによるベクトル抽出から、クラスタリング、平滑化、
# Whisperのタイムスタンプマッピングまでを行うメインコード

import os
import torch
import librosa
import numpy as np
from sklearn.cluster import AgglomerativeClustering
from scipy.signal import medfilt
import sherpa_onnx
import whisper

# --- 設定値 ---
AUDIO_PATH = "audio.wav"
OUTPUT_PATH = "diarization_result.txt"
WINDOW_SEC = 1.5      # スライディングウィンドウの幅(秒)
SHIFT_SEC = 0.25       # ずらし幅(秒)
NUM_SPEAKERS = 2      # 推定話者数
SMOOTHING_WINDOW = 5  # メディアンフィルタの窓枠

device = torch.device("cuda")

# 1. WeSpeaker(話者埋め込み)の準備
config = sherpa_onnx.SpeakerEmbeddingExtractorConfig(
    model="models/wespeaker/voxceleb_resnet34.onnx",
    num_threads=4,
    provider="cuda",
)
extractor = sherpa_onnx.SpeakerEmbeddingExtractor(config)

# 2. スライディングウィンドウによる特徴量抽出
audio, sr = librosa.load(AUDIO_PATH, sr=16000)
window_len, shift_len = int(WINDOW_SEC * sr), int(SHIFT_SEC * sr)

embeddings, timestamps = [], []
for start_idx in range(0, len(audio) - window_len, shift_len):
    chunk = audio[start_idx : start_idx + window_len]
    stream = extractor.create_stream()
    stream.accept_waveform(sr, chunk)
    
    embeddings.append(extractor.compute(stream))
    timestamps.append((start_idx / sr) + (WINDOW_SEC / 2)) # 窓の中央を刻む

# 3. クラスタリングと時間的平滑化
clustering = AgglomerativeClustering(n_clusters=NUM_SPEAKERS, metric='cosine', linkage='average')
labels = clustering.fit_predict(np.array(embeddings))
smoothed_labels = medfilt(labels, kernel_size=SMOOTHING_WINDOW)

# 4. 連続するラベルを話者区間(Diarized Segments)に変換
diarized_segments = []
curr_spk, seg_start = smoothed_labels[0], 0.0

for i in range(1, len(smoothed_labels)):
    if smoothed_labels[i] != curr_spk:
        diarized_segments.append({"start": seg_start, "end": timestamps[i], "speaker": f"SPEAKER_{int(curr_spk):02d}"})
        curr_spk, seg_start = smoothed_labels[i], timestamps[i]

diarized_segments.append({"start": seg_start, "end": len(audio) / sr, "speaker": f"SPEAKER_{int(curr_spk):02d}"})

# 5. Whisperによる文字起こしと話者マッピング
whisper_model = whisper.load_model("medium", device=device)
transcript = whisper_model.transcribe(AUDIO_PATH)

with open(OUTPUT_PATH, "w", encoding="utf-8") as f:
    for seg in transcript["segments"]:
        s_start, s_end, text = seg["start"], seg["end"], seg["text"]

        # 最も重なり(Overlap)の大きい話者を割り当て
        best_spk, max_overlap = "SPEAKER_00", 0
        for d in diarized_segments:
            overlap = max(0, min(s_end, d["end"]) - max(s_start, d["start"]))
            if overlap > max_overlap:
                max_overlap = overlap
                best_spk = d["speaker"]

        line = f"[{s_start:0.1f}s - {s_end:0.1f}s] {best_spk}: {text}"
        print(line)
        f.write(line + "\n")

4. 性能比較検証(vsPyannote版)

同年代の女性2名による2分間の対談音声(全26文・話者スイッチ6回)を用いた検証の結果、以下の識別パフォーマンスを確認しました。

  • 比較検証結果

    • 話者切り替えの認識精度(100%)
      Pyannote版・自作アルゴリズム版のどちらにおいても100%の正解を出せました。同年代の女性同士という声質が近い条件下においても、誤判定なく識別できました。
  • タイムスタンプの精度

    • 全26文の区切り位置および話者割り当てにおいて、Pyannoteの出力とほぼ同等のセグメンテーション結果を得られました。

5. 終わりに

「既製のブラックボックスモデルに頼らなければ高精度な話者分離は実現できない」という固定観念を捨て、軽量なオープンソース部品とパラメータチューニングに向き合うことで、Pyannoteと同水準のカスタムエンジンを構築することができました。

ライセンスの制約や重い依存関係に縛られず、環境に合わせて自由に制御できる軽量パイプラインの自作は、商用プロダクトやエッジ環境における極めて強力な選択肢となります。本記事の実装が、同様の課題を抱える開発者の参考になれば幸いです。


📬 お仕事・開発のご相談について

医療・法務・金融などの高セキュリティ領域に向けた、オンプレミス・オフライン完結型の議事録生成システムの構築を承っております。お気軽にご連絡ください。

0
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
0
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?