"ja" を "JP" と書いて喃語モデルができるまで — 音声学習でハマった罠カタログ
音声合成モデルを20本近く学習させる過程で踏んだ罠を並べる。どれも「エラーが出ないまま間違った結果になる」タイプで、原因に辿り着くまでに時間を溶かした。
1. "JP" と書くと日本語が英語として学習される
学習APIに言語を渡すパラメータがある。素直に書いた。
resp = await client.post(f"{TRAINER_URL}/train", json={
"model_id": model_id,
"language": "JP", # ← これ
"training_audio": clips,
})
学習は正常に完走した。エラーもワーニングも無い。損失も下がる。
できあがったモデルに喋らせると、喃語しか出てこなかった。日本語の音として成立していない、意味のない音の連なり。
トレーナー側のコードを読んで理由が分かった。
if language.startswith("ja"):
phonemes = japanese_g2p(text)
else:
phonemes = english_g2p(text) # ← ここに来る
"JP".startswith("ja") は False。大文字なので一致しない。日本語のテキストが英語のG2Pを通り、音素列がほぼ空のまま学習された。
音素が空でも学習は回る。損失も下がる(何も学習していないので、平均的な音を出すモデルに収束する)。パイプラインのどこにも異常が出ない。
"ja" と書けば直る。ISO 639-1 は小文字が正しく、"JP" は国コード(ISO 3166)の書き方で、そもそも混同していた。
教訓: 言語コードを渡す箇所は、渡した後に音素列を1回ログに出して目視する。空だったり英語っぽかったりしたら即分かる。学習を回してから気づくには高すぎるコストだ。
2. curl --data @file が280MBでOOM死する
学習データをまとめて投げるとき、手動で叩くことがある。
curl -X POST http://trainer:8000/train \
-H "Content-Type: application/json" \
--data @payload.json # 280MB
これがOOMで死ぬ。curl は --data @file でファイル全体をメモリに読み込み、さらにJSONとして扱うために追加のバッファを取る。280MBのファイルで数GB使う。
Pod内から python で投げれば通る。
import json, urllib.request
req = urllib.request.Request(
"http://trainer:8000/train",
data=open("payload.json", "rb").read(),
headers={"Content-Type": "application/json"},
)
urllib.request.urlopen(req, timeout=3600)
これでも読み込みはするが、curl のような多重バッファが無いぶん収まった。根本的にはストリーミングアップロードにするか、音声をbase64でJSONに詰めるのをやめるべきだが、手動運用の範囲では上で足りた。
教訓: 巨大ペイロードを扱う運用手順は、想定サイズで一度試しておく。「curlで叩けばいい」は数十MBまでの話。
3. 連続学習で CUDA のアサーションエラーが再発する
学習を何本も続けていると、あるタイミングでこれが出る。
SBV2 train 失敗 (rc=-6): ...device-side assertions.
Exception raised from c10_cuda_check_implementation at
../c10/cuda/CUDAException.cpp:43
rc=-6 は SIGABRT。CUDAのデバイス側アサーションで落ちている。
対処として PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True を入れた。メモリアロケータが断片化した領域を再利用できるようになり、確かに改善した。
env:
- name: PYTORCH_CUDA_ALLOC_CONF
value: "expandable_segments:True" # 連続学習でのNVML assert対策
ただし根治ではなかった。この設定を入れた後も、約15本連続で学習した時点で再発した。Podを再起動すると直る。
つまりこの設定は寿命を延ばしているだけで、長時間動かし続ければいずれ同じ状態になる。まとまった本数を焼くときは、10本程度ごとにサービスを再起動する運用にした。
kubectl rollout restart deployment/tts-trainer -n <ns>
kubectl rollout status deployment/tts-trainer -n <ns> --timeout=600s
⚠️ 再起動するとその時点で走っている学習が死ぬので、キューが空いたタイミングを狙う。今回は「クリップ生成中(GPUの別サービスを使っている)」のタイミングで再起動して、学習中のジョブに当てずに済んだ。
教訓: GPUの状態依存な不具合は「設定で直った」と思っても、再発までの本数を数えておく。15本で再発すると分かっていれば、10本ごとの再起動という運用に落とせる。
4. 参照音声の長さに上限がある
ゼロショットの参照音声を長くすると品質が上がる、という直感で伸ばしていったら壁があった。
55秒 → 通る
110秒 → NVML assert で落ちる
さらに厄介なのは、小さいGPUスライスでは55秒でも落ちることだった。逐次生成でメモリが断片化していくので、単発では通る長さでも量産中に落ちる。
対処は2つ。
- 量産時は余裕のあるGPUスライスを確保する
expandable_segments:Trueを併用する(断片化に効く)
教訓: 「単発で通る」と「連続で通る」は別。量産する前に、本番と同じ本数を流す小規模なリハーサルをやると、この種の劣化が出る。
5. 参照音声の登録が Pod 再起動で消える
参照音声を一時ディレクトリに置く実装になっていて、Podが再起動すると消えた。学習中に気づかず、次のジョブで「参照が見つからない」と落ちる。
volumes:
- name: voices
emptyDir: {} # ← Pod再起動で消える
教訓: 「アップロードして登録する」系のリソースは、置き場が永続かどうかを最初に確認する。開発中は emptyDir で問題なく動くので、Podが落ちるまで気づかない。
6. 同一イメージを共用しているサービスでワーカーが二重に動く
学習ワーカーを持つバックエンドと、別用途のサービスが同じコンテナイメージを使っていた。結果、両方でワーカーが起動し、2つのジョブが同時に学習を始めて分散学習のポートが衝突した。
# 学習ワーカーを持たない側で明示的に無効化する
env:
- name: VOICE_DESIGN_WORKER_ENABLED
value: "false"
教訓: イメージを共用するなら、「このデプロイでは何を動かさないか」を env で明示する。デフォルトで全部起動する設計だと、共用した瞬間に事故る。
7. 話体はコーパスに焼き付き、後から変えられない
これは罠というより設計上の性質だが、知らないと詰む。
語尾の伸び方、話速、感情スタイルの構成は、学習コーパスの内容で決まる。合成時のパラメータでは変えられない。実際、合成APIに話速のパラメータを渡して測ったが、値を変えても出力は変わらなかった。
length=1.00 → 話速5.3
length=1.15 → 話速5.2
length=1.35 → 話速5.1
length=0.85 → 話速5.2 ← 誤差の範囲。効いていない
パラメータを受け取るインターフェースはあるが、実装が無視している。「渡せる」と「効く」は別で、実測しないと分からない。
したがって、用途(ナレーター、コールセンター、司会など)は製造時に決めきる必要がある(学習後に話速は変えられない)。同じ声で別の用途が欲しければ、同じ設計値(キャプション+seed)で話体だけ変えてもう1本焼く。1本1〜2時間で作れるので、運用としては現実的だった。
教訓: ランタイムで調整できると思っているパラメータは、一度実測して効果を確認する。効かないと分かれば、設計時に決める前提で全体を組める。効くと思い込んだまま進むと、後から取り返せない。
7つ並べてみると、共通点がある。**どれも「エラーが出ない」**ことだ。
言語コードは学習が完走し、話速パラメータは受け取られ、ワーカーの二重起動は片方が成功し、参照音声の消失は次のジョブまで表面化しない。動いているように見えて、意図した結果になっていない。
この種の不具合を減らすには、「処理が通ったか」ではなく「意図した中身になっているか」を確認する工程を挟むしかない。音素列を1行ログに出す、話速を実測する、起動時にワーカーの有効/無効をログに出す。どれも数分の作業で、後から数時間を節約する。
シリーズ: 拡散TTSから実用ボイスを量産する
キャプション1行から声を設計し、学習コーパスを製造して、役割別の実用ボイスを量産するまでの記録です。この記事は第4部 運用にあたります。
← 前: 70分かけた学習素材が、ネットワークの一瞬の瞬きで消えた → 次: 登録経路が4つ、管理画面が0
シリーズ全18本
- 音質で選んだTTSが、会話には遅すぎた
- 声をガチャで引く
- 「ナレーターっぽい声」を24候補から機械に選ばせる
- 品質ゲートを厳しくするほど、棒読みが生き残る
- 学習後に話速は変えられない
- 生成するたび「録音場所」が変わるTTS
- クリップ1本の粗さが、スタイル全体を嗄れさせる
- AIが「こんにちわー」と伸ばす癖は、どこで身についたのか
- 「少々」が「しょも」になる — 許可文字リストが日本語を削っていた
- ハルシネーション対策のコードが、ハルシネーションの時だけ動かなかった
- 品質ゲートが許した「3文字」が、モデルの口癖になった
- 治せる欠陥で候補を落としていた
- 文字起こしでは見つからない欠陥がある
- 70分かけた学習素材が、ネットワークの一瞬の瞬きで消えた 15. “ja” を “JP” と書いて喃語モデルができるまで ← いまここ
- 登録経路が4つ、管理画面が0
- デプロイのたびに互いの成果を消していた
- 測れていないものを閾値で追い込むと、必ず失敗する
知見の元になったノートは 拡散TTSから実用ボイスを量産する製造パイプライン にまとめてあります。