はじめに
本記事では、Flow Matchingベースの高速音声合成 システム「ZipVoice」をUnityで動作させるために行った技術的な取り組みを解説します。事前調査から実装、遭遇した問題の解決策まで、一連の流れを紹介します。
今回作成したライブラリは以下で公開しています。
github.com
ZipVoiceについて
ZipVoice とは以下のようなTTSです
123Mパラメータの軽量ゼロショットTTS
Flow Matchingによる高速生成(4-16ステップ)
サンプリングレート: 24kHz
特徴量: Vocos fbank(100次元)
デモ
VIDEO youtu.be
事前調査
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 系の演算子 がサポートされていないことは、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側のコードに修正が必要です。
CompactRelPositionalEncodingクラスのforwardメソッドを修正し、ONNX tracing時は事前計算済みの位置エンコーディング を使用するようにします。
def forward (self, x: Tensor, left_context_len: int = 0 ) -> Tensor:
if torch.jit.is_scripting() or torch.jit.is_tracing():
pe = self.pe.to(dtype=x.dtype, device=x.device)
else :
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
_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);
トーク ン変換の流れ:
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の量子化 の対応など必要になりそうです
参考リンク