Architecture Deep Dive

LLM Local Chat

ローカルLLM × 音声 × TTS × 外部API 統合チャットアプリの設計

llama.cpp Whisper SAPI5 TTS Tkinter UI Kakeibo / Biolog API

全体 アーキテクチャ

UI
ChatApp(Tkinter)
ウィジェット・イベント配線・root.after() による UI 更新
↓
CTL
Controller
handle_text / handle_voice / stop — 唯一の制御点
↓
SVC
Services
LLMService · TTSWorker · VoiceRecognizer · API Clients
↓
INF
Infrastructure
llama-cpp-python · PyAudio · win32com (SAPI5) · urllib

クラス 構成

クラス責務スレッド
ChatAppTkinter ウィンドウ全体・イベント配線・UI 更新メインスレッド
Controller入力統合・LLM 制御・後処理・TTS トリガーメイン + ワーカー生成
LLMServiceストリーミング推論・abort フラグ管理ワーカー(デーモン)
TTSWorkerSAPI5 発話キュー・on_start / on_stop コールバックデーモン常駐
VoiceRecognizerPyAudio + RMS-VAD + Whisper 音声認識デーモン常駐
AvatarWindow瞬き・口パクアニメーション(Toplevel)メインスレッド
SettingsDialog設定編集・モデルパス・温度・VAD 閾値メインスレッド

スレッド 設計

🖥 メインスレッド
Tkinter mainloop
UI 描画・イベント処理。root.after(0, cb) 経由でのみ UI 更新を受け付ける
🤖 LLM ワーカー
都度生成・デーモン
ストリーミング推論ループ。完了時 root.after(0, _on_llm_done)
🔊 TTSWorker
常駐デーモン
発話キューをポーリング。発話開始/終了時 root.after(0, on_start/on_stop)
🎤 VoiceRecognizer
常駐デーモン
VAD → Whisper ループ。テキスト確定時 root.after(0, _voice_input)
🌐 API ワーカー
都度生成・デーモン
Kakeibo / Biolog への POST。完了時 root.after(0, _chat_write)

⚠ Tkinter を直接操作できるのはメインスレッドのみ

Controller パターン導入

Before — ChatApp 直結
class ChatApp:
  def _send(self):
    # UI読取
    text = self._entry.get(...)
    # LLM スレッド生成
    threading.Thread(
      target=self._llm_worker
    ).start()

  def _llm_worker(self, ...):
    # 推論・ストリーミング
    ...

  def _on_llm_done(self, ...):
    # 後処理・TTS・保存
    ...
After — Controller 委譲
class ChatApp:
  def _send(self):
    self._ctrl.handle_text()

  def _voice_input(self, text):
    self._ctrl.handle_voice(text)

  def _stop_all(self):
    self._ctrl.stop()

class Controller:
  # 制御ロジックをここに集約
  ...

チャット 送信フロー

👤 ユーザー入力
_entry (Tkinter Text)
→
ChatApp._send()
薄いラッパー
→
Controller.handle_text()
入力検証・UI更新
↓
PromptBuilder.build()
messages 構築
→
ResourceManager.decide()
VRAM チェック
→
LLMService.generate()
ストリーミング推論
↓
Controller._on_llm_done()
履歴・要約・API
→
TTSWorker.speak()
コードブロック除外
→
ChatApp._save_now()
JSON 保存

音声認識 フロー

🎤 PyAudio
16kHz / int16
→
RMS-VAD
閾値超過で音声開始検出(VAD_SPEECH_CHUNKS=6 連続)
📼 録音バッファ
→
無音検出で終了
VAD_SILENCE_CHUNKS=30 または VAD_MAX_SECONDS=6 秒
Whisper 変換
→
ノイズ・重複フィルタ
WHISPER_NOISE / WHISPER_NOISE_PARTIAL · 同一テキスト 10 秒排除
root.after()
UI スレッドへ
→
Controller.handle_voice(text)
_tts_active=True の間はスキップ(ハウリング防止)

TTS & アバター制御

🔊
TTSWorker
Windows SAPI5 (win32com) を使う常駐デーモン。
テキストを Queue に積み、_play_loop が順次発話。
stop_all() でキューをフラッシュ&中断。
🔒
マイク排他制御
TTS 発話中は voice._tts_active = True にセット。
VoiceRecognizer はこのフラグを確認し、音声チャンクを読み捨て。
TTS 終了 800ms 後に復帰(ハウリング防止)。
🐱
AvatarWindow
別 Toplevel ウィンドウで常駐。
瞬き: ランダム間隔 2〜6 秒、150ms 閉じる
口パク: TTS 発話中に 140ms ごとにトグル
tts.speak(text)
→
on_start cb
→
voice._tts_active=True
VAD threshold↑
→
SAPI5 発話
→
on_stop cb
→
800ms後に復帰

外部 API 連携

💴 Kakeibo(家計簿)モード
  • ポート: 8767
  • プロンプト注入: _kakeibo_build_prompt() で支出カテゴリ一覧とスキーマを挿入
  • JSON 抽出: _extract_kakeibo_json() で ```json ... ``` ブロックを正規表現でパース
  • 送信: POST /api/kakeibo/record をデーモンスレッドで非同期 POST
  • 必須フィールド: amount / category / store / date / type
❤ Biolog(健康記録)モード
  • ポート: 8766
  • プロンプト注入: _health_build_prompt() で体重・体脂肪・食事・活動スキーマを挿入
  • JSON 抽出: _extract_health_json() で計測値を取得
  • 送信タイミング: TTS 開始直後に非同期送信(VAD 排他を損なわない)
  • DB: SQLite(WAL 禁止・NTFS 非互換)
LLM 応答
→
JSON 抽出
→
API POST
デーモンスレッド
→
root.after()
結果を chat に表示

セッション & 設定管理

セッション JSON(chat_logs/)
{
  "title": "今日の夕飯は…",
  "history": [
    {
      "user": "メッセージ",
      "assistant": "応答"
    }
  ],
  "summary": "要約テキスト"
}

ターン数が SUMMARY_THRESHOLD(4) の倍数になると
バックグラウンドで LLM 要約を生成

設定 JSON(chat_settings.json)
{
  "model_path": "models/gemma...",
  "n_ctx":      8192,
  "max_tokens": 1024,
  "temperature":0.7,
  "vad_threshold":150,
  "mic_enabled": false,
  "tts_enabled": false
}

VRAM セーフティフィルタ

📊
ResourceMonitor
バックグラウンドで GPU 使用率を監視。
Whisper の δgpu% も考慮した動的 max_tokens 算出。
🛡
VRAMGuard
LLM ロード前に VRAM 残量を確認。
adjust_llm() で gpu_layers を自動削減。
不足なら CPU フォールバック。
⚡
adjust_inference()
推論直前に再チェック。
高負荷時は max_tokens を削減して実行。
閾値超過なら実行拒否 → エラーメッセージ表示。
LLM ロード時
→
VRAMGuard
adjust_llm()
→
推論直前
→
adjust_inference()
ResourceManager
→
OK → 実行
/
NG → 拒否

まとめ