はじめに
本記事では、Flow Matchingベースの高速音声合成システム「ZipVoice」をUnityで動作させるために行った技術的な取り組みを解説します。事前調査から実装、遭遇した問題の解決策まで、一連の流れを紹介します。
今回作成したライブラリは以下で公開しています。
ZipVoiceについて
ZipVoiceとは以下のようなTTSです
- 123Mパラメータの軽量ゼロショットTTS
- Flow Matchingによる高速生成(4-16ステップ)
- サンプリングレート: 24kHz
- 特徴量: Vocos fbank(100次元)
デモ
事前調査
ZipVoiceをUnityで動作させるにあたり、以下の3つの技術課題を解決する必要がありました。
- ONNXモデルの推論: ZipVoiceの3つのモデル(TextEncoder, FMDecoder, Vocos)をUnity上で実行する方法
- テキストの音素変換(G2P): Python版で使用しているpiper_phonemizeと互換性のある音素変換
- 波形生成(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をある程度理解しているのも関係しています)
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)
修正済みのコードは以下で公開しています。
上記の修正を行った後、以下のコマンドで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);
トークン変換の流れ:
- espeak-ng初期化:
espeak_Initialize()でデータパスを指定して初期化 - テキスト→音素変換:
espeak_TextToPhonemes()でIPA音素列を取得 - 音素→トークンID変換:
tokens.txtのマッピングを使用してIDに変換 - 特殊トークン追加: 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の量子化の対応など必要になりそうです
