# 仕様書: 受信側プレイヤー(エージェントの返事 → TTS → ノンストップ再生) 版: 1.0(2026-11-12)/ 対象読者: この仕組みを**別の環境(OS・TTS エンジン・出力デバイス・送信元エージェント・ネットワーク)に合わせて実装・移植する開発者や AI エージェント** 参照実装: `playback-bridge/`(Windows 10 / Python 3.11 / Irodori-TTS-Server(v4-Small)/ RTX 5070 Ti / Logicool G535 / 送信元は Tailscale 上の VM で動く Muse) 関連文書: [企画書](irodori-playback-bridge-plan.md) / [実装計画](irodori-playback-implementation-plan.md) / [実測値](playback-bridge/env-report.md) / [送る側の手順](muse-protocol.md) / [プロダクト固有ルール](DEVELOPMENT_PLAYBOOK.md) 対になる仕組み: 送信側ブリッジ(音声 → 入力欄)`../irodori-TTS_Bridge/handsfree-stt-bridge-spec.md` --- ## 0. この文書の使い方 - **§2〜§10 は環境に依存しない仕様**。移植先でもこの通りに振る舞うこと - **§11 は環境に依存する部分の一覧**と置き換え候補。移植ではここだけを差し替える想定 - **§12 は参照実装で実際に踏んだ落とし穴**。実装前に必ず読む - 要件の強さは「**必須**(MUST)」「**推奨**(SHOULD)」「任意(MAY)」で示す - 数値(秒数・上限)は参照環境での実測による初期値。移植先では §13 の手順で測り直す --- ## 1. 概要 ### 1.1 目的 チャットで動くエージェント(参照実装では Muse)の返事のうち**セリフ部分**を、ユーザーの PC で TTS により声にし、**文と文の間で途切れずに**流し続ける。1 文目の合成が終わった時点で再生を始め、残りの文は再生の裏で合成する。 ### 1.2 設計の原則(どの環境でも変えないこと) 1. **頼まれた時だけ喋る**。送る側はユーザーが ON にした時だけ送る。プレイヤーは受けたものしか鳴らさず、自分から喋らない(必須) 2. **止める手段は必ず効き、止めた後は絶対に鳴らない**。停止の手段は複数用意し、どれも同じ停止処理を呼ぶ。停止の瞬間に合成中だった文も鳴らさない(必須) 3. **受け口は閉じた網の中だけ**。全アドレス(0.0.0.0)で待ち受けない。送信元を限定する(必須) 4. **送る側が整え、受ける側でも防ぐ**。文の分割や読ませない部分の除去は送る側の責任だが、漏れても変な読み上げにならないよう受ける側でも除去・上限を設ける(必須) 5. **声はチャットの写し**。チャットへの表示は今までどおり行い、声は付加的な出力とする。声が出なくても会話は成立すること(必須) ### 1.3 用語 | 用語 | 意味 | |---|---| | 返事(request) | 送る側が 1 回の `/enqueue` で送る文の並び | | 文(sentence) | 1 回の TTS 合成の単位。原則 1 センテンス | | Caption | 文ごとの声の調子の指示(例「照れながら、小さめの声で」)。送る側の項目名は `instructions` | | 世代(generation) | 「今有効な返事」を表す番号。差し替えと停止のたびに進む | | 合成キュー/再生キュー | 合成待ちの文/合成済みで再生待ちの音声 | | 先行バッファ | 返事の再生を始める前に合成しておく文数 | | 空白(underrun) | 前の文のポーズ明けから次の文の再生開始までの、合成待ちで生じた無音 | --- ## 2. 機能要件 | # | 要件 | 強さ | |---|---|---| | F1 | HTTP で返事(文の配列)を受け取り、**合成を待たずに**受理を返す | 必須 | | F2 | 文を受けた順に 1 文ずつ合成し、順に再生する | 必須 | | F3 | 合成と再生を並行させ、文間に合成待ちの空白を作らない | 必須 | | F4 | 新しい返事が来たら、既定では古い返事を打ち切って新しい返事を読む(replace)。後ろに足す方式(append)も選べる | 必須 | | F5 | 停止: API・ホットキー・画面のボタンのいずれでも止まり、キューも空になる | 必須 | | F6 | 停止・差し替えの後に、古い返事の文が鳴らない | 必須 | | F7 | 受けた文から URL・コード・空文を除き、長文を分割し、文数に上限を設ける | 必須 | | F8 | 文ごとに絵文字(感情)と Caption を TTS に渡せる。声は返事ごとに指定でき、未指定なら TTS 側の既定 | 推奨 | | F9 | 状態(再生中か・処理中か・キュー数・現在の文・最後の再生終了時刻)を API で公開する | 必須 | | F10 | 受信・合成・再生・破棄・停止をすべてログに残す | 必須 | | F11 | 小さな画面で状態表示・停止・音量調整ができる | 推奨 | | F12 | 出力デバイスを名前で指定し、無ければ既定デバイスで動く | 推奨 | ### 非機能要件 | 項目 | 目標(参照環境の実測) | |---|---| | 受理の応答 | 50 ms 以内(実測 5〜7 ms) | | 返事を受けてから 1 文目が鳴るまで | 2 秒以内(実測 1.2〜1.6 秒) | | 文間の空白 | 全区間 0(実測 0.000 秒) | | 停止の応答と無音化 | 1 秒以内(実測 API 応答 1〜2 ms、同時刻に無音化) | | 鳴り始めの欠け | なし(ワイヤレスヘッドセットでも、§8.1) | --- ## 3. 全体構成 ``` [送る側エージェント] --POST /enqueue, /stop (閉じた網内)--> [受信サーバー] │ 送信元・トークン確認 → 入力フィルタ(§5) ▼ Pipeline.enqueue / stop(世代を進める) │ [合成キュー] ──> 合成スレッド ──HTTP──> TTS サーバー │ 音声(float32 モノラル) ▼ [再生キュー] ──> 再生スレッド ──> StreamPlayer(常時オープンの出力ストリーム)──> イヤホン ▲ [小窓] 状態表示・停止・音量 ─────────────────┤ [グローバルホットキー] 停止 ────────────────┘ [送信側ブリッジ等] GET /status(再生中かどうか) ``` ### 3.1 スレッド(必須の性質) | スレッド | 役割 | 性質 | |---|---|---| | HTTP サーバー | 受理して即応答 | 合成・再生を待たない。待ち受けアドレスごとに 1 本でよい | | 合成 | 合成キューを 1 本で直列に処理 | TTS への同時リクエストは 1 件(TTS 側の同時合成数に合わせる) | | 再生 | 再生キューを順に鳴らす | 1 文の再生中はブロック。ポーズ中も停止を即座に検知する | | 音声コールバック | 出力ストリームへサンプルを渡す | 常に動く。音声が無い時は無音を出す | | UI | 状態表示・停止・音量 | メインスレッド(tkinter の制約) | | ホットキー | 停止 | OS のフックを使うライブラリのスレッド | ### 3.2 状態 プレイヤーに「ON/OFF」の状態は**持たせない**。ON/OFF は送る側の状態であり、プレイヤーは届いたものを読むだけ(§1.2-1)。プレイヤーが持つのは、世代・2 つのキュー・返事ごとの進行状況(文数・合成済み数・再生開始済みか)・現在の文・最後の再生終了時刻。 --- ## 4. 受信 API ### 4.1 共通 - 待ち受け: ループバック(127.0.0.1)と、閉じた網(参照実装では Tailscale)の自分のアドレスだけ。**0.0.0.0 を設定されたら起動を拒否する**(必須) - 送信元の許可リスト: 一致しない送信元には 403 を返し、ログに残す(推奨) - 任意の共有トークン: 設定時は `X-Player-Token` ヘッダーで照合し、不一致は 401(任意) - 待ち受けに失敗したアドレス(網が未接続など)は警告を出し、ループバックだけで起動を続ける(推奨) ### 4.2 `POST /enqueue` ```json { "sentences": [ {"text": "おかえりなさい、今日もお疲れさまでした。", "emoji": "😊"}, {"text": "ふふ、そんなに褒められると照れちゃうよ。", "instructions": "照れながら、小さめの声で恥ずかしそうに話す。"} ], "voice": "VoiceType_AK", "mode": "replace" } ``` | 項目 | 必須 | 内容 | |---|---|---| | `sentences[].text` | ○ | 1 文 | | `sentences[].emoji` | | 絵文字 1 個。TTS 入力の文頭に付ける(参照環境の試聴で文頭に決定) | | `sentences[].instructions` | | Caption | | `voice` | | 声 ID。省略時は設定の既定、それも空なら TTS に声を送らない | | `mode` | | `replace` / `append`。省略時は設定の既定(`replace`)。それ以外は 400 | 応答: **202** ```json {"accepted": 2, "dropped": 0, "req_id": 7, "generation": 5} ``` - フィルタで全文が落ちた場合も 202(`accepted: 0`、パイプラインには触れない=今の読み上げも止めない) ### 4.3 `POST /stop` 本文なし。応答 200 `{"stopped": true, "generation": 6, "discarded": 3, "interrupted": true}` (`discarded` = 両キューから捨てた文数、`interrupted` = 再生中の文を止めたか) ### 4.4 `GET /status` ```json {"playing": true, "busy": true, "generation": 5, "synth_queue": 3, "play_queue": 1, "current": "読み上げ中の文", "synthesizing": "合成中の文", "last_play_end": "2026-11-12T22:28:05.120+09:00", "tts_ok": true} ``` | 項目 | 意味 | |---|---| | `playing` | 音声を出力中(文間ポーズ中は false) | | `busy` | 処理中(合成中・再生中・ポーズ中・キューに残りあり)。「返事を読み終えたか」はこちらで判断する | | `last_play_end` | 最後に文の再生を終えた時刻。マイク側が残響分だけミュートを延ばす用途 | | `tts_ok` | 直近の合成が成功したか(未実行なら null) | ### 4.5 `GET /health` `{"ok": true, "tts_ok": , "device": "<出力デバイス名>"}` --- ## 5. 入力フィルタ(純関数) 受けた `sentences` を、以下の順で `Sentence(text, emoji, caption, voice)` の列にする。 1. `text` に ``` を含む文は丸ごと捨てる(理由 `code_block`) 2. URL(`http(s)://…`、`www.…`)とインラインコード(`` `…` ``)を取り除き、空白を詰める 3. 読み上げられる文字(文字・数字)が残らなければ捨てる(理由 `empty`) 4. 絵文字が長すぎる(8 文字超)なら絵文字だけ無視する 5. 1 文が上限(既定 120 字)を超えたら分割する: 上限内で最後の「、,,;;::))」』」の直後で切る。区切りが上限の 1/3 より前にしか無ければ上限で機械的に切る。**絵文字は最初の断片だけ、Caption は全断片に**引き継ぐ 6. 分割後の文数が上限(既定 10)を超えたら、**先頭から上限まで**を残し、残りは捨てる(理由 `over_limit`、要約はしない) 捨てた文は理由付きでログに残す。 --- ## 6. 合成 ### 6.1 TTS エンジンに求めること | 項目 | 要件 | |---|---| | 呼び出し | HTTP などで 1 文を渡し、**1 文分の完結した音声**(WAV 等)が返る | | 速度 | 合成時間 ÷ 音声長(RTF)が 1 を十分下回る。目安 0.5 以下(参照環境 0.31) | | 同時実行 | 同時リクエストでエラーにならない(順番待ちでよい) | | 調子の指示 | 文ごとに Caption・感情などを渡せる(任意。無ければ無視する) | ### 6.2 1 文 = 1 リクエスト(必須) TTS 側に長文の自動分割やストリーミング(SSE 等)機能があっても**使わない**。理由: 文ごとに絵文字・Caption を変えられない/停止・差し替えを文単位で効かせられない。参照実装では `irodori.chunking_enabled=false` を明示する。 ### 6.3 リクエスト例(参照実装: Irodori-TTS-Server、OpenAI TTS 互換) ```json POST /v1/audio/speech { "model": "irodori-tts", "input": "😊おかえりなさい、今日もお疲れさまでした。", "voice": "VoiceType_AK", "response_format": "wav", "irodori": {"chunking_enabled": false, "caption": "照れながら、小さめの声で恥ずかしそうに話す。"} } ``` - `voice` は指定がある時だけ付ける(無ければサーバー既定) - Caption は OpenAI 式の `instructions` ではなく `irodori.caption` に入れる(§12-2) - TTS 側に認証キーがあれば `Authorization: Bearer` を付ける。キーは**プレイヤー側の設定にだけ置き**、送る側には渡さない - 合成に失敗した文はログに残して飛ばし、返事の残りは続ける --- ## 7. パイプライン ### 7.1 世代番号(必須) - `/enqueue`(replace)と `/stop` のたびに世代を +1 し、両キューと返事の進行状況を空にする - 各文は受理時の世代を持つ。次の **3 か所**で現世代と照合し、古ければ捨てる(ログ理由 `stale_before_synth` / `stale_after_synth` / `stale_in_play_queue`) 1. 合成を始める前 2. 合成が終わった時(TTS 側の合成は途中で止められないので、結果を捨てる) 3. 再生を始める直前(**出力側のロックの中**で判定してから音声をセットする) - 止める順序は「**世代を進める → 出力を止める**」。この順序と 3. の判定で、「判定は通ったが直後に止められた」場合も、出力側の停止でバッファが消える。逆順にすると停止後に 1 文鳴る隙ができる ### 7.2 合成スレッド 1. 合成キューから 1 文取る(空なら待つ)→ 世代確認 → 合成 2. 結果をロック内で再生キューに積み、返事の「合成済み数」を +1(**失敗も 1 と数える**。全文合成済みの判定を止めないため)、1 文目なら「最初の合成完了時刻」を記録 ### 7.3 再生スレッド 1. 再生キューの先頭が古い世代なら捨てる 2. その返事の再生がまだ始まっていなければ、`should_start_playback()` が真になるまで待つ(50 ms 間隔で再判定) 3. 取り出して再生(ブロック)→ 文間ポーズ(既定 0.9 秒。世代が変わったら即抜ける) ### 7.4 先行バッファ判定(純関数 `should_start_playback`) ``` ready: その返事で合成済み・未再生の文数 真になる条件(いずれか): ready >= max(prebuffer, 1) ready >= 1 かつ その返事の全文が合成済み … 短い返事を待たせない ready >= 1 かつ 1 文目の合成完了から max_wait 秒経過 ``` - 判定は**返事ごとに 1 回だけ**。一度再生を始めた返事は、以後は合成済みの文から順に鳴らす - 既定 `prebuffer = 1`、`max_wait = 2.0` 秒。参照環境では N=1 でも空白ゼロで、N=2 より 1 秒早く鳴り始めた(env-report §8)。合成が遅い環境では N=2 にする ### 7.5 replace と append | | replace(既定) | append | |---|---|---| | 世代 | +1 | そのまま | | キュー | 両方空にする | 残す | | 再生中の文 | 即停止 | 最後まで読む | | 用途 | 新しい返事を最優先 | 前の返事に続けて読む | --- ## 8. 再生 ### 8.1 出力ストリームの常時オープン(必須) **プレイヤーの起動中は出力ストリームを開きっぱなしにし、音声が無い間は無音を流し続ける**。再生はコールバックが読むバッファに音声をセットするだけ。 - 理由: ワイヤレスヘッドセット(参照環境 G535)は出力が始まってから受けられる状態になるまでの数百 ms を取りこぼし、**文頭が欠ける**。文ごとに出力を開く方式(`play()` 型 API)では毎回欠ける。先頭に無音を足す方式より、常時オープンの方が自然だった(ユーザーの聴き比べで決定) - 文間ポーズも出力は開いたまま(無音を流す) ### 8.2 コールバックの処理 ``` lock 内で: バッファが無い → 無音を出す ある → 残りから frames 分をコピーし、音量を掛けて出す。足りない分は無音 最後まで出したら バッファを捨てて「再生完了」を通知 ``` - 音量はコールバック内で掛ける(再生中の文にも即反映。0.0〜1.0) - `play(audio, sr, valid)` は、ロック内で `valid()` を確認してからバッファをセットし、完了か停止まで待つ。最後まで鳴れば True、止められたら False - `stop()` はロック内でバッファを捨てて完了を通知するだけ。次のコールバック(数十 ms)で無音に戻る ### 8.3 その他 - サンプルレートは TTS の出力(参照環境 48 kHz)に合わせてストリームを開く。違う場合は再生前に変換する - 出力デバイスは**名前の部分一致**で探す(番号は抜き差しで変わる)。同じ機器が複数のオーディオ API に出る環境では、安定して開ける API を優先する(参照環境では MME) - 再生が「音声長+5 秒」を過ぎても終わらない時は、コールバックが止まった(デバイス切断など)とみなし、オーディオライブラリを再初期化してストリームを開き直す(推奨) --- ## 9. 送る側(エージェント)の約束 詳細は [muse-protocol.md](muse-protocol.md)。移植時は送る側のエージェントにこの内容を守らせる。 | 項目 | 内容 | |---|---| | ON/OFF | 既定 OFF。合言葉(参照実装「声が聴きたい」/「声は止めて」)で切り替え。OFF にする時は `/stop` を 1 回送り、以後は何も送らない | | 送る範囲 | セリフ部分のみ。説明・コード・URL・表・コマンド列は送らない | | 分割 | 1 センテンス = 1 要素。最大 10 文、1 文 120 字目安 | | 絵文字・Caption | 絵文字は 1 文に最大 1 個。Caption は調子を変えたい文だけ | | 失敗時 | 接続失敗・タイムアウト(5 秒)・5xx ならチャットで一言伝える。再送は 1 回まで。ON/OFF は変えない | | 表示 | チャットへの返答は通常どおり表示する | --- ## 10. 設定・ログ・UI ### 10.1 設定ファイル(TOML。キー名は参照実装と同じ) ```toml [server] listen = ["127.0.0.1", "100.x.x.x"] # ループバック+閉じた網の自アドレス。0.0.0.0 禁止 port = 8090 allow_clients = ["127.0.0.1", "100.x.x.x", "100.y.y.y"] # 空なら制限なし token = "" [tts] url = "http://127.0.0.1:8088" model = "irodori-tts" voice = "" # 空 = TTS 側の既定に任せる api_key = "" timeout_sec = 60 num_steps = 0 # 0 = TTS 側の既定 emoji_position = "head" # head / tail [queue] on_new_request = "replace" # replace / append prebuffer = 1 prebuffer_max_wait_sec = 2.0 pause_sec = 0.9 max_sentences = 10 max_chars_per_sentence = 120 [playback] device = "G535" # 名前の部分一致。無ければ既定デバイス samplerate = 48000 volume = 0.2 # 起動時の音量(UI で変えた値は保存しない) [hotkey] stop = "++s" # 空なら登録しない [log] dir = "logs" ``` ### 10.2 ログ(JSONL、1 行 1 イベント、日ごとのファイル `player_YYYYMMDD.jsonl`) | イベント | 主な項目 | |---|---| | `startup` / `shutdown` | 待ち受け、TTS 疎通、デバイス、prebuffer、音量 | | `playback_device` | 指定キーワード、選ばれたデバイス、既定へのフォールバック有無 | | `received` / `rejected_client` / `filter_dropped` | 送信元 IP、文数/拒否理由/捨てた文と理由 | | `enqueue` / `stop` | req_id、世代、mode、捨てた数、再生を中断したか、停止の手段(api / gui / hotkey) | | `synth_start` / `synth_done` / `synth_error` | 文、合成秒、音声秒 | | `play_start` | `latency_sec`(受信→1 文目再生、1 文目のみ)、`underrun_sec`(空白、2 文目以降)、`waited_in_queue_sec` | | `play_end` | 最後まで鳴ったか、再生秒 | | `discard` | 理由(§7.1) | | `playback_timeout` / `playback_restarted` / `listen_failed` / `hotkey_ready` / `volume` | 異常・状態変化 | 集計ツール(`tools/gap_report.py`)で、返事ごとに「1 文目までの秒数・空白の最大・空白があった区間数・合成平均」を出す。`req_id` は起動ごとに 1 から振り直すので、集計は `startup` で区切る。 ### 10.3 UI の最小構成 - 状態(待機中/合成中/再生中)、現在の文、合成待ち・再生待ちの数 - 停止ボタン(ホットキー名を併記) - 音量スライダー(0〜100) - 出力デバイス名、TTS の疎通(数秒おき) - 閉じたらプレイヤー全体を終了。コンソールの Ctrl+C でも終了できる --- ## 11. 環境ごとの置き換え(移植ガイド) ### 11.1 置き換えが必要な部分の一覧 | 部分 | 参照実装 | 置き換え候補 | 変えてはいけないこと | |---|---|---|---| | TTS | Irodori-TTS-Server(OpenAI TTS 互換、localhost:8088) | VOICEVOX(`/audio_query`→`/synthesis` の 2 段)、Style-Bert-VITS2、OpenAI TTS、その他 OpenAI 互換サーバー | 1 文 1 リクエスト、TTS の長文分割は使わない | | 調子の指示 | `irodori.caption`+文頭の絵文字 | エンジンのスタイル ID・感情パラメータ・話速等に読み替え。無ければ無視 | 送る側の項目名(`emoji` / `instructions`)は変えない | | 音声出力 | sounddevice(PortAudio)の OutputStream+コールバック | macOS / Linux も sounddevice で可。他言語なら WASAPI / CoreAudio / PulseAudio / PipeWire のコールバック型出力 | 常時オープン、停止はバッファ破棄、ロック内の世代判定 | | HTTP サーバー | FastAPI+uvicorn(アドレスごとにスレッド) | 任意の軽量 HTTP サーバー | 受理は即応答、0.0.0.0 禁止 | | 閉じた網 | Tailscale(PC 100.66.54.45 ← VM muse 100.124.240.24) | WireGuard、ZeroTier、同一 PC 内(127.0.0.1 のみ)、SSH トンネル | 公開インターネットに出さない | | ファイアウォール | Windows: 送信元 IP 限定の受信規則 | macOS: アプリ単位の許可、Linux: ufw / nftables | 送信元を限定する | | ホットキー | pynput の GlobalHotKeys | macOS はアクセシビリティ権限が必要、Wayland はグローバルホットキー不可のことが多い → UI ボタンと API で代替 | 停止手段が最低 2 つあること | | UI | tkinter(メインスレッド) | 任意。無くても API とホットキーで成立 | — | | 送る側 | Muse(VM 上のエージェントが HTTP POST) | 任意の LLM エージェント・チャットボット・Webhook | §9 の約束 | ### 11.2 移植の手順(推奨) 1. **TTS の実測**: 20〜40 字の文を 10 回合成し、合成秒・音声秒・RTF を測る(参照: `tools/check_tts.py`)。RTF が 0.7 を超えるなら、軽いモデル・ステップ数削減・GPU を検討してから進む(二重キューにしても途切れる) 2. **出力の実測**: 使う出力デバイスで、①そのまま再生 ②先頭に無音 300 ms ③ストリームを開いて無音 1 秒後に再生、を聴き比べる(参照: `tools/check_playback.py --lead-ms / --warm-sec`)。③で欠けないことを確認 3. **直列版**: 受信 API+入力フィルタ+合成→再生を 1 スレッドで。5 文が欠けず、停止が効くことを確認 4. **二重キュー**: 合成と再生を分け、世代判定を 3 か所に入れる。10 文 × 3 回で空白ゼロ、差し替えで古い文が鳴らないことを確認 5. **UI・ホットキー** 6. **網越し**: 待ち受けアドレス追加、送信元の許可、ファイアウォール。送る側の端末から `curl` で届くことを確認 7. **送る側との実会話**: ON/OFF の合言葉を含めて 3 往復 ### 11.3 権限・制約 - 受信ポートのファイアウォール許可は管理者権限が必要。送信元 IP を限定した規則にする - グローバルホットキーは OS によって権限が要る(macOS のアクセシビリティ等) - スリープ中の PC には届かない。送る側は失敗をチャットで伝える(§9) --- ## 12. 実装上の落とし穴(参照実装で実際に起きたこと) 1. **企画書の声 ID が実在しなかった**(`VoiceType_A` → 実際は `VoiceType_AK` / `VoiceType_T2` 等)。TTS サーバーの声一覧を実物で確認してから決める。最終的にプレイヤー側では既定を持たず TTS 側の既定に任せた 2. **OpenAI 互換 TTS でも `instructions` は解釈されないことがある**。Irodori は `irodori.caption` でしか Caption を受けない。互換 API の拡張項目はサーバーのソースか README で確かめる 3. **TTS の SSE 分割合成は使えない**。文ごとの調子を変えられず、停止の粒度も粗い(§6.2) 4. **ワイヤレスヘッドセットで文頭が欠けた**。ストリーム常時オープンで解消(§8.1)。`sd.play()` / `sd.stop()` 型の API は停止にも約 200 ms かかった 5. **合成中の文は TTS 側で止められない**。停止後に合成が終わって鳴る事故を、世代判定(合成後・再生直前)で防いだ 6. **停止と再生開始の競合**。世代確認と音声のセットを出力側の同じロック内で行い、停止は「世代 → 出力」の順にする(§7.1) 7. **企画書内で再生開始条件が矛盾していた**(「1 文目ができたら」と「2 文溜まったら」)。判定関数にして両方を設定で表現し、実測で N=1 に決めた 8. **短い返事が先行バッファで止まる**。文数が N 未満の返事は永遠に N に届かない → 「全文合成済みなら開始」の条件が必須 9. **「処理中」の判定に `playing` を使うと誤る**。合成中・ポーズ中は `playing=false`。読み終わりの判定は `busy` を使う(テストの待ち合わせでも同じ失敗をした) 10. **`req_id` は起動ごとに 1 から**。日ごとのログを `req_id` だけで集計すると別の起動の返事が混ざる → `startup` で区切る 11. **`python.exe` の一括終了は厳禁**。TTS サーバーも Python で動いている。プレイヤーは UI を閉じるか Ctrl+C、または自分が起動したプロセスだけを止める 12. **0.0.0.0 で待ち受けると LAN 全体に露出する**。アドレスを列挙して待ち受け、0.0.0.0 は設定で拒否する 13. **網(Tailscale)未接続で起動すると、網側の待ち受けだけ失敗する**。警告を出してループバックで起動を続け、接続後に再起動する 14. **出力デバイス名が API ごとに違う・切れる**(MME では「G535 Wireless Gam」で切れていた)。部分一致で探す 15. **常駐型のプレイヤーと送信テストを同じコンソールで実行して「接続拒否」になった**。プレイヤーを終了した後に送っていた。テスト送信ツールは接続失敗時に「プレイヤーを別ウィンドウで起動して」と案内する 16. **Windows の `.bat` は Shift-JIS+CRLF、日本語リテラルを書かない**(送信側で文字化けの実例あり) --- ## 13. テストと受け入れ基準 ### 13.1 単体テスト(純関数・スタブで書く) - 入力フィルタ: URL・インラインコード除去、コードブロック破棄、記号だけの文の破棄、読点優先の分割、機械的分割、絵文字は最初の断片だけ、文数上限は先頭を残す、長すぎる絵文字の無視、絵文字の位置 - 先行バッファ判定: 0 文、待機、N 到達、全文合成済み、最大待ち超過、N=1、N=0 - パイプライン(合成・再生をスタブ化): 全文が順に鳴る/再生中の停止で以後鳴らない/合成中の停止で結果を捨てる/replace で古い返事が鳴らない/append で続けて鳴る/再生中に次の文の合成が始まっている(並列化)/N=2 で 2 文目の合成完了まで待つ/1 文だけの返事は待たない - 受信 API(テストクライアント): 202 と既定 mode/フィルタ結果の件数/全文落ちならパイプラインに触れない/不正 mode は 400/許可リスト外は 403 でパイプラインに触れない/トークン - タイミングに依存するテストは、境界ぎりぎりの値を避け、「鳴らないこと」など本質の性質で判定する ### 13.2 実機テスト - TTS 実測(RTF)、出力デバイスの聴き比べ(§11.2-1, 2) - テスト送信(`tools/send_test.py --count 10`、`--stop-after 3`)と空白の集計(`tools/gap_report.py`) - 待ち受けアドレスの確認(`netstat` 等で 0.0.0.0 が無いこと) ### 13.3 受け入れ基準 | 段階 | 基準 | |---|---| | 直列版 | 5 文が欠けなく順に鳴る。停止で 1 秒以内に止まり、以後鳴らない | | 二重キュー | 10 文の返事 × 3 回で、全区間の空白ゼロ。再生中の差し替えで古い文が鳴らない | | UI | 停止ボタン・ホットキー(他アプリ前面でも)・音量がユーザー操作で効く | | 網越し | 送る側の端末から届き、許可外の送信元は拒否される | | 実会話 | 送る側との実会話 3 往復で返事のたびに声が流れ、OFF 後は一切流れない | --- ## 14. 参照実装のファイル対応 | ファイル | 内容 | |---|---| | `playback-bridge/player/request_filter.py` | §5 入力フィルタ、絵文字付与(純関数) | | `playback-bridge/player/pipeline.py` | §7 世代番号・二重キュー・`should_start_playback` | | `playback-bridge/player/playback.py` | §8 常時オープンの出力(`StreamPlayer`)、デバイス探索、再初期化 | | `playback-bridge/player/synth.py` | §6 TTS 呼び出し(`build_payload` は純関数) | | `playback-bridge/player/server.py` | §4 受信 API、送信元制限、トークン | | `playback-bridge/player/gui.py` / `hotkey.py` | §10.3 UI、停止ホットキー | | `playback-bridge/player/app.py` | 起動・配線、待ち受け失敗の警告、`--tone`(TTS なしのビープ確認)、`--no-gui`、`--prebuffer` | | `playback-bridge/player/config.py` / `logger.py` | §10.1 設定、§10.2 ログ | | `playback-bridge/tools/check_tts.py` / `check_playback.py` | §11.2 実測 | | `playback-bridge/tools/send_test.py` / `gap_report.py` | §13.2 テスト送信・空白の集計 | | `playback-bridge/tests/` | §13.1 単体テスト(28 件) | | `muse-protocol.md` | §9 送る側の手順 | --- ## 15. 送信側(音声入力)との連携(未実装・参考) 音声入力ブリッジと同じ PC で使う場合、読み上げを自分のマイクが拾うと入力欄に貼られてしまう。次の連携を想定している(参照実装では未着手、送信側の改修を伴う)。 - 送信側が `GET /status` を数百 ms 間隔で見て、`busy` の間(推奨。`playing` だと文間ポーズに拾う)と `last_play_end` から数百 ms はマイク入力を捨てる - 例外として「ストップ」のコマンドだけは受け付け、`POST /stop` を送る(バージイン)。難しければホットキー停止を正とする - イヤホン/ヘッドセットの使用を前提にする(スピーカーでは回り込みが大きい)