日本語 full-duplex 対話モデル J-Moshi-ext に「お嬢様口調」を追加学習する

初めに

前回の記事では、日本語 full-duplex 対話モデル J-Moshi-ext (7.5B) に「あみたろ」の声だけを転移させました。今回はその上に「話し方」を上乗せします。

具体的には、汎用「お嬢様口調」(「わたくし」「〜ですわ / のよ / かしら」) を、前回の voice clone checkpoint (あみたろ声) に対して 2 段構成で追加学習しました。1 段目は text 側の transformer のみ、2 段目は全パラメータを対象にしています。

結果は以下のとおりです。

  • 話し方 (お嬢様語尾) の学習は成功。text 側の分布が明確に shift した (perplexity 検証で +83-89% gain)
  • あみたろ声はほぼそのまま保持され、話し方だけが変わった
  • text 側の transformer だけを学習した軽量版 (全体の ~5% を触るだけ) で、全パラメータ学習と同水準の gain が得られた

デモ

moshi.server の gradio-tunnel 経由でブラウザから live 対話した様子です。

J-Moshi-ext にお嬢様口調を追加学習したデモ

https://youtu.be/YXOF1onNkYI

「声はあみたろ、語尾は〜ですわ / かしら」の応答が返ってくる状態を確認しました。

開発環境

今回の追加学習側のみ記載します。声を移した checkpoint (前回の voice clone) を base に使うところまでは前回記事と同一構成です。

項目
GPU A100 SXM4 80GB × 2 (追加学習)
OS Ubuntu 22.04
Python 3.11
PyTorch 2.7.0+cu126
DeepSpeed 0.15.x (ZeRO-3, bf16, no offload)
Base checkpoint 前回の voice clone checkpoint (ayousanz/phase1b-jmoshi-ft-2026-07-06、あみたろ声)
学習用コーパス 100 dialog / 800 turn の合成お嬢様対話 (JSONL)

何をやったか

最終的な pipeline は 4 段構成です。

  1. 合成お嬢様対話コーパスの生成: 10 topic × 10 dialog = 100 dialog、平均 8 turn。text は Claude Sonnet で生成し、正規表現で「〜ですわ / のよ / ますの / でしょう / かしら」の語尾出現率 ≥ 90% を要求。
  2. 前回の Irodori-TTS (あみたろ Full FT) で 2 話者 stereo 音声化: Speaker A = お嬢様 (あみたろ声)、Speaker B = 執事役 (中立)。24 kHz stereo mix で約 40 分。
  3. 前回の voice clone checkpoint を base に、text 側の transformer だけを追加学習: 全体の ~5% のパラメータのみ trainable、5 epoch。以下「軽量版」。
  4. 同じ base・同じ corpus で全 7.5B パラメータを追加学習: 5 epoch。以下「フル版」。

Moshi は「何を話すか」を担う text 側の transformer (tempformer) と、「どう発音するか」を担う音響側 (depformer) の 2 段構造になっており、軽量版 (3) は音響側を完全に freeze することであみたろ声を保護しつつ、語尾 pattern だけを text 側に学習させる設計です。フル版 (4) は両者を joint に学習して一貫性を狙う版で、比較のため両方走らせました。

うまくいったこと

  • 話し方 (お嬢様語尾) の学習: perplexity 検証で軽量版 +83%、フル版 +89% の gain。両者とも 10 prompt 中 7 個でお嬢様側を選好 (voice clone のみの状態は 6/10)。
  • あみたろ声の保持: live 対話の聴取判定で、フル版でもあみたろ寄りの声色は失われていない。軽量版は音響側 freeze なので当然保持。
  • 軽量版で十分だった: パラメータ ~5% しか触っていない軽量版が、フル版とほぼ同じ perplexity gain を出した。cost / 効果比では軽量版が優位。

うまくいかなかったこと

  • 対話能力の劣化 (base 由来): live 対話では、user 発話への追随が遅れる / 応答が短くなる / 話題が浅くなるといった劣化が体感でかなり感じられる。ただしこれは今回の追加学習で新たに壊したわけではなく、base に使った前回の voice clone checkpoint の時点で既に対話能力が落ちていたものを、そのまま継承してしまった形。前回記事でも副作用として書いた「発音の明瞭度が base より劣化」と同じ layer の話で、Irodori-TTS で合成した対話コーパスの質 (発音精度・応答の自然さ) が上限を規定している。

副産物として得られた知見

  • Base J-Moshi-ext だけでも「わたくしも」「紅茶を淹れました」の後に女性的継続を予測する分布を既に持っていた (お嬢様選好 60%)。日本語事前学習が想定より丁寧語文脈をカバーしている副産物。
  • 稀語彙 (「〜ですわ」「わたくし」) は Irodori-TTS の学習分布外だが、事前の 10 sample × 3 語尾チェックでは 3 group 全 pass。Moshi 側の追加学習でも発音破綻は観測されなかった。

fine-tune までの手順

1) お嬢様対話コーパスの合成

前回の合成対話生成 pipeline (10 topic × 10 dialog = 100 dialog) をそのまま流用し、text 側だけをお嬢様語尾に書き換えます。1 dialog は Speaker A (お嬢様、amitaro voice) と Speaker B (執事役、中立) の 2 話者、平均 8 turn です。

{
  "id": "cp-ojousama-mild-0101",
  "topic": "朝の挨拶",
  "turns": [
    {"speaker": "A", "text": "ごきげんよう、田中。今日もよいお天気ですわね。"},
    {"speaker": "B", "text": "おはようございます。お目覚めはいかがでしたか。"},
    {"speaker": "A", "text": "よく眠れました。窓から鳥のさえずりが聞こえます。"},
    {"speaker": "B", "text": "それは何よりです。朝食のご用意ができております。"},
    {"speaker": "A", "text": "ありがとう。今日はパンにしようかしら。"},
    ...
  ]
}

対応する manifest.jsonl は前回と同構造で、turns[] に turn 単位の話者 / 時刻 / kanji / kana を持ちます。

2) 軽量版: text 側だけを追加学習

moshi-finetune の config を以下のようにします。

# configs/persona-tempformer.yaml
model:
  base: ayousanz/phase1b-jmoshi-ft-2026-07-06  # 前回の voice clone checkpoint
train:
  params_to_ft: tempformer     # text 側だけ、音響側は freeze (~5% trainable)
  num_processes: 2
  lr: 3e-5
  ds_config: configs/zero3-bf16-nooffload.json
  per_device_batch_size: 1
  grad_accum: 4
  precision: bf16
  epochs: 5
data:
  manifest: ojousama_mild_100/manifest.jsonl

Loss は total 0.49 (音響側 1.10、text 側 0.07) で 30 分程度でした。text 側の loss が非常に低く、text 側の transformer が語尾 pattern を pin-point で学習しているのが数字に出ています。

3) フル版: 全パラメータを追加学習

同じ corpus・同じ voice clone base に対して、今度は全パラメータを対象にします。

# configs/persona-full.yaml
model:
  base: ayousanz/phase1b-jmoshi-ft-2026-07-06
train:
  params_to_ft: all            # 全 7.5B
  ...  # 他は軽量版と同一

Loss は total 1.10 (text 側 0.30)。軽量版の text loss (0.07) より高くなるのは、音響側も同時に学習するため text 側だけを一極集中させないためです。

4) checkpoint の後処理

前回同様、ZeRO-3 checkpoint を推論用 safetensors に変換します。今回 tools 側の torch.loadweights_only=True 前提の path があり、ZeroStageEnum の unpickle で落ちるので weights_only=False に patch する必要がありました。

# ZeRO-3 → fp32 safetensors → dep_q=8 変換
python -m deepspeed.utils.zero_to_fp32 ...
python -m moshi.tools.clean_moshi ...

5) Perplexity で「学習できたか」を数値で確認

live 対話に進む前に、text 側だけの数値評価を挟みました。

10 個の同じ prompt に対して「中立語尾」と「お嬢様語尾」の 2 種の続きを用意し、学習後の model がどちらに高い確率を割り当てるかを測定します。両者の log 確率の差が正なら「お嬢様側を選好している」= 学習成功、負なら「中立側を選好」= 学習失敗 という判定です。

実装は Moshi の text stream に prompt + continuation を feed し、forward_text で得られる logits から continuation 部分の log 確率を積み上げるだけです。

# persona_perplexity.py (要約)
prompt_ids = sp.encode(prompt)
cont_ids   = sp.encode(continuation)
# Moshi は text と audio codebook を同時に入力する必要があるので
# audio 側は zero token で埋め、text 側だけに実データを載せる
codes = torch.full((1, K, T), zero_token_id, dtype=torch.long, device=device)
codes[:, 0, :] = torch.tensor([prompt_ids + cont_ids])

with lm.streaming(1):
    _, text_logits = lm.forward_text(codes)
log_probs = torch.log_softmax(text_logits[0, 0], dim=-1)

total_logprob = sum(log_probs[len(prompt_ids)+i-1, tok].item()
                    for i, tok in enumerate(cont_ids))

Test pair の例:

  • 「今日はいい天気」 + 「ですね」 vs 「ですわ」
  • 「行ってみましょう」 + 「か」 vs 「かしら」
  • 「紅茶を淹れました」 + 「よ」 vs 「ますの」

結果:

Checkpoint お嬢様選好率 log prob 合計差 追加学習の効果
Voice clone のみ (前回、あみたろ声だけ入っている状態) 6/10 (60%) +6.48 基準
軽量版 (text 側だけ追加学習) 7/10 (70%) +11.86 +5.38 (+83%)
フル版 (全パラメータ追加学習) 7/10 (70%) +12.26 +5.78 (+89%)

顕著な gain 例 (Δ = フル版 − Voice clone のみ):

  • 「よくおできになった」 + 「でしょう」: -1.06 → +0.81 (逆転, +1.87 gain)
  • 「今日はいい天気」 + 「ですわ」: -1.77 → -0.08 (ほぼ同等, +1.69 gain)
  • 「それは素敵」 + 「ですわ」: -1.48 → -0.15 (ほぼ逆転, +1.33 gain)

Voice clone だけの状態でも既に 60% はお嬢様側を選好している ― これは J-Moshi-ext の大規模日本語事前学習の副産物と考えられます (「わたくしも」「紅茶を淹れました」等の丁寧文脈で女性的継続を予測する分布が pre-training に含まれている)。今回の追加学習はその上にお嬢様の語尾パターンを積み増した形です。

6) Live 検証 (moshi.server + gradio-tunnel)

学習した checkpoint を実 live 対話で確認します。今回は Mac ↔ vast.ai の直接 SSH port forwarding が過去に不安定だったため、moshi.server 内蔵の gradio-tunnel を使う経路にしました。

# vast.ai 側 (RTX 3090)
pip install "moshi==0.1.0" "sphn<0.2" gradio

# フル版 checkpoint を HF から取得
huggingface-cli download ayousanz/moshi-persona-stage3-ojousama-2026-07-06 \
  model.safetensors moshi_lm_kwargs.json --local-dir /workspace/stage3

# gcc を install (torch.compile の triton kernel build に必要)
apt-get install -y gcc g++
export CC=/usr/bin/gcc CXX=/usr/bin/g++

# moshi.server を gradio-tunnel 付きで起動
python -m moshi.server \
  --host 0.0.0.0 --port 8998 \
  --hf-repo nu-dialogue/j-moshi-ext \
  --moshi-weight /workspace/stage3/model.safetensors \
  --gradio-tunnel

数分後にログに Tunnel started, ... https://xxx.gradio.live が出るので、その URL を Mac / iPhone のブラウザで開き、マイク permission を許可して対話開始。声色はあみたろのまま、応答語尾に「〜ですわ / かしら / でしょう」が混ざる状態を確認できました。

詰まったポイントと解決

(a) sphn の API 変更で server が音声受信で crash する

moshi==0.1.0 は Opus 音声受信で sphn.OpusStreamReader.read_pcm() を呼びますが、sphn>=0.2.0 ではこの API が消えています。ブラウザ側から見ると WebSocket 接続後すぐに切断され、DevTools console に以下が延々出続けます。

index-XXX.js:425 WebSocket is already in CLOSING or CLOSED state.
index-XXX.js:425 WebSocket is already in CLOSING or CLOSED state.
...

Server 側は表向き Running on http://0.0.0.0:8998 まで正常に見えますが、実接続で以下が発生します。

File ".../moshi/server.py", line 115, in opus_loop
  pcm = opus_reader.read_pcm()
AttributeError: 'builtins.OpusStreamReader' object has no attribute 'read_pcm'

対処は sphn を 0.1.12 に downgrade するだけです。

pip install "sphn<0.2"

moshi==0.1.0 を使う限り必ず踏むので、実質的な requirement pin です。

(b) moshi_lm_kwargs.json は復元 side channel

moshi.server は checkpoint の .safetensors しか読まず、model architecture は moshi/models/loaders.py::_lm_kwargs に hardcode されています。fine-tuning で config を変えた場合、moshi_lm_kwargs.json を checkpoint と同じ dir に置いておくと後から可搬性が保てます。今回は base J-Moshi-ext と同一 architecture (dim=4096, num_layers=32, dep_q=8) なので trivial ですが、Depformer size を変える実験を後で走らせる場合は必須です。

評価結果

4 次元で見ます。

能力 素の J-Moshi-ext 前回 (voice clone のみ) 今回 (話し方も追加) 判定
テキスト生成の破綻の有無 正常 保持 保持 + お嬢様化 良化
声質 (target = あみたろ) 汎用 Moshi 声 あみたろ寄り あみたろ寄り (保持) 保持
対話能力 (turn-taking) 動作 保持 保持 保持
話し方 (お嬢様語尾) ほぼ中立 (60%) ほぼ中立 (60%) お嬢様選好 70% / +89% gain 転移成功

軽量版とフル版の対比。

項目 軽量版 (text 側だけ) フル版 (全パラメータ)
学習対象のパラメータ割合 ~5% 100% (7.5B)
Loss (合計) 0.49 1.10
Loss (text 側のみ) 0.07 0.30
お嬢様選好率 7/10 7/10
log prob gain (vs voice clone のみ) +5.38 (+83%) +5.78 (+89%)

結論

前回 voice clone した J-Moshi-ext の上に、合成お嬢様対話 100 dialog を text 側だけの追加学習 (軽量版) または全パラメータ追加学習 (フル版) で上乗せすることで、あみたろの声を保持したまま、お嬢様の話し方だけを text 層に焼き込むことができました。Perplexity 検証で voice clone base から +83-89% の gain、live 対話でも語尾の shift を確認しています。Cost はコーパス生成 $2-3 + 学習 $5-15 + live check $0.5 程度、合計 $10-20 に収まりました。

参考

日本語 full-duplex 対話モデル J-Moshi-ext に「あみたろ」の声を追加学習する

初めに

Kyutai の Moshi は、12.5 Hz frame 単位で「聞く / 話す」を同時に動かす full-duplex 音声対話モデルです。日本語版として名古屋大 nu-dialogue から J-Moshi-ext (7.5B, Helium を日本語約 10,000 時間で継続事前学習) が公開されています。

一方で「特定の声で話させたい」という要求は、Moshi のような end-to-end full-duplex モデルではとても厄介です。話者 identity が Temporal Transformer と Depth Transformer の joint 分布に埋め込まれているため、cascade 型 TTS のように「声だけ差し替える」ことができません。

今回は J-Moshi-ext に 「あみたろ」 (amitaro.net の公式コーパスの声を追加学習し、full-duplex 対話能力を保ったまま声質だけを移せるかを検証しました。

結果は以下のとおりです。

  • 声質の転移(それっぽい声になっているが似ているかどうかは怪しい) + 対話能力の保持 は成功
  • 発音の明瞭度 は base より劣化 (副作用)
  • ボトルネックは Moshi fine-tune 側ではなく、種になる データセット (Irodori-TTS で合成した対話コーパス) の質

開発環境

Moshi 側の fine-tune 環境を記載します。Irodori での合成データ作成は手元の PC (数 GB VRAM の GPU) でも動くので割愛します。

項目
GPU A100 SXM4 80GB × 2
OS Ubuntu 22.04
Python 3.10
CUDA 12.4
PyTorch 2.4.0+cu124
DeepSpeed 0.15.x (ZeRO-3, bf16, no offload)
Base 音声 LM nu-dialogue/j-moshi-ext (dep_q=16, 7.5B)
学習リポジトリ ayutaz/moshi-finetune (nu-dialogue 版からの fork + 2 patch)
Corpus あみたろコーパス (ITA 2.2 + MANA、実 189.9 分 = 3.16h)

何をやったか

最終的な pipeline は 3 段構成です。

  1. TTS 側: Irodori-TTS を「あみたろ」で full param fine-tune し、単一話者 TTS として validate する
  2. 合成対話生成: 上の TTS で 2 話者対話音声を 100 対話 (612 turn, 31.7 分) 合成する
  3. Moshi 側: 合成対話コーパスで J-Moshi-ext を 全パラメータ fine-tune する (5 epoch)

この構成に至る前に、Depformer-only LoRA / Hybrid System Prompt (PersonaPlex) / Depformer-only fine-tune を試しましたが、いずれも J-Moshi-ext + 日本語では機能しませんでした。詳細は後半の「学習時に困ったこととその対応方法」で触れます。

fine-tune までの手順

1) Irodori-TTS を「あみたろ」で fine-tune する

Aratako/Irodori-TTS-500M-v3 をベースに、あみたろコーパス (ITA 2.2 + MANA) で全パラメータ fine-tune し、あみたろ声の単一話者 TTS にします。これが次の合成対話生成の「Speaker A の声」になります。学習自体は数 GB VRAM の GPU で数十分程度なので、詳細は Irodori-TTS のリポジトリを参照してください。

なお、この記事のメインは J-Moshi 側の学習なので TTS 側は種として扱います。

2) 合成対話データセットを作る

Irodori の全 param FT あみたろ (Speaker A) と、Irodori base モデル (Speaker B、別声) の 2 話者で対話音声を合成します。

  • 100 個の 2 話者 script (10 topic × 10 対話。天気 / 食 / 仕事 / 家族 / 旅行 / 趣味 / 健康 / 買い物 / 技術 / 雑談)
  • 総 612 turn、テキストは pyopenjtalk で漢字 → カナ (YOMI) 変換
  • Stereo mix (Left=A, Right=B, Moshi format)、24 kHz、31.7 分
  • プロセス内バッチ推論 で subprocess-per-turn の 10 倍高速化

出力ファイルは以下の構成です。

synthetic-dialogs-2026-07-04/
├── stereo/dlg_001.wav ... dlg_100.wav   # 100 × 24 kHz stereo (A=Left, B=Right)
├── manifest.jsonl                        # 100 行、1 行 = 1 dialog metadata
├── dialogs_source.jsonl                  # 100 行、original script (kanji + kana)
└── README.md

manifest.jsonl は 1 行 1 dialog で、各 dialog は turns[] に turn 単位の話者 / 時刻 / テキストを持ちます。

{
  "id": "dlg_001",
  "topic": "weather_sunny",
  "wav_path": "/workspace/data/dialog_stereo/dlg_001.wav",
  "sample_rate": 24000,
  "duration_sec": 18.2,
  "turns": [
    {"turn_id": 0, "speaker": "A", "start_sec": 0.0, "end_sec": 1.9,
     "text_kanji": "おはよう、今日はすごくいい天気だね。",
     "text_kana": "オハヨー、キョーワスゴクイイテンキダネ。"},
    ...
  ]
}

3) J-Moshi-ext を全 param fine-tune する

moshi-finetune の config を以下のようにします。

# configs/j-moshi-ext-amitaro.yaml (抜粋)
model:
  base: nu-dialogue/j-moshi-ext
train:
  params_to_ft: all           # 全 7.5B が学習対象
  num_processes: 2            # A100 SXM4 × 2
  lr: 3e-5                    # AdamW
  ds_config: configs/zero3-bf16-nooffload.json
  per_device_batch_size: 1
  grad_accum: 4               # effective batch 8
  precision: bf16
  epochs: 5                   # 65 step
data:
  manifest: synthetic-dialogs/manifest.json

DeepSpeed 側の zero3-bf16-nooffload.json は CPU offload を切っています (A100 80GB × 2 なら余裕がある + offload させると 5-10 倍遅くなる)。

accelerate launch \
  --config_file configs/accelerate_ds.yaml \
  train_moshi.py --config configs/j-moshi-ext-amitaro.yaml

28 分、Loss は 12.53 → 1.24 でした。

4) checkpoint の後処理

ZeRO-3 checkpoint はそのままだと推論できません。以下の順で変換します。

# 1. ZeRO-3 (94 GB) → fp32 safetensors (32 GB)
python -m deepspeed.utils.zero_to_fp32 \
  ckpt/step_65 ckpt/step_65_fp32.safetensors

# 2. dep_q=16 → dep_q=8 変換 (推論用に user_stream 側を残す)
python -m moshi.tools.clean_moshi \
  --input ckpt/step_65_fp32.safetensors \
  --output ckpt/inference_ready.safetensors \
  --remove_modules_for_user_stream

# 3. 推論用サイズは 15 GB

5) live 検証

学習に使ったサーバ上で moshi.server を立て、Mac から SSH port forwarding で Web UI にアクセスします。

# 推論サーバ側
python -m moshi.server --moshi_weight ckpt/inference_ready.safetensors --port 8998

# Mac 側
ssh -N -L 8998:localhost:8998 root@<server_ip> -p <ssh_port>
open http://localhost:8998

ブラウザで話しかけると、あみたろ寄りの声で応答してきます。

詰まったポイントと解決

(a) Depformer-only fine-tune は対話能力を破壊する

最初は「音響側だけ動かせば安く済むだろう」と思い、Depformer だけを実あみたろ corpus (2.58h) + 合成 320 対話で fine-tune しました。Audio loss は 6.88 → 1.02 と綺麗に下がります。ここで「うまくいっている」と勘違いします。

moshi.server を立てて話しかけると:

  • ユーザー入力を完全に無視して独話 loop
  • 日本語 system prompt では「ありがとうございましたー ありがとうございましたー バイバイ ヒヒヒヒヒ...」に mode collapse
  • 声はあみたろではなく汎用日本語女性 + 強いアーティファクト

原因は Depformer と Tempformer の役割分担です。Depformer は「1 frame ごとの RVQ 生成 (どう発音するか)」を担当し、Tempformer が「何を話すか」を持ちます。Depformer だけを朗読 corpus (1 話者独話, 平均 2.4 秒) で FT すると、tempformer に「応答」を返す信号が伝わらず、Depformer 側は "応答しないこと" を学習します。 loss は下がっているのに実運用では死んでいる、という典型的な学習信号の設計ミスです。

対処は 全パラメータ fine-tune + 2 話者 stereo 合成対話 に切り替えることでした。「合成音でいいのか」という懸念はデータセット品質 (合成対話コーパスの発音精度) の議論に移りますが、少なくとも mode collapse は消えました。

(b) SECS 単独評価が「棒読み」を高スコアにする

Approach 5 の step 200 で SECS 0.534 を観測して「いい線」と思ったのですが、実音を聴くとあみたろではありませんでした。

原因は 2 つあります。

  1. Silence の混在。ECAPA-TDNN embedding は有声区間の spectral profile に支配され、silence が多い出力は「silence baseline」に引きずられます。有声区間だけ抽出 + RMS 正規化してから embedding を取らないと、+0.2〜0.3 のアーティファクトが乗ります
  2. 識別性能への過剰適合。Irodori 側で軽量手法 (12K params の speaker embedding のみ学習, SECS 0.763) と全 param FT (500M params, SECS 0.718) を比べると、後者のほうが SECS は低いのに主観的には明確に「あみたろらしい」抑揚が出ています。「高 SECS = 棒読み」の逆相関 が発生します

対処は評価を 3 軸に分けることです。

  1. 有声区間限定 + RMS 正規化した SECS
  2. 日本語 native listener の主観評価
  3. 対話能力の live 確認 (独話 loop していないか)

SECS だけ見ていると (a) の Depformer-only 事故に気付けません。

(c) 朗読コーパスは Moshi 訓練分布と構造的にミスマッチ

公開ライセンス可能な日本語 voice corpus (あみたろ、ITA、MANA、JVS、common voice, …) はすべて 1 話者朗読形式です。声優の録音セッションと TTS 訓練 pipeline から自然に生まれる形態ですが、Moshi は 2 話者対話音声で訓練されています。

この分布ミスマッチは同種データを増やしても解決しません。今回のように合成側で 2 話者 stereo にする、または Speaker A のみ実 corpus、Speaker B は別 TTS といった形で、Moshi 側が期待する分布に合わせて合成する必要があります。

なお、今回の Full FT で発音明瞭度が劣化した根本原因は Irodori 全 param FT あみたろの韻律が朗読側に偏っており、合成対話コーパスがその欠陥を継承しているためです。Moshi fine-tune の失敗に見えて、実際は種になった合成対話データセットの質 (発音精度) が上限を決めていた、というのが最後にわかった構造でした。

(d) --parameters_to_finetune=depformer で DeepSpeed が empty param_group で死ぬ

moshi-finetune の学習スクリプトは tempformer / depformer の 2 つの param_group を常に定義しています。ここで --parameters_to_finetune=depformer を指定すると、tempformer 側の group は requires_grad=False のパラメータしか残らず、実質的に空になります。

DeepSpeed Zero stage 1/2 (stage_1_and_2.py:310-356) は各 param_group を requires_grad で filter したあと flatten_dense_tensors_aligned() を呼びます。空になった group で torch.cat([]) が実行されて以下で落ちます。

RuntimeError: torch.cat(): expected a non-empty list of Tensors

対処は param_group を動的に組み立てて 空 group をあらかじめ落とす ことです。学習率ロギング側も group index ではなく name で参照するように直します。fork にはこの修正を入れた fix: drop empty param_groups so DeepSpeed Zero 1/2 doesn't crash on partial finetune を含めています。

(e) tools/tokenize_text.py は 1 dialog で失敗すると worker 全体が止まる

upstream の tools/tokenize_text.py は per-dialog エラーを catch していません。以下のいずれかで 1 個でも死ぬと worker が停止します。

  • chars[0] の IndexError (空 transcript)
  • assert not char_transcript の失敗 (末尾に空白などが残る)
  • token 数と char 数の不一致 (英字 / 記号 / 絵文字が transcript に混ざるケース)

合成対話や相槌データのように quality が混在するコーパスで tokenize すると、1 ファイル目の途中で終わっている、という状態になります。

対処は per-dialog の処理を try/except で囲み、失敗した dialog は [skip] ログを出して次へ進める、というだけです。fork に feat: skip dialogs that fail to tokenize instead of stopping the whole worker として入れています。

評価結果

4 次元で見ます。

能力 Base J-Moshi-ext fine-tune 後 判定
テキスト生成 (tempformer) 正常 保持 悪影響なし
声質 (target = あみたろ) 汎用 Moshi voice あみたろ寄り 一部転移成功
対話能力 (turn-taking) 動作 保持 mode collapse なし
発音の明瞭度 明瞭 劣化 副作用

Approach 5 (Depformer-only, 実 corpus) との対比。

項目 Approach 5 今回 (Full FT + 合成対話)
声質の転移 失敗 (SECS 0.534 頭打ち) 一部成功
対話能力 失敗 (独話 loop) 保持
テキスト整合性 失敗 (「ありがとう」loop) 保持
発音の明瞭度 観測不能 劣化 (新規 failure mode)

結論

J-Moshi-ext を Irodori-TTS で合成した「あみたろ」2 話者対話 100 対話 (612 turn, 31.7 分) で全 param fine-tune した結果、声質の転移と full-duplex 対話能力の保持は両立しました。一方で発音の明瞭度は base モデルより劣化しています。

参考

StableTTS v1.1を日本語データセットで日本語継続事前学習をする

以下の記事で StableTTS をつくよみちゃんコーパスで fine tuning をしてみました。

ayousanz.hatenadiary.jp

今回は fine tuning の土台になるベースモデル側を改善するために、日本語の演技音声コーパス moe-speech を使って 378 時間の継続事前学習を行い、本家モデルとの比較とつくよみちゃんでの fine tuning までやってみます。

以下がデモ動画になります

youtu.be

初めに

StableTTS は flow-matching と DiT を組み合わせた約 31M パラメータの軽量 TTS モデルで、単一チェックポイントで中国語・英語・日本語に対応しています。話者 ID を使わず、reference encoder が参照音声から話者性を抽出するゼロショット方式です。

github.com

本家 v1.1 のチェックポイント(中英日あわせて約 600 時間で学習)を日本語で評価したところ、以下のような印象でした。

  • 発音・イントネーションはそこまで悪くない
  • ただし表現力(感情の乗り)が低い
  • ゼロショットの話者類似性が低く、つくよみちゃんを参照にしても「似ていると言われれば似ている」程度

本家の学習データのうち日本語は一部(数百時間規模の多言語の中の1言語)なので、日本語データを大量に追加で学習させれば表現力や韻律が改善するのではないか、というのが今回の仮説です。ゼロからの学習ではなく、本家チェックポイントを初期値にした継続事前学習で行います。

作業はフォークリポジトリで行っています。

github.com

結論

項目 結果
表現力 明確に向上(主目的を達成)
長文の韻律 安定性が向上
読みの正確さ 微改善(非劣化を確認)
ゼロショット類似性 378 時間でも依然弱い → fine tuning で解決

ゼロショットの類似性は事前学習で多少改善されましたが、事前学習後のモデルにつくよみちゃん 100 発話で 30 分 fine tuning するだけで、本人らしい声質と改善された表現力を両立できました。

開発環境

  • 学習: RTX 5090 ×2
  • 評価・推論: Windows 11
  • Python 3.13 / PyTorch 2.8 (cu128) / uv

g2p を pyopenjtalk-plus に置き換える

本家の日本語 g2p は素の pyopenjtalk(OpenJTalk 辞書)です。フォークではこれを pyopenjtalk-plus に置き換えて、読み補正(Sudachi による同形異音語の補正や「何」の読みの ONNX 推定)を有効にしています。

# text/japanese.py
labels = pyopenjtalk.extract_fullcontext(sentence, use_vanilla=False)

ポイントは学習と推論で g2p を統一することです。本家チェックポイントは素の OpenJTalk の読みで学習されているため、推論側だけ g2p を良くしても学習時の音素列と食い違ってしまいます。自前で事前学習をするなら、学習データの前処理から補正込みの g2p で通せるので、g2p 改善の効果をそのまま取り込めます(音素セットは互換なので語彙サイズは変わらず、本家チェックポイントを初期値にできます)。

前処理

StableTTS の学習は音声を事前に mel 特徴量(.pt)へ変換しておく方式です。前処理の並列数がコード内で 2 固定だったので環境変数化して、48 並列で約 20 分で完了しました(g2p 失敗は 0 件)。

PREPROCESS_WORKERS=48 python preprocess.py

学習

学習設定は以下です。モデル構成(v1.1 の 44.1kHz / 128 mel、エンコーダ 3 層・デコーダ 6 層)は変更していないので、生成されるチェックポイントは本家と互換です。

  • 初期値: 本家 v1.1 の checkpoint_0.pt(optimizer なしで配置すると重みのみロードされ epoch 0 から学習できます)
  • batch 32 × 2GPU(実効 64 = 本家と同じ)、lr 1e-4、cosine スケジューラ、warmup 200
  • 15 epochs = 55,170 steps
NCCL_P2P_DISABLE=1 NCCL_IB_DISABLE=1 python train.py

平均 6〜7 it/s で、15 epochs が約 2.5 時間・GPU 費用約 $2.5 で終わりました。31M パラメータの軽量モデルなので、378 時間のデータでもこの程度で回ります。

ハマった点

症状 対処
HF からのダウンロードが無進捗でハング HF_HUB_DISABLE_XET=1 + リトライ
DDP 初期化で SIGSEGV NCCL_P2P_DISABLE=1 NCCL_IB_DISABLE=1(コンシューマ GPU ホストの NCCL P2P 問題)
batch 64 で OOM 長尺バケット(〜1291 フレーム)の attention がメモリを食うため batch 32 × 2GPU に変更
長尺データの 4.74% が黙って学習から除外される バケットサンプラーの境界が 1000 フレーム上限だったため 1300 まで拡張

特に 4 つ目は気づきにくく、filelist の件数と実際に学習に使われる件数が合わないことから発覚しました。長さベースのバケットサンプラーを使うモデルでは、境界の外に落ちたデータはエラーにならず静かに捨てられるので、データ追加時は要注意です。

本家 v1.1 との比較

事前学習後の epoch 14 と本家チェックポイントで、同一テキスト・同一参照音声の A/B 比較をしました。参照はつくよみちゃんと、学習から除外したホールドアウト話者です。

項目 判定 所見
表現力 向上 感情文での演技の乗りが明確に改善
長文の韻律 向上 長文でも安定。ただしカタカナ語・アルファベットのアクセントはまだ不安定
読みの正確さ 微改善 「何」の読みなど、pyopenjtalk-plus 化による非劣化を確認
ゼロショット類似性 課題 多少改善

youtu.be

カタカナ語・アルファベットの弱さは g2p の未知語アクセント推定の限界で、学習データを増やすだけでは解決しにくそうです。英字→カタカナ読みのテキスト正規化を前処理に入れるのが次の改善課題です。

つくよみちゃんで fine tuning

ゼロショット類似性は事前学習では解決しなかったので、前回記事と同じくつくよみちゃんコーパス(100 発話)で fine tuning をします。今回は初期値が「日本語 378 時間で表現力を上げたモデル」になっている点が前回との違いです。

  • 初期値: 事前学習後の checkpoint_14
  • batch 16 × 2GPU、lr 1e-4、warmup 10
  • 401 epochs(約 4,000 steps、実時間約 30 分)で loss は 2.70 → 1.44

エポック別(100 / 150 / 200 / 400)に聴き比べたところ、以下のようになりました。

epoch 所見
100 良好。声質はかなり本人に寄る
200 類似性と音質のバランスが最良 → 採用
400 声がかすれる。100 発話に対する過学習で音質が劣化

piper-plus の学習用 MAS を Super-MAS (Triton GPU) で置き換える

初めに

piper-plus は VITS2 ベースの TTS で、 学習時にテキストと音声フレームのアライメント推定で MAS (Monotonic Alignment Search) を使っています。 既存実装は Glow-TTS 由来の Cython で CPU 上で動くため、 GPU 学習でも MAS のところだけ CPU に降りてボトルネックになります。

そこで Triton GPU 実装の Super-MAS (arXiv:2409.07704) を piper-plus に組み込みました。 デフォルトでは従来の Cython 実装のまま動かし、 pip install "piper-train[super-mas]" で明示的に有効化したとき (かつ GPU テンソルが渡ってきたとき) だけ Triton 版に自動で切り替わる仕組みにしてあります。

Python 3.13 + torch 2.11.0+cu128 + Triton 3.6.0上の RTX 4070 Ti SUPER で計測したところ:

  • MAS 単独では 38-187x の高速化 (shape 依存)
  • MAS は学習 step 全体の 0.4% しか占めない ため、 end-to-end の wall-clock 短縮は約 0.37% にとどまる (= 1 epoch 8h53m なら ~2 分短縮)

github.com

開発環境

  • GPU: NVIDIA GeForce RTX 4070 Ti SUPER
  • OS: Windows 11 + WSL2 (Ubuntu)
  • Python 3.13.14 / PyTorch 2.11.0+cu128 / Triton 3.6.0

実装

src/python/piper_train/vits/monotonic_align/__init__.py に dispatcher を入れて、 GPU + パッケージ導入時のみ Triton kernel に分岐します。

def _use_super_mas(neg_cent: torch.Tensor) -> bool:
    if _super_mas_fn is None:
        return False
    if not neg_cent.is_cuda:
        return False
    return torch.cuda.is_available()


def maximum_path(neg_cent, mask):
    if _use_super_mas(neg_cent):
        return _maximum_path_super_mas(neg_cent, mask)
    # ...既存 Cython パスへフォールバック


def _maximum_path_super_mas(neg_cent, mask):
    # upstream kernel が value を in-place 変更するため clone 必須
    value = neg_cent.detach().to(dtype=torch.float32).contiguous().clone()
    attn_mask = mask.detach().to(dtype=torch.int32).contiguous()
    path = _super_mas_fn(value, attn_mask, dtype=torch.float32)
    return path.to(dtype=neg_cent.dtype)

使い方

opt-in install (commit SHA pin):

pip install "piper-train[super-mas]"

強制 disable したいときは:

export PIPER_DISABLE_SUPER_MAS=1

ベンチマーク

piper-plus の学習で典型的に出現する shape (B=20, T=64-400, S=4T、 T はテキスト長、 S は音声フレーム数) で triton.testing.do_bench(rep=50, warmup=5) 計測。 MAS 単独の wall-time 比較で、 学習全体の end-to-end 時間ではないことに注意。

Shape (B, T, S) Triton (ms) Cython (ms) Speedup
(20, 64, 256) 0.20 7.41 37.7x
(20, 128, 512) 1.00 66.61 66.5x
(20, 192, 768) 6.29 87.42 13.9x (※ outlier)
(20, 256, 1024) 1.32 246.98 186.8x
(20, 400, 1600) 3.66 417.88 114.1x
  • 頻出する T=64-128 範囲では 38-67x の高速化を実測
  • 大行列の T=256-400 では 114-187x に増幅 (CPU↔GPU 転送オーバーヘッドの除去が効くため)
  • T=192 は単発測定の outlier (Triton autotune の cache warm-up タイミング依存、 同じ条件で T=256 が 1.32ms に戻っているため)

学習 1 step での end-to-end 計測

SynthesizerTrn を piper-plus の標準 hparams (hidden=192 / n_layers=6 / MB-iSTFT decoder) で初期化、 forward+backward+optimizer.step 1 step を Triton ON / Cython OFF で各 30 回実測 (warmup 15)。

項目 Triton ON Cython OFF
MAS median 1.69 ms 15.90 ms
MAS 占有率 (step 内) 0.027 % 0.42 %

つまり MAS は学習 step 全体に対して 0.4% 程度しか占めていない ことが実測で分かりました (Glow-TTS 論文の「2% 未満」 推定より更に低い)。

end-to-end の短縮見込みは:

0.42 % × (1 − 1/9.4) ≈ 0.37 %

= 1 epoch 8h53m に対して 約 2 分短縮。 MAS 単独で 30-100x になっても、 全体に占める割合が小さすぎるため、 学習 wall-clock の改善はマージナルです。 大バッチ + 長文 dataset (T=400 以上) では MAS 占有率がもう少し上がる可能性がありますが、 桁が変わるほどではないでしょう。

参考

ModernBERT-Ja で日本語 TTS(G2P)の誤読を検出する

はじめに

日本語の TTS(音声合成)、特に OpenJTalk のような G2P(辞書)ベースの方式 を使っていて一番困るのが、固有名詞や新語の 誤読 です。Hugging Face がぎこちなく区切られたり、潤羽るしあ のような VTuber 名がまったく違う音で読まれたり。辞書に無い語はそのまま事故になりますが、今は「合成して耳で聞いてはじめて気づく」後手対応しかありません。

これを 合成する前に「ここが事故りそう」と一覧で出す モデルを作りました。 yomi-linter という名前で、入力した日本語文から TTS が誤読しそうな固有名詞・未知語のスパンを返します。

例 A: 技術記事の事前チェック(英字固有名詞混在)

uv run python scripts/lint.py --quantize --format text \
  "PyTorch で学習した LangChain アプリを Anthropic に繋いだ"
検出 3件:
  ⚠ PyTorch    (0-7)    score 1.0
  ⚠ LangChain  (13-23)  score 1.0
  ⚠ Anthropic  (28-38)  score 1.0

例 B: 漢字専門語(医療)

uv run python scripts/lint.py --quantize --format text \
  "蜂窩織炎の症状が出た患者に冠動脈瘤の検査も実施した"
検出 2件:
  ⚠ 蜂窩織炎の  (0-5)    score 0.974
  ⚠ 瘤          (16-17)  score 0.916

蜂窩織炎 は OpenJTalk が「ハチ窩織エン」と誤読する語で、これを正確に検出できています。冠動脈瘤 は「瘤」だけ拾って熟語全体を取れていません(既知の取りこぼし)。

例 C: ニッチ固有名詞(辞書では絶対に拾えない)

uv run python scripts/lint.py --quantize "潤羽るしあの配信で魔王魂の楽曲が流れた"
[
  {"surface": "潤羽るしあの", "start": 0, "end": 6, "label": "RISK", "score": 1.0},
  {"surface": "魔王魂", "start": 9, "end": 12, "label": "RISK", "score": 0.999}
]

VTuber 名 潤羽るしあ とフリー BGM レーベル 魔王魂。どちらも汎用的な読み辞書には載っていませんが、しっかり検出できています。

demo

hugginfaceのspaceでデモを公開しています

モデルは以下で公開しています

huggingface.co

背景

「固有名詞の読みなら辞書を引けば済むのでは?」という発想は自然です。実際、日本語の読み辞書として有名な JmnedictFurigana + JmdictFurigana を合わせると 82 万語あります。

ところが、これを筆者のブログコーパスにぶつけて検証したところ、自分のニッチ語彙の 2% しかカバーできない という結果でした。VTuber 名、新興サービス名、技術略語、人物名 ― そのほとんどが 82 万語には載っていません。

辞書を諦めると、次の選択肢はモデルです。日本語の固有名詞・未知語を文脈から検出するために、軽量で日本語専用にチューニングされている ModernBERT-Ja(MIT)をベースに選びました。130m / 70m / 30m のサイズが公開されていて、用途で選び分けができる点も決め手です。

データセットの作成

固有表現抽出(NER)のモデルを fine-tune するには、ラベル付きデータが必要です。ここで人手アノテーションを始めるとコストが青天井になるので、学習データは全部自動生成する 方針にしました。

データ源は 2 系統です。

①辞書ミス抽出(LLM 不要) ― MeCab(UniDic)と pyopenjtalk の両方で形態素解析・読み付与をかけて、読みが一致しない語 を集めます。これだけで「OpenJTalk が読み間違える語」が無料で取れます。

②固有名詞 NER ― 文中の固有名詞スパンは LLM で合成データを作成しました。

①と②を BIO 整列して、自分のブログ + Wikipedia NER データセット を結合して学習データに使います。Wikipedia を加えることで、作者のニッチ語彙を保持したまま、汎用的な日本語の固有名詞にも汎化させられます。

学習

最初に学習したモデル(=「漢字弱ラベル注入前」)は 蜂窩織炎 のような漢字熟語を取りこぼしがちでした。そこで UniDic と OpenJTalk の読みが食い違う漢字熟語を抽出して codex で監査し、真の誤読語 118 個をキャリア文 107 文に注入して追加学習。こうしてできたのが本記事の最終モデル(=「漢字弱ラベル注入後」)です。

評価

OpenJTalk が実際に誤読する固有名詞・専門語を、モデルが合成前に拾えた割合(recall)で比較しました。

比較対象 純日本語固有名詞 漢字専門語(医療含む)
OpenJTalk + 読み辞書のみ 14%
モデル(漢字弱ラベル注入前) 71-100% 47%
モデル(漢字弱ラベル注入後・本記事) 71-100% 82%

漢字弱ラベル注入で、特に医療ドメインの recall は 0/6 → 5/6 まで改善しました。

LFM2.5-Audio-1.5B-JP の音声対話を CUDA Graph でリアルタイム化する

初めに

LFM2.5-Audio-1.5B-JP は、音声入力に対してテキストと音声を生成する日本語 speech-to-speech(s2s)会話モデルです。

前回までの記事で、このモデルに LoRA でずんだもん口調と声を追加学習しました。ただし実際に動かすとリアルタイムでは推論ができなかったので、これを高速化してリアルタイムに推論できるようにしてみました。

ayousanz.hatenadiary.jp

結論

1 生成ステップを分解すると、Depthformer の 8 codebook 逐次生成が全体の約 58% を占める単一最大のボトルネックでした。そこで Depthformer と backbone を CUDA Graph 化したところ、本番品質(top_k=4 サンプリング)で RTF 0.580 まで高速化できました。

開発環境

項目 バージョン / 構成
OS Windows 11
GPU RTX 4070 Ti SUPER
パッケージ管理 uv
Python 3.12
torch 2.11 + CUDA 12.8 wheel
liquid-audio 1.3.0
peft 0.19.1

Windows では Triton の公式ビルドがないため、torch.compile が実質使えません。

import os

os.environ.setdefault("NO_TORCH_COMPILE", "1")
os.environ.setdefault("NO_CUDA_GRAPH", "1")
os.environ.setdefault("PYTHONUTF8", "1")
os.environ.setdefault("PYTHONIOENCODING", "utf-8")

対策: Depthformer と backbone を CUDA Graph 化する

ボトルネックの大きい順に、Depthformer → backbone と 2 段階で Graph 化します。

Depthformer ループ

greedy 生成なら _sample_audio_frame(8 codebook を生成するループ全体)は静的な CUDA オペ列になるため、ループ丸ごとを単一の CUDA Graph に捕捉できます。ロード済みの modelLFM2AudioModel)に対して、次のように本物の _sample_audio_frame を「graph を replay する関数」へ差し替えます。

import torch

hidden = model.lfm.config.hidden_size
# replay のたびに値をコピーする固定入力。出力も固定アドレスに返る
static_emb = torch.randn(hidden, device="cuda", dtype=torch.bfloat16)

# 1) warmup(capture 前に数回まわしてアロケータを安定させる)
s = torch.cuda.Stream()
s.wait_stream(torch.cuda.current_stream())
with torch.cuda.stream(s):
    for _ in range(5):
        model._sample_audio_frame(static_emb, temperature=None, top_k=None)
torch.cuda.current_stream().wait_stream(s)
torch.cuda.synchronize()

# 2) 1 フレーム分(8 codebook 逐次)を丸ごと 1 つの graph に捕捉
g = torch.cuda.CUDAGraph()
with torch.cuda.graph(g):
    static_out = model._sample_audio_frame(static_emb, temperature=None, top_k=None)

# 3) 「入力をコピー → replay → 出力を返す」で差し替える
def graphed_frame(embedding, *, temperature=None, top_k=None):
    static_emb.copy_(embedding)
    g.replay()
    return static_out.clone()

model._sample_audio_frame = graphed_frame

あとは通常どおり generate_interleaved を回すだけで、フレーム生成部が graph replay になります。end-to-end は次のとおりです(greedy。入力が同一ならフレーム数も一致するので、差はフレーム生成の速度だけです)。

条件 RTF フレーム時間
eager-greedy 2.500 約 200ms/frame
graphed-greedy 1.051 約 84ms/frame

最大のボトルネック(58%)をほぼ消去でき、出力トークンは eager と完全一致しました。ただしこれ単独では RTF<1.0 を跨げません(1.051)。残る backbone + mimi が 80ms 予算を僅かに超えるためです。

backbone

backbone は decode のたびに KV キャッシュが伸びるため、そのままでは単一グラフを replay できません。ここで HF の StaticCache が LFM2 hybrid をネイティブ対応していることが効きました。固定アドレスのバッファに KV を書き込むので、prefill だけ eager で済ませ、seq=1 の decode step を 1 度だけ捕捉して各ステップ replay すれば正しく動きます。生成ループ内の self.lfm(...) 呼び出しを、次の関数に置き換えます。

import torch
from transformers.cache_utils import StaticCache

lfm = model.lfm
hidden = lfm.config.hidden_size
# 固定長バッファ。KV を固定アドレスに書くので decode step を graph 化できる
cache = StaticCache(config=lfm.config, max_cache_len=prefill_len + max_new_tokens + 8)

graph, static_out = None, None
static_in = torch.empty(1, 1, hidden, device="cuda", dtype=torch.bfloat16)

# warmup は KV を書き換えてしまうので、前後でスナップショット/復元する
def snapshot(c):
    return [{k: v.clone() for k, v in vars(l).items() if torch.is_tensor(v)} for l in c.layers]
def restore(c, snap):
    for l, d in zip(c.layers, snap):
        for k, t in d.items():
            getattr(l, k).copy_(t)

def lfm_decode_step(emb):  # emb: (1, 1, hidden)。prefill は eager で済ませた後の各 decode step
    global graph, static_out
    if graph is None:
        snap = snapshot(cache)
        s = torch.cuda.Stream(); s.wait_stream(torch.cuda.current_stream())
        with torch.cuda.stream(s):
            for _ in range(3):
                static_in.copy_(emb)
                lfm(inputs_embeds=static_in, past_key_values=cache, use_cache=True)
        torch.cuda.current_stream().wait_stream(s); torch.cuda.synchronize(); restore(cache, snap)
        graph = torch.cuda.CUDAGraph()
        with torch.cuda.graph(graph):
            static_out = lfm(inputs_embeds=static_in, past_key_values=cache, use_cache=True)
        restore(cache, snap)
    static_in.copy_(emb)
    graph.replay()
    return static_out.last_hidden_state.clone()

ポイントは、position_ids と attention mask が cumulative_length(固定アドレスの GPU tensor)から毎ステップ再計算されるため、1 度捕捉した graph を replay するだけで各 decode step が正しくなることです。手動の buffer 実装も torch.compile(Triton)も要りません。backbone と Depthformer の両方を Graph 化した end-to-end は次のとおりです。

条件 RTF lfm decode step
eager 2.292 28.30ms
+ depthformer graph のみ 1.156 28.23ms
+ backbone graph(both graph, greedy) 0.747 2.67ms

backbone graph で lfm decode step が 28.30→2.67ms(10.6 倍)になり、RTF < 1.0 を直接達成(0.747)しました。torch.compile(= Triton)を使わず生 CUDA Graph だけで成立するため、Windows でも動きます。

最後に本番品質(サンプリング)。本番のずんだ声生成は top_k=4 の multinomial サンプリングですが、CUDA Graph は乱数生成(multinomial)も捕捉するため、greedy と同じ graph がそのまま使えます。

条件 RTF lfm step
eager 2.243 27.34ms
+ depthformer graph 1.114 27.75ms
+ both graph(greedy) 0.700 2.43ms
+ both graph(sampled top_k=4・本番品質) 0.580 1.21ms

LFM2.5-Audio-1.5B-JP に LoRA でずんだもんの声を追加学習をしてずんだもんの口調およびボイスのモデルを作る

初めに

LFM2.5-Audio-1.5B-JP は、音声入力に対してテキストと音声を interleaved に生成できる日本語 speech-to-speech(s2s)会話モデルです。

前回の記事では、このモデルに LoRA で ずんだもん口調を追加学習しました。ただし前回の口調のみ版は assistant の教師を text-only にしていたため、声色はほぼベースモデルのままでした。

ayousanz.hatenadiary.jp

今回はその続きとして、VOICEVOX ずんだもん音声を教師にして、口調だけでなく声もずんだもん寄りにするところまで進めます。

デモ

youtu.be

開発環境

項目 バージョン / 構成
OS Windows 11
GPU RTX 4070 Ti SUPER 16GB
パッケージ管理 uv
Python 3.12(>=3.12,<3.13 固定)
torch / torchaudio CUDA 12.8 wheel
liquid-audio 1.3.0
peft 0.19.1
ruff dev dependency として導入
VOICEVOX ENGINE ローカル API(http://127.0.0.1:50021

Windows では Triton の公式ビルドがないため、Mimi decode 周りで torch.compile を使うと落ちます。前回の口調のみ版と同じく、liquid_audio import より前に以下を設定して eager 実行に落とします。

import os

os.environ.setdefault("NO_TORCH_COMPILE", "1")
os.environ.setdefault("NO_CUDA_GRAPH", "1")
os.environ.setdefault("PYTHONUTF8", "1")
os.environ.setdefault("PYTHONIOENCODING", "utf-8")

口調のみ版から声込み版で何を変えたか

口調のみ版と声込み版の差分は、assistant の教師データです。

項目 口調のみ版 声込み版
目的 ずんだもん口調だけを学習 ずんだもん口調 + ずんだもん寄りの声を学習
assistant 教師 TextSegment(text=ztext) InterleavedSegment(text=ztext, audio=VOICEVOX音声)
audio_out 損失 0 有効
教師音声 不要 VOICEVOX ずんだもん speaker=3
LoRA target backbone 基線 backbone 基線 + 音声側 target
主な評価 口調率・応答妥当性 口調率・崩壊有無・話者類似度・音声品質

口調のみ版では、以下のように assistant を text-only にしていました。

ChatMessage(role="assistant", content=[TextSegment(text=ztext)])

この場合、audio_out は空なので、モデルは音声 codebook を教師として見ません。つまり、返答テキストはずんだもん口調になりますが、声はベースモデルの声のままです。

声込み版ではここを次の形に変えます。

ChatMessage(
    role="assistant",
    content=[InterleavedSegment(text=ztext, audio=zundamon_voicevox_wav)],
)

InterleavedSegment に同一テキストの VOICEVOX 音声を入れると、mapper が Mimi で audio_out を 8 codebook にエンコードし、assistant 側の text/audio 両方に supervision がかかります。これで、ずんだもん口調とずんだもん寄りの声を同時に学習できます。

重要なのは、InterleavedSegment.text と VOICEVOX 合成元テキストを必ず一致させることです。テキストと音声が違うと、同じ assistant 区間で「この文字列」と「別内容の音声 token」を同時に当てに行くことになり、学習が壊れます。

データ作成

声込み版のデータ作成は、前回の QA/ずんだ口調データに VOICEVOX 教師音声を追加する形です。

1) VOICEVOX ずんだもん音声の合成

data/synth_zundamon.py で、各行の zundamon_text を VOICEVOX ずんだもん speaker=3 で合成します。出力は Mimi と合わせて 24kHz mono WAV にします。

項目 内容
VOICEVOX 音声キャッシュ data/zundamon_audio
音声規模 2095 wav / 約 4.814 時間 / 平均 8.27 秒
音声教師データ 1982 件
mapper LFM2AudioChatMapper(codebooks=8, interleaved_text_tokens=6, interleaved_audio_tokens=9)
context 512

声込み版の 1 サンプルは以下の 3 メッセージ構成です。

samples.append([
    ChatMessage(role="system", content=[TextSegment(text=SYSTEM_ZUNDA)]),
    ChatMessage(role="user", content=[AudioSegment(audio=in_bytes)]),
    ChatMessage(role="assistant", content=[
        InterleavedSegment(text=ztext, audio=z_bytes)
    ]),
])

汎用会話リプレイ行は前回と同じく text-only にして、声を汚さず会話能力の忘却を抑える方針にしています。

assistant_seg = TextSegment(text=replay)

学習

学習スクリプトは口調のみ版と同じ train/train_zundamon_lora.py を使います。口調のみ版では text_only=Trueaudio_loss_multiplier=0 にしていましたが、声込み版では --allow-audio-loss を付けて audio_out 損失を有効化します。

最終採用モデルの構成は以下です。

項目
採用モデル checkpoints/zundamon_m2_v4/best
データ data/dataset_m2/arrow_train_v4 / arrow_val_v4
LoRA rank / alpha r=96 / alpha=192
target q_proj,k_proj,v_proj,out_proj,w1,w2,w3,depth_linear,to_logits
dropout 0.05
semantic codebook factor 1
best val_loss 2.3147 @ step4000
adapter size 約 287MB

実行イメージは以下です。

$env:PYTHONUTF8="1"
$env:PYTHONIOENCODING="utf-8"
$env:NO_TORCH_COMPILE="1"
$env:NO_CUDA_GRAPH="1"

uv run --no-sync python -u train/train_zundamon_lora.py `
    --train-data data/dataset_m2/arrow_train_v4 `
    --val-data data/dataset_m2/arrow_val_v4 `
    --output checkpoints/zundamon_m2_v4 `
    --allow-audio-loss `
    --semantic-codebook-factor 1 `
    --lora-r 96 `
    --lora-alpha 192 `
    --lora-targets q_proj,k_proj,v_proj,out_proj,w1,w2,w3,depth_linear,to_logits `
    --max-steps 4000

TTS 評価は以下です。

tag WavLM cos to ref delta vs base gap closed log-mel cos to ref
base 0.5798 ± 0.0985 +0.0000 +0.0% 0.9652
音声追加学習モデル 0.9054 ± 0.0189 +0.3256 +75.8% 0.9888

実行すると前回と同じく以下のような画面が起動します 話しかけることでずんだもんの口調およびボイスで返信が返ってきます