RTX3050Ti 4GBでSTT+LLM+TTSサーバーを立てる
目次
環境
今回の環境は以下の通り。
| 項目 | 内容 |
|---|---|
| 音声チャットサーバー | Ryzen 7 5800HS / RTX 3050 Ti Laptop(VRAM 4GB) / RAM 16GB / Windows 11 |
| STT | Qwen/Qwen3-ASR-0.6B(CPU実行) |
| LLM | Qwen(Qwen AmbassadorでのAPIアクセスを利用、ModelScope経由) |
| TTS | Irodori-TTS-500M-v3(GPU常駐) |
| サーバー間接続 | VPSと音声チャットサーバーはTailscaleで接続 |
最初の設計、VPS中継
flowchart LR
A[デバイス] -->|プロンプトをPOST| B[VPS]
B -->|テキスト生成| C[Qwen]
C -->|返信テキスト| B
B -->|音声合成をリクエスト| D[ローカルTTSサーバー]
D -->|WAV| B
B -->|WAVを返す| A
VPSと自宅のTTSサーバーは、Tailscaleでつないでいる。
テキストだけで十分な用途と、音声までまとめて欲しい用途を分けて、VPS側にエンドポイントを2つ作った。
STTもローカルで呼ぶことにした
ここまでの設計は「デバイスからテキストや音声を送る」前提だったが、実際にはマイクで録音した音声をテキストに変換するSTTの工程が抜けていた。
STTはモデルサイズ的に小型デバイス上では動かせないので、どのみちどこかのサーバーで処理する必要がある。
TTSはすでに構築したので、そのマシンにSTTも同居させて、音声を受け取ってから返信の音声を作るところまで一台で完結させることにした。
VPSを経由してテキストだけ受け渡しする構成より、経由するサーバーが1つ減る。
flowchart TD
A[デバイス] -->|録音した音声をPOST| B[音声チャットサーバー]
B -->|音声認識| C[STT: Qwen3-ASR-0.6B]
C -->|文字起こし| B
B -->|テキスト生成| D[Qwen: ModelScope]
D -->|返信テキスト| B
B -->|文単位で音声合成| E[TTS: Irodori-TTS]
E -->|WAVチャンク| B
B -->|チャンクを順に返す| A
STT比較、kotoba-whisperとQwen3-ASR-0.6B
STTの候補として、kotoba-tech/kotoba-whisper-v2.0-faster(faster-whisper、CPU int8)とQwen/Qwen3-ASR-0.6B(CPU fp32)の2つを比較した。
TTSがVRAM 4GBをほぼ使い切っている状態(実測3,945/4,096 MiB)なので、STTはCPU実行が前提になる。
| kotoba-whisper-v2.0-faster(int8) | Qwen3-ASR-0.6B(fp32) | |
|---|---|---|
| モデル読み込み | 31.9秒 | 44.2秒 |
| 認識時間(ウォーム後) | 11.3〜14.3秒/件 | 4.4〜8.9秒/件 |
| 初回1件目(ウォームアップ込み) | 21.8秒 | 22.5秒 |
| 精度 | ほぼ正確、句読点なし、数字は算用数字化 | 完全一致、句読点も再現 |
速度も精度も句読点の再現もQwen3-ASR-0.6Bの方が良かったので、これを採用した。
モデル読み込みの44秒はサーバー起動時の1回だけで済む。
文単位ストリーミングTTSとenable_thinkingオフ
STT・LLM・TTSを1つのFastAPIサーバーにまとめた。
返答までの時間を縮めるためにやったことが2つある。
1つ目は、LLM呼び出し時にenable_thinkingをオフにしたこと。
オンのままだと、一言の返信にも879文字分の思考が挟まり、約5秒の遅延が加わる(実測で7.4秒から2.8秒に短縮)。
2つ目は、LLMの応答を文単位に分割して、生成中から順にTTSへ回す方式にしたこと。
LLMが2文目を生成している間に1文目のTTSを走らせられるので、LLM待ちとTTS待ちが重なる分、合計の待ち時間が短くなる。
システムプロンプトには「読み上げ用に2文以内・記号なし」という指示も入れている。
入力音声をIrodori-TTSで作った質問文で、音声から音声までのエンドツーエンドを実測した。
| 1回目(コールド) | 2回目(ウォーム) | |
|---|---|---|
| STT | 20.0秒 | 2.4秒 |
| LLM初トークン | 3.0秒 | 1.4秒 |
| TTS待ち | 7.0秒 | 7.6秒 |
| 合計(音声→音声) | 30.3秒 | 11.9秒 |
認識は「日本で一番高い山は何ですか」「おすすめの朝ごはんを教えてください」の2問とも完全一致した。
実際に使った音声はこちら。1回目(コールド)は「日本で一番高い山は何ですか」の質問と、その返答。
2回目(ウォーム)は「おすすめの朝ごはんを教えてください」の質問と、その返答。
STTループバックでTTSの読み誤りを確認
返答音声をもう一度STTに入れて、実際に正しく発話されているかを確認する検証もしている。
このとき、長文の「十時四十九分」は、両方のSTTエンジンが揃って「19分」系に誤認していた。
誤り箇所が完全に一致しているので、これはSTT側の誤認識ではなく、TTSが「四十九分」を正しく発音できていないことが原因だと推測できる。
STTベンチで使った音声のひとつがこちら。「十時四十九分」の部分がもにょっている。
ジョブポーリングとフィラー音声で待ち時間を埋める
最初に作った同期方式は音声から音声までを1回のリクエストで返すが、STT・LLM・TTSを直列でつなぐとレスポンスまで数秒から十数秒かかる。
デバイス側が無音のままだと不自然なので、非同期のジョブ方式に作り直した。
デバイス側の使い方は次のようになる想定だ。
- 録音した音声を送る
- ジョブの状態を0.5〜1秒間隔でポーリングする
- 増えたチャンクからダウンロードして順に再生する
ジョブIDが返ってきた時点でフィラーWAVの再生を始め、ポーリングのたびに完成したWAVのURLが順に増えていき、完了フラグが返ってきたら終了する。
フィラー音声は「えーっと、ちょっと考えますね。」などの3種類をあらかじめTTSで作り置きしてあり、URL一覧をサーバーから取得できる。
実際に動かして測ったタイムラインは次の通り。
| 経過時間 | できごと |
|---|---|
| 0.0秒 | ジョブ受付、ジョブIDを返却(デバイスはここでフィラー再生を開始) |
| 4.7秒 | 認識テキストが確定 |
| 10.5秒 | 1文目のWAVが完成、ダウンロード・再生可能 |
| 14.1秒 | 2文目が完成、全文完了 |
1文目(約5秒の音声)の再生が終わる前に2文目ができあがっているので、生成は再生に追いついている。
各チャンクは完結した小さいWAV(1文目は数百KB)なので、小型デバイスのメモリにも収まり、ストリーミングデコードやバッファ管理はいらない。
同期版もそのまま残してあり、フォールバックとして使える。
この一連の検証はPCから疑似的な音声ファイルを送って行ったもので、デバイス側のファームウェアはまだ書いていない。
キャラクター性は最低限にした
かなちゃんという名前と、元気で明るい性格という一言だけをシステムプロンプトに入れた。
以前、キャラクターの設定を細かく作り込みすぎて、かえって受け答えが不自然になった経験があるので、今回は最初から作り込みすぎない方針にした。
Markdown記法を使わないという指示だけは明確に入れている。
音声で読み上げる前提だと、アスタリスクや見出し記号がそのまま読み上げられたり文字として残ったりすると困る。
実際にテストしてみると、返信にアスタリスクや見出し記号は出てこなかった。