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