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

文字起こしでは見つからない欠陥がある — 無音0.5秒の先で、AIが何か言っている


音声合成モデルの検収を、こういう手順でやっていた。

  1. モデルにプローブ文を読ませる
  2. Whisperで書き起こす
  3. 台本と突き合わせて一致率と語尾の伸びを見る
  4. 波形から発話秒・音圧・F0を測って異常がないか確認する

12本のボイスを作って、全部この検収を通した。whisper 4/4、語尾伸び0、音圧も正常域。全数合格として報告した。

その後、別の観点で測り直したら、4本に欠陥が残っていた。STTを通した検査では原理的に見えないものだった。

STTは短い音を落とす

最初に見落としに気づいたのは、こういう報告が来たときだった。

ご覧ください。   全長1.61s  本体0.88s → 無音0.48s → 【0.16sの発声】
こちらです。     全長1.65s  本体0.72s → 無音0.56s → 【0.28sの発声】

台本を言い終わって、0.5秒の完全な無音があって、その後に0.1〜0.3秒の発声がある。語尾の余韻ではありえない。モデルが台本に無い音を発している。(原因は学習コーパスの汚染だった。品質ゲートが許した「3文字」が、モデルの口癖になった

自分の検査でこれが出なかった理由は単純で、Whisperがこの音を落としていた

ご覧ください。   → STT: "ご覧くださいああ"     ← かろうじて拾っている
こちらです。     → STT: "こちらです"           ← 完全に落ちている

0.28秒の発声が書き起こしに一切現れない。意味のある語として認識できない短い音は、STTの出力に出てこないことがある。書き起こしを見ている限り、この欠陥は存在しないことになる。

しかも私は「STTで0/6だったので付加音は無い」と結論していた。測定手段の盲点を、対象の性質と取り違えていた。

波形の包絡で見る

音として出ているものは波形に出る。RMSの包絡から有音ブロックを取り、その並びを見ればいい。

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:              # 30ms未満のブロックは無視
                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

閾値を二重にする理由

max(rms.max() * 0.06, 0.004) の部分が地味に重要だった。

相対閾値だけだと、音圧の小さいボイスで壊れる。全体が静かだと最大値も小さいので、無音のノイズフロアまで有音と判定される。

絶対閾値だけだと、音圧の大きいボイスで壊れる。息継ぎや口の開閉音まで有音になる。

12本のボイスは音圧が −13.3〜−18.8dB とばらついていたので、どちらか片方では全ボイスに使える検出器にならなかった。

30ms未満のブロックを捨てるのも要る。入れないとリップノイズや量子化ノイズが細かいブロックとして大量に出て、後段の判定が壊れる。

判定条件

有音ブロックが取れたら、「最後のブロックの直前に十分な無音があり、そのブロックが十分な長さを持つか」を見る。

GAP_MIN  = 0.25      # これ以上の無音を挟んだら「別の発声」とみなす
TAIL_MIN = 0.06      # これ以上続く発声を付加音とみなす

def has_trailing_artifact(wav):
    segs = segments(wav)
    if len(segs) < 2:
        return None                      # ブロックが1つなら付加音なし
    gap  = segs[-1][0] - segs[-2][1]     # 最後のブロックの直前の無音
    tail = segs[-1][1] - segs[-1][0]     # 最後のブロックの長さ
    if gap >= GAP_MIN and tail >= TAIL_MIN:
        return (gap, tail)
    return None

GAP_MIN=0.25 は、語尾の余韻や自然な間と、明らかに切り離された発声を分ける線として置いた。実測された付加音は無音0.26〜0.91秒を挟んでいたので、0.25で十分に拾える。

TAIL_MIN=0.06 は、フェードアウトの尻尾を拾わないため。実測の付加音は0.07〜0.36秒だった。

読点で12モデル全部が偽陽性になった

最初の走査で、12モデル全部が引っかかった。プローブ文にこれが入っていた。

では、始めます。
  ブロック[0] = 「では」
  ブロック[1] = 「始めます」   ← 0.40sの無音を挟んで0.75s続く

読点のポーズだった。「では、」の後に間があって「始めます。」が続く。最後のブロックは台本の一部そのものなのに、判定条件(最後のギャップ+その後の発声)に完全に合致する。

「最後のギャップの後ろに発声がある」という条件は、読点を含む文で必ず偽陽性になる。 台本の構造を見ていないからだ。

対処は2つある。

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

台本の終端位置と突き合わせる。 Whisperのセグメントから台本の内容が終わる位置を求め、そこより後ろにエネルギーがあるかを見る。汎用的だが、STTへの依存が復活する。付加音がWhisperのセグメントに現れないケースでは、そもそも終端位置が正しく取れない可能性がある。

偽陽性を除いた結果:

修正前(読点を含む文あり): 29 / 72件 で検出   ← 12本全部が該当
修正後(単文のみ):        16 / 72件 で検出   ← 4本のみ該当
モデル 付加音
男性ナレーター 6/6
女性オペレーター 4/6
女性プレゼンター 4/6
男性プレゼンター 2/6
残り8本 0/6

最初の結果をそのまま報告していたら「12本全滅」という誤った危機感を伝えるところだった。検出器を作ったら、まず「引っかかってはいけないもの」で試す必要がある。

検査の層を意識する

この件で分かったのは、音声品質の検査には見える層が違う手段が複数要るということだ。

手段 見えるもの 見えないもの
STT(書き起こし) 語の欠落・置換・大きな挿入 短い付加音、無音の構造、音質
波形包絡 発話の区切り、無音、付加音 それが何と言っているか
音響特徴(F0・音圧・抑揚) 声の高さ・大きさ・起伏 内容の正しさ
試聴 全部(ただし主観的・スケールしない)

STTだけで検収していたのは、一番よく使う手段で全部見えると思い込んでいたからだ。実際には、STTは「言葉として認識できるもの」しか見せてくれない。

そして興味深いことに、この4本の付加音は試聴でも気づきにくい。0.1秒の音は、注意して聴いていないと「なんとなく余韻がある」程度にしか感じない。数値で並べて初めて「無音0.5秒の後に音がある」という異常な構造だと分かる。

主観で気づけないものは、機械で測るしかない。そして機械の測り方には、それぞれ盲点がある。

検出器を作るときの手順

今回の反省を手順にすると、こうなる。

  1. 見つけたい欠陥の実例を先に確保する。今回は「無音を挟んだ短い発声」という具体例があったので、検出器の正解が決まった
  2. 引っかかってはいけない例も用意する。読点を含む文、自然な間のある文、無音で終わる文。これを先に用意していれば偽陽性に即気づけた
  3. 閾値は実データの分布から決める。実測の付加音が無音0.26〜0.91秒・発声0.07〜0.36秒だったので、0.25と0.06に置いた。切りのいい数字から始めない
  4. 全対象に流して、分布を見る。「12本中12本が該当」は成功ではなく異常のサインだ。全部引っかかったら検出器を疑う

4番目が今回の教訓そのもので、検出率が高すぎるときは対象ではなく検出器を疑う


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

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

← 前: 治せる欠陥で候補を落としていた → 次: 70分かけた学習素材が、ネットワークの一瞬の瞬きで消えた

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

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

← 記事一覧へ