オンラインMTGの音声をローカルWhisperだけでスマホ字幕化した話(クラウドAPI不使用)
きっかけは「検証用に持っていたスマホを何かに活用できないか」だった。使い道を考えているうちに、オンラインMTG中に聞き取れなかった発言をその場で文字で確認したいというニーズと結びつき、余っていたスマホを字幕専用ディスプレイにする方向にたどり着いた。
ただし実現手段には制約があった。Zoom/Google Meetの画面共有・録画は社内ポリシーやプライバシー上避けたいし、macOSのLive Captionsは画面キャプチャ経由のためオンラインMTGアプリ側のアンチキャプチャ制限(DRM系)に阻まれて動かないケースがある。クラウド文字起こしAPIに音声を送るのも情報の扱い上避けたい。
この3つの制約を同時に満たすため、PC音声をローカルのWhisper(whisper.cpp)だけでリアルタイム文字起こしし、同じWi-Fi上のスマホを字幕ディスプレイとして使うツールを自作した。ソースは公開済み: https://github.com/ymatsuza/mtg-captions(MIT License)
使い方・インストール手順はリポジトリのREADME.mdを参照。
できること
- PC音声のリアルタイム日本語字幕化。会議相手の発言を、余っているスマホの画面に数秒遅れで表示する
- 完全ローカル。音声もテキストも外部に一切送信せず、文字起こしはすべて手元のMacで完結する
- 画面キャプチャ不使用。会議アプリのアンチキャプチャ制限に引っかからず、字幕用に会議へ二重参加する必要もない
- 自分の発言も字幕に含められる(任意)。マイク音声をミックスすれば、双方向のやり取りをまるごと字幕化できる
- 固有名詞・専門用語の語彙補正(任意)。画面に映った単語や手動辞書を使い、Whisperが誤認識しやすい用語を後段で補正する
全体構成
[会議アプリの出力音声]
→ macOS Multi-Output Device(AirPods + BlackHole 2ch)
→ AirPods(自分が聞く用)
→ BlackHole 2ch(仮想オーディオ)
→ whisper-stream(ローカルWhisper, リアルタイム文字起こし)
→ transcript.txt に逐次追記
→ server.py(Pythonローカルサーバー, ポーリング配信)
→ スマホのブラウザ(同一Wi-Fi, LAN経由)
- 音声ルーティングはmacOS標準の「複数出力装置」機能でAirPods(自分の耳用)とBlackHole 2ch(仮想オーディオ、キャプチャ用)に分岐。会議の音声を自分で聞きながら、同じ音を裏で文字起こしに回せる
- 文字起こしはHomebrewの
whisper-cpp(whisper-streamコマンド)。モデルは精度と速度のバランスでggml-small.bin - 配信はPython標準ライブラリのみの自作HTTPサーバー。フレームワーク不使用、スマホ側はポーリングで
/captions?since=<offset>を1秒間隔で叩くだけの素朴なJS - 全処理がローカル完結。外部に音声もテキストも一切送信しない
実装のポイント
1. すべてローカルで完結させる
文字起こしはHomebrewで入るwhisper-cpp(whisper.cppのCLI)をそのまま使う。Apple Silicon上ではMetal + flash-attentionが既定で効くため、ggml-small.bin(多言語モデル)で実用的なリアルタイム性が出る。クラウドの文字起こしAPIに音声を送らないので、会議内容が外部に出ていく経路そのものが存在しない。
2. 画面キャプチャではなく音声を捕まえる
字幕化の手段として真っ先に思いつくのはmacOSのLive Captionsだが、これは画面キャプチャ経由で動くため、会議アプリ側のアンチキャプチャ(DRM系)制限に阻まれることがある。また「字幕を取るためだけに別アカウントで会議へ二重参加する」といった運用は、参加者に不信感を与えかねない。
このツールは音声出力を仮想オーディオ(BlackHole 2ch)で分岐させて捕まえるアプローチを取る。macOS標準の「複数出力装置」で自分の耳(AirPods)とキャプチャ経路(BlackHole)へ同時に音を流すだけなので、会議アプリからは何も見えず、二重参加も不要になる。
3. 標準ライブラリだけの軽量配信サーバー
スマホへ字幕を届ける部分は、Python標準ライブラリだけで書いた小さなHTTPサーバー(server.py)。Webフレームワークは使わない。whisper-streamが書き足していく文字起こしファイルを、スマホ側が/captions?since=<offset>で1秒ごとにポーリングして差分を受け取るだけの素朴な構成にした。依存が少ないぶん、セットアップも「Pythonが動けばよい」で済む。
4. 自分の発言もミックスできる(任意)
デフォルトでは会議相手の発言(システム音声)だけを文字起こしするが、自分の発言も字幕に残したいこともある。そこで、ffmpegでマイク入力をBlackHole 2ch側へ別ストリームとして流し込み、CoreAudio上でシステム音声とミックスさせる経路を用意した。MTG_CAPTIONS_MIC_DEVICE/MTG_CAPTIONS_MIC_MIX_DEVICEを指定するだけで、双方向のやり取りをまるごと字幕化できる。
5. 固有名詞・専門用語の語彙補正(任意)
Whisperは一般語には強いが、社内用語・製品名・人名といった固有名詞は取りこぼしやすい。これを後段で補正する仕組みを2系統用意した。どちらも同じあいまい一致ロジック(difflib.SequenceMatcher)に合流するので、片方だけでも併用でも使える。
- 画面OCR語彙補正: 定期的に画面全体をスクリーンショットし、macOSのShortcuts経由でローカルOCRにかけて候補語を抽出する。直近ほど重みが高い減衰スコアリングで語彙リストを更新し、文字起こし結果と照合して補正する。OCRもmacOSのオンデバイス処理で、クラウドには送らない
- 手動語彙辞書: 会議に出てこない人名・社内用語を、あらかじめ1行1語のテキストファイルで登録しておく方式。画面に映らない用語も毎回正しく補正できる
いずれも「スコアと僅差判定の両方をクリアしたときだけ置き換える」設計にして、誤補正で字幕が荒れないようにしている。
6. 無音区間のノイズ字幕を構造フィルタで除去
Whisper系モデルは無音・非発話区間を、学習データに含まれるパターン((音楽)や[BLANK_AUDIO]のような注釈)で埋めようとする性質がある。個別の文言をブロックリスト化しても網羅しきれないので、「この種の注釈は必ず半角/全角の括弧・角括弧で囲まれた短い文字列になる」という形式的特徴だけを見る正規表現フィルタを配信直前に噛ませた。
_HALLUCINATION_RE = re.compile(r"[((\[][^()()\[\]\n]{0,30}[))\]]")
ストリーミング出力に適用する際は、whisper-streamが1ステップの文字起こしごとに必ず改行を入れる性質を使い、常に完全な1ステップ分のテキストだけをフィルタにかけるようにしている。これで注釈が途中で分断されて素通りすることがなくなる。
7. 体感レイテンシのチューニング
whisper-streamは「何ミリ秒ぶんの音をためてから推論するか(--step)」で、字幕の出る速さと精度がトレードオフになる。ステップ幅を詰めることで、発言から字幕がスマホに出るまでの体感遅延を1秒程度まで短縮できた。ステップ・窓長のパラメータは、whisper-streamが1ステップぶんの音声窓を毎回書き出す挙動を踏まえて調整している。
まとめ
- クラウドAPIを一切使わず、ローカルWhisperのみでMTG音声をリアルタイム字幕化できる
- 画面キャプチャを使わないため、Live CaptionsのDRM系アンチキャプチャ制限やMTGへの二重参加を回避できる
- マイクミックス・語彙補正・ノイズ除去まで載せて、実運用に耐えるところまで作り込んだ
- 余っていた検証用スマホが、会議のたびに活躍する字幕ディスプレイに生まれ変わった
ソース・セットアップ手順は https://github.com/ymatsuza/mtg-captions を参照。