piper-plus v1.11.0のRust SDKで高速TTSを実現する

初めに

今回は、piper-plus v1.11.0のRust SDKについて紹介します。Rust SDKはメモリ安全で高速な推論に加え、feature gatesによる柔軟な機能選択が特徴です。

crates.ioで公開されているクレートは以下の3つです。すべてワークスペースバージョン 0.2.0 を共有しています。

クレート crates.io 説明
piper-plus v0.2.0 コアライブラリ(推論エンジン、G2P統合、ストリーミング)
piper-plus-cli v0.2.0 CLIツール
piper-plus-g2p v0.2.0 独立G2Pパッケージ(eSpeak-ng不要)

cargo install piper-plus-cli で即座に使い始められます。

github.com

開発環境

  • OS: Windows 11
  • Rust: 1.88+(edition 2024)
  • GPU: NVIDIA RTX 4090(CUDA使用時)
  • ONNX Runtime: ort クレートが自動ダウンロード

環境構築

CLIのインストール

cargo install piper-plus-cli

ort クレート(v2.0.0-rc.12)がビルド時にONNX Runtimeの共有ライブラリを自動ダウンロードするため、手動でのセットアップは不要です。download-binaries featureがデフォルトで有効になっています。

モデルのダウンロード

利用可能なモデルの一覧を確認し、ダウンロードします。

# 日本語モデルの一覧
piper-plus-cli --list-models ja
Available models:
  tsukuyomi-6lang-v2 (ja-en-zh-es-fr-pt) - Tsukuyomi-chan 6-language model (JA/EN/ZH/ES/FR/PT)
  css10-6lang (ja-en-zh-es-fr-pt) - CSS10 Japanese 6-language model fine-tuned from multilingual base (FP16)
# つくよみちゃんモデルをダウンロード
piper-plus-cli --download-model tsukuyomi
Downloading model: tsukuyomi to %APPDATA%\piper-plus\models
  Downloading... 100.0%
Model saved to: %APPDATA%\piper-plus\models\tsukuyomi-chan-6lang-fp16.onnx
Config saved to: %APPDATA%\piper-plus\models\config.json

CLIの基本的な使い方

テキストから音声合成

# 日本語の音声合成(-q でログを抑制)
piper-plus-cli -m tsukuyomi --text "こんにちは、今日は良い天気ですね。" -f output.wav -q

初回実行時にモデルが未ダウンロードの場合は自動でダウンロードされます。-q--quiet)を付けないとONNX Runtimeの詳細ログが表示されるため、通常は付けることを推奨します。

パラメータの調整

# ゆっくり話す
piper-plus-cli -m tsukuyomi --text "ゆっくり話してみます。" -f output.wav --length-scale 1.3 --noise-scale 0.5 --noise-w 0.6

# 話者IDの指定(マルチスピーカーモデルの場合)
piper-plus-cli -m tsukuyomi --text "Hello" -f output.wav --speaker 3 --language en

デバイス指定

# 自動検出(CUDA → CoreML → DirectML → CPU)
piper-plus-cli -m tsukuyomi --text "テスト" -f output.wav --device auto

# CUDA を明示指定
piper-plus-cli -m tsukuyomi --text "テスト" -f output.wav --device cuda

# CPU を明示指定
piper-plus-cli -m tsukuyomi --text "テスト" -f output.wav --device cpu

raw PCM出力

WAVヘッダなしのraw PCMを標準出力に流し、外部プレイヤーにパイプできます。

piper-plus-cli -m tsukuyomi --text "パイプで再生します" --output-raw | aplay -r 22050 -f S16_LE

バッチ処理

テキストファイルから1行ずつ読み込んで合成します。

piper-plus-cli -m tsukuyomi --batch input.txt -d output/ --language ja

CLIの全オプションは以下の通りです。

フラグ デフォルト 説明
-m, --model ONNXモデルのパスまたはモデル名
-c, --config 自動検出 config.jsonのパス
--text 合成するテキスト
-l, --language 自動検出 言語コード(ja, en, zh, ko, es, fr, pt, sv)
-s, --speaker 0 話者ID
-f, --output-file WAV出力パス(- でstdout)
-d, --output-dir 出力ディレクトリ
--noise-scale 0.667 生成ノイズスケール
--length-scale 1.0 発話速度
--noise-w 0.8 音素長ノイズ
--device auto 推論デバイス
--stream false ストリーミング合成
--timing フォネムタイミング出力(json/tsv/srt)
--custom-dict カスタム辞書パス(複数指定可)
--output-raw false raw PCM int16をstdoutに出力
--batch バッチファイル(1行1発話)
--sentence-silence 0.2 文間の無音秒数
--phoneme-silence 特定音素の後に追加する無音(例: "_ 0.5"
--list-models モデル一覧(言語フィルタ可)
--list-devices 利用可能なデバイス一覧
--download-model モデルをダウンロード
--model-dir モデルディレクトリ(ダウンロード先)
--test-mode false 推論スキップ、phoneme IDsのみ出力
--no-warmup false 起動時のORT warmupを無効化
--debug false デバッグログ出力
-q, --quiet false ログ出力を無効化

8言語G2P

piper-plus-g2p クレート(v0.2.0、crates.io公開済み)はeSpeak-ng不要のMITライセンスG2Pを提供します。

対応言語とfeature flags

[dependencies]
piper-plus-g2p = { version = "0.2.0", features = ["all-languages"] }
言語 feature flag デフォルト 実装方式
日本語(JA) japanese / naist-jdic No jpreprocess(Rust純粋OpenJTalk互換)
英語(EN) english Yes CMU辞書134K語 + 形態素フォールバック
中国語(ZH) chinese Yes ピンイン変換(単漢字42K + 多音字110K)
韓国語(KO) korean Yes ハングル算術分解 + 音韻規則
スペイン語(ES) spanish Yes 規則ベース(依存なし)
フランス語(FR) french Yes 規則ベース(依存なし)
ポルトガル語(PT) portuguese Yes 規則ベース(依存なし)
スウェーデン語(SV) swedish Yes 規則ベース(依存なし)

日本語の jpreprocess はRustで書かれたOpenJTalk互換ライブラリで、C言語のOpenJTalkをFFIで呼び出すのではなく、Rust純粋実装として動作します。これにより、WASMを含む全ターゲットで一貫したビルドが可能です。naist-jdic featureを有効にすると、NAIST-JDIC辞書がバイナリに埋め込まれます。

必要な言語だけを選択してバイナリサイズを抑えることもできます。

# 日本語と英語のみ
[dependencies]
piper-plus-g2p = { version = "0.2.0", default-features = false, features = ["naist-jdic", "english"] }

G2P APIの使い方

use piper_plus_g2p::Phonemizer;
use piper_plus_g2p::japanese::JapanesePhonemizer;
use piper_plus_g2p::english::EnglishPhonemizer;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // 日本語(bundled NAIST-JDIC辞書を使用、feature = "naist-jdic" 必須)
    let ja = JapanesePhonemizer::new_bundled()?;
    let (tokens, prosody) = ja.phonemize_with_prosody("こんにちは")?;
    println!("JA tokens: {:?}", tokens);
    // ["k", "o", "ɴ", "n", "i", "ch", "i", "w", "a"]

    // 英語(CMU辞書を自動検索: CMUDICT_PATH env → ./cmudict_data.json → /usr/share/piper/)
    let en = EnglishPhonemizer::new()?;
    let (tokens, _) = en.phonemize_with_prosody("Hello, world!")?;
    println!("EN tokens: {:?}", tokens);
    // ["h", "ʌ", "l", "oʊ", ",", " ", "w", "ɝ", "l", "d", "!"]

    Ok(())
}

PhonemizerRegistry を使って複数言語のPhonemizerをまとめて管理できます。

use piper_plus_g2p::{Phonemizer, PhonemizerRegistry};
use piper_plus_g2p::english::EnglishPhonemizer;

let mut registry = PhonemizerRegistry::new();
let en = EnglishPhonemizer::new()?;
registry.register("en", Box::new(en));

let phonemizer = registry.get("en").unwrap();
let (tokens, prosody) = phonemizer.phonemize_with_prosody("Hello, world!")?;

多言語テキストの自動言語検出にも対応しています。MultilingualPhonemizer はUnicode文字範囲に基づいてテキストを言語セグメントに分割し、各セグメントを適切なPhonemizerに委譲します。

use piper_plus_g2p::multilingual::MultilingualPhonemizer;

// 複数言語の Phonemizer を登録
let mut phonemizers = std::collections::HashMap::new();
phonemizers.insert("ja".to_string(), Box::new(ja) as Box<dyn piper_plus_g2p::Phonemizer>);
phonemizers.insert("en".to_string(), Box::new(en) as Box<dyn piper_plus_g2p::Phonemizer>);

let multi = MultilingualPhonemizer::new(
    vec!["ja".to_string(), "en".to_string()],
    "en".to_string(),  // ラテン文字のデフォルト言語
    phonemizers,
);

// カナ → 日本語、ラテン → 英語として自動判定
let (tokens, prosody) = multi.phonemize_with_prosody("こんにちは、Hello!")?;

Rust APIでの合成

PiperVoice がテキストから音声合成までの高レベルAPIを提供します。

use std::path::Path;
use piper_plus::PiperVoice;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // モデルの読み込み
    // phonemizer と engine の初期化は自動的に並列化される
    let mut voice = PiperVoice::load(
        Path::new("tsukuyomi-chan-6lang-fp16.onnx"),
        None,       // config は自動検出
        "auto",     // デバイス(auto で GPU 自動検出)
    )?;

    // テキストから直接合成(phonemize + inference)
    let result = voice.synthesize_text(
        "こんにちは、今日は良い天気ですね。",
        Some(0),        // speaker_id
        Some("ja"),     // language_override(None で自動検出)
        0.667,          // noise_scale
        1.0,            // length_scale
        0.8,            // noise_w
    )?;

    // 結果の確認
    println!("サンプルレート: {}", result.sample_rate);
    println!("音声長: {:.2}秒", result.audio_seconds);
    println!("推論時間: {:.3}秒", result.infer_seconds);
    println!("RTF: {:.3}", result.real_time_factor());

    // WAV ファイルに書き出し
    let spec = hound::WavSpec {
        channels: 1,
        sample_rate: result.sample_rate,
        bits_per_sample: 16,
        sample_format: hound::SampleFormat::Int,
    };
    let mut writer = hound::WavWriter::create("output.wav", spec)?;
    for sample in &result.audio {
        writer.write_sample(*sample)?;
    }
    writer.finalize()?;

    Ok(())
}

PiperVoice::load() は内部でphonemizerとONNXエンジンの初期化を std::thread::spawn で並列化しています。これにより初期化時間が max(phonemizer_time, engine_time) に短縮されます(WASM環境では逐次実行にフォールバック)。

便利メソッドとして text_to_wav_file() も提供されています。デフォルトパラメータ(noise_scale=0.667, length_scale=1.0, noise_w=0.8)で直接WAVファイルに出力します。

voice.text_to_wav_file("こんにちは", Path::new("output.wav"), Some(0))?;

SynthesisRequestSynthesisResult の構造は以下の通りです。

/// 合成パラメータ(低レベルAPI用)
pub struct SynthesisRequest {
    pub phoneme_ids: Vec<i64>,
    pub prosody_features: Option<Vec<[i32; 3]>>,  // プロソディ情報
    pub speaker_id: Option<i64>,
    pub language_id: Option<i64>,
    pub noise_scale: f32,
    pub length_scale: f32,
    pub noise_w: f32,
}

/// 合成結果
pub struct SynthesisResult {
    pub audio: Vec<i16>,           // PCMサンプル(モノラル、16-bit)
    pub sample_rate: u32,          // サンプルレート(例: 22050)
    pub infer_seconds: f64,        // 推論時間(秒)
    pub audio_seconds: f64,        // 音声の長さ(秒)
    pub durations: Option<Vec<f32>>, // 音素ごとのフレーム数(タイミング用)
}

impl SynthesisResult {
    // RTF < 1.0 ならリアルタイムより高速
    pub fn real_time_factor(&self) -> f64;
}

ストリーミング合成

AudioSink トレイトを実装することで、センテンス単位の逐次合成が可能です。

use piper_plus::streaming::{AudioSink, BufferSink, WavFileSink};
use piper_plus::error::PiperError;

// AudioSink トレイトの定義
pub trait AudioSink {
    fn write_chunk(&mut self, samples: &[i16], sample_rate: u32) -> Result<(), PiperError>;
    fn finalize(&mut self) -> Result<(), PiperError>;
}

組み込みの実装として BufferSink(メモリ内バッファ)と WavFileSink(WAVファイル直接書き込み)が提供されています。

use std::path::Path;
use piper_plus::streaming::{BufferSink, WavFileSink};

// メモリバッファに蓄積
let mut buffer_sink = BufferSink::new();

// WAVファイルに逐次書き込み
let mut wav_sink = WavFileSink::new(Path::new("output.wav"))?;

WavFileSink は最初の write_chunk 呼び出し時にWAVヘッダを書き込み、finalize() でファイルサイズを更新します。Drop トレイトも実装されているため、finalize() を呼び忘れても安全です。

CLIからのストリーミング使用例です。

# センテンス単位で逐次合成(--stream には -d が必須)
piper-plus-cli -m tsukuyomi --text "最初の文です。次の文です。最後の文です。" --stream -d chunks/ -q

# raw PCMをパイプして逐次再生
piper-plus-cli -m tsukuyomi --text "ストリーミングで再生します。リアルタイムに聴けます。" --stream --output-raw | aplay -r 22050 -f S16_LE

フォネムタイミング

VITSモデルのDuration Predictorが出力するフレーム数を時間情報に変換し、音素ごとの開始・終了時刻を取得できます。

# JSON形式で出力
piper-plus-cli -m tsukuyomi --text "こんにちは" --timing json
{
  "phonemes": [
    {"phoneme": "ph_0", "start_ms": 0.000, "end_ms": 6.122, "duration_ms": 6.122},
    {"phoneme": "ph_1", "start_ms": 6.122, "end_ms": 20.248, "duration_ms": 14.126},
    {"phoneme": "ph_2", "start_ms": 20.248, "end_ms": 31.106, "duration_ms": 10.858},
    ...
  ],
  "total_duration_ms": 553.357,
  "sample_rate": 22050
}
# TSV形式(スクリプト処理向き)
piper-plus-cli -m tsukuyomi --text "こんにちは" --timing tsv
start_ms end_ms  duration_ms phoneme
0.000   6.122   6.122   ph_0
6.122   20.248  14.126  ph_1
20.248  31.106  10.858  ph_2
31.106  44.400  13.293  ph_3
...
# SRT形式(字幕ファイル)
piper-plus-cli -m tsukuyomi --text "こんにちは" --timing srt
1
00:00:00,000 --> 00:00:00,006
ph_0

2
00:00:00,006 --> 00:00:00,020
ph_1

3
00:00:00,020 --> 00:00:00,031
ph_2

フォネムタイミングはリップシンク同期、字幕生成、カラオケ表示などに活用できます。PhonemeTimingInfoTimingResult の構造は以下の通りです。

pub struct PhonemeTimingInfo {
    pub phoneme: String,
    pub start_ms: f64,
    pub end_ms: f64,
    pub duration_ms: f64,
}

pub struct TimingResult {
    pub phonemes: Vec<PhonemeTimingInfo>,
    pub total_duration_ms: f64,
    pub sample_rate: u32,
}

impl TimingResult {
    pub fn to_json(&self) -> Result<String, PiperError>;
    pub fn to_json_compact(&self) -> Result<String, PiperError>;
    pub fn to_tsv(&self) -> String;
    pub fn to_srt(&self) -> String;
}

GPU推論(feature gates)

GPU推論はfeature flagsで制御されます。piper-plus クレートのCargo.tomlに以下のfeatureが定義されています。

[features]
default = ["naist-jdic", "dict-download"]
onnx = ["dep:ort", "dep:ndarray", "dep:hound"]
inference = ["onnx"]  # backward compat alias
japanese = ["dep:jpreprocess", "piper-plus-g2p/japanese"]
naist-jdic = ["japanese", "jpreprocess/naist-jdic", "piper-plus-g2p/naist-jdic"]
playback = ["dep:rodio"]
download = ["dep:reqwest"]
dict-download = ["download", "dep:sha2", "dep:flate2", "dep:tar"]
resample = ["dep:rubato"]
cuda = []
coreml = []
directml = []
tensorrt = []

CLIでは --device フラグで指定します。

# CUDA
piper-plus-cli -m tsukuyomi --text "CUDAで合成" --device cuda -f output.wav

# CoreML(macOS)
piper-plus-cli -m tsukuyomi --text "CoreMLで合成" --device coreml -f output.wav

# DirectML(Windows)
piper-plus-cli -m tsukuyomi --text "DirectMLで合成" --device directml -f output.wav

# 自動検出(利用可能なGPUを順に試行、失敗時はCPUにフォールバック)
piper-plus-cli -m tsukuyomi --text "自動検出" --device auto -f output.wav

--device auto はデフォルト値で、CUDA / CoreML / DirectML の順に検出を試み、いずれも利用できない場合はCPUにフォールバックします。

ライブラリから使う場合は PiperVoice::load() の第3引数にデバイス文字列を渡します。

// CUDA
let voice = PiperVoice::load(Path::new("model.onnx"), None, "cuda")?;

// 自動検出
let voice = PiperVoice::load(Path::new("model.onnx"), None, "auto")?;

WASMターゲット

piper-plus-wasm クレートはブラウザ向けのG2P + 合成を提供します。ファイルシステムに依存しない設計で、モデルやconfig はバイトスライスとして受け取ります。

feature flags

[features]
default = []
multilingual = ["ja", "zh", "ko", "es", "fr", "pt", "sv"]
multilingual-external = ["ja-external", "zh-external", "ko", "es", "fr", "pt", "sv"]
ja = ["piper-plus-g2p/naist-jdic"]      # JA辞書をバイナリに埋め込み
ja-external = ["piper-plus-g2p/japanese"]  # JA辞書を実行時に外部から渡す
zh = ["piper-plus-g2p/chinese"]          # ZH G2P有効化(辞書はランタイムロード)
zh-external = ["piper-plus-g2p/chinese"]   # zh と同等(命名の対称性のため)
バリアント WASM サイズ 用途
multilingual ~58MB 全機能(辞書埋め込み)
ja (ja-only) ~57.5MB 日本語のみ(辞書埋め込み)
ja-external (ja-lite) ~2MB 日本語(辞書を実行時にダウンロード)

ja-external バリアントでは辞書をバイナリに埋め込まないため、WASMサイズを大幅に削減できます。辞書はIndexedDBにキャッシュされ、2回目以降のアクセスではダウンロードが発生しません。

ビルドには wasm-pack を使います。

# ja-lite バリアント(2MB)
cd src/rust/piper-wasm
wasm-pack build --release --features ja-external

# 全言語バリアント
wasm-pack build --release --features multilingual

WASM版のAPIはJavaScript/TypeScript側から wasm-bindgen を介して呼び出します。

PyO3 Pythonバインディング

piper-plus-python クレートはPyO3を使ったPythonバインディングです。Rust実装の高速な推論エンジンをPythonから直接呼び出せます。

[dependencies]
piper_core = { package = "piper-plus", path = "../piper-core", version = "0.2.0", features = ["naist-jdic", "onnx"] }
pyo3 = { version = "0.24", features = ["extension-module"] }
numpy = "0.24"

numpy クレートとの連携により、合成結果のPCMデータをNumPy配列として効率的にPython側に渡せます。コピーを最小限に抑えた設計になっています。

# PyO3バインディング経由でRust推論エンジンを使用
import piper_plus

voice = piper_plus.PiperVoice("tsukuyomi-chan-6lang-fp16.onnx", device="cpu")
result = voice.synthesize("こんにちは", language="ja")

# NumPy配列として取得
audio = result.audio_int16()    # numpy.ndarray (int16)
# audio = result.audio_float32()  # numpy.ndarray (float32, [-1.0, 1.0])
print(f"サンプル数: {len(audio)}, サンプルレート: {result.sample_rate}")

実行結果

CPU環境でのReal-Time Factor(RTF)を計測しました。RTFが1.0未満であれば実時間より高速に合成できていることを意味します。

piper-plus-cli -m tsukuyomi --text "こんにちは、今日は良い天気ですね。音声合成のテストをしています。" -f output.wav --device cpu
Synthesized: 3.866s audio, 0.168s infer, RTF=0.043
デバイス RTF 備考
CPU (Ryzen 9 5900X) 0.043 実時間の約23倍速

RTF 0.043は、3.87秒の音声を0.17秒で合成できたことを意味します。CPUのみでもリアルタイムの23倍以上の速度が出ています。CUDA featureを有効にしたビルドではさらに高速化が期待できます。

所感

Rust SDKの設計で特にこだわったのは jpreprocess の採用です。日本語G2PにC言語のOpenJTalkを使わずRust純粋実装で処理することで、WASMを含む全ターゲットで一貫したビルドを実現しています。CGO不要でクロスコンパイルも容易です。

feature gatesによる柔軟な機能選択も意識して設計しました。日本語のみのアプリケーションでは features = ["naist-jdic", "onnx"] だけを有効にしてバイナリサイズを抑えられますし、WASM向けには ja-external で辞書を外部化して2MBまで軽量化できます。全言語が必要なサーバーでは all-languages を有効にすれば済みます。用途に応じて依存を最小化できるため、組み込みからサーバーまで幅広いデプロイ先に対応できます。

v1.11.0で全Rustクレート(piper-plus、piper-plus-cli、piper-plus-g2p)をcrates.ioに公開し、cargo installcargo add でのセットアップが格段に簡単になりました。