#音声合成#TTS#機械学習#データセット

品質ゲートを厳しくするほど、棒読みが生き残る — 検証が生む選択バイアス


感情表現のできる音声合成モデルを作るために、学習コーパスを自動生成していた。喜び・悲しみ・怒り・恐れなど12種類の感情それぞれについて、感情の乗った音声を数本ずつ用意する。品質チェックも当然入れる。生成した音声をWhisperで書き起こして台本と突き合わせ、ちゃんと読めているものだけを採用する。

できあがったモデルは、見事に棒読みだった。

原因はその品質チェックそのものだった。

感情スタイルを切り替えても同じ声に聞こえる

このモデルは感情ごとにスタイルを持つ。喜びスタイル、悲しみスタイル、と切り替えて合成できる。ところが切り替えても聞いた印象がほとんど変わらない。

数値で見ると明白だった。各感情スタイルで合成した音声を、中立スタイルとのコサイン類似度で測る。感情が乗っていれば中立から離れる、つまり値が小さくなるはずだ。

一括生成レシピのコーパス:  cos 0.77〜0.94
別系統で作った良い個体:    cos 0.164

0.9 近いというのは、喜びスタイルで合成しても中立とほぼ同じ音になっているという意味だ。感情スタイルという機能が実質的に死んでいた。

正直なところ、耳で聴いた段階では「まあこんなものか」と流しかけていた。数値が0.9と出て初めて、これは異常だと確信できた。

原因は3つあった

うち2つは設定の問題で、3つ目が本題になる。

話者CFGの設定で感嘆詞が別人になる

「わあ!」「えっ!」のような感嘆詞から始まる台本で、冒頭だけ別人の声になる現象があった。参照音声への忠実度を制御するパラメータが、感情表現に振ったときに話者性を保てなくなっていた。

絵文字が音になる

台本に感情マーカーとして絵文字を入れていたら、それが読まれたり謎の効果音を誘発したりしていた。マーカーはテキストの外に置くべきだった。

Whisper検証だけで選ぶと、平坦なテイクほど合格する

これが本命だった。

検証は感情が乗った音声を落としやすい

複数の候補テイクを生成してWhisper検証に通ったものを採用する。このとき何が起きるか。

感情が強く乗った音声は、声が震え(恐れ)、語尾が伸びたり跳ねたり(喜び)、音量が歪み(怒り)、語尾が消え入る(悲しみ)。いずれもWhisperにとっては認識が難しくなる方向の変化だ。結果として書き起こしが台本からずれ、不合格になる。

一方、感情が乗っていない棒読みのテイクは明瞭で認識しやすい。すんなり合格する。

つまり「台本どおり読めているか」で選抜すると、感情が薄いテイクが優先的に生き残る。ゲートを厳しくするほどこの偏りは強くなる。ゲートが優秀に機能するほど、コーパスは平坦になっていく。

目的(感情豊かな声を学習させる)と手段(台本忠実度で選ぶ)が真っ向から対立していた。しかも各パーツは正常に動いているので、どこにもエラーが出ない。

足切りと順位付けを分ける

解決策は、選抜を2段階にすることだった。

# 変更前: 合格したものを先着で採る
for seed in seeds:
    wav = gen(text, ref, seed)
    if judge(text, whisper(wav)).ok:
        return wav                      # ← 最初に受かった=一番平坦なテイク

# 変更後: 合格者を全部集めてから、最も感情が乗ったものを選ぶ
candidates = []
for seed in seeds:
    wav = gen(text, ref, seed)
    if judge(text, whisper(wav)).ok:            # ① 足切り
        candidates.append((wav, style_distance(wav, neutral_ref)))
if not candidates:
    return None
return max(candidates, key=lambda c: c[1])[0]   # ② 中立から最も遠いもの

①は品質の足切りであって、順位付けには使わない。順位は②で決める。

スタイル距離の測り方

「感情が乗っているか」は、中立で合成した同一話者の音声とのスタイル埋め込み距離で測る。使っているTTSは音声からスタイルベクトルを抽出できるので、それを利用する。

def style_distance(wav, neutral_wav):
    a = extract_style_vector(wav)          # TTS側の埋め込み抽出APIを使う
    b = extract_style_vector(neutral_wav)
    cos = np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
    return 1.0 - cos                       # 遠いほど大きい

埋め込み抽出が使えない環境なら、F0の中央値・レンジ(半音)・RMSの分散あたりの組み合わせでも代用は効く。中立テイクを基準に、そこからの逸脱量を測るという考え方は同じだ。

重要なのは絶対値ではなく中立との相対距離を見ること。「喜びスタイルは明るいはず」といった絶対的な基準を作ると話者ごとに閾値を調整する羽目になるが、相対距離なら話者に依存しない。

閾値は単純な足切りにしない

判定は2条件のORにした。

def accept(text, transcript):
    v    = judge_transcript(text, transcript)
    tail = trailing_elongation_mismatch(text, transcript)   # 台本にない語尾伸び
    return (v.ratio >= 0.82 and tail <= 2) or (v.ratio >= 0.70 and tail == 0)

一致率が低めでも語尾が崩れていなければ通す。感情表現による多少の認識低下は許容しつつ、「台本にない伸ばし」だけは厳しく見る。

後者を緩められない理由は明確で、モデルが「書いてなくても語尾を伸ばす」癖を覚えるからだ。実際、初期のモデルは「こんにちは」を「こんにちわー」と伸ばすようになっていて、原因はコーパスに語尾の伸びたクリップが混ざっていたことだった(AIが「こんにちわー」と伸ばす癖は、どこで身についたのか)。

⚠️ この語尾チェックはWhisperの生の書き起こしで判定する必要がある。かな正規化は長音記号を落とすので、正規化後の文字列を比較しても伸びを検出できない。一致率の判定と語尾の判定で、入力する文字列が違う。

感情アンカー方式

もう一段の工夫が要った。感情ごとにアンカーを先に作る方式だ。

1. 感情ごとに、台本外の「感情マーカーが強い文」を複数seedで生成
2. 合格者の中でスタイル距離が最遠のものを選抜 → アンカーとして登録
3. コーパス本文は、そのアンカーを参照音声にして生成
4. 本文の選別も、合格者の中でスタイル距離最遠を採る

コードにするとこうなる。

# 段階1: 感情ごとのアンカーを確定させる
ANCHOR_TEXTS = {
    "joy":     "やった、ついにできましたね!本当に、本当に嬉しいです!",
    "fear":    "怖い、怖いです。どうしよう。",
    "sadness": "もう、どうにもならないんです……。",
    ...
}
anchors = {}
for emo, text in ANCHOR_TEXTS.items():
    cands = []
    for seed in ANCHOR_SEEDS:                       # 複数seedで振る
        wav = gen(text, base_ref, seed)             # 話者は長尺参照で担保
        if accept(text, whisper(wav)):
            cands.append((wav, style_distance(wav, neutral)))
    anchors[emo] = max(cands, key=lambda c: c[1])[0]   # 最も感情が乗ったもの
    register_voice(f"anchor_{emo}", anchors[emo])

# 段階2: 本文はアンカーを参照に生成する
for emo, lines in CORPUS.items():
    for line in lines:
        cands = [w for w in (gen(line, anchors[emo], s) for s in SEEDS)
                 if accept(line, whisper(w))]
        clip = max(cands, key=lambda w: style_distance(w, neutral))
        save(clip, line, group=emo)

要は「この声で、この感情のとき、こう喋る」という見本を先に1つ確定させてから量産する。一括生成が失敗したのは、感情表現と話者性を同時に一発で出そうとしていたからだった。アンカーを挟むと、感情は参照の韻律から継承され、話者性はアンカー自身が担保する。

アンカー文を台本の外から選ぶのもポイントで、コーパスに入れる文をそのままアンカーにすると、その文だけ極端に感情が乗った不均衡なデータセットになる。

アンカー方式:  cos 0.45〜0.61
一括生成:      cos 0.8前後

ハマったところ

震え系の感情はアンカーの作り方が違う。 恐れのように声が震える感情では、参照への忠実度を高めに設定しないと前半と後半で別人になった。感情の種類ごとに設定を変える必要がある。忠実度を上げると感情表現は弱まるので、感情ごとに「話者寄り/表現寄り」のどちらに振るかを決めることになる。

子音のどもりを台本に書くと不自然になる。 「だ、誰ですか」「や、やだ」のような表記はTTSが不自然にレンダリングする。「怖い、怖いです」のように単語の繰り返しに書き換えると自然に出た。人間の演技指導とは逆の発想が要る。

参照音声には長さの上限がある。 55秒までは通り、110秒でGPUのアサーションエラー。しかも逐次生成でメモリが断片化するので、小さいGPUスライスでは55秒でも落ちた。長尺の参照を使うなら、メモリアロケータの設定(PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True)を入れた上で、余裕のあるGPUスライスを確保しておく。

フィルタとランキングを同じ指標でやると偏る

この失敗の型は音声合成に限らないと思う。

  • コード生成で「テストが通るか」だけで選ぶと、単純な実装が生き残る
  • 要約で「原文との一致率」で選ぶと、抜粋のような要約が生き残る
  • 画像生成で「プロンプト忠実度」で選ぶと、平凡な構図が生き残る

いずれも品質チェックとしては正しく、良いものを選ぶ基準としては間違っている。チェックは足切りに留め、順位は目的に直結する別の指標で決めるべきだった。

そして厄介なのは、この偏りがエラーを出さないことだ。全部のパーツが正常に動いて、正常に平坦なデータセットができあがる。気づくには完成品を確認して「なんか違う」と思い、そこから遡るしかなかった。

自動化されたパイプラインほど最後に現物を確認する工程が要る、という平凡な結論になる。ただし今回は現物を聴いても流しかけたので、感覚だけでは足りなかった。数値で「中立との類似度0.9」と出たことが決め手になっている。感覚と数値の両方が要るというのが、実際に踏んでみての実感だ。


シリーズ: 拡散TTSから実用ボイスを量産する

キャプション1行から声を設計し、学習コーパスを製造して、役割別の実用ボイスを量産するまでの記録です。この記事は第2部 製造にあたります。

← 前: 「ナレーターっぽい声」を24候補から機械に選ばせる → 次: 学習後に話速は変えられない

シリーズ全18本
  1. 音質で選んだTTSが、会話には遅すぎた
  2. 声をガチャで引く
  3. 「ナレーターっぽい声」を24候補から機械に選ばせる 4. 品質ゲートを厳しくするほど、棒読みが生き残る ← いまここ
  4. 学習後に話速は変えられない
  5. 生成するたび「録音場所」が変わるTTS
  6. クリップ1本の粗さが、スタイル全体を嗄れさせる
  7. AIが「こんにちわー」と伸ばす癖は、どこで身についたのか
  8. 「少々」が「しょも」になる — 許可文字リストが日本語を削っていた
  9. ハルシネーション対策のコードが、ハルシネーションの時だけ動かなかった
  10. 品質ゲートが許した「3文字」が、モデルの口癖になった
  11. 治せる欠陥で候補を落としていた
  12. 文字起こしでは見つからない欠陥がある
  13. 70分かけた学習素材が、ネットワークの一瞬の瞬きで消えた
  14. “ja” を “JP” と書いて喃語モデルができるまで
  15. 登録経路が4つ、管理画面が0
  16. デプロイのたびに互いの成果を消していた
  17. 測れていないものを閾値で追い込むと、必ず失敗する

知見の元になったノートは 拡散TTSから実用ボイスを量産する製造パイプライン にまとめてあります。

← 記事一覧へ