井出草平の研究ノート

Kotoba Whisper と pyannote を連携して話者分離+文字起こしをする(Windows+Docker+NVIDIA GPU)

github.com

本記事は、Windows 11 上で Docker Desktop(WSL2)と NVIDIA GPU を用い、話者分離(pyannote)→ セグメントごとの文字起こし(Kotoba Whisper)を一気通貫で実行するためのメモである。

  • 音声の「誰が話したか」を推定(話者分離)し、区間ごとに話者ラベルを付与する
  • 区間ごとにASR(自動文字起こし)し、話者名つきの txt と srt を生成する
  • Windows 11 環境でありがちな Cドライブ逼迫や キャッシュ肥大化を避けるため、モデルキャッシュや一時ファイルの置き場を工夫する
  • PyTorch側の仕様変更に由来する ロード時エラーを回避できるようにする(後述のパッチ)

1. 前提(GPU / WSL / Docker)

本手順は以下を前提とする。

  • Windows 11
  • Docker Desktop(WSL2 backend)
  • NVIDIA Driver 導入済み(CUDA対応)
  • nvidia-smi が Windows 側で動く
  • Docker から GPU が見える(--gpus all が使える)

前提チェック(PowerShell)

docker context show
wsl -l -v
nvidia-smi

# Docker上でGPUが認識されるか確認
docker run --rm --gpus all nvidia/cuda:12.6.0-base-ubuntu22.04 nvidia-smi

ここで最後の nvidia-smi がコンテナ内で表示されれば、GPUパススルーは概ね問題ない。

2. ストレージ設計(どこに置くと速いか)

本構成では大きく3種類の「置き場」が登場する。

  1. Dockerプロジェクト(Dockerfileなどのコード)
  2. 入力音声・出力テキスト
  3. Hugging Face モデルキャッシュ、分割WAVなどの一時ファイル

結論(おすすめ)

  • モデルキャッシュ(Hugging Face)と一時ファイルはSSD推奨である。 ダウンロード自体も重いが、推論中も小さなファイルI/Oが発生しやすく、HDDだと体感で待ち時間が伸びる。
  • 入力音声・出力は、速度要求がそこまで厳しくないなら HDDでも成立する。 ただし「長時間音声を大量に回す」「分割WAVを大量生成する」用途だと、ここもSSDが効いてくる。

置き場の指針

  • SSD必須寄り

    • hf_cache(モデルキャッシュ)
    • tmp(分割WAVや中間生成物が集まる)
  • HDDでも可(ただし遅くなる可能性あり)

    • work/source(入力音声)
    • work/source/output(SRT/TXTなど出力)

3. ディレクトリ構成(例)

ここではファイル名・パスを「任意のもの」に差し替えて例示する。読み替えやすいように、以下のような構成を採用する。

例:作業フォルダ

  • Dockerプロジェクト(どこでも良いが、管理しやすい場所に置く)

    • C:\work\asr-docker\kotoba_diar_asr
  • 入力音声・出力・キャッシュ(別ドライブへ逃がす例)

    • 入力/出力: D:\asr\work\source
    • HFモデルキャッシュ: D:\asr\hf_cache
    • 一時ファイル: D:\asr\tmp

補足:Docker Desktop(WSL2)環境では、C: でも D: でも動く。だが「Cドライブ(システムドライブ)の残量を守る」「キャッシュ肥大化を隔離する」という意味で別ドライブ運用が便利である。

Dドライブ側のディレクトリ作成(PowerShell)

$base = "D:\asr"
New-Item -ItemType Directory -Force -Path "$base\work\source\output" | Out-Null
New-Item -ItemType Directory -Force -Path "$base\hf_cache" | Out-Null
New-Item -ItemType Directory -Force -Path "$base\tmp" | Out-Null  

4. Dockerプロジェクトを作る

以下の3ファイルを、プロジェクトディレクトリ(例:C:\work\asr-docker\kotoba_diar_asr)に置く。

  • requirements.txt
  • Dockerfile
  • diar_asr.py(実行スクリプト)

4.1 requirements.txt

requirements.txt 例:

--extra-index-url https://download.pytorch.org/whl/cu126

transformers>=4.48.0
huggingface_hub>=0.23.0
accelerate>=0.26.0
sentencepiece
pyannote.audio==3.3.2
pytorch-lightning
speechbrain
pydub
tqdm

4.2 Dockerfile

CUDA 12.6 / cuDNN runtime ベースの例:

FROM nvidia/cuda:12.6.0-cudnn-runtime-ubuntu22.04

ENV DEBIAN_FRONTEND=noninteractive

# 必要パッケージのインストール
RUN apt-get update && apt-get install -y --no-install-recommends \
    python3.11 python3.11-venv python3-pip \
    ffmpeg git ca-certificates \
    && rm -rf /var/lib/apt/lists/*

RUN python3.11 -m pip install -U pip setuptools wheel

WORKDIR /app

# Torch系を先行インストール(cu126指定)
RUN python3.11 -m pip install --no-cache-dir --index-url https://download.pytorch.org/whl/cu126 \
    torch==2.8.0+cu126 torchaudio==2.8.0+cu126

COPY requirements.txt .
RUN python3.11 -m pip install --no-cache-dir -r requirements.txt

COPY diar_asr.py .

CMD ["python3.11", "diar_asr.py"]

4.3 diar_asr.py(話者分離→ASR→出力)

  • Hugging Face キャッシュと一時フォルダを 環境変数でリダイレクトする
  • PyTorchの weights_only 周りでコケるケースを想定し、torch.load をパッチする(安全性・互換性重視の妥協策)
  • diarization と ASR の間で gc / empty_cache を挟み、VRAMをなるべく解放する
  • ffmpeg を subprocess で叩き、失敗時に追跡しやすい形に寄せる
import os
import time
import datetime
import tempfile
import shutil
import warnings
import subprocess
import gc
from pathlib import Path

# quiet warnings
warnings.filterwarnings("ignore", message=".*torchaudio._backend.list_audio_backends.*")
warnings.filterwarnings("ignore", category=UserWarning)
os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")

def log(msg: str):
    print(msg, flush=True)

# Path Settings
WORK_SOURCE = Path(os.getenv("WORK_SOURCE", "/work/source"))
WORK_OUTPUT = WORK_SOURCE / "output"
WORK_HF     = Path(os.getenv("WORK_HF_CACHE", "/work/hf_cache"))
WORK_TMP    = Path(os.getenv("WORK_TMP", "/work/tmp"))

WORK_OUTPUT.mkdir(parents=True, exist_ok=True)
WORK_HF.mkdir(parents=True, exist_ok=True)
WORK_TMP.mkdir(parents=True, exist_ok=True)

# HF/Temp Cache redirection
os.environ.update({
    "HF_HOME": str(WORK_HF),
    "HF_HUB_CACHE": str(WORK_HF / "hub"),
    "TRANSFORMERS_CACHE": str(WORK_HF / "transformers"),
    "XDG_CACHE_HOME": str(WORK_HF),
    "TMPDIR": str(WORK_TMP), "TEMP": str(WORK_TMP), "TMP": str(WORK_TMP)
})

import torch

# PyTorch 2.6+ workaround(ロード互換性のためのパッチ)
try:
    from torch.serialization import add_safe_globals
    from pyannote.audio.core.task import Specifications, Problem
    add_safe_globals([torch.torch_version.TorchVersion, Specifications, Problem])
except Exception:
    pass

# 環境変数で明示的に無効化できるようにしておく(安全弁)
if os.getenv("TORCH_LOAD_WEIGHTS_ONLY", "0").lower() in ("0", "false"):
    _orig_torch_load = torch.load
    torch.load = lambda *a, **k: _orig_torch_load(*a, **{**k, "weights_only": False})
    log("[INFO] Patched torch.load(weights_only=False)")

from huggingface_hub import login
hf_token = os.getenv("HF_TOKEN")
if hf_token:
    login(token=hf_token)

# Environment variables
INPUT_FILENAME = os.getenv("INPUT_FILENAME", "input.m4a")
INPUT_FILE = WORK_SOURCE / INPUT_FILENAME

NUM_SPEAKERS_ENV = os.getenv("NUM_SPEAKERS", "").strip()
NUM_SPEAKERS = None if NUM_SPEAKERS_ENV in ("", "none", "auto") else int(NUM_SPEAKERS_ENV)

MODEL_DIAR = os.getenv("MODEL_DIAR", "pyannote/speaker-diarization-3.1")
MODEL_ASR  = os.getenv("MODEL_ASR",  "kotoba-tech/kotoba-whisper-v2.2")

device = 0 if torch.cuda.is_available() else -1
torch_dtype = torch.float16 if device == 0 else torch.float32

if not INPUT_FILE.exists():
    raise FileNotFoundError(f"Input not found: {INPUT_FILE}")

from transformers import pipeline
from pyannote.audio import Pipeline
from pydub import AudioSegment
from tqdm.auto import tqdm

# Convert to WAV
log("[STEP] Converting input to WAV(16kHz mono)...")
tmp_in_dir = tempfile.mkdtemp(prefix="wav_", dir=str(WORK_TMP))
wav16 = Path(tmp_in_dir) / "input_16k_mono.wav"
subprocess.run(
    ["ffmpeg", "-nostdin", "-y", "-i", str(INPUT_FILE), "-ac", "1", "-ar", "16000", str(wav16)],
    check=True
)
audio16 = AudioSegment.from_file(str(wav16))

# Diarization
log("[STEP] Loading Diarization model...")
dia = Pipeline.from_pretrained(MODEL_DIAR, use_auth_token=hf_token)
if device == 0:
    dia.to(torch.device("cuda"))

log("[STEP] Running Diarization...")
t0 = time.time()
params = {"num_speakers": NUM_SPEAKERS, "min_speakers": NUM_SPEAKERS, "max_speakers": NUM_SPEAKERS} if NUM_SPEAKERS else {}
diar = dia(str(wav16), **params)
segments = list(diar.itertracks(yield_label=True))
log(f"[OK] Diarization finished: {len(segments)} segments ({time.time()-t0:.2f}s)")

# VRAM Release
del dia
gc.collect()
if device == 0:
    torch.cuda.empty_cache()

# ASR
log("[STEP] Loading Whisper...")
asr = pipeline(
    "automatic-speech-recognition",
    model=MODEL_ASR,
    device=device,
    trust_remote_code=True,
    return_timestamps=True,
    torch_dtype=torch_dtype
)

log("[STEP] Running ASR per segment...")
tmp_seg_dir = tempfile.mkdtemp(prefix="seg_", dir=str(WORK_TMP))
results = []

for idx, (turn, _, spk) in enumerate(tqdm(segments, desc="ASR")):
    st, ed = float(turn.start), float(turn.end)
    if ed <= st:
        continue

    seg_path = Path(tmp_seg_dir) / f"seg_{idx:04d}.wav"
    audio16[int(st*1000):int(ed*1000)].export(str(seg_path), format="wav")

    out = asr(str(seg_path), generate_kwargs={"language": "japanese", "task": "transcribe"})
    results.append({"speaker": spk, "start": st, "end": ed, "text": out.get("text", "").strip()})

# Output
base = INPUT_FILE.stem

def fmt(t):
    td = datetime.timedelta(seconds=float(t))
    return f"{td.seconds//3600:02}:{(td.seconds//60)%60:02}:{td.seconds%60:02},{td.microseconds//1000:03}"

srt_body = [
    f"{i}\n{fmt(r['start'])} --> {fmt(r['end'])}\n{r['speaker']}: {r['text']}"
    for i, r in enumerate(results, 1)
]

(WORK_OUTPUT / f"{base}.srt").write_text("\n\n".join(srt_body), encoding="utf-8")
(WORK_OUTPUT / f"{base}.txt").write_text("\n".join([f"[{r['speaker']}] {r['text']}" for r in results]), encoding="utf-8")

shutil.rmtree(tmp_seg_dir, ignore_errors=True)
shutil.rmtree(tmp_in_dir, ignore_errors=True)

log(f"[DONE] Outputs saved to {WORK_OUTPUT}")

5. ビルドと実行

5.1 ビルド

cd C:\work\asr-docker\kotoba_diar_asr
docker build -t kotoba-diar-asr:cu126 .

5.2 実行

例として、入力音声を D:\asr\work\source\meeting_2026-02-11.m4a に置いた場合を示す。

docker run --rm --gpus all `
  -e HF_TOKEN="$env:HF_TOKEN" `
  -e INPUT_FILENAME="meeting_2026-02-11.m4a" `
  -e NUM_SPEAKERS="2" `
  -v D:\asr\work\source:/work/source `
  -v D:\asr\hf_cache:/work/hf_cache `
  -v D:\asr\tmp:/work/tmp `
  kotoba-diar-asr:cu126

実行後、D:\asr\work\source\output に以下が出力される。

  • meeting_2026-02-11.txt(話者ラベルつきテキスト)
  • meeting_2026-02-11.srt(字幕形式)

6. 運用上の注意点

6.1 Hugging Face 認証(pyannoteの利用規約)

pyannote/speaker-diarization-3.1 は Hugging Face 上での利用規約同意が必要になることが多い。先にブラウザで Hugging Face にログインし、対象モデルのページで規約に同意しておくこと。

そのうえで、コンテナ実行時に HF_TOKEN を渡せるよう、Windows 側にトークンを環境変数として登録しておく。

手順A(推奨):PowerShellで「ユーザー環境変数」として保存する

以下は 現在ログインしているユーザー の環境変数として HF_TOKEN を保存する例である。1回設定すれば、次回以降は docker run のたびに手入力しなくてよい。

重要:ここで貼り付けるトークンは秘密情報である。画面共有やログ採取に混ざらないよう注意すること。

# 1) まずは一時的に変数へ入れる(この行の右側に自分のトークンを入れる)
$token = "hf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

# 2) ユーザー環境変数として永続化(次回ログイン後も有効)
[Environment]::SetEnvironmentVariable("HF_TOKEN", $token, "User")

# 3) いったん新しいPowerShellを開き直してから、反映確認
$env:HF_TOKEN

反映が確認できたら、以後はコンテナ実行時に次のように渡せる。

docker run --rm --gpus all `
  -e HF_TOKEN="$env:HF_TOKEN" `
  -e INPUT_FILENAME="meeting_2026-02-11.m4a" `
  -e NUM_SPEAKERS="2" `
  -v D:\asr\work\source:/work/source `
  -v D:\asr\hf_cache:/work/hf_cache `
  -v D:\asr\tmp:/work/tmp `
  kotoba-diar-asr:cu126

手順B:その場限りで一時的に渡す(永続化しない)

「環境に保存したくない」場合は、実行するPowerShellのセッション内だけで変数を作って渡す。

$env:HF_TOKEN = "hf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

docker run --rm --gpus all `
  -e HF_TOKEN="$env:HF_TOKEN" `
  -e INPUT_FILENAME="meeting_2026-02-11.m4a" `
  -e NUM_SPEAKERS="2" `
  -v D:\asr\work\source:/work/source `
  -v D:\asr\hf_cache:/work/hf_cache `
  -v D:\asr\tmp:/work/tmp `
  kotoba-diar-asr:cu126

PowerShellを閉じれば HF_TOKEN は消える。

手順C:GUIで設定(Windowsの環境変数画面)

コードを使わずGUIで設定する場合は、Windowsの「環境変数」設定で HF_TOKEN を追加してもよい。どちらにせよ、設定後は新しいターミナルを開き直して echo $env:HF_TOKEN(PowerShell)で反映確認するのが確実である。

6.2 VRAMと安定性

話者分離モデルとASRモデルを同一プロセスで扱うため、VRAM消費が重なりやすい。スクリプト中で gc.collect() と torch.cuda.empty_cache() を挟んでいるが、それでもGPUメモリに余裕がないとロード時に落ちることがある。少なくとも 8GB以上、できれば余裕のあるVRAMが望ましい。

6.3 I/Oと体感速度(SSDかHDDか)

  • hf_cache と tmp を HDD に置くと「動くが遅い」状態になりやすい。 特に初回ダウンロード後も、小さな読み書きが積み重なって待ち時間になる。
  • 入力音声だけ HDD、キャッシュとtmpはSSD、という分離は効果が出やすい。

6.4 SRT改行

SRTはセグメント間を空行で区切る形式が一般的であるため、"\n\n" で区切る形に寄せている。テキストエディタやプレイヤーでの互換性が上がる。

7. 付録:環境変数で調整できる項目

  • INPUT_FILENAME:入力音声ファイル名(/work/source からの相対)
  • NUM_SPEAKERS:話者数(例:2、未指定なら推定寄り)
  • MODEL_DIAR:話者分離モデル(デフォルト:pyannote/speaker-diarization-3.1)
  • MODEL_ASR:ASRモデル(デフォルト:kotoba-tech/kotoba-whisper-v2.2)
  • HF_TOKEN:Hugging Face トークン
  • TORCH_LOAD_WEIGHTS_ONLY:1 にすると torch.load パッチを抑止(切り分け用)