【AI】TTS参照音声の密度で品質が決まる話【mean_volumeとzero-shot voice clone】


こんにちは、フリーランスエンジニアのmohです。この記事はほとんどAIが書いたものを、私が加筆修正しています。検証不十分な部分もあるかと思いますが、ご容赦ください。ご指摘等ございましたら、Github issueか、Xでお願いいたします。

前々回の記事で Fish Audio の zero-shot voice clone は毎回だいたい同じ声に着地する、という話を書きました。実運用に入れてしばらく回したところ、別の破綻モードが見つかりました。行単位で完全に別の話者になったり、日本語で頼んだのに中国語風の音節が出たりします。原因を掘ったら、reference audio の「発話密度」が主犯でした。ref の内容 (文言・話者・録音機材) より前に、単に「その 10 秒のうち何秒本当に喋っているか」で品質が決まっていた、という話です。

結論

  • Zero-shot voice clone の TTS は、ref audio の mean_volume (平均音圧、RMS) が低いと行単位でハルシネート・言語ドリフトする。「これが私のお気に入りのリンリンです」への完全置換や中国語音節への切り替えを実観測
  • 現行の「too_quiet ref を弾く」実装は max_volume ベースになっていることが多いが、max が -7dB 前後で通常値でも mean が -28dB のような ref は破綻する。max ベースのゲートは網羅性がなく、mean_volume で追加ゲートしないと素通りする
  • 同一 generation・同一エンジン・同一 API 呼び方で、mean -18dB の話者は 100% 正常、mean -28.6dB の話者だけが破綻。engine 側の確率的ゆらぎではなく ref に対する応答であることが話者境界で綺麗に分離した
  • 破綻した ref を silenceremove + loudnorm で密度だけ上げて同じパイプラインに再投入したところ、同じスクリプトが日本語で最後まで綺麗に生成された。ref を差し替えずに救える
  • 副作用として silenceremove は prosody (間の情報) を削るため話者らしさが微変する可能性はあるが、破綻するより軽微という判断
  • 統計は取っていない (n=1 の対照実験) ため、閾値の妥当値と副作用の程度は実データで詰める必要がある

症状の切り分け

観測されたのは 15.5 秒の ref を使った日本語 TTS の生成で、7 行のスクリプトのうち特定話者の 2 行が完全に破綻していました。他の話者 (同じ generation・同じ Fish 呼び方) の 2 行は 100% 正常。破綻の中身は 2 種類あって、

  1. 完全ハルシネート: 期待「掛け合いの検証です。準備はいいですか?」→ 実出力「これが私のお気に入りのリンリンです。」。日本語ではあるが元スクリプトと 1 文字も一致しない
  2. 言語ドリフト: 期待「話者が交代するたびに、音声は分割されます。」→ 実出力が中国語風の音節 (whisper の言語判定でも中国語判定)

両方とも同じ話者側 (avA) にだけ発生し、対の話者 (avB) は 4 回中 4 回正常。差は ref の音量プロファイルだけです。

話者ref 尺max_volumemean_volume破綻率
正常側15.5s-0.1dB-17.8dB0/2
破綻側15.5s-7.2dB-28.6dB2/2

max だけを見ると破綻側は -7.2dB で「too_quiet」の判定線 (-15dB) を余裕で通過します。max ベースのゲートを 1 段しか置いていないパイプラインだと、この ref はそのまま TTS に渡されます。

なぜ mean が効くのか

max_volume は全サンプルの中の 1 個のピーク値です。「15 秒の ref のうち 0.5 秒だけ大きく喋って、残り 14.5 秒は無音」でも max は満点になります。zero-shot voice clone がその 0.5 秒だけを頼りに声の特徴 (音色・韻律・言語同定) を抽出するのは無理があります。

mean_volume は全サンプルの二乗平均 (RMS) を dB にしたもので、その ref の中で「どれだけ実質的な音が鳴っているか」の平均値です。max と mean の差 = クレストファクターは、通常の会話音声で 15〜20dB に収まります。上の破綻側 (差 21dB) は境界だとしても、「max はあるけど mean が薄い」というシグナルとして機能します。

理屈の推察は、有効な発話が少ない ref = 特徴抽出のサンプル不足 → モデルが「この ref から何を保持すべきか」判定できない → 学習分布の中で見慣れた方向にドリフトする、というものです。中国語がよく混ざるのは Fish 側の学習データの重みの現れではないかと考えていますが、直接検証はしていません。

直し方

パイプライン側で ref を透明に加工します。ffmpeg 一発で済みます。

ffmpeg -i input.m4a \
  -af "silenceremove=start_periods=1:start_silence=0.2:start_threshold=-40dB:detection=peak:stop_periods=-1:stop_silence=0.3:stop_threshold=-40dB,loudnorm=I=-18:TP=-1.5:LRA=11" \
  output.m4a
  • silenceremove: 無音区間を短縮する。stop_silence=0.3 で「発話と発話の間の無音を最大 0.3 秒まで残す」= 会話ペースの自然さを保ちつつ長すぎる沈黙は詰める。stop_threshold=-40dB で背景ノイズは無音と扱う
  • loudnorm: EBU R128 準拠のラウドネス正規化。I=-18 は音声用途としてやや控えめの目標値 (broadcast 標準は -23、music は -14 あたり、-18 前後だと会話音声で自然)

上の破綻していた ref に対する実測:

尺meanmax
原本60.16s-27.3dB-4.7dB
編集後44.20s-17.8dB-1.5dB

編集後は正常側 (mean -18.0dB) と同等の密度に到達しました。この編集後 ref で同じ TTS engine・同じスクリプトを再生成したところ、破綻していた症状は再現せず、日本語がスクリプト通り最後まで安定しました。ref の内容も TTS の設定も 1 バイトも変えず、無音を詰めて音量を揃えただけでこの差です。

パイプラインに入れるときの設計

「密度が薄い ref はゲートで弾いてユーザーに差し戻す」路線と「透明に変換して救う」路線があります。どちらも v の cache キーを世代 bump して既存の薄い ref のキャッシュを失効させる必要があります。

  • ゲート型: 予測可能で副作用ゼロ。ただし「持ち込み素材でこれしかない」ユーザーが詰む
  • 変換型: UX コストなし。ただし silenceremove が prosody を削る副作用が乗るので、密度閾値未満のときだけ発火させて発火頻度を絞る

自分は変換型を先に入れて、変換後も破綻するケースだけ後日ゲートで弾く順序を採用しました。理由は、変換で救えるケースが多いなら UX を守る方が実務価値が高いのと、変換型は生成起票時に必ず走るのに対しゲート型は「アップロード後に素材を差し替えて生成」の抜けが出るからです。

閾値の初期値は -22dB あたりから始めるのが安全側です。上の破綻側 (-28.6dB) は当然弾け、微妙域の -20dB 前後は変換なしで通す設定になります。実データでの調整は必要です。

注意点

  • n=1 の対照実験なので、統計的裏取りはこれから。「他の言語ペア (日→英、日→中の意図的切替時) でも同じか」「他の zero-shot clone engine (ElevenLabs, CosyVoice 系, IndexTTS 系) でも同じか」は未検証
  • mean_volume は分かりやすい代理指標ですが、真の変数は「声質特徴の抽出に十分な発話時間」です。理想は VAD (voice activity detection) で実発話区間長を出すか、silencedetect で無音区間の総和を引いた「実発話秒数」で判定する方が精度が高いはずです。mean は「発話が薄い ref は音量平均も低くなる」相関を利用した近似指標
  • silenceremove は「文の区切りの間」まで含めて詰めるため、話者らしい呼吸間隔が壊れる可能性はあります。resemblyzer などで ref vs 出力の話者類似度をビフォーアフターで測ると、副作用の程度が数値化できます
  • max_volume の「too_quiet」ゲートも撤廃してはいけません。max が本当に小さい ref (収録レベル自体が低い) は依然として問題で、mean と max は独立した壊れ方をします

まとめ

Zero-shot voice clone の TTS を実運用に載せるとき、reference audio に対して max_volume だけをゲートするのは不十分です。mean_volume が薄い ref (無音が多い、実発話密度が低い ref) は max ベースのゲートを素通りしてモデルを静かに狂わせます。silenceremove + loudnorm で密度を上げると、同じ ref・同じ engine・同じスクリプトで破綻が消えるケースがあります。パイプライン側でこの前処理を挟むか、ゲートで弾くかは UX と副作用のバランスで選ぶ話です。

「ref の中身より、ref の無音の多さの方が壊す」という発見でした。