はじめに
「誰かに話を聞いてほしいけど、言葉にするのが難しい」
そんな夜、ありませんか。
このプロジェクト 「AI 心の伴走者」 は、クラウドに一切データを送らず、マイクに向かって話しかけるだけで、温かく共感的な応答を返してくれる 完全ローカル動作の AI コンパニオン です。
音声で話しかける → AI が聞き取る → 共感的に応答 → 声で読み上げる
この一連の流れがすべて自分のマシンの中で完結します。プライバシーを守りながら、心の負担を少し軽くしてくれる存在を目指しました。
なぜ「ローカル完結」にこだわったのか
感情に関わる会話は、特にプライベートです。
クラウド型の AI サービスは便利ですが、「今日、仕事でひどく怒られた」「人間関係が辛い」といった内容を毎回外部サーバーに送信するのは、心理的ハードルが高いと思います。
ローカル完結にすることで得られるもの:
- 🔒 完全なプライバシー — 会話内容が外部に出ることが一切ない
- 📴 オフライン動作 — 一度セットアップすれば、インターネット不要
- ⚡ 低遅延 — サーバーへの往復なしで高速レスポンス
- 💾 永続記憶 — 再起動後も会話を覚えている(ベクトル DB 活用)
使用技術スタック
| カテゴリ | 技術 | 役割 |
|---|---|---|
| 音声認識 | faster-whisper large-v3 | 日本語を高精度でテキスト化 |
| LLM | Ollama + Qwen 2.5 7B | 共感的な日本語応答を生成 |
| TTS | Piper TTS 1.4+(en_US-amy-medium) | 英語音声で読み上げ(espeak-ng 内蔵) ※ Windows Harukaがインストールされていれば日本語対応 |
| VAD | WebRTC VAD | 発話の開始・終了を自動検出 |
| ベクトル記憶 | ChromaDB + nomic-embed-text | 会話を意味的に記憶・検索 |
| 履歴保存 | SQLite | 会話履歴を永続化 |
| GUI | Tkinter(ダークモード) | シンプルで軽量な UI |
すべてオープンソース・無料で利用できます。
アーキテクチャ全体像
マイク入力
↓
WebRTC VAD(発話区間を検出)
↓
faster-whisper(音声 → テキスト)
↓
ChromaDB 記憶検索 + SQLite 履歴取得
↓
Qwen 2.5 / Ollama(文脈付きで応答生成)
↓
ChromaDB / SQLite へ保存
↓
Piper TTS(テキスト → 音声)
↓
スピーカー再生
処理は複数のスレッドで並列実行されます。
マイク入力 → VAD は常時バックグラウンドで動作し、発話セグメントをキューで後段に渡す設計です。
プロジェクト構造
ai_companion/
├── main.py # エントリポイント・全コンポーネントの統合
├── config.py # 型付き設定クラス(dataclass)
├── installer.py # Piper・音声モデルの自動インストール
├── ui/
│ └── app.py # Tkinter ダークモード UI
├── audio/
│ ├── listener.py # 継続的マイク入力
│ ├── vad.py # WebRTC VAD による発話検出
│ ├── transcriber.py # Whisper 音声認識
│ └── tts.py # Piper TTS 音声合成・再生
├── ai/
│ ├── llm.py # Ollama LLM 応答生成
│ ├── memory.py # ChromaDB ベクトル記憶
│ └── embeddings.py # nomic-embed-text 埋め込み
└── database/
└── storage.py # SQLite チャット履歴
実装のポイント
1. WebRTC VAD による自動発話検出
ウェイクワードなし・ボタン押下なしで、自然に話しかけるだけで動作します。
class VADProcessor:
def process_frame(self, frame: np.ndarray) -> Optional[bytes]:
"""
30ms のフレームを処理し、発話セグメントが完成したら返す。
無音が 1200ms 続いたら発話終了と判定する。
"""
is_speech = self._vad.is_speech(frame.tobytes(), self._cfg.sample_rate)
if is_speech:
self._speech_buffer.append(frame)
self._silence_frames = 0
else:
self._silence_frames += 1
if self._in_speech and self._silence_frames >= self._silence_threshold:
# 発話終了 → セグメントを返す
segment = self._flush_buffer()
if len(segment) >= self._min_speech_samples:
return segment
return None
プリバッファリングにより、VAD が発話を検出する前の音声も取り込み、語頭の切り抜けを防いでいます。
2. スレッドキューによる非同期パイプライン
音声認識・LLM 推論・TTS は時間のかかる処理です。UI がフリーズしないよう、全ステージをワーカースレッドで分離し、queue.Queue で接続しています。
[Audio Thread] → segment_queue → [Transcriber Thread]
↓
text_queue → [Response Thread]
↓
LLM + TTS(直列)
text_queue.put(None) という番兵値(sentinel)でスレッドの終了を伝えるパターンを使っており、クリーンシャットダウンを実現しています。
3. ChromaDB によるセマンティック記憶
ChromaDB と nomic-embed-text の組み合わせで、会話を「意味的に」記憶します。
def retrieve(self, query: str, top_k: int = 5) -> List[MemoryEntry]:
"""クエリに近い過去の会話を検索する。"""
embedding = self._embedder.embed(query)
results = self._collection.query(
query_embeddings=[embedding],
n_results=top_k,
include=["documents", "distances", "metadatas"],
)
return [MemoryEntry(...) for doc, dist, meta in zip(...)]
単純なキーワード検索ではなく、「仕事が辛い」と「職場のストレス」のような意味的に近い発話も引き出せるのが特徴です。再起動後も記憶が維持されます。
4. LLM への共感的プロンプト設計
Ollama で動かす Qwen 2.5 7B に、感情サポートに特化したシステムプロンプトを与えています。
SYSTEM_PROMPT = """
あなたは「伴走者」という名の、温かく共感的な AI コンパニオンです。
ユーザーの感情に寄り添い、判断せず、否定せず、ただそっと話を聞いてください。
- 相手の気持ちをまず受け止めてから応答する
- アドバイスより共感を優先する
- 短く、やさしい言葉で話す
- 必要に応じてプロの相談窓口を案内する
"""
会話履歴(直近 10 件)とセマンティック記憶(類似 5 件)の両方をコンテキストとして渡すことで、一貫性のある応答を実現しています。
5. Tkinter ダークモード UI
UI はシンプルな Tkinter で構築。Catppuccin Mocha カラーパレットをベースにしたダークテーマを採用しています。
| 要素 | カラーコード | 用途 |
|---|---|---|
#1e1e2e |
背景 | メインバックグラウンド |
#2a2a3e |
サーフェス | チャット・ボタン背景 |
#cba6f7 |
アクセント(紫) | ユーザー発話ラベル |
#89b4fa |
ブルー | AI 発話ラベル |
#cdd6f4 |
テキスト | メッセージ本文 |
#a6adc8 |
サブテキスト | タイムスタンプ・ヒント |
ユーザーの発話ラベルは 紫、AI(伴走者)のラベルは 青 と色で区別し、視覚的に話者を即座に判別できます。
ミュートボタンはミュート中に 赤背景 に変わり、状態を一目で把握できます。Ctrl+M のキーボードショートカットにも対応しています。
セットアップ方法
動作要件
| 項目 | 要件 |
|---|---|
| OS | Windows 11 |
| Python | 3.11+ |
| GPU | NVIDIA RTX(CUDA 12.1+)推奨 |
| RAM | 16GB 以上(32GB 推奨) |
CPU のみでも動作しますが、Whisper の推論速度が低下します。
手順
1. Ollama をインストールして Ollama サーバーを起動
# ollama.com からインストール後
ollama serve
# 別ターミナルでモデルを取得
ollama pull qwen2.5:7b-instruct-q4_K_M
# ollama pull qwen2.5:14b-instruct-q4_K_M もよく速度が落ちますが、回答精度が向上します)
ollama pull nomic-embed-text:latest
2. Python 依存パッケージのインストール
pip install -r requirements.txt
# GPU 版 PyTorch を使わない場合(その後のテストは現在できていません)
pip install torch
3. 起動
python main.py
初回起動時に以下が自動ダウンロードされます:
- Whisper large-v3 モデル(約 3GB、HuggingFace から自動取得)
- CUDA 12 ランタイムライブラリ(cublas64_12.dll / libcublas.so.12)
piper-tts 1.4+ から espeak-ng が Python ホイールに内蔵されたため、Piper バイナリの個別ダウンロードは不要になりました。
2 回目以降はスキップされます。
UI の使い方
起動すると音声認識が自動的に開始されます。
🎙 聴いています... ← 待機中
⚙️ 書き起こし中... ← 音声をテキストに変換中
💭 考えています... ← AI が応答を生成中
🔊 話しています... ← TTS で読み上げ中
| 操作 | 方法 |
|---|---|
| 話しかける | マイクに向かって話す(自動で発話を検出) |
| ミュート | 「🎙 ミュート」ボタン または Ctrl+M
|
| 終了 | 「✕ 終了」ボタン または ウィンドウを閉じる |
技術的に工夫した点
グレースフルデグラデーション
# CUDA が使えなければ CPU にフォールバック
try:
model = WhisperModel("large-v3", device="cuda", compute_type="float16")
except Exception:
model = WhisperModel("large-v3", device="cpu", compute_type="int8")
GPU なしでも動作し、記憶検索が失敗してもメインの会話は継続するよう、各コンポーネントが独立して障害を吸収します。
設定の型安全管理
すべての設定値は dataclass(frozen=True) で管理し、起動時にバリデーションを実行します。
@dataclass(frozen=True)
class VADConfig:
aggressiveness: int = 2 # 0(低感度)〜3(高感度)
silence_threshold_ms: int = 1200 # 発話終了判定の無音時間
min_speech_ms: int = 300 # 最小発話長(ノイズ除去)
辞書やグローバル変数ではなく型付きオブジェクトにすることで、IDE の補完・型チェックが効きます。
今後の展望
- Windows 以外への対応 — macOS・Linux 向け TTS バイナリの対応
- 感情分析の可視化 — 会話のトーンをグラフで見える化
- 複数音声の選択 — ユーザーが音声を選べるように
- 会話のエクスポート — PDF・テキストでの履歴出力
- 危機検出の強化 — 深刻な状態をより精度高く検知し、相談窓口を案内
- エッジデバイスでの対応 — RaspberryPiやArduino、JetsonNanoを使用してポータブルに近いデバイスのディプロイ
コミュニティへのお願い: Piper で使える日本語音声モデルをご存知の方がいれば、ぜひコメントで教えてください!公式リポジトリには存在しないため、コミュニティ製モデルや代替アプローチの情報をお待ちしています。
開発中にハマったポイントと解決策
問題 1:piper-phonemize が Windows にインストールできない
当初、Piper TTS は音素変換に piper-phonemize という別パッケージを必要としていました。しかしこのパッケージは PyPI に Windows 用ホイールが存在せず、そのまま pip install しても失敗します。
回避策として GitHub Releases から Python バージョンに合ったホイールを手動ダウンロード・インストールするコードを書いていたのですが、その後 Piper のプロジェクト自体が移管・刷新されていることを発見しました。
新プロジェクト: OHF-Voice/piper1-gpl
piper-tts 1.4+ から espeak-ng が Python ホイールに直接内蔵されるようになり、piper-phonemize は一切不要になりました。API も大きく簡潔になっています。
# 旧 API(piper-phonemize 必須・複雑)
sentences = list(voice.text_to_ids(text))
for phoneme_ids, *_ in sentences:
for audio_bytes in voice.synthesize_ids_to_raw(phoneme_ids):
wav_file.writeframes(audio_bytes)
# 新 API(piper-tts 1.4+・espeak-ng 内蔵)
voice = PiperVoice.load("model.onnx", use_cuda=False)
with wave.open(buf, "wb") as wav_file:
voice.synthesize_wav(text, wav_file)
DLL パス登録・ESPEAK_DATA_PATH 設定・Windows ホイール手動取得のコードがすべて不要になり、大幅にシンプルになりました。
問題 2:AI が自分の声に応答してしまう(TTS フィードバックループ)
アプリを動かしていると AI が英語で喋り始め、その声をマイクが拾い、また Whisper が書き起こして、LLM が応答して…という 無限ループ が発生しました。
原因は単純で、TTS 再生中もマイク入力(VAD)が動き続けていたためです。
MicrophoneListener にはもともと _muted フラグがあったので、TTS の前後でミュート・アンミュートするだけで解決できました。ポイントは VAD バッファのリセットとセグメントキューの空読み を同時に行うことで、ミュート直前に取り込んでいた音声が後から処理されないようにしている点です。
def mute(self) -> None:
self._muted = True
self._vad_processor.reset() # 蓄積中の発話バッファを破棄
self._remainder = np.array([], dtype=np.int16)
while not self._segment_queue.empty(): # キュー内の残存セグメントを破棄
try:
self._segment_queue.get_nowait()
except Exception:
break
呼び出し側は finally で確実にアンミュートします:
if self._listener:
self._listener.mute()
try:
self._tts_svc.speak(english_text)
except TTSError as e:
logger.error("TTS エラー: %s", e)
finally:
if self._listener:
self._listener.unmute()
エラーで TTS が途中終了しても、マイクが永遠にミュートされたままになる事故を防げます。
問題 3:Piper の日本語音声モデルが存在しない
当初、AI が日本語で返した応答をそのまま日本語 TTS で読み上げることを目指していました。README にも「日本語女性音声 nakamura medium」と書いていたほどです。
しかし調べると、Piper の公式リポジトリに日本語(ja)モデルは存在しない ことが判明しました。
Piper が公開しているモデル一覧 (rhasspy/piper-voices) を確認すると、対応言語は英語・ドイツ語・フランス語など多数あるものの、日本語は含まれていません。「nakamura」という名前のモデルも存在しません。
採った解決策:日本語優先 TTS + 英語フォールバック
LLM の応答フォーマットを変更し、英語と日本語を両方出力させます:
# llm.py のシステムプロンプト(抜粋)
"""
Always respond in BOTH English and Japanese using this exact format:
[EN] <English response here>
[JA] <Japanese response here>
"""
優先順位は「日本語 SAPI → 英語 Piper」 です。起動時に Windows SAPI から日本語音声を自動検出し、見つかれば [JA] ブロックのみを読み上げます。日本語音声が利用できない場合にだけ英語 Piper にフォールバックします。
# audio/tts.py(抜粋)
def _find_sapi_japanese_voice(self) -> str | None:
"""起動時に SAPI 日本語音声を検索して ID を返す。"""
if platform.system() != "Windows":
return None
import pyttsx3
engine = pyttsx3.init()
for v in engine.getProperty("voices"):
name_lower = v.name.lower()
lang_str = "".join(v.languages or []).lower()
if "japanese" in name_lower or "haruka" in name_lower or "ja" in lang_str:
logger.info("SAPI 日本語音声を検出: %s", v.name)
engine.stop()
return v.id
engine.stop()
return None # 見つからなければ英語 Piper にフォールバック
@property
def has_japanese_tts(self) -> bool:
return self._sapi_voice_id is not None
def speak_japanese(self, text: str) -> None:
"""Windows SAPI で日本語テキストを読み上げる。"""
import pyttsx3
engine = pyttsx3.init()
engine.setProperty("voice", self._sapi_voice_id)
engine.say(text)
engine.runAndWait()
# main.py(抜粋)
def _extract_japanese(text: str) -> str:
match = re.search(r'\[JA\]\s*(.+?)$', text, re.DOTALL)
return match.group(1).strip() if match else ""
# TTS 再生ブロック
ja_text = _extract_japanese(response)
if ja_text and self._tts_svc.has_japanese_tts:
self._tts_svc.speak_japanese(ja_text) # 日本語 SAPI で読み上げ
else:
self._tts_svc.speak(_extract_english(response)) # 英語 Piper にフォールバック
日本語音声パック(Microsoft Haruka)が Windows にインストールされていれば追加設定なしで日本語読み上げが有効になります。インストールされていない場合は英語 Piper で読み上げられます(pip install pyttsx3 のみ必要)。
Piper で使える日本語モデルをご存知の方は、ぜひコメントで教えてください! 公式リポジトリには存在しないため、コミュニティ製モデルや代替アプローチの情報をお待ちしています。
最後に
このアプリはあくまで技術的なプロジェクトです。深刻な悩みを抱えているときは、カウンセラー・医師・公的相談機関などの専門家にご相談ください。
リポジトリ
ソースコードは GitHub で公開しています:
フィードバック・Issue・PR お待ちしています 🙌
この記事が参考になったら、
をお願いします!
参考
- faster-whisper — 高速 Whisper 実装
- Ollama — ローカル LLM サーバー
- Piper TTS — ニューラル TTS エンジン(OHF-Voice/piper1-gpl)
- ChromaDB — ベクトルデータベース
- Catppuccin — ダークテーマカラーパレット