#音声合成#TTS#Whisper#品質管理

ハルシネーション対策のコードが、ハルシネーションの時だけ動かなかった


音声合成モデルの学習コーパスを自動生成するパイプラインを運用している。テキストをTTSに読ませ、Whisperで書き起こして台本と突き合わせ、合格したクリップだけを学習素材として保存する。素直な作りだ。

12本のボイスを量産するバッチを流していたら、プレゼンター2本だけが揃って落ちた。

error: 基礎コーパスの不合格が多すぎます:
       ['base_07', 'base_11', 'base_46', 'base_51', 'base_55']

不合格が20本と18本。他のボイスは0〜10本で通っているので、明らかにおかしい。

掘っていくと、末尾ハルシネーション対策として書いたトリム処理が、ハルシネーションが起きた時にだけ何もしていなかった。数か月動かしていて気づかなかった。

短い文だけが落ちている

落ちた文を並べると傾向が出た。

base_07 (17モーラ): 駅前のカフェで待ち合わせをしましょう。
base_11 (15モーラ): 猫が窓辺で丸くなって眠っている。
base_46 (16モーラ): えっと、それってどういう意味ですか。
base_51 (14モーラ): じゃあ、また明日ここで会いましょう。
base_55 (16モーラ): 大丈夫ですよ、気にしないでください。

基礎コーパス65文のモーラ数は最小13・中央21・最大33。落ちたのは全部、短い方だ。

手元で再現する。プレゼンター用のキャプション(「明瞭でメリハリがあり、大事なところを強調しながら聴衆に語りかける話し方」)で生成してWhisperに通す。

原文: 猫が窓辺で丸くなって眠っている。
転写: 猫が窓辺で丸くなって眠っている。たふにボンジュそのごう。

原文: 大丈夫ですよ、気にしないでください。
転写: 大丈夫ですよ。気にしないでください。プラチュアフォールド・フレンズ

原文: 駅前のカフェで待ち合わせをしましょう。
転写: 駅前のカフェで待ち合わせをしましょう。どうしようと不安。

台本を言い終わった後に、意味不明な発話が続いている。典型的な末尾ハルシネーションだ。

2回試すと出力が完全に一致した。使っている拡散TTSはキャプションとseedが同じなら決定論的に同じ音声を返すので、「たまたま」ではない。同じ入力からは必ず同じ幻覚が出る。

対策は最初から書いてあった

厄介なのは、この現象への対策を最初から入れていたことだ。クリップ生成のリトライは3段構えにしてある。

  1. 生成 → Whisper → 台本突合。合格なら保存
  2. 不合格なら num_steps を変えて再抽選(キャプションとseedは固定なので声は変わらず、拡散軌道だけ振り直す)
  3. それでもダメなら、Whisperのセグメント終端でトリムして再判定

3番目が末尾ハルシネーション対策そのものだ。

# 最終手段: 最良テイクをwhisperセグメント終端でトリムして再判定
if best is not None:
    wav, tr, _ = best
    segs = tr.get("segments") or []
    if segs:
        trimmed = verify.trim_to_segment_end(wav, float(segs[-1].get("end", 0.0)))
        ...
def trim_to_segment_end(wav_bytes, end_sec, pad_ms=40, fade_ms=30):
    """whisperセグメント終端+pad でトリムし短フェードを付ける(末尾ハルシネーション最終手段)。"""

意図は明快だし、実装も動く。単体テストを書けば通る。それでも幻覚は除去されていなかった。

segments[-1].end が犯人だった

Whisperが実際に何を返しているかを見て分かった。

原文: 猫が窓辺で丸くなって眠っている。
全体転写: '猫が窓辺で丸くなって眠っている。たふにボンジュそのごう。'
セグメント数: 2
  [0] 0.00〜3.50s : '猫が窓辺で丸くなって眠っている。'
  [1] 3.50〜5.10s : 'たふにボンジュそのごう。'

Whisperは幻覚を別セグメントとして返していた。台本は segments[0] で3.50秒に終わり、幻覚は segments[1] で5.10秒まで続く。

そして自分が書いたのは segments[-1].end、つまり 5.10秒。音声の終わりそのものだ。

トリムは何も切っていなかった。正確には、最後のセグメントより後ろの無音を40msのパディングを残して落とすだけの処理になっていた。切れていない音声を再判定するので当然また不合格になり、クリップは除外される。

もう一例。

原文: 大丈夫ですよ、気にしないでください。
  [0] 0.00〜2.50s : '大丈夫ですよ。気にしないでください。'
  [1] 3.98〜5.84s : 'プラチュアフォールド・フレンズ'

台本は2.50秒で終わり、1.5秒近い無音を挟んで幻覚が始まる。これだけ明確に分離しているのに、切る位置の選び方が1つ違うだけで対策が丸ごと死んでいた。

嫌なのは、この実装は幻覚が起きなかった時には正しく動くことだ。セグメントが1つなら末尾の無音を落とす無害な処理になる。幻覚が起きた時にだけ、つまり必要な時にだけ動かない。

台本の内容が終わる位置を探す

正しいのは「最後のセグメント終端」ではなく「台本の内容が終わる位置」だ。セグメントを先頭から足しながら、台本との一致率が最大になる境界を選べばいい。

def script_end_sec(script: str, segments: list) -> float | None:
    """台本の内容が終わる位置(秒)を返す。

    末尾ハルシネーションは whisper が別セグメントに分けることが多い。
    最後のセグメント終端で切ると幻覚ごと残る(トリムが無効化される)ため、
    セグメントを先頭から足しながら台本との一致率が最大になる位置を選ぶ。
    """
    best_end, best_ratio = None, -1.0
    acc = ""
    for seg in segments:
        acc += str(seg.get("text", "") or "")
        ratio = judge_transcript(script, acc, min_ratio=0.0, max_inserted=10 ** 9).ratio
        if ratio > best_ratio:
            best_ratio, best_end = ratio, float(seg.get("end", 0.0) or 0.0)
    return best_end

猫が窓辺で〜 なら、segments[0] まで足した時点で台本と完全一致して ratio 1.00、segments[1] まで足すと幻覚が混ざって 0.73。最大値をとる 3.50秒が選ばれる。

リトライ梯子の全体像

修正後の生成ロジックはこうなっている。フォールバックが3段あることと、それぞれが違う失敗に対応しているのがポイントだ。

_RETRY_STEPS = [None, 32, 48, 24]   # None=既定値。以降は拡散ステップ数を振り直す

async def make_clip(text, caption, seed, min_ratio):
    best = None                      # 最も惜しかったテイクを保持しておく
    for steps in _RETRY_STEPS:
        wav = await generate(text, caption, seed, num_steps=steps)
        tr  = await transcribe(wav)
        res = judge_transcript(text, tr["text"], min_ratio=min_ratio)

        # 語尾伸びは生の書き起こしで見る(かな正規化は長音を捨てるため)
        if trailing_elongation_mismatch(text, tr["text"]):
            continue                 # 語尾が伸びたテイクは即再抽選

        if res.ok:
            return save(wav, text)

        if best is None or res.ratio > best[2]:
            best = (wav, tr, res.ratio)

    # 最終手段: 台本が終わる位置で切って再判定
    if best:
        wav, tr, _ = best
        segs = tr.get("segments") or []
        end_sec = script_end_sec(text, segs) if segs else None
        if end_sec:
            trimmed = trim_to_segment_end(wav, end_sec)
            tr2 = await transcribe(trimmed)
            if judge_transcript(text, tr2["text"], min_ratio=min_ratio).ok:
                return save(trimmed, text)
    return None                      # 除外

best を保持しているのが効いている。全リトライが不合格でも、一番惜しかったテイクに対してトリムを試せる。毎回捨てていたら、トリムに渡す素材がなかった。

トリム本体は、渡された秒数で切って短いフェードをかけるだけだ。

def trim_to_segment_end(wav_bytes, end_sec, pad_ms=40, fade_ms=30):
    with wave.open(io.BytesIO(wav_bytes)) as w:
        params, sr = w.getparams(), w.getframerate()
        frames = w.readframes(w.getnframes())
    samples = array.array("h"); samples.frombytes(frames)
    end = min(len(samples), int((end_sec + pad_ms / 1000.0) * sr))
    samples = samples[:end]
    # ぶつ切りだとクリックノイズが乗るので30msだけフェードアウト
    fade_n = min(len(samples), int(sr * fade_ms / 1000.0))
    for i in range(fade_n):
        idx = len(samples) - fade_n + i
        samples[idx] = int(samples[idx] * (1.0 - i / max(fade_n, 1)))
    ...

40msのパディングは、子音の破裂やリリースを削らないための余裕。フェードを入れないと切断面でプチッと鳴り、それが学習素材に入る。

同じ音声で、判定が変わる

修正の効果を同じ音声に対して測った。

修正前 修正後
駅前のカフェで〜 ratio 0.82 NG(5.67sで切る) ratio 1.00 OK(2.44s)
猫が窓辺で〜(女性) ratio 0.73 NG(5.10s) ratio 1.00 OK(3.50s)
大丈夫ですよ〜(女性) ratio 0.71 NG(5.84s) ratio 1.00 OK(2.50s)
猫が窓辺で〜(男性) ratio 0.75 NG(4.92s) ratio 1.00 OK(3.16s)
えっと、それって〜 0.74 NG 0.65 NG
大丈夫ですよ〜(男性) 0.70 NG 0.70 NG

6件中4件が合格に転じた。残り2件はWhisperが幻覚を台本と同じセグメントに融合させたケースで、境界が存在しないので切りようがない。これは想定内で、そういうクリップは捨てればいい。

デプロイして実ジョブを流し直した結果がこれ。

ボイス 修正前 修正後
女性プレゼンター error・不合格20本 完成・不合格3本
男性プレゼンター error・不合格18本 完成・不合格3本

不合格上限は3本なので、ぎりぎり通ったのではなく余裕をもって収まった。手元の6件検証(4件救済)から予想した以上に効いている。基礎コーパスの短文がほぼ全部救われた計算になる。

なぜ数か月気づかなかったか

失敗が「除外」に吸収されていた

トリムが効かないと、そのクリップは不合格になって除外されるだけだ。パイプラインは止まらない。不合格が数本なら「まあそういうものか」で通る。

実際、ナレーター話体のボイスは不合格0〜4本で普通に完走していた。ナレーター用のキャプション(「ゆったりとした語り口で、長い文章を丁寧に読み上げる」)は幻覚を誘発しにくいので、トリムの出番自体が少なかったからだ。

露呈したのは、幻覚を誘発しやすいキャプション(プレゼンターの「メリハリがあり、大事なところを強調しながら」)と短い文が重なった時だけだった。条件が揃って初めて不合格が上限を超え、ジョブが落ちた。

コードを読んでも間違いに見えない

trimmed = verify.trim_to_segment_end(wav, float(segs[-1].get("end", 0.0)))

この行だけ見て「間違いだ」と分かる人はいないと思う。関数名も引数の型も意図も正しい。Whisperが幻覚を別セグメントに分けるという外部の挙動を知って初めて [-1] が誤りだと分かる。

「実装した」で安心していた

これが一番大きい。このパイプラインを説明するとき「末尾ハルシネーションはトリムで除去している」と何度も書いていた。docstring にもそう書いた。実装したという事実を、機能しているという事実と同じものとして扱っていた。

フォールバックは腐る

同じバッチで、同じ型のバグを他に2つ見つけた。

クリップのダウンロードにリトライが無かった(70分かけた学習素材が、ネットワークの一瞬の瞬きで消えた)。学習直前に200本超を連続で取得するのに、ストレージへの一過性の接続断が1回起きただけで、70分かけた製造が全滅する。実際に落ちて初めて気づいた。

台本突合の挿入許容が3文字だった。3文字以下の幻覚はそのまま合格して学習素材に入る。学習後のモデルが「〜です。ヴェー」と短い付加音を再現するようになっていて、ゲートを通り抜けたサイズと再現された音のサイズが一致していた(品質ゲートが許した「3文字」が、モデルの口癖になった)。

3つに共通するのは、正常系が通っている限り実行されない・観測されないことだ。リトライは瞬断が起きるまで、トリムは誘発条件が揃うまで、挿入許容はモデルを学習させて聴くまで、症状が出ない。

フォールバック処理は、書いた直後が一番信頼できて、そこから腐っていく。 正常系はデプロイのたびに何百回も実行されるが、フォールバックは何か月も呼ばれない。呼ばれないコードは、周辺の変更に追随できているかを誰も確認しない。

フォールバックが効いているかを確認する手順

対策として実際にやったことを、再現できる形で書いておく。

1. 発火する入力を固定資産にする

このバグの検証には「必ず幻覚が出る入力」が要る。幸い拡散TTSは決定論的なので、キャプション+seed+テキストの三つ組を記録しておけば、何度でも同じ幻覚が再現できる

# 幻覚が確実に出る組み合わせ(実測で固定)
HALLUCINATION_CASES = [
    ("F", 123456, "駅前のカフェで待ち合わせをしましょう。"),
    ("F", 123456, "猫が窓辺で丸くなって眠っている。"),
    ("F", 123456, "大丈夫ですよ、気にしないでください。"),
    ("M", 864200, "猫が窓辺で丸くなって眠っている。"),
    ...
]

決定論的でない生成系なら、幻覚が出た音声ファイルそのものを保存しておけばいい。重要なのは「フォールバックが発火する入力」を手元に持つことで、これが無いとフォールバックは永久に検証できない。

2. 修正前後で同じ音声を通す

for g, seed, text in HALLUCINATION_CASES:
    wav  = gen(text, CAPTION[g], seed)
    segs = transcribe(wav)["segments"]

    old_end = float(segs[-1]["end"])          # 旧実装
    new_end = script_end_sec(text, segs)      # 新実装

    before = judge_transcript(text, transcribe(wav)["text"])
    after  = judge_transcript(text, transcribe(trim(wav, new_end))["text"])

    print(f"{text}")
    print(f"  旧: ratio={before.ratio:.2f} {'OK' if before.ok else 'NG'} (切る位置 {old_end:.2f}s)")
    print(f"  新: ratio={after.ratio:.2f} {'OK' if after.ok else 'NG'} (切る位置 {new_end:.2f}s)")

切る位置を両方出すのが肝だった。判定結果だけ見ていると「なぜか直らない」で終わる。old_end=5.10s / new_end=3.50s と並べて初めて、旧実装が音声の終わりを指していたと分かる。

3. 救えなかったケースを分類する

6件中2件は修正後も不合格だった。これを「まだバグがある」と見るか「想定内」と見るかで対応が変わるので、中身を見た。

えっと、それってどういう意味ですか。
  転写: 'えっと、それってどういうユミですか?えっと、フォロードファンの'

Whisperが幻覚を台本と同じセグメントに融合させていた。セグメント境界が存在しないので、この方式では切りようがない。ここは追わずに「除外して次へ」と割り切った。

フォールバックの検証では「救える範囲」を明示する。全件救えないと失敗、ではない。今回は不合格上限が3本なので、6件中4件救えれば通る。その計算をしてから修正の合否を決めた。

4. 同じ型のバグを機械的に探す

[-1] は、列の末尾に異常が混ざりうる場面では危ない。今回はまさに「末尾に異常が付く」現象への対策で [-1] を使っていた。

# フォールバック処理の中の [-1] を洗い出す
grep -rn "\[-1\]" --include=*.py apps/ | grep -iE "fallback|最終手段|last|retry|except"

同様に、こういうパターンは棚卸しの価値がある。

  • except: pass — エラーを握り潰している箇所
  • if not X: return — 早期リターンで無言に諦めている箇所
  • リトライループの外にある「最後の手段」

5. 説明を書くときに区別する

「トリムで除去している」と書くとき、それが実データで確認された事実なのか、コードがあるという事実なのかを区別する。今回は後者だった。docstring も同じで、「〜する」と書いてあることは仕様であって、実装が仕様を満たしている保証にはならない。

ドキュメントに「実測: 6件中4件で不合格→合格(2026-08)」のように確認した日付と数字を書くようにした。数字が書けないなら、それは確認していないということだ。


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

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

← 前: 「少々」が「しょも」になる — 許可文字リストが日本語を削っていた → 次: 品質ゲートが許した「3文字」が、モデルの口癖になった

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

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

← 記事一覧へ