# 仕様書: ハンズフリー送信ブリッジ(音声 → 任意アプリの入力欄) 版: 1.0(2026-11-12)/ 対象読者: この仕組みを**別の環境(OS・GPU・マイク・貼り付け先アプリ)に合わせて実装・移植する開発者や AI エージェント** 参照実装: `handsfree-bridge/`(Windows 10 / Python 3.11 / faster-whisper / RTX 5070 Ti / Muse on Firefox・Chrome) 関連文書: [企画書](handsfree-stt-bridge-plan.md) / [実装計画](handsfree-stt-bridge-implementation-plan.md) / [実測値](handsfree-bridge/env-report.md) / [プロダクト固有ルール](DEVELOPMENT_PLAYBOOK.md) --- ## 0. この文書の使い方 - **§2$301C§7 は環境に依存しない仕様**。移植先でもこの通りに振る舞うこと - **§8 は環境に依存する部分の一覧**と、他 OS・他エンジンでの置き換え候補。移植ではここだけを差し替える想定 - **§9 は参照実装で実際に踏んだ落とし穴**。同じ失敗を繰り返さないために、実装前に必ず読む - 要件の強さは「**必須**(MUST)」「**推奨**(SHOULD)」「任意(MAY)」で示す - 数値(閾値・秒数)は参照環境での実測による初期値。移植先では §10 の手順で測り直す --- ## 1. 概要 ### 1.1 目的 PC のマイクに向かって話した内容を文字に起こし、**フォーカス中のアプリの入力欄へ連続して貼り付ける**。あわせて、短い音声コマンド(「エンター」「送信」「改行」「スペース」「キャンセル」など)で**キー操作を代行する**。 ### 1.2 設計の原則(どの環境でも変えないこと) 1. **送信の判断は常にユーザーが持つ**。発話の区切りで自動的に送信することはしない。キーを押すのは、ユーザーがコマンド語を明示的に言ったときだけ(必須) 2. **許可したアプリにしか入力しない**。貼り付けもキー送信も、実行の直前に前面のウィンドウを確かめる(必須) 3. **迷ったら実行しない側に倒す**。コマンドの判定条件を一つでも満たさなければ実行しない(必須) 4. **ユーザーの操作を壊さない**。ユーザーが打鍵中は割り込まない。クリップボードは戻す。どこを消すか分からない取り消しはしない(必須) ### 1.3 用語 | 用語 | 意味 | |---|---| | 発話(utterance) | VAD が切り出した、ひと続きの音声区間 | | 本文 | コマンドではない発話の認識結果。入力欄に貼り付ける | | コマンド語 | 動作に結び付けた短い語(別名を含む) | | 1 段目 / 2 段目 | 同じ発話に対する通常の認識と、コマンド判定用の再認識(§5.3) | | 許可リスト | 入力してよいアプリの条件(プロセス名+ウィンドウタイトル) | | 注入イベント | ブリッジ自身が OS に送ったキー・マウスのイベント | | 取り消し対象 | 「キャンセル」で消してよい、送信前に貼った文の履歴 | --- ## 2. 機能要件 | ID | 要件 | 強さ | |---|---|---| | F-1 | マイク入力を常時受け取り、VAD で発話を切り出す | 必須 | | F-2 | 発話を文字に起こし、句読点を含めて本文として扱う | 必須 | | F-3 | 本文を、許可リストのアプリの入力欄へ**区切りを足さずに**順番どおり追記する | 必須 | | F-4 | 音声コマンドに対応する(§5.4 の表)。コマンド語は設定ファイルで追加・変更できる | 必須 | | F-5 | 「キャンセル」で、送信前に貼った文を新しい方から 1 つずつ消す | 必須 | | F-6 | UI で開始と停止を切り替えられる。状態・使用中のマイク・認識結果・判定理由を表示する | 必須 | | F-7 | 期待するヘッドセットが無い・使われていないときは、UI に警告を出す | 必須 | | F-8 | OS の既定の録音デバイスに追従する。UI で特定のデバイスに固定もできる(名前で保持する) | 必須 | | F-9 | 「コマンド」前置き(例:「コマンド、エンター」)を必須にするモードを、オン・オフで切り替えられる(初期値オフ) | 必須 | | F-10 | 誤認識の置換辞書("誤" = "正")を設定ファイルで持てる | 必須 | | F-11 | 認識・判定・出力をすべてローカルのログに残す | 必須 | | F-12 | 録音済みの音声を流して認識と判定を再現するモード(replay) | 推奨 | | F-13 | 保留・破棄・取り消し・キー送信を効果音で知らせる | 推奨 | | F-14 | 測定用に、発話ごとの音声を WAV で保存できる(既定はオフ) | 推奨 | ### 非機能要件 | ID | 要件 | 参照環境での実測 | |---|---|---| | N-1 | 発話の終わりから本文が確定するまで 3 秒以内 | 約 1.1$301C1.25 秒(無音判定の 1.0 秒を含む) | | N-2 | 発話の終わりからコマンドのキー送信まで 2 秒以内 | 同上 | | N-3 | コマンド語の判定率 96% 以上(同じ人の声で 50 回中 48 回以上) | 「エンター」36/36、混在 35 件で 34/35 | | N-4 | 通常の会話でコマンドを誤判定しない | 本文のつもりの発話 20 件以上で誤判定 0 | | N-5 | 外部へ音声もテキストも送らない(完全ローカル) | $2014 | --- ## 3. 全体構成 ``` [UI] 開始/停止・状態・マイク選択・前置き・hotwords・結果一覧・ヘッドセット警告 │(イベントキューを UI スレッドが 50ms ごとに取り出す) ▼ [AudioCapture] OS のオーディオ API → モノラル float32 → 16kHz にリサンプル │ 既定デバイスを数秒おきに確認し、変わったら開き直す/止まったら開き直す ▼ [Segmenter] VAD(silero)で発話を切り出す。前置き音声 2 種類(本文用・コマンド用) ▼ [STT ワーカー] 1 段目(通常)→ 必要なら 2 段目(hotwords 付き)。GPU は 1 本で直列 ▼ [後処理] 置換辞書・正規化・ハルシネーション破棄 ▼ [コマンド判定] 純関数(§5.4) ▼ [振り分け] 本文 → 貼り付け / コマンド → 取り消し・文字入力・キー送信・ミュート ▼ [OutputExecutor] 1 スレッドで直列に実行。実行の直前に「許可リスト」「打鍵中でないか」を確認 ▲ [InputMonitor] ユーザーの物理キー・クリックを監視(注入イベントは除外) [Logger] JSONL(+任意で WAV) ``` ### 3.1 スレッド(必須の性質) - 音声コールバックは重い処理をせず、キューに積むだけにする - **認識(GPU)は 1 本に直列化する** - **貼り付けとキー送信は 1 本のスレッドで直列に実行する**。「本文を貼り終える前に Enter が押される」順序事故を、構造的に起こせなくするため - UI スレッドは処理スレッドを直接呼ばず、イベントキューで受け取る ### 3.2 状態 ``` stopped ─開始→ loading(モデル読み込み・暖機)→ listening $21C4 speaking → recognizing → listening └─「マイクオフ」→ muted ─「マイクオン」→ listening ``` - **UI の「停止」と音声の「マイクオフ」は別物にする**(必須) - 「停止」はマイクも認識も止める完全停止。音声では再開できない - 「マイクオフ」は一時ミュート。認識は続け、「マイクオン」だけを受け付ける - 停止したら、保留中の出力はすべて捨てる --- ## 4. 音声入力と発話の切り出し ### 4.1 取り込み - 16kHz・モノラル・float32 に変換して後段に渡す。デバイス本来のレートで開いてからリサンプルする(参照実装は 44.1k/48k → soxr) - 同じ物理マイクが複数のオーディオ API に重複して見える環境では、**UI の選択肢を 1 つの API に絞る** - 手動で選んだデバイスは**名前で**保持する(抜き差しで番号がずれるため) - 既定デバイスへの追従: OS の既定録音デバイス名を数秒おきに取得し、変わったらストリームを開き直す。2 秒以上コールバックが来ない(切断など)ときも開き直す - ヘッドセットの判定: 設定のキーワード(例 `["G535"]`)をデバイス名に含むかどうか。警告は次の 3 種類 - 一覧に無い - 一覧にはあるが、別のマイクを使っている - マイクが無い・開けない ### 4.2 VAD による切り出し(silero-vad、16kHz・512 サンプル窓) - **ストリーミングで使う**: VAD の内部状態(h, c)と直前 64 サンプルの文脈を、呼び出しをまたいで持ち回す - **ヒステリシス**: 発話の開始は確率が `threshold`(0.5)以上。発話中は `threshold $2212 0.15` 以上なら継続とみなす(語の途中の落ち込みで切れないように) - 無音が `silence_sec`(1.0 秒)続いたら発話を確定する。末尾の無音は 0.2 秒だけ残す - `min_speech_sec`(0.25 秒)未満の発話は捨てる。`max_speech_sec`(20 秒)で強制的に区切る - **前置き音声を 2 種類持つ**(§9-6) - `audio`: 発話開始の `pre_roll_sec`(0.3 秒)前から。本文の認識に使う - `audio_cmd`: `command_pre_roll_sec`(0.6 秒)前から。コマンドの 2 段目に使う - 発話の長さ(`duration_sec`)は**前置きを含めない**長さ。コマンド判定に使う --- ## 5. 認識と判定 ### 5.1 認識エンジンに求めること - 日本語の文字起こしができ、**句読点を出せる** - 区間ごとの平均対数確率(avg_logprob)と、無音である確率(no_speech_prob)が取れる(取れない場合は §8.4) - 認識語を寄せる仕組み(hotwords / プロンプト)がある(推奨) - 1 発話あたり 0.5 秒以内(参照環境: large-v3-turbo / float16 で 0.1$301C0.25 秒) - 起動時に空の推論を 1 回流して暖機する(最初の 1 回は GPU 初期化で数秒かかる) ### 5.2 後処理(純関数) - **置換辞書**: 認識結果の部分文字列を "誤" → "正" に置き換える(上から順)。本文にもコマンド判定にも効く - **判定用の正規化** `normalize_for_match`: 1. NFKC 2. 小文字化 3. ひらがな → カタカナ 4. 空白と句読点・記号を除去(`。、,,..!!??・…「」『』()()[]【】$301C~-$2014$2013―::;;"'\``)。**長音「ー」は残す** - **ハルシネーション破棄**: 正規化後が空、または既知の定型文(「ご視聴ありがとうございました」など)と一致したら捨てる ### 5.3 2 段の認識(重要) ``` 1 段目: audio を通常設定で認識 → 本文の候補 text(句読点あり) ※発話が短い(duration $2264 コマンドの最大長)ときは temperature=0・max_new_tokens=16 ハルシネーション → 破棄 text をコマンド判定 → コマンドなら確定 そうでなく、hotwords が有効で、発話が短いとき: 2 段目: audio_cmd を hotwords 付き・temperature=0・max_new_tokens=16 で認識 → text2 text2 は「同じ語の繰り返し」なら 1 語にまとめる(「エンター エンター」→「エンター」) text2 に異なるコマンド語が 2 種類以上 → hotwords の暴走とみなして捨てる text2 がコマンド → コマンドとして確定 text2 がコマンド語には一致したが条件で外れた → 「聞き取ったが実行しない」として記録 本文として扱うのは常に 1 段目の text ``` - 本文に hotwords を使わない理由: hotwords を付けると句読点が落ちる(§9-2) - 短い発話で temperature のやり直しをしない理由: 16$301C20 秒かかった実例がある(§9-3) ### 5.4 コマンド判定(純関数 `classify`) 入力は、テキスト・発話長・avg_logprob・no_speech_prob・現在時刻・前回コマンドの時刻・許可リストの判定結果・前置きモード。次の順に判定し、**一つでも満たさなければ本文として扱う**(コマンド語に一致していた場合は `matched_word` に記録する)。 1. コマンド機能が無効なら本文 2. 前置きモードがオンなら、正規化後のテキストが前置き語(「コマンド」)で始まるときだけ、それを取り除いて続ける。始まらなければ本文 3. 語の照合(照合では促音「ッ」を無視する) - まず別名との**完全一致** - 無ければ、**3 文字以上の別名**だけを対象に「別名 + 末尾に `fuzzy_suffix_chars`(2)文字まで」を許す(例:「エンタープ」「エンターホー」)。2 文字の語(送信・改行)には適用しない(「送信者」「改行して」に反応させないため) - 文中に含まれるだけでは一致としない 4. 発話長 $2264 `max_utterance_sec`(1.5 秒)。前置きオンなら `max_utterance_sec_with_prefix`(2.5 秒) 5. `no_speech_prob` $2264 `max_no_speech_prob`(0.6) 6. `avg_logprob` $2265 `min_avg_logprob`($22121.5。実声のコマンドは $22120.57$301C$22121.16、雑音の例は $22122.17) 7. 前回のコマンドから `cooldown_sec`(1.5 秒)以上たっている 8. 前面のウィンドウが許可リストに一致する ### 5.5 コマンド表(参照実装の初期値) | 名前 | 別名 | 動作(action) | |---|---|---| | enter | エンター, enter | `key:enter` | | send | 送信, そうしん | `app_send`(許可リストのアプリごとの送信キー) | | newline | 改行, かいぎょう, 開業 | `key:shift+enter` | | space | スペース, space | `text: `(半角スペースを貼り付けで入れる) | | cancel | キャンセル, 取り消し | `undo_last_paste` | | mute | マイクオフ, ストップ | `mute`(内部状態のみ) | | unmute | マイクオン | `unmute`(内部状態のみ) | action の書式: - `key:<キー指定>`: `enter` / `shift+enter` / `ctrl+enter` など。修飾キーは `+` でつなぐ - `app_send`: 許可リストの `send_key` に置き換える - `text:<文字列>`: その文字列を貼り付けで入れる(取り消し対象になる)。**Space キーは使わない**(IME がオンだと全角になったり変換が始まったりする) - `undo_last_paste` / `mute` / `unmute` ### 5.6 出力への振り分け | 判定 | 出力 | |---|---| | 本文(コマンド語に一致しない) | 貼り付け | | コマンド語に一致したが条件で外れた | **何もしない**(「エンター」の文字を入力欄に残さない) | | ハルシネーション・ミュート中 | 破棄 | | `undo_last_paste` | 取り消し | | `text:` | 貼り付け | | `key:` / `app_send` | キー送信(`key_dry_run` の間は表示だけ) | | `mute` / `unmute` | 内部状態を切り替える(他のアプリには触れない) | --- ## 6. 出力(OutputExecutor) ### 6.1 キュー処理 - 出力(paste / undo / key)は FIFO のキューに積み、1 本のスレッドで先頭から順に処理する - 先頭の 1 件ごとに、次の順で判定する 1. **undo と key は、言ってから `command_timeout_sec`(5 秒)を過ぎていれば捨てる**。条件が整っていても実行しない(前の貼り付けの後ろで待たされた古い Enter を押さないため。§9-9) 2. 前面のウィンドウが許可リストに一致しない → 保留(理由「対象外のウィンドウ」) 3. ユーザーの最後の物理キー入力から `typing_hold_sec`(2 秒)以内 → 保留(理由「キーボード入力中」) 4. 保留が timeout(paste は `held_timeout_sec` = 30 秒、undo と key は 5 秒)を超えたら捨てる 5. 条件を満たせば実行する - 保留の通知は、理由が変わったときだけ出す(毎回出さない) ### 6.2 貼り付け 1. 現在のクリップボード(テキスト)を退避する 2. 本文をクリップボードに入れ、50ms 待ってから貼り付けのショートカット(Windows は Ctrl+V)を送る 3. `restore_delay_sec`(0.3 秒)待ってから、退避した内容を戻す(早すぎるとアプリが古い内容を貼る) 4. (貼った文字列, 貼った先のウィンドウ)を取り消し対象に積む - 文字を 1 文字ずつ打つ方式にはしない(IME を通ると壊れる) - 区切り文字は足さない(`separator = ""`。句読点は認識結果のまま) ### 6.3 取り消し - 取り消し対象の最後の文について、**前面のウィンドウが貼ったときと同じ**なら、その文字数ぶん Backspace を送って取り除く - 次のときは何もしない(理由を表示して効果音を鳴らす) - 対象が空 - ウィンドウやタブが違う - **取り消し対象のリセット**(必須) - ユーザーの物理キー入力・クリック・Enter があったら、取り消し対象を空にする。カーソル位置が分からなくなり、送信済みの文や関係ない文字を消すおそれがあるため - ブリッジ自身が Enter(Shift なし)を送ったときも空にする(送信済みとみなす) ### 6.4 キー送信 - `app_send` は、実行時の前面アプリの `send_key` に置き換えてから送る - 修飾キーは押す → 本キーを押して離す → 修飾キーを逆順に離す ### 6.5 ユーザー入力の監視(InputMonitor) - キーボードとマウスの低レベルフックで、ユーザーの物理入力を検知する - **ブリッジが送ったイベントは除外する**(必須)。除外しないと、自分の Ctrl+V を「ユーザーが打鍵中」と誤認したり、取り消し対象を勝手にリセットしたりする(§8.3 の注入フラグ) - 記録するのは「最後の物理キー入力の時刻」と「キー / Enter / クリックの種別」 --- ## 7. 設定・ログ・UI ### 7.1 設定ファイル(TOML の例。キー名は参照実装と同じ) | セクション.キー | 初期値 | 意味 | |---|---|---| | audio.device | "default" | "default" は既定に追従する。デバイス名を書くとそのデバイスに固定する | | audio.poll_default_sec | 3.0 | 既定デバイスを確認する間隔 | | audio.headset_keywords | ["G535"] | ヘッドセットとみなすデバイス名の部分文字列 | | stt.model / device / compute_type | large-v3-turbo / cuda / float16 | 認識エンジンの設定 | | stt.language / beam_size | ja / 1 | | | stt.use_hotwords / hotwords | true / コマンド語を空白区切り | 2 段目に使う | | vad.threshold | 0.5 | 発話開始の確率(継続は $22120.15) | | vad.silence_sec | 1.0 | 発話の終わりとする無音の長さ | | vad.min_speech_sec / max_speech_sec | 0.25 / 20.0 | | | vad.pre_roll_sec / command_pre_roll_sec | 0.3 / 0.6 | 本文用・コマンド用の前置き音声 | | postprocess.hallucinations | 定型文の配列 | 一致したら捨てる | | postprocess.replace | {} | 置換辞書 "誤" = "正" | | paste.enabled / separator | true / "" | | | paste.typing_hold_sec | 2.0 | 打鍵からこの秒数は出力を保留する | | paste.held_timeout_sec / command_timeout_sec | 30.0 / 5.0 | 保留を捨てるまでの秒数 | | paste.restore_clipboard / restore_delay_sec | true / 0.3 | | | paste.beep | true | 効果音 | | command.enabled / key_dry_run | true / false | key_dry_run=true でキー送信を表示だけにする | | command.prefix_enabled / prefix_words | false / ["コマンド"] | | | command.max_utterance_sec / _with_prefix | 1.5 / 2.5 | | | command.cooldown_sec | 1.5 | | | command.min_avg_logprob / max_no_speech_prob | $22121.5 / 0.6 | | | command.fuzzy_suffix_chars | 2 | 0 で完全一致のみ | | command.words.<名前> | { aliases, action } | §5.5 | | apps.allow[] | name, process, title_contains, send_key | 許可リスト(複数可) | | log.dir / save_utterances | "logs" / false | | - 参照実装の `command.pending_timeout_sec` は使われていない(`paste.command_timeout_sec` に統合済み)。移植先では作らなくてよい - タイトルに見分けにくい文字が入る場合は、エスケープで書く(例: EM DASH は `"$2014 Muse"`。§9-11) ### 7.2 ログ(JSONL、1 行 1 イベント、日ごとのファイル) | event | 主な項目 | |---|---| | utterance | text, raw_text, hotwords_text, kind(text/command/discard), command, action, reason, matched_word, duration_sec, avg_logprob, no_speech_prob, stt_sec, latency_sec, window, allowed_app, prefix_enabled, hotwords, wav | | output | op(pasted/held/dropped/undone/undo_reset/key), kind, text, key, reason, wait_sec | | device | name, is_headset, headset_available, error | | start / stop / model_loaded / error / set_* | 状態の変化 | **しきい値の調整と不具合の調査は、このログから行う**。`reason` は必ず人が読める文で残す。 ### 7.3 UI の最小構成 - 状態表示と開始・停止ボタン - ヘッドセット警告のバナー(必要なときだけ表示) - マイク選択(「既定に追従」と各デバイス)、使用中のデバイス名 - チェック: 「コマンド」前置き、hotwords - 現在の動作モードの注記(貼り付け有効・キー送信有効かドライランか) - 結果一覧: 本文、$25B6 コマンド(2 段目で取れたときは「通常認識: …」も表示)、△ 聞き取ったが実行しない(理由)、× 破棄(理由)、貼り付け・保留・取り消し・キー送信。各行の下に発話長・認識時間・logprob・no_speech・対象アプリを小さく表示する --- ## 8. 環境ごとの置き換え(移植ガイド) ### 8.1 置き換えが必要な部分の一覧 | 部品 | 参照実装(Windows) | 置き換えの候補 | |---|---|---| | 音声取り込み | sounddevice(PortAudio)/ WASAPI | macOS: CoreAudio(sounddevice のままで可)/ Linux: PulseAudio・PipeWire(sounddevice のままで可) | | 既定デバイス名の取得 | pycaw(Core Audio の既定キャプチャ端点) | macOS: `AudioObjectGetPropertyData(kAudioHardwarePropertyDefaultInputDevice)`/ Linux: `pactl get-default-source` | | デバイス一覧の再取得 | `sd._terminate()` → `sd._initialize()`(内部 API) | PortAudio は起動時の一覧を持ち続ける点は共通。開き直す前に再初期化する | | リサンプル | soxr | 共通 | | VAD | faster-whisper 同梱の silero_vad_v6.onnx(onnxruntime) | 共通。別の VAD に替える場合も、ストリーミング・ヒステリシス・2 種類の前置き(§4.2)は守る | | 認識 | faster-whisper / CUDA float16 | §8.4 | | 前面ウィンドウ | pywin32 `GetForegroundWindow` + psutil | macOS: `NSWorkspace.frontmostApplication` + Accessibility API(要権限)/ Linux X11: `xdotool getactivewindow getwindowname`、Wayland は原則取得できない(§8.5) | | キー送信 | pynput Controller | macOS: pynput(要アクセシビリティ権限)。貼り付けは **Cmd+V**/ Linux X11: pynput・xdotool、Wayland: ydotool・wtype(要権限) | | 注入イベントの判定 | 低レベルフックの flags: キー `LLKHF_INJECTED (0x10)`・マウス `LLMHF_INJECTED (0x01)` | macOS: `CGEventGetIntegerValueField(kCGEventSourceStateID)` や独自のイベントソースで見分ける/ Linux: 自分が送る直前と直後に「送信中フラグ」を立てて無視する(近似)。近似にしたことを明記する | | クリップボード | pyperclip(テキストのみ) | macOS: pbcopy・pbpaste(pyperclip で可)/ Linux: xclip・wl-clipboard | | 効果音 | winsound.Beep | 任意の短い WAV 再生。§9-12 の無線ヘッドセット対策も参照 | | UI | tkinter | 何でもよい。Tk は BMP 外の絵文字が苦手なので、記号は BMP の範囲から選ぶ | | 起動 | `.bat`(ASCII のみ・CRLF) | シェルスクリプトなど | ### 8.2 許可リスト(貼り付け先アプリ)の決め方 1. 対象アプリの**実際のウィンドウタイトルをプログラムで取得**する(参照実装 `tools/check_windows.py`)。目で見たタイトルと違うことがある - 例: Firefox は `Chat $2014 Muse $2014 Mozilla Firefox`(ブラウザ名の前も EM DASH)、Chrome は `Chat $2014 Muse - Google Chrome` 2. 「プロセス名の完全一致」かつ「タイトルの部分一致」で判定する。変わりうる部分(例「Chat」)は条件に入れない 3. 似たタブ(同じブラウザの別ページ)が**一致しない**ことも確認する 4. 送信キーを手で確かめる(Enter か Ctrl+Enter か、Shift+Enter が改行か) 5. **手でコピーした文を Ctrl+V(Cmd+V)で貼り、入力欄の実体に入るかを最初に確かめる**。ブラウザ拡張のなかには、入力欄の上のレイヤーに表示するだけで実体に入らないものがあった(Voice-In で実例)。OS レベルの貼り付けなら入るが、アプリごとに必ず確認する ### 8.3 権限・制約 - Windows: 通常権限のブリッジから、管理者権限のウィンドウにはキーが届かない(UIPI)。ブリッジを昇格させてはいけない(安全上) - macOS: 「アクセシビリティ」と「入力監視」の許可が必要。許可が無いと、キー送信・前面アプリの取得・監視が黙って失敗することがある。起動時に確認し、UI に表示する - Linux Wayland: 前面ウィンドウの取得とグローバルなキー監視は、セキュリティ上できない構成が多い。**許可リストを確かめられない環境では、貼り付けとキー送信を有効にしてはいけない**(原則 2)。代わりに、ブリッジの UI 内のテキスト欄に溜めて、手動でコピーする縮退モードを検討する ### 8.4 認識エンジンの置き換え | 環境 | 候補 | 注意点 | |---|---|---| | NVIDIA GPU | faster-whisper(CTranslate2)CUDA | Windows では pip 版の cuBLAS / cuDNN の DLL パスを、ctranslate2 を import する前に通す。cuBLAS の最新版は Linux 用 wheel しか無い場合がある(§9-10) | | Apple Silicon | whisper.cpp(Metal)、mlx-whisper | logprob と no_speech が取れるか確認する。取れなければ §5.4 の 5・6 は外し、代わりに前置きモードを既定オンにすることを検討する | | CPU のみ | faster-whisper int8(small・medium) | 遅延の N-1・N-2 を測り直す。無音判定の 1.0 秒が遅延の大半を占める点は同じ | | hotwords が無いエンジン | initial_prompt で代用する | プロンプトの語をそのまま出力する暴走が起きやすい(§9-4)。暴走の検出は必ず入れる | - 句読点を出さないエンジンの場合は、`separator` を「、」などにする設定を検討する(参照実装では足さない) - 言語を変える場合は、正規化(§5.2)・ハルシネーションの定型文・コマンド語・ファジー照合の文字数を見直す ### 8.5 移植の手順(推奨) 1. **Phase 0(環境の棚卸し)** - GPU・VRAM、認識エンジンが動くか、マイク一覧と既定デバイスの追従、対象アプリのタイトルと送信キー、手動貼り付けで入るかを確認する - 結果は env-report に記録する 2. **Phase 1(認識とドライラン)** - 貼り付けもキー送信もしない状態で、認識と判定を UI とログに出す。§10 の測定をする 3. **Phase 2(貼り付け)** - 貼り付け・保留・取り消しを有効にする。キー送信はドライランのまま 4. **Phase 3(キー送信)** - `key_dry_run = false` にする。実利用で誤反応が無いことを確認する - 各段階で、ユーザーの確認を得てから次に進む。**キーを実際に送る変更は、必ずドライランで確かめてから有効にする** --- ## 9. 実装上の落とし穴(参照実装で実際に起きたこと) | # | 現象 | 原因 | 対策 | |---|---|---|---| | 1 | 無音や電子音から「ご視聴ありがとうございました。」が出る | Whisper の既知の癖 | 定型文を破棄する(§5.2) | | 2 | hotwords を付けると本文の句読点が消える(「…ですね 散歩に…」) | hotwords がプロンプトとして働く | 本文は hotwords なしで認識し、hotwords は短い発話の 2 段目だけに使う(§5.3) | | 3 | 短い発話の認識に 16$301C20 秒かかる | 信頼度が低いと temperature を変えて最大 6 回まで認識し直す。さらに 4 の暴走が重なる | 短い発話は temperature=0、max_new_tokens=16 | | 4 | 2 段目が「エンター エンター 放信 改行 キャンセル…」と hotwords の一覧を出す | hotwords の暴走 | 異なるコマンド語が 2 種類以上 → 捨てる。同じ語の繰り返しは 1 語にまとめる(それは本当に聞き取った結果) | | 5 | 「エンター」が「エンタープ」「エンターブル」「エンターホン」「エンタ」「Enter」「んた」になる | 実声の短い語は崩れやすい | 末尾 2 文字までの許容、促音の無視、英字の別名、2 段目の再認識。実声 36/36 を達成 | | 6 | 「スペース」が「ペース」になる | 無声音「ス」は VAD の確率が 0.1$301C0.3 と低く、開始の判定が約 0.38 秒遅れる | コマンド用の前置き音声を 0.6 秒にする。本文用は 0.3 秒のまま(0.5 秒にすると、合成音声で長文の句読点が消えた) | | 7 | 「改行」が「開業」、「送信」が「そうしん」になる | 同音異義語・かなのままの出力 | 別名に入れる(「開業」は誤爆しても送信されないので許容) | | 8 | VAD の確率が語の途中で 0.4 前後に落ちて切れる | 合成音声・小声 | ヒステリシス(継続は $22120.15) | | 9 | 遅れて Enter が押されうる | 前の貼り付けの後ろで待つ間に時間が経つ | undo と key は順番が来た時点で古ければ捨てる(§6.1-1) | | 10 | Windows で cuBLAS が入らない | nvidia-cublas-cu12 の最新版が Linux 用 wheel しか無かった | uv の `required-environments` で、Windows 用 wheel がある版に解決させる | | 11 | タイトルの判定が一致しない | 「$2014」が EM DASH(U+2014)で、ハイフンや長音記号と見分けにくい | 設定ではエスケープで書く。cp932 のコンソールには出力できないので、出力は UTF-8 にする | | 12 | 無線ヘッドセットで、効果音や読み上げの頭が切れる | 無音が続くとヘッドセットが省電力に入り、復帰に約 0.5 秒かかる | 音の前に 0.6$301C1.0 秒の無音を入れる(`voicevox/proposal_無線ヘッドセットで頭切れ対策.md`。参照実装は未対応) | | 13 | 設定を変えたのに動作が変わらない | 起動中のプロセスが古いまま | 変更したら必ず再起動する。テストを依頼するときも再起動を頼む | | 14 | テストでメモ帳を前面にできない・入力がメニューに吸われる | Windows の前面ロック。回避のために押した Alt で、メニューバーの操作に入る | テストスクリプトだけの問題。Alt の後に Esc を送る | | 15 | ブリッジの画面が前面のまま話すと、すべて「対象外のウィンドウ」になる | 仕様どおり | UI の結果一覧に「聞き取ったが実行しない: 対象外のウィンドウ」と出し、ユーザーが原因を分かるようにする | --- ## 10. テストと受け入れ基準 ### 10.1 単体テスト(純関数・偽の backend で書く) - 正規化: 句読点・全角・ひらがな・EM DASH・長音を残すこと - コマンド判定: 各コマンドと別名、文中の語、長すぎる発話、低い信頼度、クールダウン、対象外のウィンドウ、前置きのオン・オフ、ファジー照合の受理と拒否(「エンタープ」は受理、「エンターフェンター」「送信者」「改行して」「エンタメ」は拒否)、暴走の検出、繰り返しのまとめ - 許可リスト: 各ブラウザのタイトル、似たタブ、ハイフンとの違い、別プロセス - 切り出し: 1 発話、短い雑音、2 発話、強制分割、flush、チャンク分割と一括で同じ結果、前置き 2 種類の長さ - 出力: 連結、クリップボードの復元、保留と再開、タイムアウトでの破棄、打鍵中の保留、取り消しを 1 つずつ、リセット後の取り消し、別タブでの取り消し拒否、貼り付けの後ろの取り消し、貼ってから Enter、古い Enter の破棄、app_send の置き換え、Shift+Enter では取り消し対象を残す、スペースの貼り付けと取り消し - 参照実装: 74 件(`uv run pytest`) ### 10.2 再現テスト(replay) - 録音(WAV)または合成音声をマイクの代わりに流し、判定と集計を出す。前後に無音を足して、実際のマイクに近づける - 合成音声は VOICEVOX などで作れる(`tools/make_test_audio.py`)。配線と判定ロジックの確認用で、**認識率の最終判定には使わない** - 実声の録音(`save_utterances = true`)を溜めて、設定を変えるたびに全件を流し、退行が無いことを確かめる ### 10.3 実機テスト - 安全な貼り付け先(メモ帳など)を一時的に許可リストに入れ、貼り付け・取り消し・各キー送信・クリップボード復元・注入イベントの除外を、入力欄の中身を読み取って自動で確かめる(`tools/check_output.py`) ### 10.4 受け入れ基準(ユーザーの実利用) - [ ] 連続 10 発話を取りこぼさず、確定まで 3 秒以内 - [ ] 同じ人の声で、コマンド語の判定率 96% 以上 - [ ] 本文がコマンドに誤判定されない - [ ] 許可リスト以外のウィンドウには、貼り付けもキー送信もしない - [ ] 打鍵中に貼り付けが混ざらない - [ ] 「キャンセル」で直前の文が消え、送信やクリックの後は何も消さない - [ ] 「エンター」で、本文が入りきってから送信される(20 回中 20 回) - [ ] ヘッドセットの抜き差しで警告と追従が働く --- ## 11. 参照実装のファイル対応 | ファイル | 役割 | 環境依存 | |---|---|---| | `bridge/config.py` | 設定の読み込み | なし | | `bridge/audio_capture.py` | 取り込み・既定デバイス追従・ヘッドセット判定 | **あり** | | `bridge/segmenter.py` | VAD による切り出し | なし | | `bridge/stt.py` | 認識エンジンのラッパ(short モード) | エンジン依存 | | `bridge/cuda_setup.py` | Windows の CUDA DLL パス | **あり** | | `bridge/postprocess.py` | 正規化・置換・ハルシネーション | なし | | `bridge/command_router.py` | コマンド判定・暴走検出・繰り返しのまとめ | なし | | `bridge/window_guard.py` | 前面ウィンドウの取得(依存あり)・許可リストの照合(依存なし) | 一部 | | `bridge/output_executor.py` | 出力キュー(依存なし)・WinBackend(依存あり) | 一部 | | `bridge/key_monitor.py` | 物理入力の監視と注入イベントの除外 | **あり** | | `bridge/feedback.py` | 効果音 | **あり** | | `bridge/app.py` | 状態機械・2 段認識・振り分け | なし | | `bridge/gui.py` | UI | UI ツールキット依存 | | `bridge/replay.py`, `__main__.py` | replay・起動 | なし | | `tools/check_*.py`, `make_test_audio.py` | 環境確認・実機テスト・素材作成 | 一部 | **移植の勧め**: 環境に依存しない部分(設定・切り出し・後処理・判定・出力キュー・状態機械)はそのまま流用し、OutputBackend(クリップボード・キー送信・前面ウィンドウ)、InputMonitor、AudioCapture の既定デバイス取得だけを、その環境向けに実装する。出力キューは backend をインターフェースで受け取る作りになっているので、偽の backend で単体テストしてから実機に移る。