RTX 4070搭載PCで、完全無料・無制限の「(模擬)Speak to Speak AI基盤」を構築する(Whisper + vLLM + VOICEVOX)
振り返り:12GB VRAMに「耳・脳・口」を詰め込む
前回は、市販のゲーミングPC(RTX 4070)を使い、外部APIを一切使わない完全ローカルなAI対話基盤の構築に着手しました。
最大の課題は「12GBというVRAM容量の壁」でしたが、以下の戦略でこれを突破しました。
-
耳(音声認識):
faster-whisperとlarge-v3-turboモデルを採用し、精度と速度を両立。 -
脳(LLM):
vLLMでQwen-4Bを FP8量子化 して稼働させ、VRAM消費を半減。 -
口(音声合成):
VOICEVOXをDocker(CPUモード)に隔離し、GPUリソースをLLMに集中。
これにより、通信費ゼロ・セキュリティ万全な「最強のインフラ」が整いました。
今回はこの基盤の上で、実際にPythonコードを動かし、「話しかければ即座に声で返ってくる」 リアルタイム対話システムの実装に入ります。
各処理を連結させ会話を実現する
前回の構築で、PCの中には会話を行うための「パーツ」が揃いました。しかし、現段階ではそれぞれがバラバラに起動しているだけです。
今回はこれらをPythonスクリプト で繋ぎ合わせ、「話しかければ、即座に声で返ってくる」という身体性を持ったAIへと進化させます。
目指すのは、チャット画面の無機質なテキスト交換ではなく、「まるでそこに人がいるかのような」リアルタイムな対話体験です。
実装するパイプライン
データの流れは以下の通りです。「いかに遅延(レイテンシ)を削るか」が実装の肝となります。
- 入力 (Ear): マイクで音声をキャッチ
-
認識 (STT):
faster-whisperで高速テキスト化 - 思考 (LLM): OpenAI互換API (vLLM) で応答生成
- 合成 (TTS): VOICEVOX で音声を生成
- 出力 (Mouth): スピーカーから再生
1. 「耳」の実装:マイク入力と高速認識
まずは音声入力部分です。Python標準のライブラリではなく、より低レベルな制御が可能な sounddevice と、高速推論エンジンCTranslate2を裏側で動かす faster-whisper を組み合わせます。
マイク録音 (sounddevice)
ファイルを経由せず、マイクから直接データをメモリに取得します。今回はシンプルにするため、ボタン操作ではなく「5秒間録音する」という仕様にしています。
サンプルコード
import sounddevice as sd
import soundfile as sf
# ... (中略) ...
print("音声取得中...")
print(f"Recording {args.record_seconds:.1f}s from microphone...")
# 指定秒数だけマイクから録音
recording = sd.rec(
int(16000 * args.record_seconds),
samplerate=16000,
channels=1,
dtype="float32",
)
sd.wait() # 録音完了まで待機
音声認識 (faster-whisper)
録音したデータを faster-whisper に渡します。ここでポイントなのが、前回選定した軽量モデル large-v3-turbo です。
サンプルコード
from faster_whisper import WhisperModel
# GPU(cuda) と float16 で高速推論設定
model = WhisperModel("large-v3-turbo", device="cuda", compute_type="float16")
print("テキスト出力中...")
segments, _info = model.transcribe(
audio_path, # 録音した一時ファイルのパス
language="ja",
)
通常のWhisperだとここで数秒待たされますが、faster-whisper × turbo モデルの組み合わせであれば、発話終了からほぼ一瞬でテキスト化が完了します。
2. 「脳」への接続:OpenAI互換設計の強み
ここがアーキテクチャ上のこだわりポイントです。
ローカルLLM(vLLM)を動かしていますが、Pythonコード上はOpenAI公式ライブラリ (openai) を使用します。
なぜこの設計なのか?(技術的選定眼)
vLLMはOpenAI互換のAPIサーバーとして機能します。そのため、base_url と api_key を書き換えるだけで、プログラムのコードを一切変更せずに、バックエンドを GPT-4 や Claude、あるいは別のローカルLLMに差し替えることが可能になります。
これは「疎結合」な設計であり、将来的なモデルのアップデートに強い構成です。
サンプルコード
from openai import OpenAI
# 環境変数から接続先を取得(デフォルトはローカルのvLLM)
client = OpenAI(
base_url=os.environ.get("VLLM_BASE_URL", "http://127.0.0.1:8000/v1"),
api_key=os.environ.get("VLLM_API_KEY", "EMPTY"),
)
# Qwenなどのローカルモデルへリクエスト
res = client.chat.completions.create(
model=os.environ.get("VLLM_MODEL", "Qwen/Qwen3-4B-Instruct-2507-FP8"),
messages=[{"role": "user", "content": transcript}], # transcriptは音声認識結果
)
3. 「口」の実装:インメモリ再生による低遅延化
AIからの返答テキストを音声に変換します。ここでは VOICEVOX Engine のAPIを叩きますが、ここにも遅延を削る工夫があります。
工夫:ファイル保存をしない (In-Memory Playback)
通常の手順では「音声生成 → wavファイル保存 → 読み込み → 再生」とファイルI/Oが発生しますが、これは遅延の原因になります。
今回は、APIから受け取ったバイナリデータをメモリ上 (io.BytesIO) で直接展開し、そのまま再生します。
サンプルコード
import io
def synthesize_voice(text: str, base_url: str, speaker: int) -> bytes:
# 1. 音声合成用のクエリを作成 (Audio Query)
query_res = requests.post(
f"{base_url}/audio_query",
params={"text": text, "speaker": speaker},
timeout=60,
)
# ... エラー処理 ...
# 2. 音声波形データの生成 (Synthesis)
synthesis_res = requests.post(
f"{base_url}/synthesis",
params={"speaker": speaker},
json=query_res.json(),
timeout=120,
)
return synthesis_res.content # バイナリデータを返す
# ... Main処理 ...
wav_bytes = synthesize_voice(answer_text, base_url="...", speaker=args.voicevox_speaker)
# io.BytesIOを使ってメモリ上から直接再生
data, sr = sf.read(io.BytesIO(wav_bytes), dtype="float32")
sd.play(data, sr)
sd.wait()
これにより、HDD/SSDへのアクセス待ち時間がゼロになり、ユーザーには「LLMが考え終わった瞬間に喋りだした」ように感じられます。
プロトタイプ稼働:一気通貫テスト
これら全ての要素を繋げたコードが single_conversation.py です。
実際に動かしてみましょう。
# マイク入力で実行(デフォルト設定)
python single_conversation.py
# 話者を変えたい場合(例: 四国めたん=2, 春日部つむぎ=8 など)
python single_conversation.py --voicevox-speaker 2
実行結果(ログ)
コマンドラインには以下のように処理の状況がリアルタイムで表示されます。
音声取得中...
Recording 5.0s from microphone...
テキスト出力中...
こんにちは。
LLMに依頼中...
出力:
こんにちは!今日はどのようにお過ごしですか?
音声合成中...
(ずんだもんの声で再生)
体感速度レポート
RTX 4070 (12GB) の環境において、話しかけ終わってから音声が返ってくるまでの時間は約2〜3秒程度でした。
クラウドAPIを経由した場合、通信ラグを含めて5秒以上かかることもザラですが、完全ローカル処理ならではの爆速レスポンスを実現できています。
ソースコード全文
最後に、今回使用した single_conversation.py の全文を掲載します。
single_conversation.py
import argparse
import io
import os
import sys
import tempfile
import requests
from faster_whisper import WhisperModel
from openai import OpenAI
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Transcribe Japanese audio to text using faster-whisper."
)
parser.add_argument(
"audio_path",
nargs="?",
help="Path to an audio file (wav/mp3/etc.). If omitted, record from mic.",
)
parser.add_argument(
"--record-seconds",
type=float,
default=5.0,
help="Recording length in seconds when using the mic (default: 5)",
)
parser.add_argument(
"--voicevox-speaker",
type=int,
default=3,
help="VOICEVOX speaker ID (default: 1)",
)
return parser.parse_args()
def synthesize_voice(text: str, base_url: str, speaker: int) -> bytes:
query_res = requests.post(
f"{base_url}/audio_query",
params={"text": text, "speaker": speaker},
timeout=60,
)
query_res.raise_for_status()
synthesis_res = requests.post(
f"{base_url}/synthesis",
params={"speaker": speaker},
json=query_res.json(),
timeout=120,
)
synthesis_res.raise_for_status()
return synthesis_res.content
def main() -> int:
args = parse_args()
audio_path = args.audio_path
temp_path = None
if audio_path:
if not os.path.exists(audio_path):
print(f"Audio file not found: {audio_path}", file=sys.stderr)
return 1
else:
import sounddevice as sd
import soundfile as sf
print("音声取得中...")
print(f"Recording {args.record_seconds:.1f}s from microphone...")
recording = sd.rec(
int(16000 * args.record_seconds),
samplerate=16000,
channels=1,
dtype="float32",
)
sd.wait()
fd, temp_path = tempfile.mkstemp(suffix=".wav")
os.close(fd)
sf.write(temp_path, recording, 16000)
audio_path = temp_path
model = WhisperModel("large-v3-turbo", device="cuda", compute_type="float16")
print("テキスト出力中...")
segments, _info = model.transcribe(
audio_path,
language="ja",
)
texts = []
for segment in segments:
text = segment.text.strip()
if text:
texts.append(text)
transcript = " ".join(texts)
print(transcript)
print("LLMに依頼中...")
client = OpenAI(
base_url=os.environ.get("VLLM_BASE_URL", "http://127.0.0.1:8000/v1"),
api_key=os.environ.get("VLLM_API_KEY", "EMPTY"),
)
res = client.chat.completions.create(
model=os.environ.get("VLLM_MODEL", "Qwen/Qwen3-4B-Instruct-2507-FP8"),
messages=[{"role": "user", "content": transcript}],
)
print("出力:")
answer_text = res.choices[0].message.content
print(answer_text)
print("音声合成中...")
wav_bytes = synthesize_voice(
answer_text,
base_url="http://127.0.0.1:50021",
speaker=args.voicevox_speaker,
)
try:
import sounddevice as sd
import soundfile as sf
data, sr = sf.read(io.BytesIO(wav_bytes), dtype="float32")
sd.play(data, sr)
sd.wait()
except OSError as exc:
print(f"再生に失敗しました: {exc}", file=sys.stderr)
if temp_path:
try:
os.remove(temp_path)
except OSError:
pass
return 0
if __name__ == "__main__":
raise SystemExit(main())
第2回のまとめ
今回はPythonを使って、個別に動作していたAIモデルたちを連携させました。
- Faster-Whisper: 高速な耳
- OpenAI Compatible API: 拡張性の高い脳
- In-Memory Playback: 遅延のない口
これで「会話」の基礎は完成です。しかし、今のままではただ一問一答を返すだけ**です。また、キャラクター性も固定されています。
次回(最終回)は、このシステムを**「自律型エージェント」**へと進化させます。「ずんだもん」から「めたん」や「つむぎ」に会話の流れで人格を変幻自在に切り替える Function Calling (Tool Use) の実装に挑みます。
(第3回へ続く)

