Skip to content

声だけで会話できるスタックちゃんを作った話 — 幻聴・エコー・遅延との全面戦争 (LT素材, 2026-07)

#135 の実装記録。wake→即相槌→ローカル即応→難問は非同期で声プッシュ の現行ルートが どういう失敗の積み重ねで出来たかを、LT「1課題=1スライド」で語れる粒度でまとめる。 実装 issue: 本リポ #116 #130 #131 #132 #133 #150 / JCARVIS #321 #324 #328。

1. 完成形 — 応答ルート全体図

音声応答ルート全体図

区間Before (2026-06)After (2026-07-14)
マイクModule-LLM AMIC (10-20cm大声のみ、遠いと[音楽]誤認)CoreS3 ES7210 — 通常距離・通常声量で認識 (#116)
呼びかけ応答なし (無言)「はい、聞いてますよ」0ms事前PCM + 傾聴窓15s (#133)
思考 (easy)Agent SDK コールドスタート 5〜11s + 推論 = 9〜27sGemma4 ローカル常駐 1〜2s・課金ゼロ (JCARVIS#324)
思考 (hard)同上 (全質問がSDK行き)フィラー1〜2s → SDK非同期 → push.js speak で声プッシュ (JCARVIS#328)
発話開始agent.done 後 + 全文合成 6〜8s最初の文が確定した瞬間 (delta後 25〜142ms、合成重畳) (#133)
発話後の受付推定ガードで 5〜10s 聾実再生+0.4s で復帰 (グラウンドトゥルースミュート #133)

2. Gemma4 司令官エンジン — 今回一番効いた工夫 (JCARVIS#324)

レイテンシの支配項は Agent SDK のコールドスタート (実測 5〜11s/リクエスト) だった。 モデルを速くするのではなく、「誰が最初に答えるか」を変えた:

  1. ローカル常駐 Gemma4 (E4B, ollama, keep_alive=-1) が全質問を最初に受ける
  2. **1コールで「難易度分類+テキスト生成」**を同時に行う ({"difficulty":"easy|hard","text":"…"} を JSON 強制)
    • 分類だけなら TTFB 0.33s、短文応答でも 1.7s (実測)
  3. easy (雑談・一般知識・計算): Gemma の text をそのまま発話 — SDK を呼ばない = 課金ゼロ・1〜2秒
  4. hard (予定・メール・天気・多段推論 = JCARVIS 内のプライベート情報が要るもの): text は Donack 人格のフィラー「調べて、分かったら声でお伝えしますね」→ 即発話。 裏で Agent SDK (Claude) が正式推論 → 完了後 push.js speak (bridge MCP) で 100字以内の話し言葉に要約して音声プッシュ — デバイス発の対話は往復とも声で完結 (JCARVIS#328)
  5. フェイルセーフ: Gemma 5s タイムアウト/ollama 停止 → SDK 直行 (聾にならない)

設計の妙: 「分類してから生成」だと2コール。「分類と生成を1コールに畳む」ことで、 easy の即答と hard のフィラーが同じ 1 レスポンスから出てくる。

3. 苦労話 — 1課題1スライド

第一部: Kokoro オンデバイス移植 (6月, #113〜#116 PoC)

#山場原因回避策
1espeak が漢字を読めず 12〜22 秒の破綻音声espeak-ng の日本語は かな限定pykakasi で漢字→かな前処理
2音声定義 300 超で segfault (EXIT=139)voices ディレクトリの !v/mb を全ロード不要 voices を退避
3音量ノブが全て無効AX 版バイナリが mixer 非対応PCM 振幅に GAIN を直接乗算
4pyopenjtalk 依存地獄sudachipy/addict/mojimoji が arm64 で入らない依存を1個ずつ潰す
5mojimoji が Cython ビルド不可クロス環境にツールチェーン無し純 Python 実装で代替
6soundfile 無しlibsndfile が無い標準 wave モジュールでシム

第二部: CoreS3 マイク移植の4つの罠 (#116 本番化)

#症状根治
1送出を loop() 駆動にした実効レート<実時間 → 無音フィルが発話中に割込み whisper が「スマッシュ」等と誤認専用 FreeRTOS タスクで 20ms ケイデンス死守
2VAD の PCM 購読は audio ユニット生存中の setup でしか確立しない注入器がソケット保持中に再起動すると購読ゼロ=無音死検証ゲート付きダンス: /mic/release→setup→購読確認→/mic/takeover→再確認、失敗時 AMIC 復帰
3systemd の Conflicts+ExecStopPost 自動復帰systemctl restart で start ジョブが自己キャンセル (Job canceled 実録)unit は線形制御のみ、復帰はエンドポイントで明示
4CoreS3 マイクは Module スピーカーの自声を拾うフィラー「ちょっと待ってくださいね」が質問として転送され jcarvis が回答推定ミュート→最終的に実再生グラウンドトゥルース(下記)

第三部: 幻聴との戦い (#133/#150)

幻聴5層防御

  • なぜ幻聴が起きるか: whisper は生成モデルで「該当なし」が言えない。環境音(音楽/ファン)の 定常スペクトル + greedy decode = 毎回同じ日本語を作文する (「スタッフの方が良い」「チョコレートを作ります」)。 無音への「ご視聴ありがとうございました」と同じアトラクタ現象。
  • 幻聴が事件になる構造: wake 誤爆 → セッション開始 → セッション中は非wake素通し → 幻聴に jcarvis が律儀に応答 → セッション延命 → 自己永続ループ。挙句、幻聴の「スタックチャンの方が良い」で 呼び名の記憶まで書き換えられた (2回)。
  • 対策は5層 (図参照): VAD閾値0.70 (silero の眠っていた設定ノブを発掘) / 静的ルール / スキップ学習 / 繰り返し検出 / jcarvis 自己防衛。
  • 迷案の教訓: 「スタッフ」単体を wake 化け許容にしたら幻聴で誤起動 →「ねえ+スタ…」呼びかけ形限定に。 LRU テーブルへのシードは正当発話に追い出されて再貫通 → 静的ルール化。
  • 未解決の本丸: whisper の no_speech_prob は NPU 版バイナリ (llm_whisper-1.8) 未実装 (#150 調査)。 実装するなら whisper.axera のデコーダ初手 logits[50362] 判定 + クロスビルド (約1日)。

第四部: 半二重の宿命と傾聴窓 (#133)

  • 即応フィラーは正義 — だがマイクとスピーカーは半二重。呼びかけ断片に即フィラー+ミュートすると、 まだ話している質問本体を無音化して食う (「ねー」「スタッフ」だけで途切れる怪現象の正体)。
  • 解: 「呼びかけだけ」の断片は転送もフィラーもせず 傾聴窓15s を開き、 「はい、聞いてますよ」(1.1s 事前PCM) とだけ応える。窓内の次の発話を質問として受け付ける。
  • 自己発話ミュートは推定(文字数×係数)をやめ、Kokoro サーバが tinyplay 実行中に立てるフラグを 注入器が見る (実再生グラウンドトゥルース)。推定誤差による「尻尾漏れ」「過剰な聾」が原理的に消滅。

4. 発話パイプライン (オンデバイス TTS 詳細)

応答テキスト → sanitize → 文境界チャンク (。!? / 最小15字マージ)
  → HTTP /say?async=1 (40〜60ms) → Kokoro 常駐サーバ (Module-LLM :8900)
  → misaki G2P (pykakasi 前処理) → kokoro (AX630C NPU, rtf≈0.5)
  → GAIN → 48kHz stereo → tinyplay  ※前チャンク再生中に次チャンクを合成 (重畳)
フィラー/相槌 → /filler?name= → 事前レンダ PCM 即再生 (合成 0ms)

5. リンク集

  • 実装 issue: #116 (CoreS3マイク) / #132 (トリガ経路) / #133 (チャンクTTS+フィラー+幻聴防御) / #150 (no_speech_prob調査/VAD閾値)
  • JCARVIS 側: #321 (高速レーン) / #324 (Gemma4司令官) / #328 (ボイス完結) / #323 (ウォームセッション, 継続中)
  • 実践動画: userInput/IMG_9058.MOV (リポジトリ外保管)
  • 図 (LTスライド流用可): docs/public/images/voice-route-2026-07.svg / docs/public/images/phantom-defense-2026-07.svg

aieo-product / stack-chan project