0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

TTS の分割位置は実時間比から決まる(測定環境を汚したまま設計して痛い目を見た話)

0
Posted at

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();
}

endederror が両方発火するケースがあり、ガードが要る。
口を閉じる処理はキューが空になったときだけ呼ぶ。文の切れ目ごとに
閉じるとぎこちない。

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_callsindex ごとに継ぎ足されてくる。名前も引数も 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)

連続で測るときは特に。

まとめ

  • 測る前にマシンが空いていることを確認するuptimedf
  • 絶対時間ではなく実時間比で見る
  • 分割位置は p ≥ r/(1+r) で決まる。実時間比 1.0 なら真ん中、0.25 なら 2 割
  • 定数ではなく式で持つ。 前提が変わったとき、式なら勝手に追随する
  • 受け側に順番待ちを入れる。endederror の二重発火に注意
  • ストリーミングにすると tool_calls の結合と usage が壊れる

この計測は、VRM アバターと話せるデスクトップアプリを作る過程で取ったものです。
p ≥ r / (1 + r) は実際にそのまま実装に入っています。

Wisp — デスクトップに、体を持ったAI

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?