はじめに
Discord の VC で相手の発言を、画面の最前面に
たろう:今日の予定を確認したいんですけど、今大丈夫ですか?
はなこ:それなら夜の9時くらいに集まりましょう。
のように 「名前:発言」 の形でリアルタイムに表示するツールを作りました。
- Bot 不要(サーバーに何も追加しない。自分の PC で exe を起動するだけ)
- 完全ローカル(音声認識は自分の GPU で実行。会話を外部に送らない)
- 話している途中から字幕が出る(約 1 秒)
- 同時に話しても話者ごとに行を分ける
リポジトリ: https://github.com/FalledCan/discord-jimaku
この記事では、作る過程でハマったポイントを中心に仕組みを紹介します。
全体構成
話し始め/終わり(user_id)
Discord アプリ ───────────────────────▶
(ローカル RPC) 発話の区切り ──▶ faster-whisper ──▶ 話者の割り当て ──▶ 字幕ウィンドウ
(0.1秒単位) (kotoba-whisper) (時刻+声紋) (tkinter)
スピーカー出力の音声
WASAPI ───────────────────────▶
ループバック録音
| 役割 | 使ったもの |
|---|---|
| 誰が話しているか | Discord のローカル RPC(SPEAKING_START / SPEAKING_STOP) |
| 音声 | WASAPI ループバック録音(PyAudioWPatch)+ soxr でリサンプリング |
| 文字起こし | faster-whisper + kotoba-whisper v2.0 |
| 声が重なった部分の話者判定 | sherpa-onnx の話者埋め込み(CAM++) |
| 表示 | tkinter(枠なし・最前面・半透明) |
| 配布 | PyInstaller |
なぜ Bot を使わないのか
最初は定番の「Bot を VC に参加させてユーザーごとの音声を受信する」方式で作り始めました。Bot ならユーザーごとに音声ストリームが分かれているので、話者の判定は簡単です。
ただし 2026 年現在、Discord の音声には E2EE(DAVE プロトコル)が必須になっています。
- discord.js(
@discordjs/voice)は受信側の DAVE 復号に対応済み - Python の
discord-ext-voice-recvは、執筆時点では DAVE の受信に未対応(対応 PR が複数出ている段階)
という状況でした。加えて「サーバーに Bot を入れずに、自分の PC だけで完結させたい」という要望もあり、方針を変えました。
音声は PC のスピーカー出力から録音し、誰が話しているかは Discord アプリ本体から教えてもらう という構成です。
1. Discord のローカル RPC で「誰が話しているか」を取る
Discord のデスクトップアプリは、名前付きパイプ \\.\pipe\discord-ipc-0〜9 でローカル RPC を受け付けています。OBS 用の Discord オーバーレイ(StreamKit)と同じ仕組みです。
Developer Portal でアプリを作り、作成者本人(=テスター)として rpc と rpc.voice.read のスコープで認可すると、次のイベントを購読できます。
| イベント | 内容 |
|---|---|
VOICE_CHANNEL_SELECT |
自分が VC を移動した |
VOICE_STATE_CREATE / UPDATE / DELETE |
メンバーの出入り・表示名(サーバーのニックネーム含む) |
SPEAKING_START / SPEAKING_STOP |
誰が話し始めた / 話し終わった(user_id) |
パケットは「opcode(4 バイト)+長さ(4 バイト)+ JSON」というシンプルな形式です。
def _send(self, op: int, payload: dict):
data = json.dumps(payload).encode("utf-8")
self._pipe.write(struct.pack("<II", op, len(data)) + data)
Windows の名前付きパイプを同期モードで開くと、読み込み待ちの間は同じハンドルへの書き込みもブロックされます。そのため送受信を 1 スレッドで順番に処理し、イベント処理の中で別のリクエストを入れ子で送らないようにしています。
2. スピーカー出力をループバック録音する
PyAudioWPatch を使うと、WASAPI のループバック録音で「PC から出ている音」をそのまま取れます。
自分の声は Discord から再生されないので、相手の声だけが録音されます。
SteelSeries Sonar のように Discord の出力先を「Chat」デバイスに分けられる環境なら、ゲーム音や動画の音が混ざりません。今回はデバイス名に Sonar - Chat を含むものを優先的に選ぶようにしました。
ハマりどころ:リサンプリングの折り返しノイズ
最初は 48kHz → 16kHz の変換を np.interp の線形補間で済ませていました。これだと高域が折り返しノイズになって音質が劣化し、認識精度が目に見えて落ちます。
soxr のストリーム用リサンプラーに変えて解決しました。ブロックの境目でも途切れません。
resampler = soxr.ResampleStream(rate, 16000, 1, dtype="float32", quality="HQ")
ただし soxr の出力長は呼び出しごとにばらつきます(4800 サンプル入力で 1468 / 1958 サンプルなど)。後段では「0.1 秒 = 1 ブロック」として時刻を計算しているので、出力を 1600 サンプルずつに切り直しています。
3. 発話を区切る
0.1 秒ごとのブロックに 「その時点で話していた人の集合」 を付けて記録し、次のタイミングで発話を確定させます。
- 0.7 秒誰も話していない
- 前の人が黙った後、別の人だけが話し始めた
- 話している途中に別の人が割り込み、元の人が話し終わった(割り込んだ位置で区切る)
- 長すぎる(10〜15 秒)
話者の交代で区切らないと、テンポの良い会話がずっと 1 つの発話としてつながってしまいます。発話が長くなるほど毎回の処理が重くなり、表示がどんどん遅れていきます。
4. 同時に話しても話者を分ける
録音した音声は全員分がミックスされた 1 本なので、そのままでは「誰の発言か」がわかりません。次の 2 段構えで話者を割り当てています。
(1) 単語の時刻 × 話していた人
faster-whisper の word_timestamps=True で単語ごとの時刻を取り、その時刻に話していた人と照合します。話していたのが 1 人だけなら確定です。
cands = [self._speakers_between(activity, w.start, w.end) for w in words]
who = [next(iter(c)) if len(c) == 1 else None for c in cands]
(2) 声が重なった部分は声紋で判定
複数人が話していた時刻の単語は、話者埋め込み(sherpa-onnx + CAM++)で「どちらの声に近いか」を判定します。
声紋は、1 人だけが話していた発言から自動で学習します。RPC のイベントで話者が確定している区間をそのまま教師データにできるのが、この構成の良いところです。
読み上げ音声で作ったテストデータでは、次のように分けられました。
| ケース | 結果 |
|---|---|
| 間を空けずに交代 | 2 行に正しく分かれる |
| A さんの話の終わり 1.2 秒に B さんが被せる |
A:…今大丈夫ですか? / B:後で確認しておきますね。 に分かれる |
完全に声が重なった部分は、Whisper が大きい方の声しか書き起こさないため、小さい方の声が一部抜けることがあります。ミックス済みの 1 本の音声からはこれ以上の分離は難しく、この方式の限界です。
5. 話している途中から字幕を出す(ストリーミング表示)
「話し終わってから文字起こし」だと、長く話すほど表示が遅れます。そこで次のようにしました。
- 話している最中は 0.5 秒ごとに、それまでの音声を文字起こしして**仮の字幕(灰色)**を更新
- 話し終わったら**確定版(白)**に置き換え
処理が追いつかないときに遅れが溜まらないよう、キューは次のように扱います。
- 確定の依頼は必ず処理する(最優先)
- 仮の依頼は最新の 1 件だけ残し、古いものは捨てる
- 仮の結果が返る前に同じ発話が確定していたら、仮の結果は表示しない
def _next_job(self):
with self._cv:
while not self._finals and self._partial is None:
self._cv.wait()
if self._finals:
return (*self._finals.popleft(), True)
job, self._partial = self._partial, None
return (*job, False)
仮の字幕は速度優先で beam_size=1、確定版は精度優先で beam_size=5 にしています。
読み上げ音声を実時間で流し込んだテストでは、話し始めから約 1 秒で最初の文字が表示されました。
6. 音声認識モデルの選定
RTX 3060 Ti(8GB)で約 7 秒の音声を処理した時間です。
| モデル | beam=1 | beam=5 |
|---|---|---|
| large-v3-turbo | 0.76 秒 | 0.86 秒 |
| large-v3 | 3.95 秒 | 5.39 秒 |
large-v3 は 7 秒の音声に 4〜5 秒かかるので、リアルタイム用途には向きません。
最終的には、日本語向けに学習し直された kotoba-whisper v2.0 を採用しました。実際の VC 音声(約 7 分、169 発話)での比較結果です。
| large-v3-turbo | kotoba-whisper v2.0 | |
|---|---|---|
| 全体の処理時間 | 126 秒 | 99 秒 |
| 「ご視聴ありがとうございました」系の幻聴 | 2 件 | 0 件 |
正解データがないので文字誤り率は出せていません。体感では精度は同程度で、kotoba-whisper のほうが速く、幻聴が少ないという結果でした。
ハマりどころ:kotoba-whisper で単語タイムスタンプを使うと落ちる
kotoba-tech/kotoba-whisper-v2.0-faster で word_timestamps=True にすると、find_alignment の中で access violation で Python ごと落ちました。
原因はモデルの config.json にありました。
- kotoba-whisper は decoder が 2 層の蒸留モデル
- ところが
alignment_headsは元の large-v3(32 層)のまま(最大で 25 層目を参照) - 存在しない層を参照してメモリ外にアクセスしていた
対処として、モデルをローカルにダウンロードしたうえで、alignment_heads を「最終層の全ヘッド」(Whisper の既定の決め方)に書き換えてから読み込んでいます。
layers, heads = 2, 20 # kotoba-whisper-v2.0 の decoder 層数とヘッド数
path = snapshot_download(model_name, local_dir=os.path.join(models_dir, "kotoba-whisper-v2.0-faster"))
config["alignment_heads"] = [[layers - 1, h] for h in range(heads)]
7. 精度を上げるための小技
-
初期プロンプト:直前の確定済みの会話(約 120 文字)と、設定ファイルの
hint(ゲーム用語や固有名詞)をinitial_promptに渡す。長くしすぎると、プロンプトの文章をそのまま出力してしまうことがある - 音量の正規化:小さい声をピーク 0.9 まで持ち上げる(最大 10 倍)
- 幻聴フィルタ:「ご視聴ありがとうございました」などを、句読点を除いた発話全体の文字列で判定する。話者の割り当て後の行単位で判定すると、「ご視 / 聴ありがとうございました」のように 2 行に割れてすり抜ける
- 語尾を切らない:RPC の「話し終わり」イベントより実際の音声が少し遅れて届くことがあるので、発話の末尾は削らず、無音の除去は Whisper の VAD に任せる
使い方
- https://discord.com/developers/applications で New Application を作成(Bot は不要)
- OAuth2 の画面で以下を行う
- Client ID をコピー
- Reset Secret で Client Secret を発行してコピー
- Redirects に
http://localhostを追加
- exe を起動して Client ID / Client Secret を入力し、Discord に出る認証画面で「認証」を押す
- VC に入ると自動で字幕が表示される
ウィンドウはドラッグで移動、ホイールで文字サイズ変更、右クリックで終了です。
NVIDIA GPU(CUDA)がない場合は CPU で動きますが、リアルタイム表示は厳しくなります。初回起動時には音声認識モデル(約 1.5GB)をダウンロードします。
まとめと今後
- Bot を使わなくても、Discord のローカル RPC(誰が話しているか)+ループバック録音(音声) の組み合わせで、話者付きのリアルタイム字幕が作れる
- 同時発話は、単語のタイムスタンプと声紋の組み合わせで実用的なレベルまで分けられる
- ただしミックス済みの音声なので、完全に重なった部分の取りこぼしは残る
今後は、確定した字幕を LLM で文脈補正する(「地方→中央」のような、音は近いが文脈的におかしい誤認識を直す)ことを検討しています。