高速・高品質なゼロショット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の量子化の対応など必要になりそうです

参考リンク