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

短い参照音声 + 歌詞から楽曲を生成できる楽曲生成AIフレームワーク「SongBloom」を動かす

初めに

Sunoなど多くの楽曲生成サービスが出ていますが、ローカルで同じくらいの精度のものが出てきたということで触ってみます

ライセンスが学術目的のみ使用可能で商用利用は禁止されているため、注意が必要です。 対応言語は英語と中国のみです

開発環境

環境構築

まずは uvの環境を作っていきます

uv python pin 3.12

次に pyproject.tomlを作成します

[project]
name = "songbloom"
version = "0.1.0"
description = "Coherent Song Generation via Interleaved Autoregressive Sketching and Diffusion Refinement"
readme = "README.md"
requires-python = ">=3.10,<3.13"
dependencies = [
    "cn2an==0.5.22",
    "descript-audio-codec==1.0.0",
    "einops==0.8.0",
    "g2p-en==2.1.0",
    "huggingface-hub==0.24.6",
    "jieba-fast==0.53",
    "lightning==2.2.1",
    "nltk==3.8.1",
    "num2words==0.5.13",
    "numpy<2",
    "omegaconf==2.2.0",
    "pypinyin==0.51.0",
    "spacy==3.7.4",
    "torch==2.2.0",
    "torchaudio==2.2.0",
    "transformers==4.44.1",
    "vector-quantize-pytorch==1.14.8",
    "wordsegment==1.3.1",
]

[[tool.uv.index]]
url = "https://download.pytorch.org/whl/cpu"

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

uv sync

実行

推論は以下のようなコマンドで実行します

# 環境変数を設定
export PYTORCH_ENABLE_MPS_FALLBACK=1
export DISABLE_FLASH_ATTN=1

# 推論実行(float32必須、bfloat16は非対応)
source set_env.sh
uv run python infer.py --input-jsonl example/test.jsonl --device mps --dtype float32

実行する際にモデルを指定できますが、モデルは以下の通りです

Name Size Max Length Prompt type 🤗
songbloom_full_150s 2B 2m30s 10s wav link
songbloom_full_150s_dpo 2B 2m30s 10s wav link
songbloom_full_240s$^{[1]}$ 2B 4m 10s wav link
...

Flow Matchingを用いた高速・高品質なゼロショットTTS「ZipVoice」を動かす

初めに

高速推論が可能なZero ShotTTSを触ってみます

github.com

処理のフローとしては以下のようになっています

テキスト → トークナイザ → Text Encoder → FM Decoder → Vocoder → 波形(24kHz)

開発環境

環境構築

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.13"
dependencies = [
    "cn2an>=0.5.23",
    "huggingface-hub>=1.2.3",
    "inflect>=7.5.0",
    "jieba>=0.42.1",
    "lhotse>=1.32.1",
    "numpy>=1.26.0",
    "pydub>=0.25.1",
    "pypinyin>=0.55.0",
    "safetensors>=0.7.0",
    "tensorboard>=2.20.0",
    "torch>=2.0.0",
    "torchaudio>=2.0.0",
    "vocos>=0.1.0",
]
# Note: piper-phonemize must be installed separately:
# uv pip install piper-phonemize --find-links https://k2-fsa.github.io/icefall/piper_phonemize.html

[[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/icefall/piper_phonemize.html"]

[tool.isort]
profile = "black"

[tool.black]
line-length = 88

次に依存ライブラリをインストールします

uv sync
uv pip install piper-phonemize --find-links https://k2-fsa.github.io/icefall/piper_phonemize.html
uv pip install -r requirements_eval.txt 

実行

単一文の場合は以下で推論を行います

uv run python -m zipvoice.bin.infer_zipvoice \
    --model-name zipvoice \
    --prompt-wav prompt.wav \
    --prompt-text "プロンプト音声の書き起こし" \
    --text "合成するテキスト" \
    --res-wav-path result.wav

以下がRTX 4070 ti superを用いたときの測度計測結果です。中国語の場合時間がかかっているのは、中国語のピンイン変換が原因みたいなので、トークンに対しての測度は同じでした

  | 言語   | 推論時間 | 生成音声長 | RTF   | 速度               |
  |--------|----------|------------|-------|--------------------|
  | 英語   | 1.25秒   | 6.73秒     | 0.186 | 5.37x リアルタイム |
  | 中国語 | 1.66秒   | 2.74秒     | 0.606 | 1.65x リアルタイム |

日本語特化の視覚言語モデル「sarashina2.2-vision-3b」を動かす

初めに

以下の記事にある「Sarashina2.2-Vision-3B」を動かしていきます

www.sbintuitions.co.jp

開発環境

環境構築

uvを使って環境構築をします。pyproject.tomlを作成します

[project]
name = "sarashina2-2-vision-3b"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
    "accelerate>=1.12.0",
    "pillow>=12.0.0",
    "protobuf>=6.33.2",
    "requests>=2.32.5",
    "sentencepiece>=0.2.1",
    "torch>=2.5.0",
    "transformers>=4.57.3",
]

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

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

次には依存関係をインストールします

uv sync

実行

次に推論をするコード (main.py) を作成します

"""
Sarashina2.2-Vision-3B サンプル実装
SB Intuitionsの日本語特化視覚言語モデル(VLM)を動かすためのスクリプト
"""

import requests
from PIL import Image
from transformers import AutoModelForCausalLM, AutoProcessor, set_seed


def load_model(model_name: str = "sbintuitions/sarashina2.2-vision-3b"):
    """モデルとプロセッサを読み込む"""
    processor = AutoProcessor.from_pretrained(
        model_name,
        trust_remote_code=True,
    )
    model = AutoModelForCausalLM.from_pretrained(
        model_name,
        torch_dtype="auto",
        device_map="cuda",
        trust_remote_code=True,
    )
    return model, processor


def load_image_from_url(url: str) -> Image.Image:
    """URLから画像を読み込む"""
    from io import BytesIO
    response = requests.get(url, headers={"User-Agent": "Mozilla/5.0"})
    return Image.open(BytesIO(response.content))


def load_image_from_file(path: str) -> Image.Image:
    """ファイルパスから画像を読み込む"""
    return Image.open(path)


def generate_response(
    model,
    processor,
    image: Image.Image,
    prompt: str,
    max_new_tokens: int = 512,
    temperature: float = 0.7,
    seed: int = 42,
) -> str:
    """画像とプロンプトからモデルの応答を生成する"""
    set_seed(seed)

    # チャットメッセージ形式でプロンプトを構築
    messages = [
        {
            "role": "user",
            "content": [
                {"type": "image", "image": image},
                {"type": "text", "text": prompt},
            ],
        }
    ]

    # プロセッサでテキストと画像を処理
    inputs = processor.apply_chat_template(
        messages,
        add_generation_prompt=True,
        tokenize=True,
        return_dict=True,
        return_tensors="pt",
    ).to(model.device)

    # 推論実行
    output_ids = model.generate(
        **inputs,
        max_new_tokens=max_new_tokens,
        temperature=temperature,
        do_sample=True,
    )

    # 入力トークンを除いて出力をデコード
    generated_ids = output_ids[:, inputs["input_ids"].shape[1] :]
    response = processor.batch_decode(generated_ids, skip_special_tokens=True)[0]

    return response


def main():
    """メイン処理"""
    print("Sarashina2.2-Vision-3B を読み込んでいます...")
    model, processor = load_model()
    print("モデルの読み込みが完了しました")

    # サンプル画像(インターネットから取得)
    sample_image_url = "https://raw.githubusercontent.com/huggingface/transformers/main/tests/fixtures/tests_samples/COCO/000000039769.png"

    print(f"画像を読み込んでいます: {sample_image_url}")
    image = load_image_from_url(sample_image_url)

    prompt = "この画像に何が写っていますか?詳しく説明してください。"
    print(f"プロンプト: {prompt}")

    print("応答を生成しています...")
    response = generate_response(model, processor, image, prompt)

    print("\n=== モデルの応答 ===")
    print(response)


if __name__ == "__main__":
    main()

以下を実行します

uv run python main.py

以下の画像を使って実行しています

結果は以下になります

この画像には、2匹の猫がピンクのソファの上に横になって眠っている様子が写っています。

  - 2匹の猫(タビー柄)がソファでリラックスして寝ている
  - ソファは柔らかいピンク色のベルベットまたはアクリル毛布のような素材
  - 2本のリモコン(Sonyのロゴ)がソファの上に置かれている
  - 穏やかで温かみのある家庭のシーン