音楽生成モデルのheartlibで英語・日本語の歌詞付き音楽を生成をする

初めに

以下でOSSで音楽生成モデルが出たので、さわってみます

github.com

開発環境

  • Python 3.10
  • CUDA 12.x対応GPU
  • UV (Pythonパッケージマネージャー)

環境構築

以下の設定をpyproject.tomlに追加(CUDA 12.4 + Flash Attention対応):

  [[tool.uv.index]]
  name = "pytorch-cu124"
  url = "https://download.pytorch.org/whl/cu124"
  explicit = true

  [tool.uv.sources]
  torch = { index = "pytorch-cu124" }
  torchaudio = { index = "pytorch-cu124" }
  torchvision = { index = "pytorch-cu124" }
  flash-attn = { url = "https://huggingface.co/lldacing/flash-attention-windows-wheel/resolve/main/flash_attn-2.7.4+cu126torch2.6.0cxx11abiFALSE-cp310-cp310-win_amd64.whl" }

依存関係のインストールします

  uv sync

モデルのダウンロードします

  # Python APIを使用(Windows環境での文字化け回避)
  from huggingface_hub import snapshot_download

  snapshot_download("HeartMuLa/HeartMuLaGen", local_dir="./ckpt")
  snapshot_download("HeartMuLa/HeartMuLa-oss-3B", local_dir="./ckpt/HeartMuLa-oss-3B")
  snapshot_download("HeartMuLa/HeartCodec-oss", local_dir="./ckpt/HeartCodec-oss")

実行

以下でGradio UIの起動します

  uv run python app.py

起動をすると以下のようになります

日本語の歌詞付きの音楽を生成すると以下になります

CosyVoiceのonnxモデルをUnityで動かして音声合成をするときのopset versionについて

初めに

昨日に CosyVoice3をonnxにして pytorchに依存せずに動かしてみました

ayousanz.hatenadiary.jp

今回はここで変換をしたonnxをUnityで動かして、UnityだけでCosyVoiceのモデルから推論をしてみたいと思います

以下でRepositoryを公開しています

github.com

記事では動かす際にopset問題とGPU対応について詳しく書いていきます。ほかの実装等はリポジトリをご確認ください。

開発環境

項目 バージョン
Unity 6000.0.58f2
Unity AI Interface 2.4.1
OS Windows 11
GPU RTX 4070 Ti SUPER

ONNX Opsetバージョン問題

Unity AI Interfaceの公式ドキュメントでは、以下のように記載されています。

サポートされるONNX opset: 7〜15

opset 15より高いモデルをインポートすると、結果が予測不能になる可能性があります。

uCosyVoiceでは14ファイルのONNXモデルを使用しています。各モデルのopsetバージョンと動作結果は以下の通りです。

Opset モデル 動作
14 campplus.onnx ✅ 正常
15 llm_backbone_initial_fp16.onnx ✅ 正常
15 llm_backbone_decode_fp16.onnx ✅ 正常
15 llm_decoder_fp16.onnx ✅ 正常
15 llm_speech_embedding_fp16.onnx ✅ 正常
15 flow_token_embedding_fp16.onnx ✅ 正常
15 flow_pre_lookahead_fp16.onnx ✅ 正常
15 flow_speaker_projection_fp16.onnx ✅ 正常
15 hift_f0_predictor_fp32.onnx ✅ 正常
15 hift_decoder_fp32.onnx ✅ 正常
16 speech_tokenizer_v3.onnx ✅ 正常
17 text_embedding_fp32.onnx ✅ 正常
17 hift_source_generator_fp32.onnx ✅ 正常
18 flow.decoder.estimator.fp16.onnx ✅ 正常

結果: 全120テストが合格し、opset 16〜18のモデルも問題なく動作しました。

そのためopsetバージョンは「そのバージョンで定義された演算子(オペレータ)が使える」ということがわかります

重要なのは以下の2点です。

  1. モデルが実際に使用している演算子がUnity AI Interfaceでサポートされているか
  2. opsetバージョンの数字そのものではない

LLMベースTTS「CosyVoice3」を完全ONNX化してPyTorchなしで推論する

はじめに

CosyVoice3は、Alibaba FunAudioLLMが開発したLLMベースの音声合成(TTS)システムです。9言語以上に対応し、ゼロショット音声クローニングが可能な最新のTTSモデルです。

今回、このCosyVoice3を完全にONNX化し、PyTorchなしで推論できるようにしました。Unity Sentisでの利用やエッジデバイスへの展開を見据えた取り組みです。

本記事では、ONNXエクスポートから推論実装までの技術的な詳細を解説します。

成果物 (ONNXモデル/推論スクリプト)

huggingface.co

CosyVoice3のアーキテクチャ

CosyVoice3は4段階のパイプラインで構成されています。

テキスト入力
    ↓
[1. Tokenizer] テキスト → トークンID
    ↓
[2. LLM] Qwen2ベース → 音声トークン生成(自己回帰)
    ↓
[3. Flow] DiT + Euler Solver → メルスペクトログラム
    ↓
[4. HiFT] F0予測 + Source生成 + Decoder → 24kHz音声波形

Zero-Shot音声クローニングの仕組み

Zero-Shotモードでは、プロンプト音声から話者特徴を抽出し、任意のテキストをその声で読み上げます。

プロンプト音声
    ├── [CAMPPlus] → 話者埋め込み(192次元)
    ├── [Speech Tokenizer] → 音声トークン(LLMコンテキスト用)
    └── [librosa] → メルスペクトログラム(Flow条件付け用)

開発環境

項目 バージョン
OS Windows 11
Python 3.10
PyTorch 2.5.1+cu124(エクスポート時のみ)
ONNX Runtime 1.18.0
NumPy 1.26.4

ONNXエクスポート

CosyVoice3を14個のONNXモデルに分割してエクスポートしました

  1. LLMのKVキャッシュ: 初回パスとデコードパスで入出力形状が異なる
  2. 精度要件: HiFTはFP32必須、LLM/FlowはFP16で動作可能
  3. Unity Sentis対応: 動的形状の制約を回避

生成されたONNXファイル

ファイル サイズ 精度 用途
text_embedding_fp32.onnx 544MB FP32 テキスト埋め込み(Qwen2)
llm_backbone_initial_fp16.onnx 717MB FP16 LLM初回パス
llm_backbone_decode_fp16.onnx 717MB FP16 LLMデコードステップ
llm_decoder_fp16.onnx 12MB FP16 Logits出力
llm_speech_embedding_fp16.onnx 12MB FP16 音声トークン埋め込み
flow_token_embedding_fp16.onnx 1MB FP16 Flowトークン埋め込み
flow_pre_lookahead_fp16.onnx 1MB FP16 Flow前処理
flow_speaker_projection_fp16.onnx 31KB FP16 話者投影
flow.decoder.estimator.fp16.onnx 664MB FP16 Flow DiT
hift_f0_predictor_fp32.onnx 13MB FP32 F0予測
hift_source_generator_fp32.onnx 259MB FP32 Source信号生成
hift_decoder_fp32.onnx 70MB FP32 HiFTデコーダ
campplus.onnx 28MB FP32 話者埋め込み
speech_tokenizer_v3.onnx 969MB FP32 音声トークナイザー

合計サイズは約3.8GBです。

LLMのKVキャッシュ分割

LLMの自己回帰推論では、KVキャッシュの効率的な管理が重要です。CosyVoice3のLLM(Qwen2ベース)は24層あり、各層にKey/Valueキャッシュが必要です。

初回パス(llm_backbone_initial): - 入力: 全コンテキスト(プロンプト + テキスト埋め込み) - 出力: hidden_states + 24層分のKVキャッシュ

デコードパス(llm_backbone_decode): - 入力: 1トークンの埋め込み + 前ステップのKVキャッシュ - 出力: hidden_states + 更新されたKVキャッシュ

# エクスポート時の入出力定義
dynamic_axes = {
    'input_embeds': {0: 'batch', 1: 'seq_len'},
    'attention_mask': {0: 'batch', 1: 'total_len'},
}
for i in range(24):
    dynamic_axes[f'past_key_{i}'] = {0: 'batch', 2: 'past_len'}
    dynamic_axes[f'past_value_{i}'] = {0: 'batch', 2: 'past_len'}

HiFTのFP32必須問題

当初、HiFTコンポーネントもFP16でエクスポートしましたが、生成音声にノイズが入る問題が発生しました。

調査の結果、HiFTのSTFT/ISTFT処理で数値精度が重要であることがわかりました。FP32に変更することで問題が解決しました。

# HiFTはFP32でエクスポート
torch.onnx.export(
    hift_decoder,
    dummy_input,
    "hift_decoder_fp32.onnx",
    # FP16変換なし
)

PyTorchフリー推論の実装

依存パッケージ

PyTorchなしで推論するため、以下の最小構成で環境を構築しました。

uv init cosyvoice-onnx --python 3.10
cd cosyvoice-onnx
uv add "onnxruntime==1.18.0" "numpy==1.26.4" "soundfile==0.12.1" \
       "librosa==0.10.2" "transformers==4.51.3" "scipy==1.13.1"

バージョン固定の理由: - onnxruntime==1.18.0: 1.19以降はFP16モデルで互換性問題あり - numpy==1.26.4: ONNX Runtime 1.18.0はNumPy 2.x非対応

NumPy/SciPyによるSTFT/ISTFT実装

HiFTの波形生成にはSTFT/ISTFTが必要ですが、PyTorchのtorch.stftを使えないため、NumPy/SciPyで実装しました。

CosyVoice3のHiFTは特殊なパラメータを使用しています。

パラメータ 備考
n_fft 16 非常に小さい値
hop_length 4
upsample_rates [8, 5, 3] 120倍アップサンプル
def stft_numpy(x, n_fft=16, hop_length=4, center=True):
    """NumPyによるSTFT実装"""
    if center:
        pad_amount = n_fft // 2
        x = np.pad(x, pad_amount, mode='reflect')

    # ハニング窓
    window = np.hanning(n_fft + 1)[:-1].astype(np.float32)

    # フレーム分割
    num_frames = (len(x) - n_fft) // hop_length + 1
    frames = np.lib.stride_tricks.as_strided(
        x,
        shape=(num_frames, n_fft),
        strides=(x.strides[0] * hop_length, x.strides[0])
    ).copy()

    # 窓関数適用してFFT
    windowed = frames * window
    return np.fft.rfft(windowed, axis=1).astype(np.complex64)

def istft_numpy(stft_matrix, hop_length=4, n_fft=16, length=None, center=True):
    """NumPyによるISTFT実装"""
    window = np.hanning(n_fft + 1)[:-1].astype(np.float32)

    # IFFT
    time_frames = np.fft.irfft(stft_matrix, n=n_fft, axis=1).real.astype(np.float32)

    # Overlap-Add
    num_frames = time_frames.shape[0]
    expected_length = n_fft + hop_length * (num_frames - 1)
    output = np.zeros(expected_length, dtype=np.float32)
    window_sum = np.zeros(expected_length, dtype=np.float32)

    for i in range(num_frames):
        start = i * hop_length
        output[start:start + n_fft] += time_frames[i] * window
        window_sum[start:start + n_fft] += window ** 2

    # 正規化
    nonzero = window_sum > 1e-8
    output[nonzero] /= window_sum[nonzero]

    # centerパディングの除去
    if center:
        pad = n_fft // 2
        output = output[pad:-pad]

    if length is not None:
        output = output[:length]

    return output

推論ループの実装

Zero-Shotモードの推論フローは以下の通りです。

class CosyVoiceONNXInference:
    def inference_zero_shot(self, text, prompt_text, prompt_wav):
        # 1. プロンプト音声の処理
        speech_feat = self.extract_mel(prompt_wav)  # メル抽出
        speaker_embedding = self.campplus(prompt_wav)  # 話者埋め込み
        prompt_speech_tokens = self.speech_tokenizer(prompt_wav)  # 音声トークン

        # 2. テキストトークン化
        prompt_ids = self.tokenizer.encode(prompt_text)
        tts_ids = self.tokenizer.encode(text)

        # 3. 埋め込み生成
        text_embeds = self.text_embedding(prompt_ids + tts_ids)
        speech_embeds = self.speech_embedding(prompt_speech_tokens)

        # 4. LLM推論(自己回帰)
        # 初回パス
        hidden, kv_cache = self.llm_initial(text_embeds, speech_embeds)

        # デコードループ
        speech_tokens = []
        for _ in range(max_length):
            logits = self.llm_decoder(hidden)
            token = sample_token(logits)
            if token == eos_token:
                break
            speech_tokens.append(token)

            token_embed = self.speech_embedding([token])
            hidden, kv_cache = self.llm_decode(token_embed, kv_cache)

        # 5. Flow推論(メル生成)
        mel = self.flow_inference(speech_tokens, speaker_embedding, speech_feat)

        # 6. HiFT推論(波形生成)
        audio = self.hift_inference(mel)

        return audio

発見した問題と解決策

言語タグが発音される問題

当初、CosyVoice3の多言語対応のため <|en|><|ja|> などの言語タグを使用していました。しかし、生成音声でこれらのタグが文字通り発音されてしまう問題が発生しました。

原因: 言語タグはQwen2トークナイザーの特殊トークンではなく、通常のテキストとしてトークン化されていました。

# 言語タグのトークン化結果
tokenizer.encode("<|en|>")
# → [27, 91, 268, 91, 29]  # '<', '|', 'en', '|', '>' の5トークン

解決策: 言語タグを使用せず、CosyVoice3の自動言語検出に任せることで解決しました。

# NG: 言語タグあり
text = "<|en|>Hello, this is a test."

# OK: 言語タグなし(自動検出)
text = "Hello, this is a test."

ONNX Runtimeのバージョン問題

ONNX Runtime 1.19以降でFP16モデルを読み込むと以下のエラーが発生しました。

RuntimeException: Attempting to get index by a name which does not exist

これはONNX Runtime 1.19以降のFP16処理の変更に起因する問題です。ONNX Runtime 1.18.0を使用することで回避しました。

使い方

環境構築

実際にexportしたonnxは以下のように動かすことができます。まずはuvで環境を作ります

uv init cosyvoice-onnx --python 3.10
cd cosyvoice-onnx

uv add "onnxruntime==1.18.0" "numpy==1.26.4" "soundfile==0.12.1" \
       "librosa==0.10.2" "transformers==4.51.3" "scipy==1.13.1" \
       "huggingface_hub>=0.30.0"

次にモデル等をダウンロードします

# ONNXモデル + 推論スクリプト + サンプル音声
uv run python -c "
from huggingface_hub import snapshot_download
snapshot_download('ayousanz/cosy-voice3-onnx',
                  local_dir='pretrained_models/Fun-CosyVoice3-0.5B/onnx')
"

# トークナイザー(元モデルから)
uv run python -c "
from modelscope import snapshot_download
snapshot_download('FunAudioLLM/Fun-CosyVoice3-0.5B-2512',
                  local_dir='pretrained_models/Fun-CosyVoice3-0.5B',
                  allow_patterns=['CosyVoice-BlankEN/*.json', 'CosyVoice-BlankEN/*.txt'])
"

推論実行

uv run python pretrained_models/Fun-CosyVoice3-0.5B/onnx/scripts/onnx_inference_pure.py \
    --text "Hello, this is a test of ONNX inference." \
    --prompt_wav pretrained_models/Fun-CosyVoice3-0.5B/onnx/prompts/en_female_nova_greeting.wav \
    --prompt_text "Hello, my name is Sarah." \
    --output output.wav

100言語での音韻変換に対応しているCharsiuG2Pで日本語精度を・英語精度を試す

初めに

G2Pライブラリの一つであるニューラルG2Pモデル CharsiuG2P で日本語および英語の精度を測ってみます

特徴

項目 内容
ライブラリ CharsiuG2P
モデル charsiu/g2p_multilingual_byT5_small_100
ライセンス MIT
対応言語 100言語(日本語含む)
アーキテクチャ byT5 (Byte-level T5)

提供モデル

モデル PER WER
tiny 8-layer 0.107 0.314
tiny 12-layer 0.098 0.287
tiny 16-layer 0.096 0.281
small (今回検証) 0.089 0.261

開発環境

環境構築

以下でuvで環境を作っていきます

uv init

次に 必要なライブラリを入れます

uv add transformers torch --index-url https://download.pytorch.org/whl/cu124

精度比較

次に英語と日本語の両方の測度と精度を測るためのスクリプトを作成します

"""CharsiuG2P G2P検証スクリプト(日本語・英語)"""

import sys
import io

# Windows環境でのUTF-8出力対応
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')

from transformers import T5ForConditionalGeneration, AutoTokenizer
import torch
import time


def test_language(model, tokenizer, lang_code, lang_name, test_words, device):
    """指定言語でG2P変換テストを実行"""
    print(f"\n[{lang_name}] G2P変換テスト")
    print("-" * 60)

    total_time = 0
    results = []

    for word, expected in test_words:
        input_text = f"<{lang_code}>: {word}"
        inputs = tokenizer(input_text, return_tensors="pt").to(device)

        # ウォームアップ(最初の1回はスキップ)
        if len(results) == 0 and device.type == "cuda":
            _ = model.generate(**inputs, max_length=50)
            torch.cuda.synchronize()

        start = time.time()
        outputs = model.generate(**inputs, max_length=50)
        if device.type == "cuda":
            torch.cuda.synchronize()
        inference_time = time.time() - start
        total_time += inference_time

        phonemes = tokenizer.decode(outputs[0], skip_special_tokens=True)
        results.append((word, phonemes, expected, inference_time))

        print(f"入力: {word}")
        print(f"  出力: {phonemes}")
        print(f"  期待: {expected}")
        print(f"  時間: {inference_time*1000:.1f}ms")
        print()

    avg_time = (total_time / len(test_words)) * 1000
    throughput = len(test_words) / total_time

    print("-" * 60)
    print(f"平均推論時間: {avg_time:.1f}ms/語")
    print(f"スループット: {throughput:.1f}語/秒")

    return results, avg_time, throughput


def run_test(device_name="cpu"):
    """指定デバイスでテストを実行"""
    device = torch.device(device_name)

    print("=" * 60)
    print(f"CharsiuG2P G2P検証(日本語・英語)- {device_name.upper()}")
    print("=" * 60)

    # モデルとトークナイザーのロード
    print("\n[1] モデルをロード中...")
    start = time.time()

    model_name = "charsiu/g2p_multilingual_byT5_small_100"
    tokenizer = AutoTokenizer.from_pretrained("google/byt5-small")
    model = T5ForConditionalGeneration.from_pretrained(model_name).to(device)

    load_time = time.time() - start
    print(f"    ロード完了: {load_time:.2f}秒")
    print(f"    デバイス: {device}")

    # モデル情報
    param_count = sum(p.numel() for p in model.parameters())
    print(f"    パラメータ数: {param_count:,}")

    # ========== 英語テスト ==========
    english_words = [
        ("hello", "hɛˈloʊ"),
        ("world", "wɜːld"),
        ("computer", "kəmˈpjuːtər"),
        ("language", "ˈlæŋɡwɪdʒ"),
        ("phoneme", "ˈfoʊniːm"),
        ("Tokyo", "ˈtoʊkioʊ"),
        ("Microsoft", "ˈmaɪkroʊsɒft"),
        ("Google", "ˈɡuːɡəl"),
        ("psychology", "saɪˈkɒlədʒi"),
        ("knight", "naɪt"),
        ("through", "θruː"),
        ("enough", "ɪˈnʌf"),
    ]

    en_results, en_avg_time, en_throughput = test_language(
        model, tokenizer, "eng-us", "英語 (eng-us)", english_words, device
    )

    # ========== 日本語テスト ==========
    japanese_words = [
        ("東京", "toːkjoː"),
        ("大阪", "oːsaka"),
        ("京都", "kjoːto"),
        ("日本", "nihoɴ / nippoɴ"),
        ("こんにちは", "konnitɕiwa"),
        ("ありがとう", "ariɡatoː"),
        ("コンピュータ", "kompjuːta"),
        ("インターネット", "intaːnetto"),
        ("人工知能", "dʑinkoːtɕinoː"),
        ("機械学習", "kikaiɡakuɕɯː"),
        ("渋谷", "ɕibuja"),
        ("秋葉原", "akihabara"),
    ]

    ja_results, ja_avg_time, ja_throughput = test_language(
        model, tokenizer, "jpn", "日本語 (jpn)", japanese_words, device
    )

    return {
        "device": device_name,
        "en_avg_time": en_avg_time,
        "en_throughput": en_throughput,
        "ja_avg_time": ja_avg_time,
        "ja_throughput": ja_throughput,
        "en_results": en_results,
        "ja_results": ja_results,
        "param_count": param_count,
    }


def main():
    # GPU情報表示
    print("=" * 60)
    print("システム情報")
    print("=" * 60)
    print(f"PyTorch: {torch.__version__}")
    print(f"CUDA available: {torch.cuda.is_available()}")
    if torch.cuda.is_available():
        print(f"CUDA version: {torch.version.cuda}")
        print(f"GPU: {torch.cuda.get_device_name(0)}")
    print()

    results = {}

    # CPUテスト
    results["cpu"] = run_test("cpu")

    # GPUテスト(利用可能な場合)
    if torch.cuda.is_available():
        print("\n\n")
        results["cuda"] = run_test("cuda")

    # ========== サマリー ==========
    print("\n\n" + "=" * 60)
    print("検証サマリー")
    print("=" * 60)

    for device, r in results.items():
        print(f"\n【{device.upper()}】")
        print(f"  英語:")
        print(f"    平均推論時間: {r['en_avg_time']:.1f}ms/語")
        print(f"    スループット: {r['en_throughput']:.1f}語/秒")
        print(f"  日本語:")
        print(f"    平均推論時間: {r['ja_avg_time']:.1f}ms/語")
        print(f"    スループット: {r['ja_throughput']:.1f}語/秒")

    # 速度比較
    if "cuda" in results:
        print("\n【速度比較 (CPU vs GPU)】")
        cpu_en = results["cpu"]["en_avg_time"]
        gpu_en = results["cuda"]["en_avg_time"]
        cpu_ja = results["cpu"]["ja_avg_time"]
        gpu_ja = results["cuda"]["ja_avg_time"]
        print(f"  英語: {cpu_en:.1f}ms → {gpu_en:.1f}ms ({cpu_en/gpu_en:.1f}x 高速化)")
        print(f"  日本語: {cpu_ja:.1f}ms → {gpu_ja:.1f}ms ({cpu_ja/gpu_ja:.1f}x 高速化)")

    print("\n" + "=" * 60)
    print("検証完了")
    print("=" * 60)

    # 結果をファイルに保存
    with open("results.txt", "w", encoding="utf-8") as f:
        f.write("CharsiuG2P G2P検証結果(日本語・英語、CPU/GPU比較)\n")
        f.write("=" * 60 + "\n\n")

        f.write(f"PyTorch: {torch.__version__}\n")
        if torch.cuda.is_available():
            f.write(f"GPU: {torch.cuda.get_device_name(0)}\n")
        f.write(f"パラメータ数: {results['cpu']['param_count']:,}\n\n")

        for device, r in results.items():
            f.write(f"{'=' * 60}\n")
            f.write(f"【{device.upper()}】\n")
            f.write(f"{'=' * 60}\n\n")

            f.write("英語 (eng-us) 変換結果:\n")
            f.write("-" * 60 + "\n")
            for word, phonemes, expected, t in r["en_results"]:
                f.write(f"入力: {word}\n")
                f.write(f"  出力: {phonemes}\n")
                f.write(f"  期待: {expected}\n")
                f.write(f"  時間: {t*1000:.1f}ms\n\n")
            f.write(f"平均推論時間: {r['en_avg_time']:.1f}ms/語\n")
            f.write(f"スループット: {r['en_throughput']:.1f}語/秒\n\n")

            f.write("日本語 (jpn) 変換結果:\n")
            f.write("-" * 60 + "\n")
            for word, phonemes, expected, t in r["ja_results"]:
                f.write(f"入力: {word}\n")
                f.write(f"  出力: {phonemes}\n")
                f.write(f"  期待: {expected}\n")
                f.write(f"  時間: {t*1000:.1f}ms\n\n")
            f.write(f"平均推論時間: {r['ja_avg_time']:.1f}ms/語\n")
            f.write(f"スループット: {r['ja_throughput']:.1f}語/秒\n\n")

        if "cuda" in results:
            f.write("=" * 60 + "\n")
            f.write("速度比較 (CPU vs GPU)\n")
            f.write("=" * 60 + "\n")
            cpu_en = results["cpu"]["en_avg_time"]
            gpu_en = results["cuda"]["en_avg_time"]
            cpu_ja = results["cpu"]["ja_avg_time"]
            gpu_ja = results["cuda"]["ja_avg_time"]
            f.write(f"英語: {cpu_en:.1f}ms → {gpu_en:.1f}ms ({cpu_en/gpu_en:.1f}x 高速化)\n")
            f.write(f"日本語: {cpu_ja:.1f}ms → {gpu_ja:.1f}ms ({cpu_ja/gpu_ja:.1f}x 高速化)\n")

    print("\n結果をresults.txtに保存しました")


if __name__ == "__main__":
    main()

速度測定結果

速度はCPU、GPUでそれぞれ以下のようになりました

項目 英語 日本語
推論速度 958 ms/語 857 ms/語
スループット 1.0 語/秒 1.2 語/秒

GPU (RTX 4070 Ti SUPER)

項目 英語 日本語
推論速度 175 ms/語 167 ms/語
スループット 5.7 語/秒 6.0 語/秒

精度比較

精度評価(英語)

入力 出力 期待値 評価
hello ˈhɛɫoʊ hɛˈloʊ ✅ ほぼ正確
world ˈwɝɫd wɜːld ✅ ほぼ正確
computer kəmˈpjutɝ kəmˈpjuːtər ✅ ほぼ正確
language ˈɫæŋɡwɪdʒ ˈlæŋɡwɪdʒ
phoneme ˈfoʊnməpə ˈfoʊniːm ❌ 余計な文字
Tokyo ˈtoʊkiˌoʊ ˈtoʊkioʊ
Microsoft ˈmaɪkɹoʊˌsɔft ˈmaɪkroʊsɒft ✅ ほぼ正確
Google ˈɡuɡəɫ ˈɡuːɡəl ✅ ほぼ正確
psychology saɪˈkɑɫədʒi saɪˈkɒlədʒi ✅ ほぼ正確
knight ˈnaɪt naɪt
through ˈθɹaʊ θruː ❌ 誤変換
enough ɪˈnəf ɪˈnʌf ✅ ほぼ正確

精度評価(日本語)

入力 出力 期待値 評価
東京 toɯkjoɯ toːkjoː △ 長音がɯに
大阪 oosakazɯki oːsaka ❌ 余計な文字
京都 kjoɯtoɯ kjoːto △ 長音がɯに
日本 nihoɴ nihoɴ
こんにちは koɴnitɕiha konnitɕiwa △ ha/waの違い
ありがとう aɾigatoɯ ariɡatoː △ 長音がɯに
コンピュータ koɴpjɯːta kompjuːta ✅ ほぼ正確
インターネット iɴtaːnetːo intaːnetto ✅ ほぼ正確
人工知能 dʑiɴkoɯtɕinoɯ dʑinkoːtɕinoː △ 長音がɯに
機械学習 kikaigakɯɕɯɯ kikaiɡakuɕɯː
渋谷 ɕibɯtani ɕibuja ❌ 誤読
秋葉原 akibahaɾa akihabara ✅ ほぼ正確

高速・高品質なゼロショットTTS「ZipVoice」をUnity AI Inference Engineで動かす

はじめに

本記事では、Flow Matchingベースの高速音声合成システム「ZipVoice」をUnityで動作させるために行った技術的な取り組みを解説します。事前調査から実装、遭遇した問題の解決策まで、一連の流れを紹介します。

今回作成したライブラリは以下で公開しています。

github.com

ZipVoiceについて

ZipVoiceとは以下のようなTTSです

  • 123Mパラメータの軽量ゼロショットTTS
  • Flow Matchingによる高速生成(4-16ステップ)
  • サンプリングレート: 24kHz
  • 特徴量: Vocos fbank(100次元)

デモ

youtu.be

事前調査

ZipVoiceをUnityで動作させるにあたり、以下の3つの技術課題を解決する必要がありました。

  1. ONNXモデルの推論: ZipVoiceの3つのモデル(TextEncoder, FMDecoder, Vocos)をUnity上で実行する方法
  2. テキストの音素変換(G2P): Python版で使用しているpiper_phonemizeと互換性のある音素変換
  3. 波形生成(ISTFT): Vocosの出力(STFT係数)から音声波形を生成する方法

Unity AI Inference Engine(旧Sentis)の調査

ZipVoiceの3つのONNXモデルをUnityで実行するため、Unity公式のONNX推論エンジンを調査しました。特に、どの演算子がサポートされているかを把握することが重要です。

Unity AI Inference Engine 2.4.1を使用してONNXモデルを推論します。

サポート範囲

  • ONNX Opset: 7-15(ZipVoiceモデルはOpset 15で互換)
  • プラットフォーム: 全Unityサポートプラットフォーム
  • バックエンド: CPU, GPUCompute

未サポート演算子(重要)

演算子 説明 代替手段
FFT/IFFT 高速フーリエ変換 C#で実装(NWavesライブラリ使用)
RFFT/IRFFT 実数FFT C#で実装
If 条件分岐 Python側で静的グラフに変換
Log1p log(1+x) Log(x+1)で代替

特にFFT系の演算子がサポートされていないことは、Vocoderの実装に大きな影響を与えます。

G2P(Grapheme-to-Phoneme)の選択肢

ZipVoiceのTextEncoderは、テキストそのものではなく「音素(phoneme)」を入力として受け取ります。Python版ではpiper_phonemize(espeak-ngベース)を使用してテキストをIPA音素に変換しています。

Unity側でも同じ形式の音素を生成しないと、モデルが正しく動作しません。そのため、Unity上でG2P(文字→音素変換)を実現する方法を調査しました。

選択肢の比較

方式 互換性 Unity完結 実装難易度
espeak-ng DLL 完全互換 ネイティブDLL必要
Misaki (辞書ベース) 要変換 C#
OpenPhonemizer ONNX espeak互換 ONNX推論
CMU辞書 + ルール 要変換 C#

採用: espeak-ng DLL

piper_phonemizeと完全互換であり、piper-unityで実績があるため採用しました。

(背景として自分が以下のようなpiperのforkライブラリを作成しており、piperベースのg2pをある程度理解しているのも関係しています)

github.com

github.com

ISTFT実装の選択肢

ZipVoiceのVocoderであるVocosは、メルスペクトログラムからSTFT係数(magnitude, phase)を出力します。最終的な音声波形を得るには、このSTFT係数に対してISTFT(逆短時間フーリエ変換)を適用する必要があります。

しかし、前述の通りUnity AI Inference EngineはFFT/IFFT演算子をサポートしていません。そのため、ISTFTをUnity側(C#)で実装する必要があり、その方法を調査しました。

選択肢の比較

ライブラリ ISTFT ライセンス Unity互換
NWaves あり MIT .NET Standard対応
FftSharp 要実装 MIT 対応
DSPLib 要実装 - 対応

採用: NWaves 0.9.6

FFT/IFFT実装済みで、MITライセンス、依存関係なしのため採用しました。

これらの調査から以下の構成になりました

コンポーネント 選定技術 バージョン ライセンス
推論エンジン Unity AI Inference Engine 2.4.1 Unity
G2P (Tokenizer) espeak-ng 1.52 GPLv3
ISTFT NWaves + カスタム実装 0.9.6 MIT
Euler Solver C#実装 - -
非同期処理 UniTask 2.5.10 MIT

注意: espeak-ngはGPLv3のため、商用利用時はライセンス確認が必要です。

またシステムアーキテクチャ図は以下のようになりました

┌─────────────────────────────────────────────────────────────┐
│                        uZipVoice                            │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  ┌─────────────┐    ┌──────────────┐    ┌───────────────┐  │
│  │   Input     │    │  Tokenizer   │    │ Text Encoder  │  │
│  │   Text      │───▶│ (espeak-ng)  │───▶│    (ONNX)     │  │
│  │  (string)   │    │              │    │               │  │
│  └─────────────┘    └──────────────┘    └───────┬───────┘  │
│                                                  │          │
│                                                  ▼          │
│  ┌─────────────┐    ┌──────────────┐    ┌───────────────┐  │
│  │   Prompt    │    │   Feature    │    │  FM Decoder   │  │
│  │   Audio     │───▶│  Extractor   │───▶│    (ONNX)     │  │
│  │   (wav)     │    │              │    │ + Euler Solver│  │
│  └─────────────┘    └──────────────┘    └───────┬───────┘  │
│                                                  │          │
│                                                  ▼          │
│  ┌─────────────┐    ┌──────────────┐    ┌───────────────┐  │
│  │   Output    │    │    ISTFT     │    │    Vocos      │  │
│  │   Audio     │◀───│   (NWaves)   │◀───│    (ONNX)     │  │
│  │ (AudioClip) │    │              │    │               │  │
│  └─────────────┘    └──────────────┘    └───────────────┘  │
│                                                             │
└─────────────────────────────────────────────────────────────┘

ONNXエクスポート

次に Pythonで動いていたモデルをUnity向けのonnxをexportする必要があります。エクスポートを行う前に、ZipVoice側のコードに修正が必要です。

ZipVoice側の修正(zipformer.py)

CompactRelPositionalEncodingクラスのforwardメソッドを修正し、ONNX tracing時は事前計算済みの位置エンコーディングを使用するようにします。

# zipvoice/models/modules/zipformer.py

def forward(self, x: Tensor, left_context_len: int = 0) -> Tensor:
    # When scripting or tracing for ONNX export, we use pre-computed PE
    # and avoid the conditional extension logic.
    if torch.jit.is_scripting() or torch.jit.is_tracing():
        # Use pre-computed PE without dynamic extension
        pe = self.pe.to(dtype=x.dtype, device=x.device)
    else:
        # Normal training/inference path with dynamic extension
        self.extend_pe(x, left_context_len)
        pe = self.pe

    x_size_left = x.size(0) + left_context_len
    pe_len = pe.size(0)
    center = pe_len // 2
    pos_emb = pe[
        center - x_size_left + 1 : center + x.size(0),
        :,
    ]
    pos_emb = pos_emb.unsqueeze(0)
    return self.dropout(pos_emb)

エクスポートスクリプト(onnx_export_sentis.py)

エクスポート前に位置エンコーディングを事前計算する処理を追加します。

def _precompute_positional_encodings(model: nn.Module, max_len: int) -> None:
    """Pre-compute positional encodings for all encoder_pos modules."""
    dummy_input = torch.zeros(max_len)

    for name, module in model.named_modules():
        if hasattr(module, 'extend_pe') and hasattr(module, 'pe'):
            module.extend_pe(dummy_input)
            if module.pe is not None:
                logging.info(f"  {name}: PE shape = {module.pe.shape}")

# エクスポート前に呼び出し
max_pe_len = 4000  # ~170 seconds at 24kHz with hop_length=256
_precompute_positional_encodings(model, max_pe_len)

修正済みのコードは以下で公開しています。

github.com

上記の修正を行った後、以下のコマンドでONNXモデルをエクスポートできます。

cd ZipVoice
uv run python -m zipvoice.bin.onnx_export_sentis \
    --model-name zipvoice_distill \
    --onnx-model-dir exp/zipvoice_distill_sentis

モデルファイルはHuggingFaceから自動的にダウンロードされます。ローカルにモデルがある場合は--model-dirオプションで指定できます。

エクスポートが完了すると、以下の3つのONNXファイルが生成されます。

生成されるモデルファイル

text_encoder.onnx(約17MB)

テキストトークンを条件ベクトルに変換します。

入力 形状
tokens [N, T] INT64
prompt_tokens [N, T] INT64
prompt_features_len scalar INT64
speed scalar FLOAT
出力 形状
text_condition [N, T, 512] FLOAT

fm_decoder.onnx(約456MB)

Flow Matchingデコーダ。Euler積分の各ステップで速度ベクトルを計算します。

入力 形状
t scalar FLOAT
x [N, T, 100] FLOAT
text_condition [N, T, 512] FLOAT
speech_condition [N, T, 100] FLOAT
guidance_scale scalar FLOAT
出力 形状
v [N, T, 100] FLOAT

vocos_opset15.onnx(約52MB)

メルスペクトログラムからSTFT係数を生成します。ISTFTはUnity側で実装するため、このモデルはSTFT係数までを出力します。

入力 形状
mel_spectrogram [N, 100, T] FLOAT
出力 形状
magnitude [N, 513, T] FLOAT
phase_cos [N, 513, T] FLOAT
phase_sin [N, 513, T] FLOAT

Tokenizer

ZipVoiceのTextEncoderは、テキスト文字列ではなくトークンID列を入力として受け取ります。そのため、テキスト→音素→トークンIDという変換が必要です。

espeak-ngのネイティブDLLをP/Invokeで呼び出します。

// EspeakNative.cs - P/Invokeラッパー
[DllImport("libespeak-ng")]
public static extern int espeak_Initialize(int output, int buflength, string path, int options);

[DllImport("libespeak-ng")]
public static extern IntPtr espeak_TextToPhonemes(ref IntPtr text, int textmode, int phonememode);

トークン変換の流れ:

  1. espeak-ng初期化: espeak_Initialize()でデータパスを指定して初期化
  2. テキスト→音素変換: espeak_TextToPhonemes()IPA音素列を取得
  3. 音素→トークンID変換: tokens.txtマッピングを使用してIDに変換
  4. 特殊トークン追加: BOS(開始)とEOS(終了)トークンを追加

ISTFTProcessor

Unity AI Inference EngineはFFT/IFFT演算子をサポートしていないため、VocosのISTFT部分をC#で実装する必要があります。Vocosが出力するmagnitude、phase_cos、phase_sinからISTFTで波形を再構成します。

以下のように実装しました。

NWavesライブラリのFFT機能を使用して実装しています。

public float[] Process(float[] magnitude, float[] phaseCos, float[] phaseSin,
                       int numBins, int numFrames)
{
    float[] output = new float[expectedLength];

    for (int frame = 0; frame < numFrames; frame++)
    {
        // 1. 複素スペクトルを構築: real = mag * cos, imag = mag * sin
        for (int f = 0; f < numBins; f++)
        {
            real[f] = magnitude[frame, f] * phaseCos[frame, f];
            imag[f] = magnitude[frame, f] * phaseSin[frame, f];
        }

        // 2. IFFTで時間領域に変換
        _fft.Inverse(real, imag);

        // 3. 窓関数を適用してオーバーラップ加算
        OverlapAdd(output, real, frame * _hopLength);
    }

    return output;
}

最後に

これらを実装してUIを実行することでいかのようになります。

Synthesizeには時間がかかるので、実際に使うにはonnxの量子化の対応など必要になりそうです

参考リンク

ZipVoiceのボコーダーをFlow2GANに置き換えて高速化できるか検証する

はじめに

ZipVoiceは、Flow Matchingを使った高速・高品質なゼロショットText-to-Speech(TTS)システムです。
今回、ボコーダー部分を最近公開されたFlow2GANに置き換えることで推論速度を向上できないか検証しました。

結論から言うと、Flow2GANに置き換えるメリットはありませんでした。 むしろVocosの方が6〜22倍高速という結果になりました。

今回統合した実装は以下のリポジトリ/ブランチに残しています。

github.com

検証環境

項目 内容
OS Windows
GPU NVIDIA CUDA対応
Python 3.11
パッケージ管理 uv

ボコーダーとは

TTSシステムは以下のパイプラインで動作します:

テキスト → モデル推論 → メルスペクトログラム → ボコーダー → 波形

ボコーダーはメルスペクトログラムを音声波形に変換するコンポーネントです。 ZipVoiceはデフォルトでVocosを使用しています。

Flow2GANとは

Flow2GANはFlow MatchingとGANファインチューニングを組み合わせたボコーダーで、1〜4ステップでの推論が可能です。

主な特徴: - 1〜4ステップ推論(設定可能) - Flow Matching + GANファインチューニング - マルチブランチConvNeXtアーキテクチャ

ZipVoiceとFlow2GANは同じMel-Spectrogram仕様(24kHz、100 bins、FFT 1024、hop 256)を使用しているため、理論上は置き換え可能です。

Flow2GANの統合

以下の手順でインストール、統合、推論まで行いました。

uv addでの依存関係追加

Flow2GANをローカルから追加:

uv add flow2gan --editable "C:\path\to\Flow2GAN"

ボコーダーパッケージの作成

VocosとFlow2GANを切り替えられるように、統一インターフェースを作成しました:

from abc import ABC, abstractmethod
import torch

class BaseVocoder(ABC):
    @abstractmethod
    def decode(self, mel: torch.Tensor) -> torch.Tensor:
        """(B, 100, T) -> (B, 1, samples)"""
        pass

def get_vocoder(vocoder_type: str = "vocos", **kwargs) -> BaseVocoder:
    if vocoder_type == "vocos":
        return VocosVocoder(**kwargs)
    elif vocoder_type == "flow2gan":
        return Flow2GANVocoder(**kwargs)

推論スクリプトへの引数追加

parser.add_argument("--vocoder-type", type=str, default="vocos",
                    choices=["vocos", "flow2gan"])
parser.add_argument("--vocoder-n-steps", type=int, default=2)

ボコーダーの処理時間の比較

まずは Vocosでの推論の時間の内訳を計測します

処理ステップ 処理時間 割合
プロンプト読込 10.73 ms 0.5%
プロンプト前処理 20.18 ms 1.0%
特徴量抽出 2.66 ms 0.1%
トークン化 1.22 ms 0.1%
モデル推論 (ZipVoice) 1929.54 ms 97.8%
ボコーダー 4.29 ms 0.2%
後処理 4.15 ms 0.2%
合計 1972.77 ms 100%

ここでボコーダーの部分だけを置き換えて比較をすると以下のようになりました。

ボコーダー速度比較

ボコーダー 処理時間 全体に占める割合
Vocos 4.29 ms 0.2%
Flow2GAN 1-step 25.76 ms 1.3%
Flow2GAN 2-step 48.24 ms 2.4%
Flow2GAN 4-step 94.89 ms 4.7%

またVocosでもFlow2GAN(各ステップ)の生成された音声は音質など大きくは分からない状態でした

Flow matchingを用いた高速・高品質なゼロショットTTS「ZipVoice」を日本語対応して学習・推論をする

初めに

この前にZipVoiceを動かして英語の生成をしてみました。しかし日本語の対応がされていなかったので求めているユースケースとは異なります。

ayousanz.hatenadiary.jp

今回は日本語対応を行って追加学習を行い、日本語を話せるようにしてみます

以下がツクヨミちゃんデータセットで行った際にデモ動画になります

youtu.be

日本語対応のために行ったこと

以下が今回日本語対応および学習のために行ったことになります

日本語対応

  • pyopenjtalk-plusによる日本語G2P
  • 日英混合テキストの正規化
  • 日本語トークン追加時のembedding拡張

高速化

  • DataLoader改善
  • DDP最適化
  • torch.compileとTF32最適化

以下のブランチに対応した内容を公開しています

github.com

開発環境

学習環境構築

k2ライブラリがWindows非対応だったため、Docker環境(linux環境)が必要でした。このライブラリをいれないとNaN勾配、訓練が発散の問題が起きていました。

そのため以下のような Dockerファイルを作成しました

# Dockerfile for ZipVoice Japanese training with k2 support
FROM pytorch/pytorch:2.5.1-cuda12.4-cudnn9-devel

# Set environment variables
ENV DEBIAN_FRONTEND=noninteractive
ENV PYTHONUNBUFFERED=1
ENV UV_SYSTEM_PYTHON=1

# Install system dependencies
RUN apt-get update && apt-get install -y \
    git \
    wget \
    curl \
    libsndfile1 \
    ffmpeg \
    && rm -rf /var/lib/apt/lists/*

# Install uv
RUN pip install uv

# Set working directory
WORKDIR /workspace

# Copy project files
COPY pyproject.toml /workspace/
COPY uv.lock /workspace/
COPY README.md /workspace/
COPY zipvoice /workspace/zipvoice

# Install project dependencies with uv sync (including cuda extras for k2)
RUN uv sync --extra cuda

# Add virtual environment to PATH
ENV PATH="/workspace/.venv/bin:$PATH"

# Default command
CMD ["bash"]

また 実行しやすいように docker-composeも作成しています

version: '3.8'

services:
  zipvoice-train:
    build:
      context: .
      dockerfile: Dockerfile
    image: zipvoice-japanese:latest
    container_name: zipvoice-japanese-train

    # GPU support
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: all
              capabilities: [gpu]

    # Mount volumes for data persistence
    volumes:
      - ./data:/workspace/data
      - ./download:/workspace/download
      - ./exp:/workspace/exp
      - ./egs:/workspace/egs

    # Environment variables
    environment:
      - WANDB_API_KEY=${WANDB_API_KEY}
      - CUDA_VISIBLE_DEVICES=0

    # Keep container running
    stdin_open: true
    tty: true

    # Working directory
    working_dir: /workspace

    # Default command for training
    # Using python directly since packages are installed via uv sync in Dockerfile
    command: >
      python -m zipvoice.bin.train_zipvoice
      --world-size 1
      --use-fp16 1
      --finetune 1
      --base-lr 0.0001
      --num-iters 10000
      --save-every-n 1000
      --max-duration 60
      --drop-last 0
      --model-config download/zipvoice/model.json
      --checkpoint download/zipvoice/model.pt
      --tokenizer japanese
      --token-file data/tokens_japanese_extended.txt
      --dataset custom
      --train-manifest data/fbank/tsukuyomi_cuts_train.jsonl.gz
      --dev-manifest data/fbank/tsukuyomi_cuts_dev.jsonl.gz
      --exp-dir exp/zipvoice_japanese
      --wandb-project zipvoice-japanese

  # Interactive shell for debugging
  zipvoice-shell:
    build:
      context: .
      dockerfile: Dockerfile
    image: zipvoice-japanese:latest
    container_name: zipvoice-japanese-shell

    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: all
              capabilities: [gpu]

    volumes:
      - ./data:/workspace/data
      - ./download:/workspace/download
      - ./exp:/workspace/exp
      - ./egs:/workspace/egs
      - ./zipvoice:/workspace/zipvoice

    environment:
      - WANDB_API_KEY=${WANDB_API_KEY}
      - CUDA_VISIBLE_DEVICES=0

    stdin_open: true
    tty: true
    working_dir: /workspace
    command: bash

このときの uv向けのpyproject.tomlは以下の通りです

[project]
name = "zipvoice"
version = "0.1.0"
description = "Fast and High-Quality Zero-Shot Text-to-Speech with Flow Matching"
readme = "README.md"
requires-python = ">=3.10,<3.12"
dependencies = [
    "cn2an>=0.5.23",
    "huggingface-hub>=1.2.3",
    "inflect>=7.5.0",
    "jaconv>=0.4.1",
    "jieba>=0.42.1",
    "lhotse>=1.32.1",
    "numpy>=1.26.0",
    "pydub>=0.25.1",
    "pyopenjtalk-plus>=0.4.1.post7",
    "pypinyin>=0.55.0",
    "safetensors>=0.7.0",
    "tensorboard>=2.20.0",
    "torch>=2.0.0",
    "torchaudio>=2.0.0",
    "vocos>=0.1.0",
    "wandb>=0.19.0",
    "piper-phonemize",
]

[project.optional-dependencies]
# k2 requires CUDA and is only available on Linux
cuda = [
    "k2==1.24.4.dev20241030+cuda12.4.torch2.5.1; sys_platform == 'linux'",
]

[[tool.uv.index]]
name = "pytorch-cu124"
url = "https://download.pytorch.org/whl/cu124"
explicit = true

[tool.uv.sources]
torch = { index = "pytorch-cu124" }
torchaudio = { index = "pytorch-cu124" }

[tool.uv]
find-links = [
    "https://k2-fsa.github.io/k2/cuda.html",
    "https://k2-fsa.github.io/icefall/piper_phonemize.html",
]

[tool.isort]
profile = "black"

[tool.black]
line-length = 88

以下のようなデータセットを作成します

utt_001   こんにちは、今日はいい天気ですね。 /path/to/audio/001.wav
utt_002 私の名前は田中です。  /path/to/audio/002.wav

データセットを作成した後に以下を実行します

uv run python -m zipvoice.bin.prepare_dataset \
    --tsv-path data/raw/custom_train.tsv \
    --prefix custom \
    --subset train \
    --num-jobs 4 \
    --output-dir data/manifests

トークン付加を行います

uv run python -m zipvoice.bin.prepare_tokens \
    --input-file data/manifests/custom_cuts_train.jsonl.gz \
    --output-file data/manifests/custom_cuts_train_tokens.jsonl.gz \
    --tokenizer japanese

Fbank特徴量計算を計算します

uv run python -m zipvoice.bin.compute_fbank \
    --source-dir data/manifests \
    --dest-dir data/fbank \
    --dataset custom \
    --subset train_tokens \
    --num-jobs 4

学習

以下を実行して学習を開始します

Dockerでファインチューニングを開始します

docker-compose build
docker-compose up zipvoice-train

推論

以下のコマンドで作成をしたモデルで推論を行います

# モデルディレクトリ準備
mkdir -p exp/custom/infer
cp exp/custom/checkpoint-10000.pt exp/custom/infer/model.pt
cp download/zipvoice/model.json exp/custom/infer/model.json
cp data/tokens_japanese_extended.txt exp/custom/infer/tokens.txt

# 推論実行
docker-compose run --rm zipvoice-shell python -m zipvoice.bin.infer_zipvoice \
    --model-dir exp/custom/infer \
    --tokenizer japanese \
    --prompt-wav data/prompt.wav \
    --prompt-text "プロンプト音声の書き起こし" \
    --text "合成したいテキスト" \
    --res-wav-path output.wav