VRM アバターと話せるデスクトップアプリを作っていて、
「話しかけてから声が返るまでが長い」と言われた。
測って直したのだが、測定環境が汚れていたせいで数字が 4 倍ずれ、
そこから導いた設計判断まで間違えた。 気づいて測り直したので、
正しい結論と、間違え方の両方を残す。
結論を先に書くと、文の分割位置は実時間比から一意に決まる。
実時間比という指標
TTS の速さは「何秒かかったか」では比べられない。長い文ほど時間がかかるのは
当たり前だからだ。見るべきは生成された音声の長さに対する比。
生成された wav の長さは、ヘッダから取れる。
import io, wave
with wave.open(io.BytesIO(audio)) as w:
seconds = w.getnframes() / w.getframerate()
rtf = synth_time / seconds # 実時間比 (Real-Time Factor)
- 実時間比 1.0 … 10 秒喋る音を作るのに 10 秒かかる
- 実時間比 0.25 … 10 秒喋る音を 2.5 秒で作れる
この 1 つの数字から、以降の設計判断が全部導ける。
測ってみる
VOICEVOX 0.25.2 / Apple M4 / 10 コア。
| 文字数 | audio_query | 合成 | 音声長 | 実時間比 |
|---|---|---|---|---|
| 3 | 0.004s | 0.38s | 0.60s | 0.64x |
| 7 | 0.011s | 0.59s | 1.44s | 0.41x |
| 21 | 0.002s | 1.16s | 4.36s | 0.26x |
| 40 | 0.004s | 1.74s | 7.71s | 0.23x |
audio_query は無視していい。効くのは synthesis の方。
注目すべきは、長い文ほど実時間比が良くなること。短い文には固定費
(モデルの準備、前後の無音)が乗るため、3 文字の 0.64x が最悪値になる。
細切れにしすぎると損をする、という指標がここに出ている。
測る前にウォームアップを 1 回入れること。初回はモデルの読み込みを含むので、
実時間比が跳ね上がって使い物にならない値が出る。
分割位置は計算で出る
返事を全部作ってから鳴らすと、その間ずっと黙る。だから文で区切って、
最初の 1 文ができた時点で鳴らしはじめる。
ここで問題になるのが、一言目をどこで割るか。早く割れば早く鳴り出すが、
割りすぎると前半を鳴らし終えても後半ができておらず、途中で黙る。
これは計算で解ける。実時間比を r、全体の音声長を D、
前半の割合を p とすると:
- 前半の合成が終わるのが
r·p·D - 前半を鳴らし終えるのが
r·p·D + p·D - 後半の合成が終わるのが
r·D(前半・後半と続けて作るので)
途切れない条件は「後半ができる ≤ 前半を鳴らし終える」なので:
r·D ≤ r·p·D + p·D
→ p ≥ r / (1 + r)
これが分割位置の下限になる。
| 実時間比 | 前半に必要な割合 |
|---|---|
| 1.0 | 50%(真ん中) |
| 0.5 | 33% |
| 0.25 | 20% |
| 0.1 | 9% |
実測の 0.25 前後なら、全体の 2 割を超えていればどこで割っても途切れない。
「うん、」のような早い読点で割っても成立する。
逆に実時間比が 1.0 の環境(非力なマシン、重いモデル)では、
真ん中まで待たないと破綻する。同じコードが環境によって壊れるということ。
// 実測は 0.23〜0.64。短い文ほど固定費が乗って悪くなるので、安全側に倒す
export function splitAtComma(text: string, rtf = 0.5): [string, string] {
const positions: number[] = [];
for (let i = 0; i < text.length; i++) {
if (text[i] === '、' || text[i] === ',') positions.push(i);
}
if (positions.length === 0) return [text, ''];
// 途切れない下限。実時間比から決まる
const minRatio = rtf / (1 + rtf);
// 条件を満たす中で最も早い読点=一番早く鳴りはじめる
for (const pos of positions) {
const ratio = (pos + 1) / text.length;
if (ratio >= minRatio && ratio <= 0.7) {
return [text.slice(0, pos + 1), text.slice(pos + 1)];
}
}
return [text, ''];
}
割るのは一言目だけでいい。二言目以降は前を鳴らしている間に作れる。
スレッド数は測ってから決める
VOICEVOX ENGINE には --cpu_num_threads がある。別ポートで立てて比較した。
| 文字数 | 既定 | 8 スレッド |
|---|---|---|
| 3 | 0.64x | 0.51x |
| 7 | 0.41x | 0.31x |
| 21 | 0.26x | 0.22x |
| 40 | 0.23x | 0.20x |
全域で 2 割ほど速い。ただしこれは空いているマシンでの話で、
後述するとおり負荷が高い状態だと逆転する。自分の環境で測ること。
再生側に順番待ちが要る
ここで一度壊した。分割して順に送ったら、後から届いた音が前を打ち切った。
送る側が非同期なので、2 つ目の合成が終わった時点で 1 つ目がまだ鳴っている。
const speechQueue: Array<{ audio: string; mimeType: string }> = [];
let speaking = false;
function playSpeech(data) {
speechQueue.push(data);
if (!speaking) void playNextSpeech();
}
async function playNextSpeech() {
const data = speechQueue.shift();
if (!data) { speaking = false; detachLipSync(); return; }
speaking = true;
const element = new Audio(url);
// ended と error が両方鳴ることがある。二度進めると 1 文飛ばす
let advanced = false;
const next = () => { if (advanced) return; advanced = true; void playNextSpeech(); };
element.addEventListener('ended', next, { once: true });
element.addEventListener('error', next, { once: true });
await element.play();
}
ended と error が両方発火するケースがあり、ガードが要る。
口を閉じる処理はキューが空になったときだけ呼ぶ。文の切れ目ごとに
閉じるとぎこちない。
LLM 側も少しずつ受け取る
合成側を詰めたら、残るのは「LLM が返事を書き終えるまで」。
全文を待たず、言い切った文が出た時点で合成に回す。
const SENTENCE_END = /[。!?!?]/;
export function takeCompleteSentences(buffer: string) {
const sentences: string[] = [];
let rest = buffer;
for (;;) {
const match = SENTENCE_END.exec(rest);
if (!match) break;
const end = match.index + match[0].length;
const sentence = rest.slice(0, end).trim();
if (sentence) sentences.push(sentence);
rest = rest.slice(end);
}
return { sentences, rest };
}
ストリーミングで壊れる 2 つ
1. ツール呼び出しが断片で届く
tool_calls は index ごとに継ぎ足されてくる。名前も引数も 1 文字ずつ
分かれることがあるので、上書きではなく連結する。
export function mergeToolCallDeltas(target, deltas) {
for (const delta of deltas) {
const index = delta.index ?? 0;
const slot = (target[index] ??= { id: '', type: 'function', function: { name: '', arguments: '' } });
if (delta.id) slot.id = delta.id;
if (delta.function?.name) slot.function.name += delta.function.name;
if (delta.function?.arguments) slot.function.arguments += delta.function.arguments;
}
}
2. usage が返ってこない
既定では使用量が来ず、料金表示が 0 のままになる。
stream_options: { include_usage: true } で最後の断片に入る。
ただし対応していない接続先(ローカルモデルなど)があるので、
料金のかかる相手にだけ付けている。
また、ツールを呼ぶ番の途中経過は読み上げない。本当の返事は道具を使った後に来る。
本題:測定環境を汚したまま設計すると、設計ごと間違える
ここからが一番伝えたいこと。
最初に測ったとき、実時間比は 1.09 倍だった。 上の表の 4 倍以上遅い。
その数字を信じて「ローカル TTS は実時間の 1 倍かかるものだ」と結論し、
p ≥ r/(1+r) に r = 1 を入れた 「真ん中で割る」を実装した。
スレッド数を増やしたときも「既定 1.09x → 8 スレッド 1.47x」と出て、
「並列化の余地がない」と結論した。
両方とも間違いだった。 測定時のマシンの状態がこうだった。
| 最初の測定 | 測り直し | |
|---|---|---|
| ロードアベレージ | 99〜214 | 5 |
| ディスク空き | 833MB(100% 使用) | 38GB |
| 実時間比 | 1.09x | 0.23〜0.64x |
| 8 スレッド | 1.47x(悪化) | 0.20〜0.51x(改善) |
ディスクが満杯でスワップが効かず、ロードアベレージが 3 桁の状態で測っていた。
その数字自体は「そのとき実際にかかった時間」なので嘘ではない。
だから疑う理由に気づけなかった。
厄介なのは、間違った数字から導いた**「真ん中で割る」が、それなりに動いてしまった**こと。
実際の下限は 20% なので、50% で割るのは条件を満たしている。過剰に保守的なだけで、
壊れない。 バグとして表面化しないから、測り直すまで気づけない。
得た教訓:
-
性能を測る前に、マシンが空いていることを確認する。
uptimeのロードアベレージとdfの空き容量。30 秒で済む -
絶対時間ではなく比を見る。 「4.7 秒かかった」は環境の話だが、
「実時間比 1.09」は異常だと気づける形をしている -
定数ではなく式で持つ。 「真ん中で割る」ではなく
p ≥ r/(1+r)で
書いておけば、実時間比を測り直すだけで正しい位置に直る
3 つ目が本質だと思う。最初から式で書いていれば、間違った測定値を入れても
測り直した瞬間に全部直っていた。
おまけ:測定中に前の音が鳴っていた
もう一つ、測り方でしくじった話。
lipSyncAttached のようなフラグを見て「声が出たか」を測っていたら 0.1 秒と出た。
速くなったのではなく、前の返事の音声がまだ鳴っていただけ。
while state["lipSyncAttached"]: # 無音になるまで待つ
time.sleep(0.1)
連続で測るときは特に。
まとめ
-
測る前にマシンが空いていることを確認する(
uptimeとdf) - 絶対時間ではなく実時間比で見る
- 分割位置は
p ≥ r/(1+r)で決まる。実時間比 1.0 なら真ん中、0.25 なら 2 割 - 定数ではなく式で持つ。 前提が変わったとき、式なら勝手に追随する
- 受け側に順番待ちを入れる。
endedとerrorの二重発火に注意 - ストリーミングにすると
tool_callsの結合とusageが壊れる
この計測は、VRM アバターと話せるデスクトップアプリを作る過程で取ったものです。
p ≥ r / (1 + r) は実際にそのまま実装に入っています。