音声 AI エージェント — 聞いて、調べて、話すパイプライン
最初の音を早める — 文分割・重ねて合成・読み上げ用テキスト
目標
TTSのRTFと最初の音の遅延を測り、文・節単位に切ってLLMストリームと重ねて合成し、画面用の文を耳で聞く文に変えて、聞き取りやすくなるかを往復WERで確認します。
なぜ重要なのか
ユーザーが待っているのは、答え全体ではなく最初の音です。最初の音は、最初の断片の長さと合成速度で決まります。そして、TTSは書かれたとおりに読むため、画面では問題ない「14:30 [kb-hours]」が、耳には見当違いに聞こえます。採点ツールは、合成したWAVをサンプル単位で比較しません(VITSは毎回少し違う波形を出します)。長さ・RTF・イベントの順序の一貫性を見て、聞き取れるかどうかは、WAVをASRで書き起こし直して確認します。
ステップ
- 1文を合成して
/root/voice/tts/hello.wavに保存し、合成時間・長さ・RTFを/root/voice/tts/synth.jsonに書いてください。 - 略語と小数で壊れない
sentences(text)を/root/voice/tts/split.pyに作ってください。 - 3文のテキストを丸ごと合成するときと、最初の文だけを合成するときの時間を、
/root/voice/tts/first_audio.jsonに書いてください。 - LLMストリームを断片に切ってTTSと重ねて合成し、イベントの時刻を
/root/voice/tts/stream.jsonに、つなげた音を/root/voice/tts/stream_out.wavに保存してください。 - 引用表示・24時間表記の時刻・長い数字・金額・略語を読み上げ用テキストに変換する
speakable(text)を、/root/voice/tts/normalize.pyに作ってください。 - 3つの文を、原文のままと読み上げ用テキストに変換したものでそれぞれ合成し、ASRで書き起こして
/root/voice/tts/roundtrip.jsonに書いてください。 stream_out.wavを16 kHz s16le 20 msのフレームに変換して、/root/voice/tts/tts_stream.pcmと/root/voice/tts/tts_stream.jsonを作ってください。- 測定結果をまとめた
/root/voice/tts/report.jsonを作ってください。
参考
- モデル:
from voicekit.models import tts→t = tts()、a = t.generate(text, sid=0, speed=1.0)。音はa.samples(floatのリスト)、サンプルレートはa.sample_rate(22050)です。 - 最初の呼び出しは、準備時間が混ざって遅くなります。測る前に、短い文を1回合成しておいてください。
- リサンプリング:
voicekit.resample_linear(x, sr_in, sr_out)。ASRに入れるときは、先頭に0.3秒の無音を付けて、100 msの断片で入れてください。TTSの音は最初のサンプルから話し声なので、無音なしで入れると最初の単語を聞き逃します(実測)。 - よくある間違いは、モデルの読み込み時間を合成時間に含めること、ピリオドごとに切ること、22050 Hzの音を16000と書いて送ることです。
- ドキュメント: Piper voices(ljspeechモデルカード)・VITS(Kim et al., 2021)・LJ Speech・sherpa-onnx
1文を合成してRTFを測る
モデルを作って短い文で1回ウォームアップしたあと、「Your appointment is on Tuesday at ten in the morning.」を合成して/root/voice/tts/hello.wav(22050 Hzモノラル)に保存し、/root/voice/tts/synth.jsonにsample_rate・synth_ms(generateにかかった時間)・audio_s・rtf(synth_ms/1000/audio_s)を書いてください。
time.perf_counter()で、generateの呼び出しだけを囲みます。RTFが1より小さければ、話す速度より速く作れています。モデルの読み込み(1–2秒)を含めると、RTFが1を超えて見えます。
壊れない文の分割
/root/voice/tts/split.pyにsentences(text)を作ってください。空白を1つにまとめたあと、.!?の後ろに空白と大文字(または文章の終わり)が来たときだけ切りますが、「Dr.」・「Mr.」・「Mrs.」・「Ms.」・「St.」・「No.」の後ろでは切りません。断片をつなげると原文と同じになる必要があり、空の文章は空のリストです。採点ツールが隠しケースで試します。
re.finditer(r".!?", text)で候補を探し、候補の直前の単語が略語のリストにあれば飛ばします。「$3.50」は、ピリオドの後ろが数字なので候補になりません。
丸ごとと文単位の比較: 最初の音まで
「Please arrive fifteen minutes before your visit. Bring a photo ID and your insurance card. If you need to cancel, call at least one day before.」を丸ごと合成する時間(whole_ms)と、split.pyで分けた最初の文だけを合成する時間(chunked_ms)、文の数(sentences)を、/root/voice/tts/first_audio.jsonに書いてください。
丸ごとなら、すべてを作らないと最初の音を出せませんが、文単位なら最初の文だけを作れば済みます。どちらもウォームアップしたモデルで測ります。
LLMと重ねて合成する
voice-llm upのあと、「You are a clinic phone assistant. Answer in two or three short sentences.」と「How do I prepare for a fasting blood test?」をvoicekit.llm.chat_stream(msgs, max_tokens=80)で受け取りながら、文がたまるたびに前の断片を切って(文が終わっていれば文、そうでなければ5単語を超えるカンマの前の節、それもなければ12単語)、キューでTTSスレッドに渡してください。イベントllm_first_token・chunk(渡した断片、textを含む)・audio(合成が終わった断片、audio_sを含む)・llm_doneを1つの時計で/root/voice/tts/stream.jsonに書き、音を順番につなげて/root/voice/tts/stream_out.wav(22050 Hz)に保存してください。
TTSはthreading.Threadで別に動かし、queue.Queueで断片を受け取ります。最後にNoneを入れてスレッドを終わらせ、joinします。断片が2つ以上ないと重ね合わせが生じません。小さなモデルが1文で答えても、カンマ・単語数で切れば済みます。
画面用の文を耳で聞く文に
/root/voice/tts/normalize.pyにspeakable(text)を作ってください。引用表示[kb-…]を消し、「H:MM a.m./p.m.」と24時間表記の「HH:MM」を言葉に(14:30 → two thirty p m、09:00 → nine o'clock a m)、3桁以上の数字は1桁ずつに(911 → nine one one)、「$80」はeighty dollars、残りの1、2桁の数字は単語に、「Dr.」はDoctor、「a.m.」・「p.m.」は「a m」・「p m」に変換します。採点ツールが隠しケースで試します。
順序が重要です。時刻(9:30)を先に変換しないと、数字のルールが9と30を別々に読んでしまいます。0–99を言葉に変える小さな関数が1つあれば、時刻・金額・数字のすべてに使えます。
TTS → ASR往復で聞き取りやすさを測る
「Your visit is at 14:30 [kb-hours].」・「Call 911 right away.」・「A visit costs $80.」を、それぞれ原文のまま(raw)とspeakableで変換したもの(norm)で合成し、16 kHzに変換して先頭に0.3秒の無音を付け、/root/voice/tts/rt{i}_{kind}.wav(iは1から3)として保存したあと、ストリーミングASRに100 msずつ入れて書き起こしてください。/root/voice/tts/roundtrip.jsonに6行{"id", "kind", "tts_input", "heard", "meant"}(meantはspeakable(原文))を書いてください。
人の耳の代わりに、ASRを聞き手として使います。採点ツールは、WAVを書き起こし直して、meantとのWERをrawとnormで比較します。断片のサイズが違うと、ストリーミングの結果が少し変わることがあるため、100 msに合わせます。
伝送形式に変換する
stream_out.wav(22050 Hz)をvoicekit.resample_linearで16 kHzに変換して、s16leの20 ms(320サンプル)フレームでつないだ/root/voice/tts/tts_stream.pcm(最後のフレームは0で埋めます)と、format・sample_rate・channels・frame_ms・frame_bytes・framesを書いた/root/voice/tts/tts_stream.jsonを作ってください。
モジュール1の最後のステップと同じ形です。22050をそのまま16000として送ると、1.38倍遅くて低い音になります。
レポート
/root/voice/tts/report.jsonに、rtf(synth.json)、whole_ms・chunked_ms(first_audio.json)、stream.jsonのイベントごとの最初の時刻であるllm_first_token_ms・first_audio_ms(最初のaudio)・llm_done_msを書いてください。
イベントのリストを先頭から読みながら、名前ごとに最初に見た時刻だけを残します(dict.setdefault)。