#音声合成#TTS#評価指標#機械学習

治せる欠陥で候補を落としていた — 工場の不良を、製品の個性として測っていた話


音声モデルの候補を機械的に選抜する仕組みを作った。24個の候補音声にプローブ文を読ませ、Whisperの一致率・語尾の伸び・話速・抑揚・ジッタを自動計測してスコア化し、上位を耳で確認して採用する。

役割別に重みを変えられるようにしてある。ナレーターなら話速の遅さを加点、MCなら抑揚幅を加点。そして共通の減点項目として Whisperの一致率を重み1.0〜1.2で入れていた。台本どおり読めない候補は落とす。妥当だと思っていた。

これが指標の設計として根本的に間違っていた、という話。

一致率だけが異常に低い役割があった

プレゼンター用の候補を出したとき、一致率が他と違いすぎた。他の役割が0.93〜1.00なのに、プレゼンターだけ0.70〜0.93。話速のばらつきも大きい。

生成物を確認すると末尾に幻覚が出ていた。

原文: なぜだと思いますか。
転写: なぜだと思いますか?そうだ!これに似てる円形と思いました

原文: ご清聴、ありがとうございました。
転写: ご清聴ありがとうございました。元気をございました。

短い文(9〜14モーラ)に強調系のキャプション(「メリハリがあり、大事なところを強調しながら」)を組み合わせると、余計な発話が付加される。プローブ文を18モーラ以上に書き直したら一致率は0.93〜0.96に戻り、順位1位が入れ替わった

最初は「計測が壊れていたので直した」と処理していた。だが順位が入れ替わったという事実が引っかかった。計測の不備で1位が変わるということは、その指標が候補の優劣を実質的に決めていたということだ。では、その指標は何を測っていたのか。

パイプラインの構造を思い出す

このシステムは2つのエンジンを使い分けている。

[設計時] 拡散TTS ── キャプション+seedで声を生成 ── 学習コーパス(約200本)

                                                      学習

[ランタイム] 学習済みモデル ──────────────── 実際にユーザーが聞く声

拡散TTSは声のデザイン専用だ。キャプションと乱数種で声が決まり、同じ組み合わせからは何度でも同じ声が出る。ただし生成が遅く(RTF 0.25〜0.54)、会話には使えない。そこで生成音声を学習素材にして軽量なモデルを焼き、ランタイムはそちらが担当する。

つまり、幻覚が起きるのは拡散TTS=コーパスを作る工程であって、ユーザーが実際に聞くのは学習済みモデル=製品だ。この2つは別物だった。

学習済みモデルは幻覚するのか

拡散TTSが必ず幻覚を出した短文6本を用意し、そこから学習した3モデルに読ませた。

モデル 幻覚
女性プレゼンター 0/6
男性プレゼンター 0/6
女性MC 0/6
拡散TTS:       「なぜだと思いますか?そうだ!これに似てる円形と思いました」(転写長2.9倍)
学習済みモデル: 「なぜだと思いますか」(ratio 1.00)

同じ9モーラの文で、片方は捏造、片方はクリーン。幻覚は製造工程の欠陥であって、製品には出ていない。

治せる傷で候補を落としていた。しかも「治せる」どころか、製品には最初から存在しない傷だった。

指標が実際に測っていたもの

スコア式はこうなっていた。

スコア = 1.0 × 一致率 − 0.25 × 語尾伸び − 1.0 × 話速cv − 2.0 × ジッタ ...

一致率は「その声が台本どおり読めるか」を測っているつもりだった。実際に測っていたのは「その caption+seed の組み合わせが、短文で幻覚を出しにくいか」だ。工程の状態であって、声の性質ではない。

さらに悪いことに、幻覚は話速まで汚染していた。付加された発話が発話時間を伸ばすので、モーラ/秒が実際より遅く出る。プローブを直したら話速が 4.0〜6.3 → 5.2〜7.2 に変わり、目標帯に収束した。1つの欠陥が別の指標まで歪めていた。

結果として何が起きたか。プレゼンターの女性1位は seed 864200 だったが、プローブを直すと seed 123456 が1位に上がる。123456 は抑揚幅26.0stで、他候補(11.7〜18.3st)より一段大きい。プレゼンに一番必要な「強調のメリハリ」を持っている候補が、幻覚を理由に3位に沈んでいた。

指標を残す理由を探してしまった

ここで一度、こう考えた。

プレゼン職務では実運用でも短い強調フレーズを喋る。だから幻覚を出しにくい声を選ぶこと自体に価値があるのではないか。

もっともらしいが、成り立たない。実運用で喋るのは学習済みモデルで、そこでは幻覚が出ないからだ。「短文に弱い声」という性質は製品に存在しない。

この理屈を思いついた時点で、自分が指標を捨てたくなかったのだと分かった。作った仕組みを守るために、後から根拠を探していた。

ただし「引き継がれない」も言い過ぎだった

話には続きがある。トリムで幻覚を除去してから学習しているのなら、そもそもコーパスに幻覚は入っていないはずだ。では今まで幻覚付きで学習させていたのか。

台本突合のコードを読み直した。

def judge_transcript(script_text, transcript,
                     min_ratio=0.40, max_inserted=3):
    """台本と書き起こしを突合する。挿入(台本外の音)>max_inserted は不合格。"""

挿入3文字までは合格する。4文字以上の幻覚(「たふにボンジュそのごう。」= 挿入11)は除外されるが、3文字以下の幻覚はそのまま学習素材に入る

学習済みモデルに読ませ直すと、こうだった。

つまり、こういうことです。       → 「つまり、こういうことです。ヴェー」   (挿入2)
大丈夫ですよ、気にしないでください → 「大丈夫ですよ。気にしないでください。いや。」(挿入1)

ゲートを通り抜けたサイズと、モデルが再現した付加音のサイズが一致していた。

さらに波形の包絡で測り直すと、STT依存の検査では見えない欠陥が出てきた(文字起こしでは見つからない欠陥がある)。

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

0.5秒の完全無音を挟んだ発声は語尾の余韻ではありえない。「0/6だから引き継がれない」は、たまたまその6文で出なかっただけで、文依存であることを確認していなかった。しかも自分で「ヴェー」を観測しておきながら、それを「微小なノイズ」と書いて一般化に進んでいた。

正しい整理はこうなる。

したがって「選抜指標から一致率を外す」は正しく、同時に「製造ゲートの許容を絞る」も正しい。別レイヤーの話が2つ重なっていた。

修正1: 計測前にトリムする

選抜側は、指標を測る前に幻覚を除去する。順序を1つ変えるだけだ。

# 変更前: 生成 → 計測
wav = gen(text, caption, seed)
ratio = judge_transcript(text, whisper(wav)).ratio   # 幻覚込みの値

# 変更後: 生成 → 幻覚除去 → 計測
wav  = gen(text, caption, seed)
segs = transcribe(wav)["segments"]
end  = script_end_sec(text, segs)          # 台本が終わる位置
wav  = trim_to_segment_end(wav, end) if end else wav
ratio = judge_transcript(text, whisper(wav)).ratio   # 声の性質だけが残る
speech_sec, level, f0, range_st = acoustics(wav)     # 話速・F0も汚染されない

これで一致率は「トリムしても直らない、本当に読めない文」だけを反映する。副次的に話速の汚染も消える。付加発話が発話秒を膨らませなくなるからだ。

同時に、プローブ文の長さに神経を使う必要がなくなった。修正前は「15モーラ未満のプローブは幻覚を招くので使うな」という警告をスクリーナーに入れていたが、トリムが効くならその制約は要らない。回避策を1つ捨てられたのが地味に大きい。

修正2: 製造ゲートの許容を絞る

挿入許容を3→1にする。ただしそのまま絞ると正常なクリップまで落ちるので、先に表記ゆれを吸収する必要があった。

促音は表記差でしかない

まずこれに気づいた。

台本: ふふっ、今日は最高に楽しい一日でした!
転写: ふっふっ、今日は最高に楽しい一日でした!     → 挿入2

台本: ど、どうしよう。胸がざわついて…
転写: どっどうしよう 胸がざわついて…            → 挿入2

どちらも「同じ音を っ で書くか 、 で書くか」の違いでしかない。既存の正規化は読点を落としていたが促音は残していたので、片側にだけ文字が残って挿入としてカウントされていた。比較時に促音も落とす。

_PUNCT_RE  = re.compile(r"[、。!?!?…・\s「」ー〜,\.]")
_REPEAT_RE = re.compile(r"(.)\1+")
_SOKUON_RE = re.compile(r"[っッ]")        # 追加

def _collapse(s):
    return _REPEAT_RE.sub(r"\1", _SOKUON_RE.sub("", _PUNCT_RE.sub("", s or "")))

「ふふっ」→「ふふ」→(連続圧縮)→「ふ」、「ふっふっ」→「ふふ」→「ふ」。一致する。

弾きたいものが残ることも確認する。「〜です。ヴェー」は挿入2のまま、「〜たふにボンジュそのごう。」は挿入11のまま。救いたいものと弾きたいものを両方並べて、正規化が後者を素通しさせないかを見る。

漢字・数字の表記ゆれは読みで吸収する

Whisperは台本と同じ表記で書き起こすとは限らない。

台本: ここが、今日いちばん大事なポイントです。
転写: ここが今日一番大事なポイントです

台本: 月々のコストを三割ほど抑えられます。
転写: 月月のコストを3割ほど抑えられます

既存の「かな正規化」はカタカナ→ひらがな変換だけで、漢字も数字も素通しだった。pyopenjtalk の g2p で両側を読みに変換してから比較する。

_G2P = None

def _reading(s: str) -> str:
    # 漢字・数字の表記ゆれを吸収するため読み(カナ)に変換する。
    # 辞書が無い環境では従来の文字面比較にフォールバックし、挙動を退行させない。
    global _G2P
    if _G2P is None:
        try:
            import pyopenjtalk
            pyopenjtalk.g2p("あ", kana=True)      # 辞書の有無をここで確かめる
            _G2P = pyopenjtalk
        except Exception:
            _G2P = False
            logger.warning("pyopenjtalk 辞書が無いため読み正規化を無効化(文字面で比較)")
    if _G2P:
        try:
            return _G2P.g2p(s, kana=True)
        except Exception:
            return s
    return s

def judge_transcript(script_text, transcript, min_ratio=0.40, max_inserted=1):
    a = _kana(_collapse(_reading(script_text)))
    b = _kana(_collapse(_reading(transcript)))
    ...
g2p('ここが今日一番大事なポイントです')      → ココガキョーイチバンダイジナポイントデス
g2p('ここが、今日いちばん大事なポイントです。') → ココガ、キョーイチバンダイジナポイントデス。

表記が違っても読みは一致する。

辞書が無いまま動く罠

ここでハマった。pyopenjtalk はライブラリが入っていても辞書が同梱されておらず、初回使用時にダウンロードしようとする。本番コンテナでは site-packages が読み取り専用なので、これが失敗する。

PermissionError: [Errno 13] Permission denied:
  '/opt/venv/lib/python3.11/site-packages/pyopenjtalk/dic.tar.gz'

つまり黙ってフォールバック経路に落ちて、読み正規化が効いていない状態で正常に動く。この記事の主題そのものだ。イメージのビルド時、root 権限のあるうちに辞書を展開しておく。

# pyopenjtalk の辞書をビルド時に展開しておく。実行時は site-packages が
# 読み取り専用で初回DLが PermissionError になり、読み正規化が無効化される。
RUN python -c "import pyopenjtalk; print(pyopenjtalk.g2p('テスト', kana=True))"

USER fastapi

デプロイ後は、Pod の中でフォールバックしていないことを確認する。_reading() が入力をそのまま返したら辞書が無い。

>>> verify._reading('ここが今日一番大事なポイントです')
'ココガキョーイチバンダイジナポイントデス'      # カナが返れば辞書が効いている

閾値は実データで決める

「3→1にする」を思いつきで決めない。既存の学習素材で再判定して、何本失うかを数える。音声と台本は保存してあるので再生成なしで測れる。

# 保存済みクリップ75本を再転写して、新旧の判定を比較する
for clip in sample_clips(75):
    wav   = download(clip.r2_key)
    trans = transcribe(wav)["text"]
    ins_old, ratio_old = measure(clip.text, trans, normalize="旧")
    ins_new, ratio_new = measure(clip.text, trans, normalize="促音無視+読み")
閾値 新方式で残る 旧方式で残る
≤0 72/75 (96%) 70/75 (93%)
≤1 75/75 (100%) 73/75 (97%)
≤2 75/75 (100%) 75/75 (100%)
≤3(従来) 75/75 (100%) 75/75 (100%)

平均ratio は 0.963 → 0.995。≤1 なら取りこぼしゼロで、2文字以上の幻覚を弾ける。

≤0 まで絞ると3本落ちるが、中身は全部Whisper側の問題だった。

台本: 本日の予定はすべて滞りなく進んでおります。
転写: 本日の予定はすべて[とどこおり/届こおり]なく進んでおります

合成音声は正しく発音しているのに、書き起こしの表記で落とすことになる。ASR側の揺れまで拾うのは行き過ぎと判断して ≤1 にした。

⚠️ この測定にはバイアスがある。サンプルは「合格して保存されたクリップ」だけなので、「絞ると何本失うか」には答えているが「緩めれば何本救えたか」には答えていない。閾値を厳しくする方向の判断にしか使えない。

それでも、この「既存資産で再判定して失う数を数える」やり方は閾値を触るとき毎回使える。思いつきで厳しくすると歩留まりが落ちて、そのことに数週間気づかない。

指標を作るときに問うべきこと

その指標は製品の性質を測っているのか、工程の状態を測っているのか。 今回の一致率は完全に後者だった。工程を直せば消える値を、候補の優劣に使っていた。

落としていいのは、直しようがない性質だけ。 工程の欠陥は工程で直す。選抜で落とすのは、その候補が本質的に持っている望ましくない性質だけにする。

指標同士は独立ではない。 幻覚が話速を汚染していたように、1つの欠陥が複数の指標を歪める。「一致率が低い候補は話速も変」という相関を見た時点で共通原因を疑うべきだった。

自分のデータと矛盾する結論を書いていないか。 都合の悪い観測を「微小」と形容して脇に置いた瞬間に、その結論は壊れている。今回はまさにそれをやった。


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

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

← 前: 品質ゲートが許した「3文字」が、モデルの口癖になった → 次: 文字起こしでは見つからない欠陥がある

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

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

← 記事一覧へ