少し前に、声で話せるAIアシスタントや、声で操作できるAIエージェントを完全ローカルで作りました。Whisperで聞き取り、ローカルLLMが考え、VOICEVOXが喋る——という構成です。繋がったときは感動したのですが、実際に使ってみるとひとつ大きな不満がありました。
返事が返ってくるまでが、長い。話しかけてから「……」と数秒〜十数秒の沈黙。この「間」が、体感をかなり損ねていました。そこで今回は、遅延そのものを主題にして、どこで待たされているのかを測り、段階的に削ってみます😊
・返事までの遅延を実測して内訳に分解(聞き取り→生成→合成)
・バッチ処理と逐次処理の違いを図解
・LLMの逐次出力+文ごとの音声合成で「間」を削る(実装コード)
・最初の音までが実測13.5秒→2.7秒(約80%短縮)
・ハマりどころ(無音判定・言い切り前・音の途切れ)
「返事が返ってこない…」あの気まずい間
音声対話の流れは、ざっくり4段階です。①発話の終わりを検出して、②声を文字にして(文字起こし)、③LLMが返事を考えて、④文字を音声にして再生する。問題は、これを全部きれいに順番待ちしていたこと。つまり、③の返事を最後の一文字まで作り終え、さらに④で全文を音声に変換し終えるまで、こちらは無音でじっと待つことになります。
まず「間」を測って分解する
「なんとなく遅い」では直せないので、まず各段階の秒数を測りました。お題は「こんにちは、あなたは何ができるのか教えて」。返事は話し言葉で3〜4文、という条件です(Mac・Apple Silicon/Whisper small・CPU/ローカルLLM Ministral 3/VOICEVOX)。
| 段階 | やっていること | 実測 |
|---|---|---|
| ① 発話終了の検出 | 録音を止める合図 | (今回は手動ボタン) |
| ② 文字起こし(STT) | Whisperで声→文字 | 約1.2秒 |
| ③ 返事の生成(LLM) | ローカルLLMが全文を作る | 約7.8秒(1文目まで0.9秒) |
| ④ 音声合成(TTS) | VOICEVOXで全文→音声 | 約4.5秒(全文まとめて) |
ここで分かったのは、入力側(文字起こし)は1.2秒と意外に速いこと。遅延の正体は、③生成と④合成を「全文まるごと・順番待ち」でやっていた返答側にありました。合計すると、最初の音が出るまで 1.2 + 7.8 + 4.5 = 約13.5秒。そりゃ気まずい間になるわけです😅
なぜ待たされる? バッチと逐次のちがい(図解)
カギは「バッチ処理」から「逐次処理」への切り替えです。バッチは全部そろってから次に渡すやり方。逐次はできたものから先に流すやり方です。料理でたとえると、バッチは「全品作り終えてから一度に配膳」、逐次は「できた皿から順に出す」。会話では、後者のほうが圧倒的に待たされ感が少ない。
改善①:返答を"逐次"生成する
まず、LLMの返事を最後まで待たずに、出てきた端から受け取ります。llama.cpp(llama-cpp-python)は stream=True でトークンを逐次返してくれるので、それを1つずつ流すメソッドを足しました。
def stream(self, messages, max_tokens=1024, temperature=0.4):
"""返答を"逐次"生成し、テキストの差分(delta)を1つずつ yield する。"""
for chunk in self.llm.create_chat_completion(
messages=messages, max_tokens=max_tokens,
temperature=temperature, stream=True):
piece = chunk["choices"][0].get("delta", {}).get("content")
if piece:
yield piece
これで「全文が完成するまで待つ」必要がなくなりました。あとは、出てきた文字をどこで区切って音声に回すかです。
改善②:文ができた端から喋る
流れてくる文字を句点(。!?や改行)で区切って1文にまとめ、その1文ができた瞬間にVOICEVOXへ投げます。合成できた音声は再生キューに積み、順番どおりに再生。生成・合成・再生をパイプラインで重ねるのがミソです。
_ENDINGS = "。.!?!?\n"
def split_sentences(stream):
"""テキストのdeltaを受け、文が完成するたびに1文ずつ yield する。"""
buf = ""
for piece in stream:
buf += piece
while True:
idx = next((i for i, ch in enumerate(buf) if ch in _ENDINGS), -1)
if idx < 0:
break
sent, buf = buf[:idx+1].strip(), buf[idx+1:]
if sent:
yield sent
if buf.strip():
yield buf.strip()
あとは、この「1文ずつ」を受けて、合成は別スレッド、再生はキューで順番に——という係を用意します。こうすると、1文目を喋っている間に2文目の生成と合成が裏で進み、音がとぎれません。
play_q = queue.Queue() # 合成済みWAVを順番に渡す再生キュー
def player(): # 別スレッドで、キューに来た順に再生
while True:
item = play_q.get()
if item is None: break
tts.play(item)
# LLMを逐次生成 → 文ごとに合成してキューへ
for i, sent in enumerate(split_sentences(llm.stream(messages))):
out = tts.synthesize(sent, f"output/stream_{i}.wav", speaker=speaker)
play_q.put(out) # ← 1文目を積んだ瞬間に、再生が始まる
play_q.put(None)
結果:最初の音までが13.5秒→2.7秒
同じ返答(4文)で、バッチと逐次を比べた実測です。ユーザが感じる「間」=最初の音が出るまでの秒数で見ると、差は歴然でした。
| バッチ(従来) | 逐次(改善) | |
|---|---|---|
| 最初の音まで(間) | 約 13.5秒 | 約 2.7秒 |
| 全部を話し終わるまで | 約 35.9秒 | 約 31.3秒 |
全部を話し終わる時間はそこまで変わりません(音声そのものの長さは同じなので当然です)。でも、体感を決めるのは「最初の音までの間」。ここが13.5秒→2.7秒になっただけで、会話のテンポが別物になりました。しかも、この逐次パイプラインはエージェント本体の読み上げにも組み込んだので、普段の音声返答がそのまま速くなっています😊
入力側(VAD・文字起こし)はどうか
今回は返答側(生成+合成)が主犯でしたが、入力側にも縮める余地はあります。ひとつが VAD(Voice Activity Detection=発話区間の検出)。今回は「話す」ボタンで手動停止しましたが、VADを入れると「無音になったら自動で録音終了」ができ、ボタンを押す手間とタイムラグが消えます。もうひとつは文字起こしのストリーミング化ですが、今回はWhisperが1.2秒と十分速かったので、まずは効果の大きい返答側から手を付けた、というのが正直なところです。
「どこがボトルネックか」を先に測ったからこそ、効く場所から直せたわけですね。測らずにVADから作っていたら、13.5秒はほとんど縮まなかったはずです。
ハマったところ
やってみて分かった、逐次ならではの落とし穴です。
- 言い切る前に区切ってしまう:句点だけで切ると、「えっと、」のような短すぎる断片までTTSに送られ、逆にリズムが悪くなります。ある程度の長さで束ねるか、読点は区切りに含めない、といった調整が要りました。
- 音の途切れ:2文目の合成が1文目の再生に間に合わないと、無音がはさまります。合成を別スレッドで先回りさせ、再生はキューで順番を守るのが肝心でした。
- 記号・URLの読み上げ:逐次でも、記号や長いURLはそのまま読ませると不自然。読み上げ用にテキストを整える処理は、逐次でも残しています。
WindowsでもMacと同じ?
このアシスタントはMac/Windows共通コードで動かしています。今回足した「逐次生成→文ごとに合成→順番に再生」の仕組みも、特別なOS依存の処理は入れていません。LLMのストリーミング(llama.cpp)も、VOICEVOXのHTTP呼び出しも、再生(sounddevice)も、どちらのOSでも同じAPIです。実際にWindows(RTX 3060・CUDA)でも、同じコードを一切変えずに実測しました。結論は「実装も挙動も同じ。数値だけ違う」——そして、その数値の違いがちょっと面白かったです😊
| 最初の音まで(間) | Mac(Metal) | Windows(CUDA / RTX 3060) |
|---|---|---|
| バッチ(従来) | 約 13.5秒 | 約 6.9秒 |
| 逐次(改善) | 約 2.7秒 | 約 1.6秒 |
| 短縮率 | 約80% | 約77% |
目を引くのは、Windows(CUDA)の方が全体に速いこと。返事の全文生成が、Macの約7.8秒に対してWindowsでは約2.5秒(およそ3倍速)でした。GPUでの生成(CUDA)が効いています。それでも逐次化の効果は両方でしっかり出て、最初の音までの「間」は Mac 80%・Windows 77% とほぼ同じ割合で縮みました。
面白いのは、もともと速い環境ほど、逐次化で"隠せる間"の絶対量は小さくなるという点。Windowsはバッチでも6.9秒とすでに短いので、削れる秒数(約5.3秒)はMac(約10.8秒)より小さい。でも割合で見れば同じくらい効く——「速いマシンでも逐次化は無駄にならない」というのが実測での結論でした。
Windowsで代表値を出すときの注意:ローカルLLMはモデルがVRAM(GPUメモリ)に載りきっているかで生成速度が激変します。今回の2.5秒はMinistral 3がVRAMに収まっている状態での値。ほかのアプリでVRAMが埋まって載りきらないと、生成が数十秒級まで落ち込むことがあります(システムメモリへの退避=スピル)。速さを測るときは、VRAMを空けてモデルを完全にGPUへ載せるのが前提です。
まとめ
ローカル音声対話の「間」を、実際に測って削ってみました。
- 遅延を分解したら、入力(文字起こし1.2秒)は速く、返答側(生成7.8秒+一括合成4.5秒)が主犯だった
- 直し方はバッチ→逐次:LLMをストリーミングで受け、文ができた端から音声合成・再生する
- 結果、最初の音までの「間」が実測13.5秒→2.7秒(約80%短縮)
- 全体の再生時間は大きく変わらないが、体感を決めるのは"最初の音まで"
- 「どこが遅いか」を先に測ると、効く場所から直せる
- Windows(CUDA)でも同じコードで動作。生成はMacの約3倍速く、逐次化の短縮率は同等(約77%)
同じモデル・同じ音声エンジンでも、渡し方を変えるだけで会話のテンポがここまで変わるのは、やってみて一番の驚きでした。速さは正義、とまでは言いませんが、「間」が短いと、AIと話すのが少し楽しくなります😊
それでは、今回はここまで。最後までありがとうございました😊
【補足】計測環境=Mac(Apple Silicon・llama.cpp Metal)/Windows(RTX 3060・llama.cpp CUDA)/文字起こし=faster-whisper small・CPU int8/LLM=Ministral 3 14B(ローカル)/音声合成=VOICEVOX ENGINE(ずんだもん)。数値は返答の長さ・話者・マシン・VRAMの空きで変わる実測の一例です。