antist.ai

制作記録 / デスクトップ音声

Windows のリアルタイム日本語字幕

Windows が再生している音を聞き取り、約 1.8 秒後に画面下部へ振り仮名つきの日本語を出し、文が終わった瞬間に高精度版で上書きし、その下に中国語訳を添えるデスクトップアプリ。このページは工学側の記録——構成、実測値、そして最も時間を奪った 3 つの故障。

状態

動作しており日常的に使っていますが、一般配布はしていません。このページにダウンロードリンクがないのは、正直に指し示せる先がまだ無いからです。ここで公開するのは移植可能な部分——構成と測定結果です。

課題:誰も字幕を付けなかった音声に字幕を付ける

字幕なしの動画、オンライン会議、面接——字幕が要る場面とは、まさに字幕が存在しない場面のことです。クラウド認識は精度の半分を解決しますが、残る二つは解決しません:会話の中で体感できる遅延と、会議の音声を他人のサーバーへ送ること。だから認識はローカルで動かす。以下の面白い判断はすべて、この制約から強制されたものです。

構成:速度と精度は別問題なので、系統を二つ走らせる

一つのモデルで速くて正しいは両立しません。ストリーミングモデルは文が成立する前に単語を出す必要があるため推測しますし、オフラインモデルは発話全体を見られるぶん遥かに正確ですが、話者が止まるまで出せません。片方だけ動かすのは、どちらの半分で失敗するかを選ぶことです。だから同じ音声に対して両方を同時に走らせ、正確な方が速い方をその場で上書きします。

システム音声(WASAPI ループバック)→ 二つの認識器を並列 → 透過オーバーレイ

速報系統 —— ストリーミング Zipformer

部分結果を連続して出し、話者から約 1.8 秒遅れます。この遅れはモデル固有の先読み窓であり、最適化で削れるオーバーヘッドではありません。相手がまだ話している間に読んでいるのは、この系統です。

確定系統 —— VAD + SenseVoice

音声区間検出が 0.6 秒の無音で区切り、完結した発話ごとにオフラインモデルへ渡します。文の終了から 1.2〜1.5 秒後に着地し、速報系統の推測を置き換えます。両者の精度差は 10 ポイント近くあります。

翻訳 —— 完全に非ブロッキング

確定した行ごとに専用ワーカーがクラウド翻訳へ送ります。往復の中央値はおよそ 0.7〜1.0 秒。そして音声スレッドには一切触れさせません——触れると何が起きるかは、下の教訓の一つ目です。

推定ではなく実測

2026-08-01 実測。47 分のポッドキャストに付いた 605 行の人手字幕を参照——プラットフォームの自動字幕ではありません。自動字幕を基準にすると、測れるのは二つの認識系がどれだけ似ているかだけです。精度は仮名の文字誤り率。

指標実測備考
字幕カバー率(2 系統の統合)100.00%605 行の参照字幕のうち、両系統が同時に黙った行は一つもなし
認識精度(確定系統)90.3%仮名 CER 9.7%、47 分全長
認識精度(速報系統)80.6%端点検出の修正前は 72.6%——バグ一つで 8 ポイント
音声フレームの欠落0.0%初期の二版はそれぞれ 48% と 10.9% を欠落。原因はどちらも取り込みスレッド上の同期処理
初字までの遅延約 1.8 秒ストリーミングモデル固有の先読み窓
確定までの遅延文末から 1.2〜1.5 秒0.6 秒の無音しきい値に構造的に支配される
計算余力RTF 0.16〜0.23両系統の同時稼働時。音声ブロックあたり中央値 2〜3ms

持ち帰る価値のある 3 つの故障

どれも字幕に固有の話ではありません。3 つとも、AI 支援開発が最も量産しやすい型のバグです——なぜなら 3 つともエラーを出さないからです。

  1. 1. 入力規模に比例して時間が伸びる処理を、取り込みスレッドに置いてはいけない

    WASAPI が渡すのはリングバッファです。間に合わなかったぶんは次の音声に上書きされ、しかも何も言いません。初版は取り込みスレッドで翻訳の HTTP を同期待ちし、音声の 48% を失いました。後の版はオフライン復号をループ内に戻し、10.9% を失いました。どちらも例外を投げたことはありません。そこから出た規則:取り込みスレッドはバイトを複製して渡すことしか許されず、ネットワーク・モデル推論・ファイル書き込みはすべてワーカー行きです。
  2. 2. reset() は「まっさらな状態に戻る」という意味ではない

    文の切れ目で認識器をリセットしても残渣が残り、モデルをループに閉じ込めました:何も出さない → 端点検出が空出力を無音と読む → 2.4 秒ごとに空撃ち → また リセット。人が 18 秒話し続けて、一文字も出ませんでした。修正は一律ではなく条件付きです:通常の文境界では左文脈を保つために reset を続け、結果が空で返ってきたときだけ新しいストリームを作る。全部を新規作成に置き換えれば停止は直りますが、精度を払うことになります。
  3. 3. 「初期化済み」フラグは最後の一歩でなければならず、副産物であってはならない

    モデル初期化が途中でハンドルを代入し、その後に例外を投げました。ガード節はそのハンドルを読み、初期化は完了したと判断し、半死のオブジェクトを永久にキャッシュしました。完了フラグの意味は「すべて準備済みでスレッドも生きている」でなければならず、「あるフィールドに代入できるところまで進んだ」ではありません。

技術スタック

各層の選定理由はただ一つ:認識を端末の外に出さないこと。

言語
Python 3.14
GUI
PyQt6 + Fluent Widgets
オーバーレイ
PyQt6-WebEngine —— 振り仮名は HTML の ruby
音声取り込み
soundcard(WASAPI ループバック)
音声認識
sherpa-onnx —— ストリーミング Zipformer + SenseVoice
振り仮名
fugashi + unidic
翻訳
Google / MiniMax、クラウド、スレッド外

なぜ振り仮名をブラウザエンジンで描くのか

目的は読む速度そのものであり、ルビ——漢字の上に小さく置かれた仮名——は、不慣れな日本語を「解析するもの」から「一目で流し読みできるもの」に変える唯一の組版機能です。ネイティブのウィジェット環境でルビを正しく実装するのはそれ自体が一つのプロジェクトですが、HTML には二十年前から ruby 要素があります。だから字幕層は、透過してクリックも通り抜けるブラウザビューです。格好よくない方の選択が、速い方でした。