#音声合成#TTS#機械学習#GPU

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

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

← 記事一覧へ