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

品質ゲートが許した「3文字」が、モデルの口癖になった


学習済みの音声モデルに短い文を読ませたら、こう返ってきた。

入力: つまり、こういうことです。
出力の書き起こし: 「つまり、こういうことです。ヴェー」

入力: 大丈夫ですよ、気にしないでください。
出力の書き起こし: 「大丈夫ですよ。気にしないでください。いや。」

語尾に短い付加音が付く。1〜2文字ぶんの、意味のない音だ。

原因はコーパスの品質ゲートにあった。通した幻覚のサイズと、モデルが再現した音のサイズが一致していた。

台本突合ゲートの許容値

コーパス生成では、TTSで読ませた音声をWhisperで書き起こし、台本と突き合わせて合格したクリップだけを保存している。判定はこれだ。

def judge_transcript(script_text, transcript,
                     min_ratio=0.40, max_inserted=3):
    """台本と書き起こしを突合する。挿入(台本外の音)>max_inserted は不合格。"""
    a = _kana(_collapse(script_text))
    b = _kana(_collapse(transcript))
    sm = difflib.SequenceMatcher(None, a, b)
    inserted = sum(j2 - j1 for op, _i1, _i2, j1, j2 in sm.get_opcodes()
                   if op == "insert")
    if inserted > max_inserted:
        return VerifyResult(False, sm.ratio(), inserted, "台本外の音の挿入")
    ...

max_inserted=3。台本に無い文字が3文字までなら通す。

この値を置いたときの理屈は覚えている。Whisperの書き起こしは完璧ではないので、多少のノイズは許容しないと歩留まりが落ちる、というものだった。実際、厳しくすると正常なクリップまで落ちる問題があった(表記ゆれの吸収をしていなかったので、これは正しい観測だった)。

ただ、「3文字なら実害がない」を確認していなかった

通った幻覚のサイズ

拡散TTSは短い文で末尾に余計な発話を付ける癖がある。大きいものはこうだ。

台本: 猫が窓辺で丸くなって眠っている。
転写: 猫が窓辺で丸くなって眠っている。たふにボンジュそのごう。   → 挿入11

挿入11なので、これは不合格になって除外される。ここは機能していた(ただし除去のためのトリム処理自体には別のバグがあった。ハルシネーション対策のコードが、ハルシネーションの時だけ動かなかった)。

問題は小さいものだ。

台本: 大丈夫ですよ、気にしないでください。
転写: 大丈夫ですよ。気にしないでください。いや。               → 挿入1

「いや。」は句読点を落とすと2文字、正規化後は1文字。合格して学習素材に入る

コーパスは1ボイスあたり約200本。そのうち何本かにこういう短い付加音が混ざり、モデルは「語尾にちょっと音を足す」パターンとして学習した。

学習後のモデルで再現する

12本のボイスを作った後、それぞれに短文を読ませて確認した。最初はWhisperの書き起こしだけを見ていて、こう判断していた。

女性プレゼンター: 6文中0件で付加音
男性プレゼンター: 6文中0件で付加音
女性MC:          6文中0件で付加音

「引き継がれていない」と結論しかけた。だがこれは測り方が甘かった。Whisperは短い付加音を落とす。無音を挟んだ0.1秒の発声は、書き起こしに出てこないことがある。

波形の包絡で測り直したら、まったく違う絵が出てきた(測り方は 文字起こしでは見つからない欠陥がある に詳しく書いた)。

ご覧ください。   本体0.88s → 無音0.48s → 【0.16sの発声】  STT: "ご覧くださいああ"
こちらです。     本体0.72s → 無音0.56s → 【0.28sの発声】  STT: 拾わず

0.5秒の完全無音を挟んだ0.16秒の発声。これは語尾の余韻ではありえない。台本を言い終わった後に、モデルが何かを発している。

12本を機械的に走査する

全ボイスでどれくらい出るのかを測った。判定は「本体の発声が終わったあと、0.25秒以上の無音を挟んで0.06秒以上の発声があるか」。

def segments(wav_bytes, thr_ratio=0.06):
    """有音ブロックの [(開始秒, 終了秒), ...] を返す"""
    w = wave.open(io.BytesIO(wav_bytes)); sr = w.getframerate()
    x = np.frombuffer(w.readframes(w.getnframes()), dtype=np.int16) / 32768
    W, H = int(sr * 0.020), int(sr * 0.010)          # 20ms窓 / 10msホップ
    rms = np.array([np.sqrt(np.mean(x[i*H:i*H+W]**2))
                    for i in range(max(0, (len(x)-W)//H))])
    act = rms > max(rms.max() * thr_ratio, 0.004)     # 相対+絶対の二重閾値
    segs, s = [], None
    for i, a in enumerate(act):
        if a and s is None: s = i
        elif not a and s is not None:
            if (i - s) * 0.010 >= 0.03: segs.append((s*0.010, i*0.010))
            s = None
    if s is not None: segs.append((s*0.010, len(act)*0.010))
    return segs

def has_trailing_artifact(wav, gap_min=0.25, tail_min=0.06):
    segs = segments(wav)
    if len(segs) < 2:
        return None
    gap  = segs[-1][0] - segs[-2][1]     # 最後のブロックの直前の無音
    tail = segs[-1][1] - segs[-1][0]     # 最後のブロックの長さ
    return (gap, tail) if gap >= gap_min and tail >= tail_min else None

閾値の二重化(rms.max() * 0.06 と絶対値 0.004 の大きい方)は、音圧の小さいボイスで無音判定が壊れるのを防ぐため。相対だけだと静かな声で全部が有音になり、絶対だけだと大きい声で息継ぎまで有音になる。

12モデル × 単文6文で走査した結果:

モデル 付加音
男性ナレーター 6/6
女性オペレーター 4/6
女性プレゼンター 4/6
男性プレゼンター 2/6
女性ナレーター / 女性カウンセラー / 男性カウンセラー 0/6
女性営業 / 男性営業 / 男性オペレーター 0/6
女性MC / 男性MC 0/6

12本中8本はクリーン、4本に付加音。汚染は全体には及ばず、ボイスによって大きく差が出た。

コーパス製造時の不合格数と突き合わせると相関があった。不合格が多かったボイス(=幻覚が頻発した条件)ほど、ゲートを通り抜けた小さな幻覚も多かったと考えられる。

閾値を絞った。効かなかった

製造側は挿入許容を 3→1 に絞った。同時に、表記ゆれ(促音、漢字・数字)を吸収する正規化を入れている。これをやらないと絞った瞬間に正常クリップが落ちる。保存済み75クリップで再判定し、許容1なら取りこぼしゼロを確認してから適用した。

「ヴェー」は挿入2なので弾かれる。混入余地は3分の1以下になる計算だ。

焼き直して測ったら、2/6 だった。 3/6 から1つ減っただけ。

もっと絞れば消えるはずだ、と考えて 1→0 にした。これは判断としては素直で、実クリップ203本で再判定して「基礎3本・会話3本が不合格になる」ことも事前に測っている。基礎の不合格が上限ちょうどだったので、上限を 3→6 に上げて併用した。

焼き直して測ったら、3/6 だった。 増えた。

設定 付加音
max_inserted=3 3/6
max_inserted=1 2/6
max_inserted=0 3/6

2/6 と 3/6 の差は測定のばらつきかもしれない。だが少なくとも「0にすれば消える」は成立しなかった。閾値を3回動かして、効果は誤差の範囲だった。

素材を波形で走査したら、全部わかった

ここでようやく、コーパスの素材そのものを見た。学習後のモデルに使った検出器を、198本のクリップに掛ける。

走査 198本
  無音0.25s+発声0.06s を検出: 5本
  うち台本に読点なし(=付加音の疑いが濃い): 4本

そして、その4本の判定値がこうだった。

base_64      無音1.96s+発声0.17s  挿入0   「気をつけて帰ってくださいね。」
conv_040     無音0.44s+発声0.27s  挿入0   「失礼いたします。」
conv_065     無音1.35s+発声0.16s  挿入0   「こちらで間違いございませんか。」
surprise_2   無音0.49s+発声1.09s  挿入0   「うそでしょう!?信じられません!」

4本とも挿入0。台本突合の観点では完璧なクリップだ。max_inserted を 3 にしようが 0 にしようが、この4本は必ず通る。

STTがこれらの音を文字として拾わないからだ。無音を挟んだ0.16〜1.09秒の音は、意味のある語として認識されず、書き起こしに現れない。書き起こしを見ている限り、これらのクリップは正常だった。

閾値をいくら絞っても効かなかったのは当然で、そもそも検出できていなかった。私は3回、見えていない対象に向かって閾値を動かしていた。

塞ぎ方: 軸を変える

対策は閾値ではなく、別の観測系を足すことだった。波形で見る検出器を、生成の合格判定に組み込む。

def trailing_artifact(wav_bytes, script_text,
                      gap_min=0.25, tail_min=0.06):
    """台本を言い終わった後の「付加音」を波形で検出する。

    ⚠️ whisper 突合では検出できない。実測でコーパス198本中4本に
    無音0.44〜1.96秒を挟んだ0.16〜1.09秒の発声があり、4本とも挿入0だった。
    ⚠️ 台本に読点があると読点のポーズを誤検出する。読点を含む台本では判定しない。
    """
    if "、" in (script_text or "") or "," in (script_text or ""):
        return None
    blocks = voiced_blocks(wav_bytes)
    if len(blocks) < 2:
        return None
    gap  = blocks[-1][0] - blocks[-2][1]
    tail = blocks[-1][1] - blocks[-1][0]
    return (gap, tail) if gap >= gap_min and tail >= tail_min else None

閾値 0.25 / 0.06 は、実測した付加音の分布から決めた(無音0.26〜1.96s、発声0.07〜1.09s)。切りのいい数字から始めていない。

生成ロジックには3箇所入れた。

# ① 合格判定の「前」に置く。判定を通っても、波形で見つかれば再抽選
art = verify.trailing_artifact(wav, text)
if art:
    logger.info(f"{line_key} steps={steps}: 付加音NG"
                f"(無音{art[0]:.2f}s+発声{art[1]:.2f}s・再抽選)")
    continue

# ② 最終手段のトリムは、波形の無音位置を優先する。
#    whisper のセグメント終端は、付加音を文字として拾えていないと使えない
end_sec = verify.artifact_free_end_sec(wav, text) or verify.script_end_sec(text, segs)

# ③ トリム後にも付加音が残っていないことを確認してから保存する
if res2.ok and verify.trailing_artifact(trimmed, text) is None:
    save(trimmed)

そして max_inserted1に戻した。0にする根拠が無くなったからだ。歩留まりだけ落として効果がない設定を残す理由はない。

結果

焼き直して、同じ6文で測った。

世代 対策 付加音
max_inserted=3 3/6
max_inserted=1 2/6
前回 max_inserted=0 3/6
波形ゲート+max_inserted=1 0/6

whisper一致 6/6、語尾伸び0、styles 12、音圧 −16.6〜−17.9dB。検収も全項目合格した。

波形ゲートが弾いたクリップは、事前の走査で特定していた4本と完全に一致した。

4回  base_64      「気をつけて帰ってくださいね。」
4回  conv_040     「失礼いたします。」
4回  conv_065     「こちらで間違いございませんか。」
4回  surprise_2   「うそでしょう!?信じられません!」

各4回は「全リトライで検出された」という意味だ。この拡散TTSはキャプションとseedが同じなら決定論的なので、何度生成しても同じ付加音が出る。偽陽性はゼロで、他の201本は素通しした。

不合格は2本だけだった(max_inserted=0 のときは7本)。許容を1に戻したぶん歩留まりも回復している。

走査で踏んだ罠

最初の走査では12本全部が引っかかった。プローブ文にこれを入れていたからだ。

では、始めます。
  本体 → 0.40sの無音 → 0.75sの発声   ← 「付加音」と判定される

読点のポーズだった。「では、」の後に間があって「始めます。」が続くだけで、最後のブロックは台本の一部だ。「最後のギャップの後ろに発声がある」という条件は、読点を含む文で必ず偽陽性になる。

対処は2つある。

プローブを単文に限定する。 読点を含まない文だけを使えば、本体の後の発声は付加音と断定できる。今回はこれを採った。

台本の終端位置と突き合わせる。 Whisperのセグメントから台本が終わる位置を求め、そこより後ろに音のエネルギーがあるかを見る。汎用的だがWhisperへの依存が増える。

偽陽性を除いた結果が上の表で、29/72件 → 16/72件まで減った。最初の結果をそのまま報告していたら「12本全滅」という誤った危機感を伝えるところだった。

閾値は、観測できている対象にしか効かない

この件の教訓は「閾値が甘かった」ではない。閾値は3回動かして効かなかった

max_inserted=3 は「3文字までのノイズを許す」という設定であり、同時に「3文字までの幻覚を学習させる」という設定でもあった。そこまでは正しく理解していた。だから絞った。

見落としていたのは、その閾値が数えている「挿入」が、STTの書き起こしに現れた文字だけだということだ。書き起こしに出ない音は挿入0として数えられる。閾値の分母に入っていない対象を、閾値で追い込もうとしていた。

これは今回に限った話ではないと思う。閾値を触るとき、確認すべきことが2つある。

厳しくしたら何本失うか。 これは測っていた。保存済み75クリップを再判定して、許容1なら取りこぼしゼロ、0なら3本落ちる、と数えた。この作業自体は正しい。

その閾値で、対象を観測できているか。 これを一度も問わなかった。1回問えば「挿入0のクリップに付加音があるかもしれない」に辿り着き、波形で見るという発想が出たはずだ。実際、素材を波形で走査するのに掛かった時間は30分だった。閾値を3回動かして焼き直すのに掛かった時間は、その10倍以上ある。

もう一つ。症状がデータではなく振る舞いに出るケースだった、という点も効いている。コーパスのデータを見ても「挿入0のクリップが200本ある」だけで異常には見えない。学習させて喋らせて、初めて癖として現れる。データセットの検査だけでは足りず、学習後のモデルに、コーパスに無かった入力を与えて確かめる工程が要る。

そして最後にもう一段。この記事の元になった検出器は、私が「STTでは見つからない欠陥がある」と気づいて作ったものだった。 それを作った直後に、自分の品質ゲートがSTTだけに頼っていることには気づかなかった。 知識としては持っていて、自分のコードには適用していない。作った側が一番その穴を見ない、という形は、この一件だけで2回起きている。


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

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

← 前: ハルシネーション対策のコードが、ハルシネーションの時だけ動かなかった → 次: 治せる欠陥で候補を落としていた

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

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

← 記事一覧へ