0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

0
Posted at

📝 この記事は forge.workstyle.tech に掲載した記事の転載です。

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

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

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

語尾に短い付加音が付く。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から実用ボイスを量産する製造パイプライン にまとめてあります。


元記事: https://forge.workstyle.tech/blog/three-chars-became-a-verbal-tic/?utm_source=qiita&utm_medium=crosspost&utm_campaign=three-chars-became-a-verbal-tic

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?