少し前に、声で話せる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秒。そりゃ気まずい間になるわけです😅

なぜ待たされる? バッチと逐次のちがい(図解)

カギは「バッチ処理」から「逐次処理」への切り替えです。バッチは全部そろってから次に渡すやり方。逐次はできたものから先に流すやり方です。料理でたとえると、バッチは「全品作り終えてから一度に配膳」、逐次は「できた皿から順に出す」。会話では、後者のほうが圧倒的に待たされ感が少ない。

最初の音が出るまでの「間」(横軸=時間) 0s 5s 10s 13.5s バッチ(従来) STT LLMで全文を生成(7.8s) 全文を合成(4.5s) 🔊 13.5s 逐次(改善) STT 1文目 🔊 2.7s 2文目以降の生成… …と合成が裏で重なって進む 逐次では「1文目の生成+1文目の合成」だけで喋り出せる。残りは再生の裏で進む
バッチは全部そろうまで無音。逐次は1文目ができた瞬間に喋り出す。この差が「間」になる

改善①:返答を"逐次"生成する

まず、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.5s 逐次 2.7s 同じ返答での実測の一例。約80%短縮(数値は返答の長さやマシンで変わります)
「全部を話し終わるまで」も少し縮むが、体感を決めるのは"最初の音までの間"。ここが13.5→2.7秒に

全部を話し終わる時間はそこまで変わりません(音声そのものの長さは同じなので当然です)。でも、体感を決めるのは「最初の音までの間」。ここが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の空きで変わる実測の一例です。