生成するたび「録音場所」が変わるTTS — 素材の音響を揃えないとスタイルが飛ぶ
音声モデルの学習コーパスを、別のTTSで生成している。同じ話者設定、同じモデル、同じサーバ。それでもクリップごとに音の質感が違う。
具体的には、感情スタイルを切り替えて合成したときに気づいた。喜びスタイルから悲しみスタイルに切り替えると、声色だけでなく音質そのものが変わる。片方は近くで録ったように聞こえ、もう片方はやや遠い。同じ話者が別の部屋で喋っているような違和感がある。
原因は学習素材だった。コーパスのクリップが、生成条件ごとに違う周波数特性を持っていた。
なぜ質感が変わるのか
このコーパスは、感情ごとに違う参照音声(アンカー)を使って生成している(品質ゲートを厳しくするほど、棒読みが生き残る)。
joy の本文 → joy用アンカーを参照に生成
sadness の本文 → sadness用アンカーを参照に生成
anger の本文 → anger用アンカーを参照に生成
参照音声が違えば、TTSはその参照の声色だけでなく音響特性も真似る。アンカー自体が別々に生成されたものなので、それぞれ微妙に違うスペクトル形状を持っている。結果、感情グループごとに音の質感がずれる。
さらに極端演技(絶叫、爆笑など)の素材は、そもそも音圧のレンジが違う。並べて学習させると、モデルは「このスタイルのときは音が遠い」まで一緒に覚えてしまう。
人間の録音なら同じマイク・同じ部屋で録るので起きない問題が、生成音声では起きる。TTSは録音環境という概念を持たないので、条件が変わればチャンネル特性も変わる。
LTASを揃える
対処は、学習前に全クリップを共通の周波数特性へ寄せることだった。
LTAS(Long-Term Average Spectrum、長時間平均スペクトル)は、音声全体を平均した周波数分布のこと。話者性や部屋の特性を反映する。これを基準に合わせる。
手順はこうなる。
- コーパス全体、あるいは基準となるクリップ群からLTASを求めて基準スペクトルにする
- 各クリップのLTASを求め、基準との差分を計算する
- その差分を打ち消すEQを各クリップに適用する
- 最後にRMSを揃える
def match_ltas(wav, ref_ltas, max_gain_db=10.0):
"""クリップのLTASを基準へ寄せる(±max_gain_db でクリップ)"""
spec = stft(wav)
ltas = np.mean(np.abs(spec), axis=1) # 時間方向に平均
gain_db = 20 * np.log10((ref_ltas + eps) / (ltas + eps))
gain_db = np.clip(gain_db, -max_gain_db, max_gain_db) # 極端な補正を防ぐ
gain = 10 ** (gain_db / 20.0)
spec = spec * gain[:, None]
return istft(spec)
±10dBでクリップする理由
補正量に上限を置いた。無制限にすると、元のクリップに存在しない帯域を無理に持ち上げることになる。そこには信号がなくノイズしかないので、ノイズだけが増幅される。
特に高域は、生成音声だと8kHz以上がほとんど無いクリップがある。そこを基準に合わせようとすると、サーというノイズが乗る。±10dBに制限すれば、寄せきれなくても破綻はしない。
ゼロ位相で処理する
EQをかけるとき、位相を動かさないようにした。通常のフィルタは群遅延を持つので、帯域ごとに時間がずれる。人間の耳には分かりにくいが、学習素材としては望ましくない。
STFTの振幅だけを操作して位相をそのまま残すか、フィルタを前後両方向にかけて位相の回転を打ち消す(filtfilt相当)。今回は前者を採った。
最後にRMSを揃える
周波数特性を揃えたら、音量も揃える。
def normalize_rms(wav, target_dbfs=-20.0):
rms = np.sqrt(np.mean(wav ** 2))
gain = 10 ** (target_dbfs / 20.0) / (rms + 1e-9)
return np.clip(wav * gain, -1.0, 1.0)
−20dBFSに揃えた。ピークではなくRMSで揃えるのがポイントで、ピーク正規化だと1発の大きな音(絶叫の頭など)に引っ張られて、全体の音量がばらつく。
効果
感情スタイルを切り替えたときの音質の飛びが消えた。これが第一の目的だった。
そして予想外の副作用があった。極端演技の素材が学習可能になった。
正規化前は、絶叫のクリップを学習させると全体が荒れる傾向があった。音圧が飛び抜けていて、スペクトルも他と違うので、モデルがそれを「別の何か」として扱いにくかったのだと思う。
正規化後は、絶叫の時間構造(ピッチが急激に上下する掃引のような動き)が学習に乗るようになった。音圧とスペクトルの違いが取り除かれて、残った「動き」の情報がモデルに届いた、という理解をしている。
つまり正規化は「揃えて情報を捨てる」処理に見えて、捨てるべき情報(チャンネル特性)を捨てることで、残すべき情報(韻律の動き)を際立たせる働きをしていた。
どこまで揃えるか
やりすぎると別の問題が出る。
話者性まで消える危険がある。 LTASには話者の声道特性も含まれるので、強く揃えると声の個性が薄まる。今回は同一話者のコーパス内で揃えているので問題にならなかったが、複数話者を混ぜる場合は注意が要る。
感情の音響的特徴も一部は周波数に出る。 怒りは高域が強く、悲しみは弱い、といった傾向がある。完全に揃えるとこれが消える。±10dBの制限は、この意味でも効いていた。強い補正がかからないので、感情由来の差はある程度残る。
基準をどう決めるかも判断が要る。今回はコーパス全体の平均を使ったが、「一番良い状態のクリップ群」を基準にする選び方もある。平均だと、悪い素材が多いときに基準自体が引きずられる。
実装上の注意
処理は学習の直前に一括でやる。 生成時に1本ずつ正規化すると、基準スペクトルが決まる前に処理することになる。全部揃ってから、まとめて通す。
元のクリップは残す。 正規化はやり直しが効くようにしておく。パラメータ(上限dB、目標RMS)を変えて再学習したくなることがある。
正規化後の音を聴く。 数値上は揃っていても、聴くとノイズが乗っていたり不自然だったりすることがある。特に補正量が上限に張り付いたクリップは要確認だ。
# 補正量が上限に達したクリップを記録しておく
clipped = np.sum(np.abs(gain_db_raw) >= max_gain_db) / len(gain_db_raw)
if clipped > 0.3:
logger.warning(f"{clip_id}: 補正量の30%以上が上限に張り付いた(元素材が基準から遠い)")
これが多いクリップは、そもそも他と違いすぎる素材なので、揃えるより除外したほうがいいこともある。
まとめ
- 生成音声は「録音環境」を持たないので、条件が変わればチャンネル特性も変わる。人間の録音では起きない問題
- 感情ごとに参照を変えると、感情ごとに音質が変わる。切り替え時の音質の飛びとして現れる
- LTASマッチング+RMS正規化で揃える。補正は±10dB程度に制限し、位相は動かさない
- 揃えることで、残すべき情報が際立つ。極端演技の時間構造が学習に乗るようになった
- 揃えすぎない。話者性や感情由来の周波数差まで消える。上限を置くことが品質を守る
シリーズ: 拡散TTSから実用ボイスを量産する
キャプション1行から声を設計し、学習コーパスを製造して、役割別の実用ボイスを量産するまでの記録です。この記事は第2部 製造にあたります。
← 前: 学習後に話速は変えられない → 次: クリップ1本の粗さが、スタイル全体を嗄れさせる
シリーズ全18本
- 音質で選んだTTSが、会話には遅すぎた
- 声をガチャで引く
- 「ナレーターっぽい声」を24候補から機械に選ばせる
- 品質ゲートを厳しくするほど、棒読みが生き残る
- 学習後に話速は変えられない 6. 生成するたび「録音場所」が変わるTTS ← いまここ
- クリップ1本の粗さが、スタイル全体を嗄れさせる
- AIが「こんにちわー」と伸ばす癖は、どこで身についたのか
- 「少々」が「しょも」になる — 許可文字リストが日本語を削っていた
- ハルシネーション対策のコードが、ハルシネーションの時だけ動かなかった
- 品質ゲートが許した「3文字」が、モデルの口癖になった
- 治せる欠陥で候補を落としていた
- 文字起こしでは見つからない欠陥がある
- 70分かけた学習素材が、ネットワークの一瞬の瞬きで消えた
- “ja” を “JP” と書いて喃語モデルができるまで
- 登録経路が4つ、管理画面が0
- デプロイのたびに互いの成果を消していた
- 測れていないものを閾値で追い込むと、必ず失敗する
知見の元になったノートは 拡散TTSから実用ボイスを量産する製造パイプライン にまとめてあります。