TimesFM 2.5を使って東京の気温とVTI株価をゼロショット予測する

初めに

今回は、Google Researchが公開した時系列基盤モデル TimesFM 2.5 を使って、東京の日次気温(最低・最高)とVTI(米国株ETF)の終値をゼロショットで予測する方法を紹介します。

TimesFMはLLMと同じデコーダーオンリーのTransformerアーキテクチャを時系列に適用したモデルで、事前学習済みの重みをそのまま使い、学習データなしで任意の時系列を予測できます。2.5では200Mパラメータに軽量化されつつコンテキスト長が16,384に拡張され、連続分位ヘッド(continuous quantile head)による確率的予測にも対応しています。

今回の実験では、気温のような強い季節性を持つデータと、株価のようなランダムウォーク的なデータの両方を予測し、モデルの予測精度や不確実性の表現がどう変わるかを確認します。

github.com

開発環境

項目 バージョン
OS Windows 11
Python 3.12.7
パッケージマネージャ uv 0.9.2
PyTorch 2.11.0+cpu
timesfm 2.0.0 (editable install)
GPU なし(CPU推論)

環境構築

Python バージョンの注意

PyTorch 2.11.0はPython 3.13.8のASTパーサとの間に非互換があり、import torch時に以下のエラーが発生します。

IndentationError: expected an indented block after function definition on line 4

Python 3.12系を明示指定する必要があります。

仮想環境の作成

cd timesfm
uv venv --python 3.12
source .venv/Scripts/activate

パッケージのインストール

TimesFM本体をPyTorchバックエンドでeditable installし、データ取得・可視化用のパッケージも合わせてインストールします。

# TimesFM本体 + PyTorchバックエンド
uv pip install -e ".[torch]"

# データ取得・可視化
uv pip install meteostat yfinance matplotlib pandas

インストールの確認

python -c "import torch; print('torch', torch.__version__); import timesfm; print('timesfm ok')"
torch 2.11.0+cpu
timesfm ok

データの取得

東京の気温(meteostat)

meteostatを使って東京の気象ステーション(WMO ID: 47662)から過去6年分の日次データを取得します。

なお、meteostat 2.xでは旧バージョンのfrom meteostat import Daily, Pointが使えなくなっており、from meteostat import daily(小文字の関数)を使用します。また、Point(lat, lon)での地理検索はProviderの対応状況によって空になることがあるため、ステーションIDを直接指定するのが確実です。

from datetime import datetime, timedelta
from meteostat import daily

TOKYO_STATION = "47662"

end = datetime(2026, 4, 12)
start = end - timedelta(days=365 * 6)

df = daily(TOKYO_STATION, start, end).fetch(fill=True)
temps = df[["tmin", "tmax"]].interpolate(method="linear").ffill().bfill()
2191 daily rows, 2020-04-13 -> 2026-04-12

VTI株価(yfinance)

yfinanceで過去6年分のVTI終値を取得します。auto_adjust=Trueで調整済み終値を使います。

import yfinance as yf

df = yf.download("VTI", start=start, end=end, auto_adjust=True, progress=False)
# yfinanceはMultiIndexを返すことがあるのでフラットにする
if isinstance(df.columns, pd.MultiIndex):
    df.columns = df.columns.get_level_values(0)
vti = df[["Close"]].rename(columns={"Close": "close"})
1507 trading days, 2020-04-13 -> 2026-04-10

モデルの読み込みとコンパイル

重みのダウンロード

from_pretrainedでHugging Face Hubからmodel.safetensors(約800MB)が自動ダウンロードされます。2回目以降は~/.cache/huggingface/のキャッシュから読み込まれます。

import torch
import timesfm

torch.set_float32_matmul_precision("high")

model = timesfm.TimesFM_2p5_200M_torch.from_pretrained(
    "google/timesfm-2.5-200m-pytorch"
)

ForecastConfigによるコンパイル

model.compile()ForecastConfigを渡して推論の設定を行います。max_contextmax_horizonは内部でパッチサイズの倍数に自動調整されます。

model.compile(
    timesfm.ForecastConfig(
        max_context=2048,                    # 入力系列の最大長
        max_horizon=60,                      # 予測ステップ数(約2ヶ月)
        normalize_inputs=True,               # 入力のスケール正規化
        use_continuous_quantile_head=True,    # 分位予測ヘッドを有効化
        force_flip_invariance=True,          # 負のスケーリングにも線形不変性を保証
        infer_is_positive=True,              # 入力が非負なら出力も非負にクランプ
        fix_quantile_crossing=True,          # 分位の交差を修正
    )
)

主要パラメータの解説です。

パラメータ 説明
max_context モデルに渡す過去データの最大長。短い系列はゼロパディング、長い系列は切り詰め
max_horizon 予測する未来のステップ数
use_continuous_quantile_head Trueにすると点予測に加えて分位予測(mean, q10〜q90)が返る
force_flip_invariance TimesFMはf(aX+b) = a*f(X)+ba≥0で保証。このフラグでa<0にも拡張
infer_is_positive 株価のように常に正の系列で負の予測を防ぐ

推論の実行

各系列を1次元のnumpy arrayとしてリストで渡し、model.forecast()を呼びます。

import numpy as np

inputs = [
    temps["tmin"].to_numpy(dtype=np.float32),
    temps["tmax"].to_numpy(dtype=np.float32),
    vti["close"].to_numpy(dtype=np.float32),
]

point_forecast, quantile_forecast = model.forecast(horizon=60, inputs=inputs)

# point_forecast.shape    = (3, 60)       — 3系列 × 60ステップ
# quantile_forecast.shape = (3, 60, 10)   — [mean, q10, q20, ..., q90]

3系列を一括で予測しています。quantile_forecastの10列はそれぞれmean, q10, q20, ..., q90に対応しています。

可視化

matplotlibで日本語ラベルを文字化けなく描画するため、Windows標準のMeiryoフォントを指定し、マイナス記号の文字化けも防止します。

import matplotlib as mpl
import matplotlib.pyplot as plt

mpl.rcParams["font.family"] = "Meiryo"
mpl.rcParams["axes.unicode_minus"] = False

描画では過去1年分の実績と予測線・予測区間を重ねてプロットしています。以下は最低気温の例です。

fig, ax = plt.subplots(figsize=(12, 5))

# 過去1年分の実績
hist = temps["tmin"].iloc[-365:]
ax.plot(hist.index, hist.values, color="#1f77b4", label="実績")

# 予測(平均)
future_idx = pd.date_range(temps.index[-1] + pd.Timedelta("1D"), periods=60, freq="D")
ax.plot(future_idx, point_forecast[0], color="#d62728", label="予測(平均)")

# 予測区間 q10-q90
q10 = quantile_forecast[0, :, 1]
q90 = quantile_forecast[0, :, 9]
ax.fill_between(future_idx, q10, q90, alpha=0.15, color="#d62728", label="予測区間 q10-q90")

ax.set_title("東京 日次最低気温 — TimesFM 2.5 予測(今後60日間)")
ax.set_ylabel("最低気温 (℃)")
ax.set_xlabel("日付")
ax.legend()
fig.savefig("tokyo_tmin_forecast.png", dpi=140)

実行結果

2026年4月12日を基準に60日先を予測した結果です。

系列 予測初日 → 60日後(平均) 60日後の不確実性(q10-q90)
東京 最低気温 12.9℃ → 20.8℃ 17.5 - 24.2 ℃
東京 最高気温 20.7℃ → 27.5℃ 23.0 - 31.4 ℃
VTI 終値 $334.8 → $340.8 $315.8 - $357.5

東京 最低気温

4月中旬から6月中旬にかけて滑らかに上昇するカーブが出ており、梅雨入り前の昇温を再現しています。予測区間は±3℃程度と狭く、モデルが高い確信度を持っていることが分かります。

東京 最高気温

最低気温と同様に季節性を正しく捉えています。60日後の予測平均は27.5℃で、6月上旬の東京としては妥当な値です。

VTI 終値

予測平均はほぼ横ばい(+1.8%)ですが、予測区間は$315〜$358と気温の5倍以上に広くなっています。株価のランダムウォーク的な性質を分位帯の広さで正直に表現しており、点予測だけでは意味がなく、予測区間を見て初めて「方向予測が困難であること」をモデルが示していると読み取れます。

トラブルシューティング

今回の実験でハマったポイントを3つ記録しておきます。

Python 3.13でimport torchが失敗する

PyTorch 2.11.0のJITコンパイラがPython 3.13.8のASTパーサと非互換です。uv venv --python 3.12でPython 3.12系を使います。

meteostatでImportError: cannot import name 'Daily'

meteostat 2.xではAPIが変更されています。

# 旧 (1.x)
from meteostat import Daily, Point

# 新 (2.x)
from meteostat import daily

dailyは関数として呼び出し、.fetch()でDataFrameを取得します。

meteostatのPoint()でデータが0件になる

Point(lat, lon)での地理検索はProvider依存で空になることがあります。ステーションIDを直接指定するのが確実です。

# 空になることがある
daily(Point(35.6762, 139.6503, 44), start, end)

# 確実に取得できる
daily("47662", start, end)  # 47662 = 東京 WMO ID

LongCat-AudioDiTの3.5Bモデルを使ってゼロショットTTSの推論をしてみる

初めに

今回は、Meituan が公開した拡散ベースのゼロショット TTS モデル LongCat-AudioDiT を使って、テキストから音声を生成する方法を紹介します。

LongCat-AudioDiT は、メルスペクトログラムを介さず波形の潜在空間で直接動作する Diffusion TTS モデルです。Seed ベンチマークで SOTA を達成しており、1B と 3.5B の 2 つのチェックポイントが HuggingFace で公開されています。

今回は 3.5B モデルを RTX 4070 Ti SUPER (16 GB VRAM) で動かします。そのままでは VRAM が足りないため、bf16 で読み込む工夫が必要になります。

github.com

開発環境

  • OS: Windows 11 Education (10.0.22631)
  • GPU: NVIDIA GeForce RTX 4070 Ti SUPER (16 GB VRAM)
  • NVIDIA ドライバ: 591.86 (CUDA 13.1 対応)
  • パッケージマネージャ: uv 0.9.2
  • Python: 3.12.7 (uv 自動管理)
  • torch: 2.11.0+cu126
  • transformers: 5.5.3

環境構築

リポジトリのクローン

git clone https://github.com/meituan-longcat/LongCat-AudioDiT.git
cd LongCat-AudioDiT

Python バージョンの固定

uv を使って Python 3.12 に固定します。

uv python pin 3.12

Python 3.14 は torch の wheel が未提供、3.13 は numba/llvmlite が不安定なため、3.12 を推奨します。

pyproject.toml の作成

requirements.txt はリポジトリに同梱されていますが、uv で PyTorch の CUDA wheel を正しく取得するには pyproject.toml が必要です。以下の内容で作成します。

[project]
name = "longcat-audiodit"
version = "0.1.0"
description = "HuggingFace-compatible inference for LongCat-AudioDiT."
requires-python = ">=3.12,<3.13"
dependencies = [
    "transformers>=5.3.0",
    "torch>=2.5.0",
    "torchaudio>=2.5.0",
    "safetensors>=0.4.0",
    "librosa>=0.10.0",
    "soundfile>=0.12.0",
    "numpy>=1.24.0",
    "einops>=0.8.0",
]

[tool.uv]
package = false

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

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

ポイントは [[tool.uv.index]][tool.uv.sources] の設定です。PyTorch の CUDA 対応 wheel は PyPI にないため、https://download.pytorch.org/whl/cu126 から取得するよう指定します。explicit = true により、torchtorchaudio 以外のパッケージは通常の PyPI から取得されます。

依存関係のインストール

uv sync

初回実行時は torch CUDA wheel (約 2.4 GB) を含む約 3 GB のダウンロードが発生します。

Resolved 77 packages in 1.32s
Prepared 3 packages in 1m 12s
Installed 57 packages in 24.19s
 + torch==2.11.0+cu126
 + torchaudio==2.11.0+cu126
 + transformers==5.5.3
 ...

3.5B モデルを動かすための工夫

VRAM の問題

3.5B モデルをデフォルト (fp32) で読み込むと、DiT transformer だけで約 14 GB の VRAM を消費します。text encoder と VAE を合わせると約 17 GB となり、16 GB GPU では OOM (Out of Memory) になります。

モデル dtype VRAM 概算 16 GB GPU
1B fp32 約 7 GB OK
1B bf16 約 4 GB OK
3.5B fp32 約 17 GB NG (OOM)
3.5B bf16 約 9 GB OK

inference.py の修正

inference.py--dtype フラグを追加し、from_pretrained に dtype を渡せるようにしました。

parser.add_argument("--dtype", type=str, default="fp32", choices=["fp32", "bf16", "fp16"],
                    help="Dtype for loading DiT transformer / text encoder. VAE always runs in fp16.")
dtype_map = {"fp32": torch.float32, "bf16": torch.bfloat16, "fp16": torch.float16}
model = AudioDiTModel.from_pretrained(args.model_dir, dtype=dtype_map[args.dtype]).to(device)
model.vae.to_half()  # VAE は常に fp16 (元チェックポイントとの数値一致のため)

VAE は to_half() で常に fp16 に変換されます。これは元の実装 (AutoencoderPretransform(model_half=True)) と数値を一致させるための仕様です。

Windows の cp932 エンコーディング問題

Windows 日本語版では標準出力が cp932 (Shift-JIS) のため、中国語テキストを print すると UnicodeEncodeError が発生します。

UnicodeEncodeError: 'cp932' codec can't encode character '\u8f6c' in position 10

inference.py の冒頭に以下を追加して解決しました。

import sys

try:
    sys.stdout.reconfigure(encoding="utf-8", errors="replace")
    sys.stderr.reconfigure(encoding="utf-8", errors="replace")
except (AttributeError, OSError):
    pass

推論の実行

中国語テキストの生成

3.5B モデルを --dtype bf16 で読み込み、APG ガイダンスで推論します。

uv run python inference.py \
  --text "今天晴暖转阴雨,空气质量优至良,空气相对湿度较低。" \
  --output_audio output_zh_3.5b.wav \
  --model_dir meituan-longcat/LongCat-AudioDiT-3.5B \
  --dtype bf16 \
  --guidance_method apg
Loading weights: 100%|██████████| 1351/1351 [00:10<00:00, 131.02it/s]
Text: 今天晴暖转阴雨,空气质量优至良,空气相对湿度较低。
Approx duration: 5.250s
Saved: output_zh_3.5b.wav (5.21s)

初回はモデルウェイト (数 GB) が HuggingFace から自動ダウンロードされるため数分かかります。2 回目以降はキャッシュから読み込まれ、ロード時間は約 10 秒です。

英語テキストの生成

uv run python inference.py \
  --text "The weather today is warm and sunny, perfect for a walk in the park." \
  --output_audio output_en_3.5b.wav \
  --model_dir meituan-longcat/LongCat-AudioDiT-3.5B \
  --dtype bf16 \
  --guidance_method apg
Text: the weather today is warm and sunny, perfect for a walk in the park.
Approx duration: 4.510s
Saved: output_en_3.5b.wav (4.44s)

ボイスクローン

リポジトリに同梱されている assets/prompt.wav を参照音声として使い、別のテキストを同じ声で生成できます。

uv run python inference.py \
  --text "合成したい任意のテキスト" \
  --prompt_text "小偷却一点也不气馁,继续在抽屉里翻找。" \
  --prompt_audio assets/prompt.wav \
  --output_audio output_clone_3.5b.wav \
  --model_dir meituan-longcat/LongCat-AudioDiT-3.5B \
  --dtype bf16 \
  --guidance_method apg

--prompt_text には --prompt_audio の音声で実際に話されている内容を渡す必要があります。

実行結果

生成された音声ファイルの仕様は以下の通りです。

項目 中国語 英語
ファイル output_zh_3.5b.wav output_en_3.5b.wav
サンプリングレート 24000 Hz 24000 Hz
長さ 5.21 秒 4.44 秒
ピーク振幅 0.52 0.56
ファイルサイズ 約 250 KB 約 214 KB

モデルのロード時間は約 10 秒、推論時間は 5 秒程度の音声で数秒〜十数秒でした。ODE ステップ数 (--nfe) はデフォルトの 16 を使用しています。

WebUtauをローカル構築して闇音レンリで日本語歌声合成を試してみた

初めに

今回は、ブラウザ上で動作する仮想シンガーワークステーション WebUtau をソースからビルドし、日本語DiffSingerボイスバンクである闇音レンリ(Yamine Renri)を導入して、実際に「さくらさくら」を歌わせるまでの手順を紹介します。

WebUtauはMIDIをインポートして歌詞を入力し、OpenUtau/DiffSingerエンジンで歌声合成を行うブラウザベースのツールです。バックエンドの.NETサーバーがDiffSingerモデルを実行し、フロントエンドはVanilla JS + Viteで構築されています。リポジトリにビルド済みランタイムは付属していないため、.NETソースからビルドする必要があります。

オリジナル版は中国語UIで提供されているため、本記事では筆者が日本語UIにローカライズしたフォーク版(feat/japanese-ui ブランチ)を使用します。

github.com

開発環境

  • OS: Windows 11
  • シェル: bash(Git Bash)
  • .NET SDK: 10.0.104(.NET 8ターゲットと互換性あり)
  • Node.js: v22.14.0
  • npm: 11.4.2
  • 推論: CPU(GPUがあればCUDA/DirectMLも可)

WebUtauの全体構成

WebUtauは3層構成のアプリケーションです。

ブラウザ (フロントエンド)        ← Vanilla JS + Vite, port 3000
        ↓ /api/* (Vite proxy)
バックエンド (.NET)              ← ASP.NET Core 8 + OpenUtau, port 38510
        ↓ ONNXモデル読み込み
DiffSingerボイスバンク + ボコーダー

歌声合成を動かすには3層すべてのセットアップが必要です。フロントエンドだけ起動した状態ではピアノ音源でのプレビュー再生になり、歌声は出ません。

環境構築

リポジトリのクローン

WebUtauはオリジナル版(Marigold1122/WebUtau)が中国語UIで提供されていますが、本記事では日本語UIにローカライズしたフォーク版(feat/japanese-ui ブランチ)を使用します。UIラベル・ダイアログ・エラーメッセージがすべて日本語化されています。

git clone -b feat/japanese-ui https://github.com/ayutaz/WebUtau.git
cd WebUtau

フロントエンドのセットアップ

依存関係をインストールします。@tonejs/miditonewanakana@sglkc/kuromojivite など89パッケージがインストールされます。

npm install

開発サーバーを起動します。

npx vite

成功すると以下のように表示されます。

  VITE v6.4.1  ready in 1090 ms
  ➜  Local:   http://localhost:3000/

npm run dev でも起動できますが、Git Bashで 'vite' は認識されていません エラーになることがあるため、その場合は npx vite を使います。

この時点ではフロントエンドのみ起動しており、まだ歌声合成はできません。

バックエンドのビルド

バックエンドは.NET 8で書かれたASP.NET Core APIです。dotnet --version で.NET 8以降のSDKがインストールされていることを確認します。

dotnet --version
# 10.0.104

NuGetパッケージを復元します。OpenUtau.Plugin.BuiltinDiffSingerApiOpenUtau.Core の3プロジェクトが復元されます。

cd server/DiffSingerApi
dotnet restore

リリースビルドを実行します。

dotnet build -c Release

ビルド時間は約17秒です。1567個の警告が出ますが、エラー0で成功します。出力は server/DiffSingerApi/bin/Release/net8.0/ に配置されます。

ボイスバンクとボコーダーのダウンロード

DiffSinger形式のボイスバンクと、対応するボコーダーが必要です。今回は日本語対応の 闇音レンリ(Yamine Renri)DiffSinger を使用します。

ボイスバンクディレクトリの準備

mkdir -p server/voicebanks
cd server/voicebanks

闇音レンリ DiffSingerのダウンロード

GitHubのリリースページから直接ダウンロードします。hop512版(約301MB)はNSF-HiFiGANボコーダーに対応しています。

curl -L -o yamine-renri-hop512.zip \
  "https://github.com/colstone/Yamine_Renri_DiffSinger/releases/download/Beta_Version/Multi-langs.yamine-renri.Normal.hop512.zip" \
  --progress-bar

github.com

ボイスバンクには hop256 版と hop512 版があり、それぞれ対応するボコーダーが異なります。

バージョン サイズ 対応ボコーダー
hop256 253MB Kouon_RefineGAN
hop512 301MB NSF-HiFiGAN(公式標準)

NSF-HiFiGANはOpenUtau公式が配布している標準ボコーダーのため、hop512版を選びました。

NSF-HiFiGANボコーダーのダウンロード

ボコーダーは .oudep という独自パッケージ形式で配布されています(実体はzipファイル)。約51MBです。

curl -L -o nsf_hifigan.oudep \
  "https://github.com/xunmengshe/OpenUtau/releases/download/0.0.0.0/nsf_hifigan.oudep" \
  --progress-bar

ボイスバンクの展開

ダウンロードしたzipは内部が二重ネスト構造になっているため、一旦テンポラリディレクトリに展開してから移動します。

unzip -o yamine-renri-hop512.zip -d _tmp
mkdir -p yamine-renri
cp -r "_tmp/Multi-langs yamine-renri Normal hop512/Multi-langs yamine-renri Normal hop512/"* yamine-renri/
rm -rf _tmp

展開後の yamine-renri/ の構成は以下のようになります。

yamine-renri/
├── 0910_multi_langs_V5_2nd_fhyy_ds1000_1.phonemes.txt
├── 0910_multi_langs_V5_2nd_fhyy_ds1000_1.renri_normal.onnx  (260MB)
├── character.txt
├── character.yaml
├── dictionary.txt
├── dsconfig.yaml
├── dsdict_JPN.txt   ← 日本語辞書
├── dsdict_CnJ.txt
├── dsdict_ENG.txt
└── dsdur/           ← 音価予測モデル

dsconfig.yaml の中身はシンプルです。

phonemes: "0910_multi_langs_V5_2nd_fhyy_ds1000_1.phonemes.txt"
acoustic: "0910_multi_langs_V5_2nd_fhyy_ds1000_1.renri_normal.onnx"
vocoder: nsf_hifigan

ボコーダーをボイスバンク内に配置

OpenUtauのコードを読むと、DiffSingerSinger.csgetVocoder() がボイスバンク直下の dsvocoder/ を優先的にチェックする仕様になっています。PathManager.Inst.DependencyPath を使う方法もありますが、ボイスバンク内に直接配置する方が簡単です。

mkdir -p yamine-renri/dsvocoder
cd yamine-renri/dsvocoder
unzip -o "../../nsf_hifigan.oudep" nsf_hifigan.onnx vocoder.yaml

dsvocoder/vocoder.yaml の中身。

name: "nsf_hifigan"
model: "nsf_hifigan.onnx"
num_mel_bins: 128
hop_size: 512
sample_rate: 44100

ここで重要なのは hop_size です。ボイスバンクの hop512 とボコーダーの hop_size: 512 が一致している必要があります。一致しないとレンダリング時にエラーになります。

バックエンドの起動

起動コマンド

MELODY_ONNX_RUNNER 環境変数で推論ランタイムを指定し、--VoicebanksPath絶対パス を渡します。GPUがない環境では CPU を指定します。

cd server/DiffSingerApi
MELODY_ONNX_RUNNER=CPU dotnet run -c Release -- \
  --VoicebanksPath="C:/Users/yuta/Desktop/AIHUB/WebUtau/server/voicebanks"

ハマりポイントが2つあります。

  1. ボイスバンクパスは絶対パス必須。相対パスを指定すると Found 0 singer(s). になってしまい、ボイスバンクが認識されません。
  2. GPU環境変数の指定。GPUがない環境で MELODY_ONNX_RUNNER を指定しないとCUDAを探そうとして失敗することがあります。CPUの場合は明示的に指定するのが安全です。

起動成功の確認

成功すると以下のログが出力されます。

[INF] ONNX runner set to CPU (from MELODY_ONNX_RUNNER)
[INF] ONNX runner: CPU
[INF] DiffSinger API starting on http://localhost:38510
[INF] Now listening on: http://0.0.0.0:38510
[INF] Searching singers.
[INF] Found 1 singer(s).
[INF]   yamine-renri (DiffSinger)
[INF] SynthesisService worker initialized.

Found 1 singer(s).yamine-renri (DiffSinger) が表示されればOKです。

API動作確認

curlでボイスバンク一覧APIを叩いて確認します。

curl http://localhost:38510/api/voicebanks

レスポンス。

[{"id":"yamine-renri","name":"Multi-langs yamine-renri Normal hop512","singerType":"DiffSinger"}]

Vite開発サーバー経由(/apiプロキシ)でも動作することを確認します。

curl http://localhost:3000/api/voicebanks

同じレスポンスが返れば、フロントエンドからバックエンドへの通信経路ができています。

デモMIDIの作成

WebUtauにはサンプルMIDIが付属していないため、テスト用に歌詞付きの「さくらさくら」MIDIを作成します。

MIDIに歌詞を埋め込む際の注意点

WebUtauのMIDI読み込み(src/modules/MidiImporter.js)は、MIDIの lyrics または text メタイベントから歌詞を抽出します。Node.js上でMIDIを生成する場合、midi-file パッケージの writeMidi を使うのが手軽ですが、このライブラリはマルチバイト文字を正しく扱えません。

midi-filewriteString は内部で codePointAt を使って1バイトずつ書き込むため、日本語のような多バイト文字をそのまま渡すと文字化けします。

// NG: 文字化けする
track.push({ deltaTime: 0, type: 'lyrics', text: 'さ' })

回避策として、UTF-8エンコードしたバイト列を1バイト=1文字のLatin1風文字列に変換してから渡します。WebUtau側の decodeMidiText.js がLatin1として読み込まれたUTF-8バイト列を自動的に検出してデコードし直す仕組みになっているため、これで正しく日本語が読み込まれます。

function utf8Str(str) {
  const bytes = Buffer.from(str, 'utf8')
  return String.fromCharCode(...bytes)
}

track.push({
  deltaTime: 0,
  type: 'lyrics',
  text: utf8Str('さ'),
})

さくらさくらMIDI生成スクリプト

scripts/create-demo-midi.cjs として以下のスクリプトを作成します。

const { writeMidi } = require('midi-file')
const { writeFileSync, mkdirSync } = require('fs')
const { join } = require('path')

const BPM = 80
const TICKS_PER_BEAT = 480
const US_PER_BEAT = Math.round(60_000_000 / BPM)

const Q = TICKS_PER_BEAT / 2   // 8分音符
const H = TICKS_PER_BEAT        // 4分音符
const W = TICKS_PER_BEAT * 2    // 2分音符

function utf8Str(str) {
  const bytes = Buffer.from(str, 'utf8')
  return String.fromCharCode(...bytes)
}

const melody = [
  { note: 69, dur: H, lyric: 'さ' },
  { note: 69, dur: H, lyric: 'く' },
  { note: 71, dur: W, lyric: 'ら' },
  { note: 69, dur: H, lyric: 'さ' },
  { note: 69, dur: H, lyric: 'く' },
  { note: 71, dur: W, lyric: 'ら' },
  // ... さくらさくらの全歌詞・全ノート(45ノート)
]

const track0 = [
  { deltaTime: 0, type: 'timeSignature', numerator: 4, denominator: 4, metronome: 24, thirtyseconds: 8 },
  { deltaTime: 0, type: 'setTempo', microsecondsPerBeat: US_PER_BEAT },
  { deltaTime: 0, type: 'trackName', text: 'Conductor' },
  { deltaTime: 0, type: 'endOfTrack' },
]

const track1 = [
  { deltaTime: 0, type: 'trackName', text: utf8Str('さくらさくら') },
]

let pendingDelta = 0
for (const n of melody) {
  track1.push({ deltaTime: pendingDelta, type: 'lyrics', text: utf8Str(n.lyric) })
  track1.push({ deltaTime: 0, channel: 0, type: 'noteOn', noteNumber: n.note, velocity: 90 })
  const noteDur = Math.round(n.dur * 0.95)
  track1.push({ deltaTime: noteDur, channel: 0, type: 'noteOff', noteNumber: n.note, velocity: 0 })
  pendingDelta = n.dur - noteDur
}
track1.push({ deltaTime: pendingDelta, type: 'endOfTrack' })

const midiData = {
  header: { format: 1, numTracks: 2, ticksPerBeat: TICKS_PER_BEAT },
  tracks: [track0, track1],
}

const output = writeMidi(midiData)
mkdirSync(join(__dirname, '..', 'public', 'demo'), { recursive: true })
writeFileSync(join(__dirname, '..', 'public', 'demo', 'sakura-sakura.mid'), Buffer.from(output))
console.log(`Created with ${melody.length} notes`)

実行します。

node scripts/create-demo-midi.cjs
# Created with 45 notes

public/demo/sakura-sakura.mid が生成されます。

歌声合成の実行

ブラウザで開く

http://localhost:3000/ をブラウザで開きます。

デモMIDIをロード

空のプロジェクト画面に表示される「さくらさくら」ボタンをクリックします(src/host/ui/ShellLayoutView.js_parseDemoMidiFiles() で組み込みデモを返すように改修した場合)。

「タイミング情報のインポート」ダイアログが表示されます。インポート元80 BPMと現在のプロジェクト120 BPMが比較表示されるので、「同期して適用」をクリックします。

ピアノロールを開く

トラックリストにインポートされたトラックが表示されます。トラックを ダブルクリック するとピアノロールエディタが開きます。

ボーカルレンダリングを開始

ピアノロール上部の「このトラックをボーカルとしてレンダリング」ボタンをクリックします。言語選択ダイアログが表示されるので、以下を選択します。

  • 言語: 日本語
  • ボイスバンク: yamine-renri

続行」をクリックすると、ピッチ予測 → オーディオレンダリングが順番に開始されます。

レンダリングの進行

CPU推論なので少し時間がかかります。進行状況はノートの色で確認できます。

状態
グレー 未レンダリング
オレンジ レンダリング中
ティール(青緑) レンダリング完了

すべてのノートがティール色になればレンダリング完了です。

実行結果

再生ボタン(▶)を押すと、闇音レンリの歌声で「さくらさくら」が再生されます。

合成された歌声は、以下のような特徴があります。

  • ピッチは正確にメロディーラインに沿う
  • 「さ」「く」「ら」など各音節の子音・母音が明確に発音される
  • ノート間の音程変化はDiffSingerが自然な遷移を生成する

歌詞をMIDIに埋め込まずに合成すると母音だけの「ハミング」のような出力になるため、必ず歌詞を入れることがポイントです。

トラブルシューティング

実際にハマったポイントをまとめます。

Found 0 singer(s) と表示される

--VoicebanksPath が相対パスになっていないか確認します。../../server/voicebanks のような相対パスではボイスバンクが認識されません。絶対パスで指定する必要があります

鼻歌のような音しか出ない

ノートに歌詞が割り当てられていません。歌詞を埋め込んだMIDIを使うか、ピアノロール上部の「クイック歌詞入力」から手動で歌詞を入力します。

ポート38510が使用中

前回起動したバックエンドプロセスがゾンビ化している場合があります。

netstat -ano | grep 38510
taskkill //PID <PID> //F

ボコーダー設定の不一致エラー

ボイスバンクの hop_size とボコーダーの hop_size が一致しているか確認します。本記事の構成では両方 512 で揃えています。

CPU推論が遅い

GPUがある環境では MELODY_ONNX_RUNNERCUDA または DirectML に変更します。

MELODY_ONNX_RUNNER=DirectML dotnet run -c Release -- --VoicebanksPath="..."

CUDAを使う場合は事前にCUDA Toolkit + cuDNNのインストールが必要です。

所感

WebUtauは「ブラウザ上で歌声合成できる仮想シンガーワークステーション」という珍しい立ち位置のツールで、OpenUtau/DiffSingerをバックエンドにラップすることで、複雑なネイティブGUIなしに歌声合成を試せるのが魅力です。一方で、リポジトリにビルド済みランタイムやサンプルMIDI・ボイスバンクが付属していないため、初回セットアップは自力で全コンポーネントを揃える必要があります。

DiffSingerモデルの推論はCPUでも動作しますが、闇音レンリのような260MBクラスの音響モデルでは数十秒〜分単位の待ち時間が発生します。実用的に試すならNVIDIA GPU(CUDA)またはWindows環境のDirectMLを使うのが望ましいです。

歌詞付きMIDIの生成では、midi-file ライブラリのマルチバイト非対応問題に遭遇しました。WebUtau側の decodeMidiText.js がLatin1として読み込まれたUTF-8を自動デコードする仕組みを持っているため、UTF-8バイト列を文字単位の「擬似Latin1文字列」に変換して渡すというトリックで回避できます。MIDIの仕様自体がテキストエンコーディングを規定していないこともあり、日本語歌詞を扱う際は注意が必要なポイントです。

合成された歌声は短いフレーズなら十分実用的な品質で、デモ・趣味用途であれば「ブラウザだけで歌声合成ができる」体験を提供できる興味深いプロジェクトでした。

piper-plusの.NET版で多言語音素変換を実装する

初めに

今回は、piper-plusの.NET版(PiperPlus.Core + DotNetG2Pパッケージ群)を使って、C#から多言語の音素変換(G2P: Grapheme-to-Phoneme)を行う方法を解説します。

piper-plusの.NET版はeSpeak-ng(GPLライセンス)への依存がなく、MITライセンスで提供しています。日本語、英語、中国語、韓国語、スペイン語、フランス語、ポルトガル語、スウェーデン語の8言語のG2P(音素変換)に対応しています。

なお、韓国語(ko)とスウェーデン語(sv)はG2P処理は動作しますが、現時点ではTTSモデルが公開されていないため、音声合成まで行う場合はja、en、zh、es、fr、ptの6言語が対象となります。

github.com

開発環境

  • OS: Windows 11(macOS / Linuxでも動作)
  • .NET SDK: 9.0(PiperPlus.Coreはnet8.0対応)

環境構築

プロジェクトの作成

dotnet new console -n G2PDemo
cd G2PDemo

パッケージのインストール

PiperPlus.Core(v0.2.0)にはPhonemizerインターフェースと各言語のPhonemizer実装が含まれていますが、G2Pエンジンの実体は別途DotNetG2Pパッケージとしてインストールする必要があります。

# コアライブラリ(IPhonemizer、各言語Phonemizer)
dotnet add package PiperPlus.Core --version 0.2.0

# 日本語G2Pエンジン(OpenJTalk/MeCabベース)
dotnet add package DotNetG2P --version 1.9.0
dotnet add package DotNetG2P.MeCab --version 1.9.0

# 英語G2Pエンジン(CMU Dictionary + g2p-enベース)
dotnet add package DotNetG2P.English --version 1.9.0

# 中国語G2Pエンジン(ピンイン辞書ベース)
dotnet add package DotNetG2P.Chinese --version 1.9.0

# 韓国語 — PiperPlus.Cli内蔵のハングル分解で動作(追加パッケージ不要)

# スペイン語G2Pエンジン(ルールベース)
dotnet add package DotNetG2P.Spanish --version 1.9.0

# フランス語G2Pエンジン(ルールベース)
dotnet add package DotNetG2P.French --version 1.9.0

# ポルトガル語G2Pエンジン(ルールベース)
dotnet add package DotNetG2P.Portuguese --version 1.9.0

# スウェーデン語 — PiperPlus.Core内蔵のSwedishG2PEngineで動作(追加パッケージ不要)

全言語を使う場合は上記すべてをインストールします。特定の言語だけ使う場合は必要なパッケージのみで構いません。

実行

日本語の音素変換

日本語の音素変換にはDotNetG2PDotNetG2P.MeCabを使用します。PiperPlus.CliのG2Pエンジンクラス(DotNetG2PEngine)はinternal宣言のため、IJapaneseG2PEngineを実装するアダプタ(MyJapaneseG2PAdapter.cs)を用意します。

using DotNetG2P;
using DotNetG2P.MeCab;
using PiperPlus.Core.Config;
using PiperPlus.Core.Phonemize;

// IJapaneseG2PEngineアダプタ
// PiperPlus.CliのDotNetG2PEngineはinternalのため、同等のアダプタを実装する
public sealed class MyJapaneseG2PAdapter : IJapaneseG2PEngine
{
    private readonly G2PEngine _engine;

    public MyJapaneseG2PAdapter(G2PEngine engine)
    {
        _engine = engine;
    }

    public G2PResult Convert(string text)
    {
        var features = _engine.ToProsodyFeatures(text);

        var phonemes = new string[features.Phonemes.Count];
        var a1 = new int[features.A1.Count];
        var a2 = new int[features.A2.Count];
        var a3 = new int[features.A3.Count];

        for (int i = 0; i < features.Phonemes.Count; i++)
            phonemes[i] = features.Phonemes[i];
        for (int i = 0; i < features.A1.Count; i++)
            a1[i] = features.A1[i];
        for (int i = 0; i < features.A2.Count; i++)
            a2[i] = features.A2[i];
        for (int i = 0; i < features.A3.Count; i++)
            a3[i] = features.A3[i];

        return new G2PResult(phonemes, a1, a2, a3);
    }
}

上記のアダプタを使ってProgram.csで音素変換を実行します。

using DotNetG2P;
using DotNetG2P.MeCab;
using PiperPlus.Core.Config;
using PiperPlus.Core.Phonemize;

// DotNetG2P.MeCabのMeCabTokenizerを使ってG2Pエンジンを構築
// DictionaryManagerがnaist-jdic辞書を自動ダウンロード
var dictPath = await DictionaryManager.EnsureDictionaryAsync();
var tokenizer = new MeCabTokenizer(dictPath);
var g2pEngine = new G2PEngine(tokenizer);

var jaPhonemizer = new JapanesePhonemizer(new MyJapaneseG2PAdapter(g2pEngine));

var phonemes = jaPhonemizer.Phonemize("こんにちは");
Console.WriteLine(string.Join(" ", phonemes));

実行します。

dotnet run
^ k o [  n i  i w a $

Phonemize()List<string>を返すため、文字列として表示するにはstring.Join()で結合します。韻律記号(^ = BOS、$ = EOS、[ = ピッチ上昇、] = ピッチ下降、# = アクセント句境界)が含まれます。なお、実際の出力では複数文字の音素トークン(chN_n等)はPrivate Use Area(PUA)の単一コードポイントにマッピングされます。

英語の音素変換

英語も同様にIEnglishG2PEngineを実装するアダプタ(MyEnglishG2PAdapter.cs)を用意し、EnglishPhonemizerに渡します。G2PエンジンにはDotNetG2P.Englishを使用します。

using DotNetG2P.English;
using PiperPlus.Core.Phonemize;

public sealed class MyEnglishG2PAdapter : IEnglishG2PEngine
{
    private readonly EnglishG2PEngine _engine = new();

    public List<List<string>> ConvertToArpabet(string text)
    {
        var result = new List<List<string>>();

        foreach (string token in text.Split(' ', StringSplitOptions.RemoveEmptyEntries))
        {
            string word = token.Trim();
            if (string.IsNullOrEmpty(word))
                continue;

            var phonemes = _engine.LookupWord(word);
            if (phonemes.Count > 0)
            {
                var wordPhonemes = new List<string>(phonemes.Count);
                foreach (var p in phonemes)
                    wordPhonemes.Add(p.ToString());
                result.Add(wordPhonemes);
            }
            else
            {
                result.Add([word]);
            }
        }

        return result;
    }
}

Program.csを以下の内容に書き換えて実行します。

using PiperPlus.Core.Phonemize;

var enPhonemizer = new EnglishPhonemizer(new MyEnglishG2PAdapter());

var phonemes = enPhonemizer.Phonemize("Hello, world!");
Console.WriteLine(string.Join(" ", phonemes));
dotnet run
H e l l o ,   w o r l d !

他の言語(中国語、韓国語、スペイン語、フランス語、ポルトガル語、スウェーデン語)も同様のパターンで実装できます。各言語のインターフェースと追加パッケージは「各言語のG2Pエンジン一覧」を参照してください。

マルチリンガル音素変換

MultilingualPhonemizerを使うと、複数言語が混在するテキストをUnicode範囲で自動判別して処理できます。Program.csを以下の内容に書き換えて実行します。

using DotNetG2P;
using DotNetG2P.MeCab;
using PiperPlus.Core.Config;
using PiperPlus.Core.Phonemize;

// 日本語・英語のPhonemizerを構築
var dictPath = await DictionaryManager.EnsureDictionaryAsync();
var tokenizer = new MeCabTokenizer(dictPath);
var jaPhonemizer = new JapanesePhonemizer(new MyJapaneseG2PAdapter(new G2PEngine(tokenizer)));
var enPhonemizer = new EnglishPhonemizer(new MyEnglishG2PAdapter());

// MultilingualPhonemizerに渡す
var phonemizers = new Dictionary<string, IPhonemizer>
{
    ["ja"] = jaPhonemizer,
    ["en"] = enPhonemizer,
};

var multiPhonemizer = new MultilingualPhonemizer(phonemizers, defaultLatinLanguage: "en");

var phonemes = multiPhonemizer.Phonemize("今日はgood dayですね。");
Console.WriteLine(string.Join(" ", phonemes));
dotnet run
 o ] [ o w a ɡ ˈ ʊ d   d ˈ e ɪ d e ] [ s U n e

PiperPlus.CliResolveTextModePhonemizer()では、言語コードをハイフン区切り(例: ja-en-zh)で指定すると自動的にMultilingualPhonemizerが構築されます。

韻律情報付きの音素変換

PhonemizeWithProsody()を使うと、各音素トークンに対応する韻律情報(ProsodyInfo)も取得できます。Program.csを以下の内容に書き換えて実行します。

using DotNetG2P;
using DotNetG2P.MeCab;
using PiperPlus.Core.Config;
using PiperPlus.Core.Phonemize;

var dictPath = await DictionaryManager.EnsureDictionaryAsync();
var tokenizer = new MeCabTokenizer(dictPath);
var jaPhonemizer = new JapanesePhonemizer(new MyJapaneseG2PAdapter(new G2PEngine(tokenizer)));

var (tokens, prosody) = jaPhonemizer.PhonemizeWithProsody("今日はいい天気ですね。");

for (int i = 0; i < tokens.Count; i++)
{
    var p = prosody[i];
    if (p.HasValue)
        Console.WriteLine($"{tokens[i]}\tA1={p.Value.A1} A2={p.Value.A2} A3={p.Value.A3}");
    else
        Console.WriteLine($"{tokens[i]}\t(prosody: none)");
}
dotnet run
^    (prosody: none)
    A1=0 A2=1 A3=3
o   A1=0 A2=1 A3=3
]   (prosody: none)
[   (prosody: none)
o   A1=1 A2=2 A3=2
w   A1=2 A2=3 A3=1
a   A1=2 A2=3 A3=1
i   A1=0 A2=1 A3=2
]   (prosody: none)
[   (prosody: none)
i   A1=1 A2=2 A3=1
t   A1=0 A2=1 A3=6
e   A1=0 A2=1 A3=6
]   (prosody: none)
[   (prosody: none)
    A1=1 A2=2 A3=5
k   A1=2 A2=3 A3=4
i   A1=2 A2=3 A3=4
d   A1=3 A2=4 A3=3
e   A1=3 A2=4 A3=3
s   A1=4 A2=5 A3=2
U   A1=4 A2=5 A3=2
n   A1=5 A2=6 A3=1
e   A1=5 A2=6 A3=1
$   (prosody: none)

APIアーキテクチャ

アーキテクチャとしては、PiperPlus.Coreが各言語のPhonemizer(JapanesePhonemizerEnglishPhonemizerなど)とG2Pエンジンのインターフェース(IJapaneseG2PEngineIEnglishG2PEngineなど)を定義し、実際のG2Pエンジン実装はDotNetG2Pパッケージ群としてPiperPlus.Cli側に配置しています。スウェーデン語のみ、ルールベースのG2Pエンジン(SwedishG2PEngine)をPiperPlus.Coreに組み込んでいます。

IPhonemizer インターフェース

PiperPlus.Core.Phonemize名前空間に定義されたIPhonemizerインターフェースが全言語共通の契約です。

public interface IPhonemizer
{
    List<string> Phonemize(string text);
    (List<string> Tokens, List<ProsodyInfo?> Prosody) PhonemizeWithProsody(string text);
    Dictionary<string, int[]>? GetPhonemeIdMap();

    // デフォルト実装付き — BOS/EOSトークン挿入やパディングなどの後処理
    (List<int> Ids, List<ProsodyInfo?> Prosody) PostProcessIds(
        List<int> phonemeIds,
        List<ProsodyInfo?> prosodyFeatures,
        Dictionary<string, int[]> phonemeIdMap)
    {
        return (phonemeIds, prosodyFeatures);
    }
}

Phonemize()は音素トークンのList<string>を返します。PhonemizeWithProsody()はトークンに加えて韻律情報(A1/A2/A3)も返します。GetPhonemeIdMap()は言語固有の音素-IDマッピングを返します(nullの場合はconfig.jsonのマップが使用されます)。

PostProcessIds()はデフォルト実装付きのメソッドで、トークン→ID変換後にBOS/EOSトークンの挿入や音素間パディングなど、言語固有のID列変換を行います。デフォルト実装では入力をそのまま返します(no-op)。言語ごとにオーバーライドして使用します。

Phonemizerの構築パターン

各言語のPhonemizerは、対応するG2Pエンジンをコンストラクタで受け取ります。PhonemizerRegistryのようなサービスロケータは存在しません。

using PiperPlus.Core.Phonemize;

// 各Phonemizerは対応するG2Pエンジンをコンストラクタに渡して構築
var jaPhonemizer = new JapanesePhonemizer(jaG2PEngine);   // IJapaneseG2PEngine
var enPhonemizer = new EnglishPhonemizer(enG2PEngine);     // IEnglishG2PEngine
var zhPhonemizer = new ChinesePhonemizer(zhG2PEngine);     // IChineseG2PEngine
var koPhonemizer = new KoreanPhonemizer(koG2PEngine);      // IKoreanG2PEngine
var esPhonemizer = new SpanishPhonemizer(esG2PEngine);     // ISpanishG2PEngine
var frPhonemizer = new FrenchPhonemizer(frG2PEngine);      // IFrenchG2PEngine
var ptPhonemizer = new PortuguesePhonemizer(ptG2PEngine);  // IPortugueseG2PEngine
var svPhonemizer = new SwedishPhonemizer(svG2PEngine);     // ISwedishG2PEngine

韻律情報の詳細

韻律情報の意味は言語によって異なります。

言語 A1 A2 A3
ja アクセント核からの相対位置 モーラ位置(1始まり) アクセント句内モーラ数
en 固定0 ストレスレベル(0/1/2) 単語内音素数
zh 声調(1-5) 語内音節位置(1始まり) 語の音節数
ko 固定0 固定0 ハングル音節数
es 固定0 ストレスレベル(0/2) 単語内音素数
fr 固定0 ストレスレベル(0/2) 単語内音素数
pt 固定0 ストレスレベル(0/2) 単語内音素数
sv 固定0 ストレスレベル(0/1/2) 単語内音素数

各言語のG2Pエンジン一覧

言語 Phonemizer G2Pエンジンインターフェース 具体実装(PiperPlus.Cli) 追加パッケージ
ja JapanesePhonemizer IJapaneseG2PEngine DotNetG2PEngine DotNetG2P + DotNetG2P.MeCab
en EnglishPhonemizer IEnglishG2PEngine DotNetEnglishG2PEngine DotNetG2P.English
zh ChinesePhonemizer IChineseG2PEngine DotNetChineseG2PEngine DotNetG2P.Chinese
ko KoreanPhonemizer IKoreanG2PEngine DotNetKoreanG2PEngine なし(Cli内蔵)
es SpanishPhonemizer ISpanishG2PEngine DotNetSpanishG2PEngine DotNetG2P.Spanish
fr FrenchPhonemizer IFrenchG2PEngine DotNetFrenchG2PEngine DotNetG2P.French
pt PortuguesePhonemizer IPortugueseG2PEngine DotNetPortugueseG2PEngine DotNetG2P.Portuguese
sv SwedishPhonemizer ISwedishG2PEngine SwedishG2PEngine なし(Core内蔵)

応用例

バッチ処理: 大量テキストの一括音素変換

テキストファイルから一括で音素変換を行う例です。input.txtを用意し、Program.csを以下の内容に書き換えて実行します。

using DotNetG2P;
using DotNetG2P.MeCab;
using PiperPlus.Core.Config;
using PiperPlus.Core.Phonemize;

var dictPath = await DictionaryManager.EnsureDictionaryAsync();
var tokenizer = new MeCabTokenizer(dictPath);
var jaPhonemizer = new JapanesePhonemizer(new MyJapaneseG2PAdapter(new G2PEngine(tokenizer)));

var lines = await File.ReadAllLinesAsync("input.txt");
var results = new List<string>();

foreach (var line in lines)
{
    if (string.IsNullOrWhiteSpace(line)) continue;
    var phonemes = jaPhonemizer.Phonemize(line);
    results.Add($"{line}\t{string.Join(" ", phonemes)}");
}

await File.WriteAllLinesAsync("output.tsv", results);
Console.WriteLine($"{results.Count}行の音素変換が完了しました。");
dotnet run
3行の音素変換が完了しました。

Python版との比較

Python版(piper-plus-g2p)と.NET版のAPI設計を比較します。

機能 Python C# (.NET)
Phonemizer取得 get_phonemizer("ja") new JapanesePhonemizer(engine)
音素変換 phonemizer.phonemize(text) phonemizer.Phonemize(text)List<string>
韻律付き変換 phonemizer.phonemize_with_prosody(text) phonemizer.PhonemizeWithProsody(text)
マルチリンガル MultilingualPhonemizer({...}) new MultilingualPhonemizer(dict)
パッケージ uv add "piper-plus-g2p[ja]" PiperPlus.Core + DotNetG2P.*
ライセンス MIT MIT

Python版はget_phonemizer("ja")のようなファクトリ関数で手軽に取得できるのに対し、.NET版はG2Pエンジンを明示的にコンストラクタで注入するDI(Dependency Injection)パターンを採用しています。テストではG2Pエンジンをモックに差し替えられるメリットがあります。

Python版では言語ごとのextras([ja], [en])で依存関係を細かく制御できます。.NET版も同様に、DotNetG2Pパッケージを言語ごとに選択してインストールできます。スウェーデン語はPiperPlus.Core内蔵のSwedishG2PEngineで、韓国語はPiperPlus.Cli内蔵のDotNetKoreanG2PEngineで動作し、いずれも追加パッケージは不要です。ただし、韓国語とスウェーデン語はG2P処理のみ対応しており、TTSモデルは現時点で未公開です。

所感

今回は.NET版のpiper-plusでG2P(音素変換)処理を試してみました。

.NETでMITライセンスのG2Pライブラリが使えるのは、C#のプロダクトに音素変換を組み込みたい場合に便利です。G2Pエンジンのインターフェースが分離されているため、テストやカスタムエンジンとの差し替えが容易です。Python版と同じ8言語をサポートしており、CIレベルで出力の一致が検証されているため、言語間でのポーティングも安心して行えます。

piper-plusのWASM版でブラウザ上の日本語TTSを実現する

初めに

今回は、piper-plusのnpmパッケージ(WASM版)を使ったブラウザ上での日本語音声合成について紹介します。サーバー不要でクライアントサイドのみで動作します。

piper-plusはWebAssemblyにも対応しており、OpenJTalk辞書を内蔵したWASMバイナリ(約60MB、gzip転送時約19MB)とJSバインディングで構成されています。モデルの読み込みから音声合成まですべてクライアントサイドで完結するため、サーバーの構築が不要です。

github.com

デモページも公開しています。

ayutaz.github.io

開発環境

  • OS: Windows 11
  • Node.js: 22.x
  • ブラウザ: Google Chrome
  • パッケージマネージャー: npm
  • piper-plus (npm): 0.3.1

環境構築

プロジェクトの作成

新しいプロジェクトを作成します。

mkdir piper-plus-web-demo
cd piper-plus-web-demo
npm init -y

パッケージのインストール

piper-plusと、peer dependencyのonnxruntime-webをインストールします。開発サーバーとしてViteを使用します。

npm install piper-plus onnxruntime-web
npm install -D vite

onnxruntime-web はバージョン1.21.0以上が必要です。

package.jsonの設定

ESモジュールを使用するため、package.json"type": "module" と開発サーバーの起動スクリプトを追加します。

{
  "name": "piper-plus-web-demo",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite"
  },
  "dependencies": {
    "onnxruntime-web": "^1.24.3",
    "piper-plus": "^0.3.1"
  },
  "devDependencies": {
    "vite": "^8.0.8"
  }
}

Vite設定ファイルの作成(vite.config.js)

piper-plusはWASMバイナリを相対パスで読み込むため、Viteのdependency pre-bundlingから除外する必要があります。プロジェクトルートに vite.config.js を作成します。

import { defineConfig } from "vite";

export default defineConfig({
  optimizeDeps: {
    exclude: ["piper-plus"],
  },
});

この設定がないと、WASMファイルのパス解決が壊れて日本語の音声合成が動作しません。

モデルの準備

piper-plusのnpmパッケージは、HuggingFaceからモデルを自動ダウンロードする仕組みになっています。PiperPlus.initialize() にHuggingFaceのリポジトリ名を指定するだけでモデルが取得されます。

今回はつくよみちゃんモデルを使用します。

huggingface.co

ダウンロードされたONNXモデルとconfigはブラウザのIndexedDB(piper-plus-models)にキャッシュされるため、2回目以降のアクセスではダウンロードが発生しません。OpenJTalkの辞書はWASMバイナリに埋め込まれているため、個別のダウンロードは不要です。

実装

最小限のHTML(index.html)

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>piper-plus Web TTS Demo</title>
</head>
<body>
    <h1>piper-plus Web TTS Demo</h1>
    <textarea id="text" rows="3" cols="50">こんにちは、今日はいい天気ですね。</textarea>
    <br>
    <button id="synthesize" disabled>音声合成</button>
    <p id="status">モデルを読み込み中...</p>
    <script type="module" src="main.js"></script>
</body>
</html>

JavaScript(main.js)

index.html<script> タグで参照している main.js を作成します。

import { PiperPlus } from "piper-plus";
import * as ort from "onnxruntime-web";

const statusEl = document.getElementById("status");
const button = document.getElementById("synthesize");
const textArea = document.getElementById("text");

// モデルの初期化
const tts = await PiperPlus.initialize({
    model: "ayousanz/piper-plus-tsukuyomi-chan",
    ort,
    onProgress: ({ stage, progress, message }) => {
        statusEl.textContent = `${stage}: ${Math.round(progress * 100)}% - ${message}`;
    },
});

statusEl.textContent = "準備完了";
button.disabled = false;

// 音声合成ボタンのイベント
button.addEventListener("click", async () => {
    button.disabled = true;
    statusEl.textContent = "合成中...";

    const text = textArea.value;
    const audio = await tts.synthesize(text, { language: "ja" });

    // ブラウザで再生
    await audio.play();

    statusEl.textContent = "再生完了";
    button.disabled = false;
});

ここまでで、プロジェクトのファイル構成は以下のようになります。

piper-plus-web-demo/
├── index.html
├── main.js
├── vite.config.js
├── package.json
└── node_modules/

開発サーバーの起動

開発サーバーを起動します。

npm run dev
  VITE v8.0.8  ready in 200 ms

  ➜  Local:   http://localhost:5173/

ブラウザで http://localhost:5173/ を開くと、初回はモデルのダウンロードが始まります(約35MB、IndexedDBにキャッシュされるため2回目以降は不要)。「準備完了」と表示されたら、テキストを入力して「音声合成」ボタンを押すと音声が再生されます。

実行結果

ブラウザでの動作結果です。デモページでも同じ動作を確認できます。

ayutaz.github.io

APIの詳細

ここからは、piper-plusのnpmパッケージが提供するAPIについて詳しく紹介します。

PiperPlus.initialize() のオプション

オプション 説明
model string 必須。HuggingFaceリポジトリ名、ショートカット("tsukuyomi")、またはONNXのURL
ort object onnxruntime-webモジュール。省略時は globalThis.ort を使用
onProgress function { stage, progress, message } を受け取るコールバック

合成結果の利用

synthesize() が返すaudioオブジェクトには複数の出力形式を用意しています。

const audio = await tts.synthesize("テスト", { language: "ja" });

// ブラウザで再生
await audio.play();

// WAV形式のArrayBuffer
const wavBuffer = audio.toWav();

// Blob形式(audio/wav)
const blob = audio.toBlob();

// ファイルダウンロード
audio.download("output.wav");

// 生のサンプルデータ
console.log(audio.samples);     // Float32Array
console.log(audio.sampleRate);  // 22050
console.log(audio.duration);    // 秒数

合成パラメータ

synthesize() には言語指定以外にも音声の品質を調整するパラメータがあります。

const audio = await tts.synthesize("パラメータ調整のテストです。", {
    language: "ja",
    noiseScale: 0.5,    // 音声のランダム性(デフォルト: 0.667)
    lengthScale: 1.3,   // 発話速度。大きいほどゆっくり(デフォルト: 1.0)
    noiseW: 0.6,        // 音素の長さのばらつき(デフォルト: 0.8)
});
パラメータ デフォルト 説明
language 自動判定 言語コード('ja', 'en', 'zh', 'ko', 'es', 'fr', 'pt', 'sv'
noiseScale 0.667 音声のランダム性。大きいほど表現が多様になる
lengthScale 1.0 発話速度。小さいほど速くなる
noiseW 0.8 音素の長さのばらつき

ストリーミング合成

長いテキストを文ごとにリアルタイム合成する場合は synthesizeStreaming() を使います。

await tts.synthesizeStreaming("長いテキストです。文ごとに合成されます。逐次的に再生できます。", {
    language: "ja",
    onChunk: (pcmFloat32Array) => {
        // 文ごとにPCMデータが返される
        // Web Audio APIなどで逐次再生可能
        console.log("チャンク受信:", pcmFloat32Array.length, "サンプル");
    },
});

言語の自動判定

piper-plusのnpmパッケージはテキストの文字種から言語を自動判定します。

文字種 判定言語
カナ文字(ひらがな・カタカナ) 日本語 (ja)
ハングル 韓国語 (ko)
CJK漢字(カナあり) 日本語 (ja)
CJK漢字(カナなし) 中国語 (zh)
スウェーデン語固有文字(å/ä/ö) スウェーデン語 (sv)
ラテン文字 英語 (en)(デフォルト)

ハングルは韓国語(ko)、スウェーデン語固有文字(å/ä/ö等)はスウェーデン語(sv)として判定されます。ただし、ラテン文字のみのテキストではスペイン語(es)、フランス語(fr)、ポルトガル語(pt)、スウェーデン語(sv)の区別ができず、デフォルトで英語(en)と判定されます。これらの言語を使用する場合は language オプションで明示的に指定してください。

明示的に言語を指定することもできます。

const audio = await tts.synthesize("Hello, world!", { language: "en" });

リソースの解放

使い終わったら dispose() でリソースを解放します。ONNXセッションやWASMモジュールが解放されます。

tts.dispose();

所感

piper-plusのWASM版はnpmパッケージをインストールするだけでブラウザ上のTTSが実現できます。モデルのダウンロードはIndexedDBにキャッシュされるため、初回以降は高速に起動します。サーバーレスで完結する点は、プライバシーが重要なアプリケーションやオフライン対応に有用です。次回はOpenJTalkのアクセントラベルを使って日本語TTSの品質改善を試みます。

piper-plus v1.11.0のGo SDKでTTS APIサーバーを構築する

初めに

今回は、piper-plusのGo SDKを使ったHTTP APIのTTSサーバー構築方法を解説します。Go SDKはシングルバイナリでデプロイでき、VoicePoolによる並行合成、6言語対応のG2P、ストリーミング合成、GPU推論に対応しています。

github.com

開発環境

  • OS: Windows 11 / Linux(Docker)
  • Go: 1.26+
  • ONNX Runtime: 1.21.0
  • GPU: NVIDIA RTX 4090(CUDA使用時)
  • piper-plus: v1.11.0

アーキテクチャ概要

Go SDKは4層の構造になっています。

Init/Shutdown  ← ONNX Runtime のライフサイクル管理
    ↓
  Voice        ← モデルの読み込みと合成
    ↓
 VoicePool     ← 並行セッション管理(セマフォ + 遅延生成)
    ↓
  Server       ← HTTP API エンドポイント

G2Pは6言語(JA/EN/ZH/ES/FR/PT)に対応しています。日本語はCGO経由でOpenJTalkを呼び出し、ピッチアクセント情報を含む高品質な音素化を行います。韓国語(KO)とスウェーデン語(SV)はG2Pの実装はありますが、対応する学習済みモデルがまだ公開されていないため、現時点では利用できません。

環境構築

ONNX Runtimeの準備

Go SDKはONNX Runtimeの共有ライブラリを必要とします。ONNX Runtime のリリースページからダウンロードしてください。

OS ファイル名
Linux libonnxruntime.so
macOS libonnxruntime.dylib
Windows onnxruntime.dll

環境変数でパスを設定します。

# Linux / macOS
export ONNX_RUNTIME_SHARED_LIBRARY_PATH=/usr/lib/libonnxruntime.so

# Windows
set ONNX_RUNTIME_SHARED_LIBRARY_PATH=C:\onnxruntime\onnxruntime.dll

CLIのビルド

Go SDKにはCLIツールを同梱しています。go.modreplace ディレクティブを含めているため、go install は使えません。リポジトリをクローンしてビルドします。

重要: CGO_ENABLED=1 が必須です。 ONNX Runtimeのバインディングに加え、日本語のG2P(OpenJTalk)がCGO経由でネイティブライブラリを呼び出すため、CGOが無効な環境ではビルドが失敗します。WindowsではMSYS2/MinGW、Linuxではgcc/musl-devなどのCコンパイラが必要です。

git clone https://github.com/ayutaz/piper-plus.git
cd piper-plus/src/go
CGO_ENABLED=1 go build -o piper-plus ./cmd/piper-plus

ビルドしたバイナリをPATHの通った場所に配置するか、make install でGOPATH/binにインストールすることもできます。

make install

モデルのダウンロード

CLIからモデルの一覧表示とダウンロードができます。

# キャッシュ済みモデルの一覧
piper-plus --list-models

# URLを指定してモデルをダウンロード
piper-plus --download-model https://huggingface.co/ayutaz/tsukuyomi-chan-6lang-v2/resolve/main/tsukuyomi-chan-6lang-fp16.onnx

Go APIの基本的な使い方

まず、最小限の合成プログラムを書いてみます。

package main

import (
    "context"
    "fmt"
    "log"
    "os"

    "github.com/ayutaz/piper-plus/src/go/piperplus"
)

func main() {
    // ONNX Runtime を初期化
    // 引数が空文字の場合、ONNX_RUNTIME_SHARED_LIBRARY_PATH 環境変数を参照
    if err := piperplus.Init(""); err != nil {
        log.Fatal(err)
    }
    defer piperplus.Shutdown()

    // モデルを読み込み(config.json は model.onnx.json から自動検出)
    ctx := context.Background()
    voice, err := piperplus.LoadVoice(ctx, "tsukuyomi-chan-6lang-fp16.onnx")
    if err != nil {
        log.Fatal(err)
    }
    defer voice.Close()

    // 日本語で音声合成
    result, err := voice.Synthesize(ctx, "こんにちは、今日は良い天気ですね。",
        piperplus.WithLanguage("ja"),
    )
    if err != nil {
        log.Fatal(err)
    }

    // WAV ファイルに書き出し
    f, err := os.Create("output.wav")
    if err != nil {
        log.Fatal(err)
    }
    defer f.Close()

    if _, err := result.WriteTo(f); err != nil {
        log.Fatal(err)
    }

    fmt.Printf("RTF: %.3f (%.2f秒の音声を%.2f秒で合成)\n",
        result.RTF(), result.Duration.Seconds(), result.InferTime.Seconds())
}

piperplus.Init("") はスレッドセーフで、最初の呼び出しのみ有効です。LoadVoice() はconfig.jsonをモデルファイルのサイドカー(model.onnx.json または同ディレクトリの config.json)から自動検出します。

SynthesisResult には以下のフィールドとメソッドがあります。

フィールド / メソッド 説明
Audio []int16 PCMサンプル(モノラル、16-bit、ピーク正規化)
SampleRate int サンプルレート(例: 22050)
Duration time.Duration 音声の長さ
InferTime time.Duration 推論にかかった時間
RTF() float64 Real-Time Factor(推論時間 / 音声時間、1.0未満なら実時間より高速)
WriteTo(w) (int64, error) WAVを書き出し(io.WriterTo 実装)
RawPCMReader() io.Reader 生PCM int16バイト列のReader

多言語の例です。

// 英語
result, _ := voice.Synthesize(ctx, "Hello, how are you today?",
    piperplus.WithLanguage("en"))

// 中国語
result, _ := voice.Synthesize(ctx, "你好,今天天气很好。",
    piperplus.WithLanguage("zh"))

// スペイン語
result, _ := voice.Synthesize(ctx, "Hola, ¿cómo estás?",
    piperplus.WithLanguage("es"))

パラメータも調整できます。

result, err := voice.Synthesize(ctx, "ゆっくり話してみます。",
    piperplus.WithLanguage("ja"),
    piperplus.WithSpeakerID(0),
    piperplus.WithNoiseScale(0.5),
    piperplus.WithLengthScale(1.3),   // ゆっくり
    piperplus.WithNoiseW(0.6),
)

HTTP APIサーバー

Go SDKにHTTP APIサーバーを組み込んでいます。CLIの serve サブコマンド、またはGoプログラムから NewServer() を使って起動できます。

CLIからの起動

piper-plus serve -m tsukuyomi-chan-6lang-fp16.onnx --addr :8080

--model--device--custom-dict などのフラグは serve サブコマンドでも共通で使えます。

Goプログラムからの起動

package main

import (
    "context"
    "log"
    "log/slog"

    "github.com/ayutaz/piper-plus/src/go/piperplus"
)

func main() {
    if err := piperplus.Init(""); err != nil {
        log.Fatal(err)
    }
    defer piperplus.Shutdown()

    ctx := context.Background()
    voice, err := piperplus.LoadVoice(ctx, "tsukuyomi-chan-6lang-fp16.onnx")
    if err != nil {
        log.Fatal(err)
    }
    defer voice.Close()

    logger := slog.Default()
    server := piperplus.NewServer(voice, logger)

    log.Println("TTS server starting on :8080")
    if err := server.ListenAndServe(":8080"); err != nil {
        log.Fatal(err)
    }
}

エンドポイント

エンドポイント メソッド 説明
/synthesize GET / POST テキストを受け取りWAVを返す
/health GET ヘルスチェック({"status":"ok"}
/info GET モデル情報(話者数、言語、サンプルレート等)

curlでの動作確認

GETリクエストでの合成です。GETのクエリパラメータは langspeaker を使います(POSTのJSONボディでは languagespeaker_id)。

# 日本語の音声合成
curl "http://localhost:8080/synthesize?text=こんにちは&lang=ja" -o output.wav

# 英語の音声合成
curl "http://localhost:8080/synthesize?text=Hello+world&lang=en" -o output_en.wav

# 話者や速度を指定
curl "http://localhost:8080/synthesize?text=テスト&lang=ja&speaker=0&length_scale=1.2" -o output_slow.wav

POSTリクエスト(JSON)での合成です。

curl -X POST http://localhost:8080/synthesize \
  -H "Content-Type: application/json" \
  -d '{"text": "こんにちは、音声合成のテストです。", "language": "ja"}' \
  -o output.wav

POSTリクエストのJSONボディは以下のフィールドを受け付けます。

{
  "text": "合成するテキスト",
  "language": "ja",
  "speaker_id": 0,
  "noise_scale": 0.667,
  "length_scale": 1.0,
  "noise_w": 0.8
}

ヘルスチェックとモデル情報の確認です。

# ヘルスチェック
curl http://localhost:8080/health
# {"status":"ok"}

# モデル情報
curl http://localhost:8080/info
# {"num_speakers":1,"num_languages":6,"languages":{"en":1,"es":4,"fr":5,"ja":0,"pt":6,"zh":2},"sample_rate":22050,...}

VoicePool: 並行セッション管理

複数のリクエストを同時に処理する場合は VoicePool を使います。database/sql.DB と同様のパターンで、Voiceインスタンスをプールして再利用します。

package main

import (
    "context"
    "fmt"
    "log"
    "os"
    "sync"

    "github.com/ayutaz/piper-plus/src/go/piperplus"
)

func main() {
    if err := piperplus.Init(""); err != nil {
        log.Fatal(err)
    }
    defer piperplus.Shutdown()

    // 最大並行数4のプールを作成
    pool := piperplus.NewVoicePool("tsukuyomi-chan-6lang-fp16.onnx", 4)
    defer pool.Close()

    // 並行して合成リクエストを処理
    texts := []string{
        "これは1番目のリクエストです。",
        "これは2番目のリクエストです。",
        "これは3番目のリクエストです。",
        "これは4番目のリクエストです。",
        "これは5番目のリクエストです。",
    }

    var wg sync.WaitGroup
    for i, text := range texts {
        wg.Add(1)
        go func(idx int, t string) {
            defer wg.Done()

            ctx := context.Background()
            result, err := pool.Synthesize(ctx, t,
                piperplus.WithLanguage("ja"),
            )
            if err != nil {
                log.Printf("request %d failed: %v", idx, err)
                return
            }

            filename := fmt.Sprintf("output_%d.wav", idx)
            f, err := os.Create(filename)
            if err != nil {
                log.Printf("request %d: failed to create file: %v", idx, err)
                return
            }
            defer f.Close()
            result.WriteTo(f)

            fmt.Printf("request %d: RTF=%.3f\n", idx, result.RTF())
        }(i, text)
    }
    wg.Wait()
}

VoicePool の特徴は以下の通りです。

  • セマフォベースの並行制御: 指定した最大並行数を超えるリクエストはブロックされます
  • 遅延生成(lazy creation): Voiceインスタンスは必要になった時点で初めて生成されます。プール作成時にはメモリを消費しません
  • リサイクル: 使い終わったVoiceは破棄せずプールに戻して再利用します
  • goroutine安全: 複数のgoroutineから安全に呼び出せます
  • context.Context 対応: タイムアウトやキャンセルに対応しています

VoicePoolとHTTP APIサーバーを組み合わせた本番向けの構成も可能です。

pool := piperplus.NewVoicePool("model.onnx", 4)
defer pool.Close()

// プールを使った並行処理可能なHTTPハンドラ
http.HandleFunc("/synthesize", func(w http.ResponseWriter, r *http.Request) {
    text := r.URL.Query().Get("text")
    lang := r.URL.Query().Get("lang")

    result, err := pool.Synthesize(r.Context(), text,
        piperplus.WithLanguage(lang),
    )
    if err != nil {
        http.Error(w, err.Error(), 500)
        return
    }

    w.Header().Set("Content-Type", "audio/wav")
    result.WriteTo(w)
})

ストリーミング合成

長いテキストをセンテンス単位で分割し、逐次的に音声を生成・送出するにはストリーミング合成を使います。

package main

import (
    "context"
    "fmt"
    "log"
    "os/exec"

    "github.com/ayutaz/piper-plus/src/go/piperplus"
)

func main() {
    if err := piperplus.Init(""); err != nil {
        log.Fatal(err)
    }
    defer piperplus.Shutdown()

    ctx := context.Background()
    voice, err := piperplus.LoadVoice(ctx, "tsukuyomi-chan-6lang-fp16.onnx")
    if err != nil {
        log.Fatal(err)
    }
    defer voice.Close()

    // aplay にパイプして逐次再生(Linux)
    cmd := exec.Command("aplay", "-r", "22050", "-f", "S16_LE", "-c", "1")
    stdin, _ := cmd.StdinPipe()
    cmd.Start()

    sink := piperplus.NewWriterAudioSink(stdin)

    text := "これはストリーミング合成のテストです。文ごとに合成されます。リアルタイムに再生できます。"
    err = voice.SynthesizeStream(ctx, text, sink,
        piperplus.WithLanguage("ja"),
    )
    if err != nil {
        log.Fatal(err)
    }

    stdin.Close()
    cmd.Wait()
    fmt.Println("ストリーミング再生完了")
}

AudioSink インターフェースは以下のように定義しています。

type AudioSink interface {
    WriteAudio(samples []int16, sampleRate int) error
    Close() error
}

SynthesizeStream() は内部でテキストをセンテンスに分割し、文ごとにONNX推論を実行してAudioSinkに送出します。隣接するチャンク間では10msのクロスフェードが適用され、境界でのクリックノイズを低減します。

CLIからもストリーミングを使えます。

# raw PCMをstdoutに出力し、aplayで再生
piper-plus -m tsukuyomi-chan-6lang-fp16.onnx -t "長いテキストをストリーミングで再生します。" --streaming | aplay -r 22050 -f S16_LE

GPU推論

WithDevice() オプションでGPU推論を有効にできます。

// CUDA(デフォルトGPU)
voice, err := piperplus.LoadVoice(ctx, "model.onnx",
    piperplus.WithDevice("cuda"),
)

// CUDA(特定のGPU)
voice, err := piperplus.LoadVoice(ctx, "model.onnx",
    piperplus.WithDevice("cuda:1"),
)

// CoreML(macOS)
voice, err := piperplus.LoadVoice(ctx, "model.onnx",
    piperplus.WithDevice("coreml"),
)

// DirectML(Windows)
voice, err := piperplus.LoadVoice(ctx, "model.onnx",
    piperplus.WithDevice("directml"),
)

// 自動検出(CUDA → CoreML → DirectML → CPU の順にフォールバック)
voice, err := piperplus.LoadVoice(ctx, "model.onnx",
    piperplus.WithDevice("auto"),
)
デバイス 説明
cpu CPU推論(デフォルト)
cuda / cuda:N NVIDIA CUDA
coreml Apple CoreML(macOS)
directml / directml:N Microsoft DirectML(Windows)
tensorrt / tensorrt:N NVIDIA TensorRT
auto 利用可能なGPUを自動検出、失敗時はCPUにフォールバック

CLIでも --device フラグで指定できます。

piper-plus -m model.onnx -t "CUDAで合成" --device cuda -f output.wav

Dockerデプロイ

Go SDKはマルチステージビルドのDockerfile(src/go/docker/Dockerfile)を提供しています。ビルドステージでOpenJTalkをソースからCMakeビルドし、日本語G2Pを有効化した状態でGoバイナリをコンパイルします。ランタイムはDebian(trixie-slim)ベースで、ONNX Runtime v1.24.4とOpenJTalk辞書を同梱しています。

# イメージのビルド
docker build -t piper-plus-go -f src/go/docker/Dockerfile .

# CLIモードで実行
docker run --rm -v ./models:/models:ro \
  piper-plus-go -m /models/tsukuyomi-chan-6lang-fp16.onnx \
  -t "Dockerで合成テスト" --language ja -f /dev/stdout > output.wav

# HTTPサーバーとして起動
docker run -p 8080:8080 -v ./models:/models:ro \
  piper-plus-go serve -m /models/tsukuyomi-chan-6lang-fp16.onnx --addr :8080

CGO_ENABLED=1が必要な点に注意してください。これはONNX Runtimeのバインディングに加え、日本語G2P(OpenJTalk)のネイティブライブラリにも必要です。CGOが無効な場合、ビルドは失敗します。

実行結果

基本的な合成プログラムをDocker(CPU推論)で実行した結果です。

$ piper-plus -m tsukuyomi-chan-6lang-fp16.onnx -t "こんにちは、今日は良い天気ですね。" --language ja -f output.wav
time=2026-04-09T05:40:24.821Z level=INFO msg="voice loaded" model=tsukuyomi-chan-6lang-fp16.onnx device=cpu
time=2026-04-09T05:40:24.998Z level=INFO msg=synthesized duration=1.95047619s infer_time=149.725369ms rtf=0.077

CPU推論でRTF 0.077、約1.95秒の音声を約150msで合成できています(実時間の約13倍の速度)。

HTTPサーバーを起動し、curlでリクエストした結果です。

$ curl http://localhost:8080/health
{"status":"ok"}

$ curl http://localhost:8080/info
{"num_speakers":1,"num_languages":6,"languages":{"en":1,"es":3,"fr":4,"ja":0,"pt":5,"zh":2},"capabilities":{"HasSpeakerID":false,"HasLanguageID":true,"HasProsody":true,"HasDurationOutput":true},"sample_rate":22050}

$ curl "http://localhost:8080/synthesize?text=こんにちは&lang=ja" -o output.wav
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100 29740    0 29740    0     0   232k      0 --:--:--  0:00:00 --:--:--  232k

合成された音声ファイルは22050Hz、16-bit モノラルのWAV形式で出力されます。

所感

Goの利点がTTSサーバーの用途にうまく合っています。シングルバイナリでのデプロイ、goroutineによる並行処理、context.Contextによるタイムアウト管理が自然に使えます。VoicePoolはリクエスト数が増えた場合の並行制御に有効で、database/sql.DBと同様の使い勝手でVoiceインスタンスを管理できます。ONNX Runtimeの共有ライブラリが必要な点はPure Goとは言えませんが、Dockerでパッケージングすればデプロイの問題にはなりません。

piper-plus-g2p: TTS不要で使えるMITライセンスの多言語G2Pパッケージ

初めに

今回は、piper-plusから独立したG2P(Grapheme-to-Phoneme、文字から音素への変換)パッケージ「piper-plus-g2p」を紹介します。

piper-plus-g2pは、TTSエンジンを使わずに8言語(ja, en, zh, ko, es, fr, pt, sv)のテキストをIPA音素列に変換できるパッケージです。このうちTTSモデルが公開済みなのは6言語(ja, en, zh, es, fr, pt)ですが、G2P単体としては8言語すべてに対応しています。従来のpiper-plusではeSpeak-ng(GPLライセンス)に依存していましたが、piper-plus-g2pはeSpeak-ngを完全に排除し、MITライセンスで利用できます。PR #300(2026-04-01)で追加し、2026-04-07にPyPI(v0.2.0)およびcrates.io(v0.2.0)で公開しました。

設計方針として「IPA-first」を採用しており、phonemize() はBOS/EOS/PUAマーカーを含まない純粋なIPAトークン列を返します。Piper互換のphoneme_idsが必要な場合はオプションの PiperEncoder を使います。

Python(PyPI: v0.2.0)、npm(@piper-plus/g2p: v0.2.0)、Rust(crates.io: v0.2.0)、Goの4つのSDKを提供しており、4実装間でPUA互換テーブル(96エントリ)をCIで一致検証しています。

github.com

開発環境

  • OS: Windows 11
  • Python: 3.12
  • パッケージマネージャー: uv
  • piper-plus-g2p: 0.2.0

環境構築

全言語をインストールする場合

uv init piper-g2p-demo
cd piper-g2p-demo
uv add "piper-plus-g2p[all]>=0.2.0"

日本語のみの場合

uv add "piper-plus-g2p[ja]"

個別言語のextras

必要な言語だけをインストールすることで依存関係を最小限にできます。

extra 言語 主な依存 備考
[ja] 日本語 pyopenjtalk-plus
[en] 英語 CMU Dictionary + g2p-en
[zh] 中国語 ピンイン辞書
[ko] 韓国語 g2pk2 (MeCab/eunjeon) Windows環境ではMeCabのインストールに問題が発生する場合あり
[es] スペイン語 ルールベース
[fr] フランス語 ルールベース
[pt] ポルトガル語 ルールベース
[sv] スウェーデン語 NST辞書

複数言語を組み合わせる場合は以下のように指定します。

uv add "piper-plus-g2p[ja,en,zh]"

基本的な使い方

言語ごとのPhonemizer取得

get_phonemizer() に言語コードを渡すと、対応するPhonemizerインスタンスが返されます。

from piper_plus_g2p import get_phonemizer

# 日本語のPhonemizer
ja_phonemizer = get_phonemizer("ja")

# テキストをIPA音素列に変換
result = ja_phonemizer.phonemize("こんにちは")
print(result)
['k', 'o', '[', 'N_n', 'n', 'i', 'ch', 'i', 'w', 'a']

各言語の出力例

from piper_plus_g2p import get_phonemizer

# 日本語
ja = get_phonemizer("ja")
print("ja:", ja.phonemize("こんにちは"))

# 英語
en = get_phonemizer("en")
print("en:", en.phonemize("Hello, world!"))

# 中国語
zh = get_phonemizer("zh")
print("zh:", zh.phonemize("你好世界"))
ja: ['k', 'o', '[', 'N_n', 'n', 'i', 'ch', 'i', 'w', 'a']
en: ['h', 'ə', 'l', 'ˈ', 'o', 'ʊ', ',', ' ', 'w', 'ˈ', 'ɜ', 'ː', 'l', 'd', '!']
zh: ['n', 'i', 'tone2', 'x', 'aʊ', 'tone3', 'ʂ', 'ɻ̩', 'tone4', 'tɕ', 'iɛ', 'tone4']

注意: 韓国語(ko)のG2Pはg2pk2を使用しており、内部でMeCab(eunjeon)を必要とします。Windows環境ではMeCabのインストールに問題が発生する場合があるため、上記の例からは除外しています。Linux/macOS環境であれば get_phonemizer("ko") で利用可能です。

IPA-first設計のため、出力にはBOS(^)やEOS($)などのマーカーは含まれません。

日本語の韻律情報(ProsodyInfo)

日本語Phonemizerでは、音素列に加えて韻律情報(アクセント・イントネーション)も取得できます。

from piper_plus_g2p import get_phonemizer

ja = get_phonemizer("ja")
tokens, prosody = ja.phonemize_with_prosody("こんにちは")
print("prosody:", prosody[0])
prosody: ProsodyInfo(a1=-4, a2=1, a3=5)

OpenJTalkのアクセント情報(a1, a2, a3)がそのまま取得でき、TTS学習時のアクセント制御に利用できます。

PiperEncoderでphoneme_idsに変換(オプション)

Piper互換のphoneme_idsが必要な場合は PiperEncoder を使います。

from piper_plus_g2p import get_phonemizer, PiperEncoder
import json

ja = get_phonemizer("ja")
phonemes = ja.phonemize("こんにちは")

# config.jsonからphoneme_id_mapを読み込み
with open("config.json") as f:
    config = json.load(f)

encoder = PiperEncoder(config["phoneme_id_map"])
phoneme_ids = encoder.encode(phonemes)
print(phoneme_ids)

PiperEncoder はコンストラクタに phoneme_id_map(モデルの config.json に含まれる音素→IDのマッピング辞書)が必要です。BOS(ID: 1)とEOS(ID: 2)は自動付加されます。

多言語テキストの自動言語検出

MultilingualPhonemizer

日本語と英語が混在するテキストを処理する場合は、get_phonemizer() にハイフン区切りの複合言語コード(例: "ja-en-zh")を渡します。内部で MultilingualPhonemizer が自動的に作成され、Unicodeブロックに基づく言語検出で混在テキストを処理します。

from piper_plus_g2p import get_phonemizer

# ハイフン区切りの複合言語コードを渡すとMultilingualPhonemizerが自動生成される
phonemizer = get_phonemizer("ja-en")

# 日本語・英語混在テキスト
result = phonemizer.phonemize("Dockerコンテナを起動する")
print(result)

言語検出はUnicodeブロックに基づいて自動判定されます。

文字種 判定言語
カナ文字を含む 日本語 (ja)
CJK文字(カナなし) 中国語 (zh)
ハングル 韓国語 (ko)
ラテン文字 英語 (en)

カスタム辞書サポート

特定の単語の読みをカスタマイズする場合、CustomDictionary クラスを使います。apply_to_text() でテキスト内の単語を辞書の読みに置換し、その後にG2Pで音素変換する流れです。

from piper_plus_g2p import get_phonemizer
from piper_plus_g2p.custom_dict import CustomDictionary

# カスタム辞書を作成し、単語を追加
d = CustomDictionary()
d.add_word("Docker", "ドッカー", priority=10)
d.add_word("piper", "パイパー", priority=10)

# テキストに辞書を適用してから音素変換
text = "Dockerコンテナを起動する"
text = d.apply_to_text(text)  # "ドッカーコンテナを起動する"

ja = get_phonemizer("ja")
result = ja.phonemize(text)
print(result)

JSON辞書ファイルからの読み込みにも対応しています。

d = CustomDictionary(dict_paths="my_dict.json")

他のSDKについて

piper-plus-g2pはPython以外にも複数のSDKを提供しています。

npm (@piper-plus/g2p: v0.2.0)

npm install @piper-plus/g2p
import { G2P, DictLoader } from "@piper-plus/g2p";

// 日本語にはOpenJTalk辞書の読み込みが必要
const loader = new DictLoader();
const jaDict = await loader.loadJaDict();
const g2p = await G2P.create({ languages: ["ja", "en"], jaDict });

const result = g2p.phonemize("こんにちは");
console.log(result.tokens);   // ['k', 'o', ...]
console.log(result.language);  // 'ja'

g2p.dispose();

G2P.create() は非同期ファクトリで、日本語はOpenJTalk WASM + 辞書データの初期化が必要です。DictLoader が辞書のダウンロードとIndexedDBキャッシュを管理します。phonemize(){ tokens, prosody, language } を返します。韻律情報が必要な場合は phonemizeWithProsody() を使います。380テストでカバーしています。

Rust (crates.io: v0.2.0)

cargo add piper-plus-g2p@0.2.0 --features naist-jdic
use piper_plus_g2p::Phonemizer;
use piper_plus_g2p::japanese::JapanesePhonemizer;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let ja = JapanesePhonemizer::new_bundled()?;
    let (tokens, prosody) = ja.phonemize_with_prosody("こんにちは")?;
    println!("{:?}", tokens);
    Ok(())
}

PhonemizerRegistry を使って複数言語をまとめて管理することもできます。Feature flagsで言語ごとの依存を制御可能です(features = ["naist-jdic", "english"])。390テストでカバーしています。

Go

Go SDKのG2Pはモノレポ内のモジュールとして提供しています。

import "github.com/ayutaz/piper-plus/src/go/phonemize"

// 各言語のPhonemizerを個別に作成
esPhonemizer := phonemize.NewSpanishPhonemizer()
result, err := esPhonemizer.PhonemizeWithProsody("Hola, mundo")

日本語G2PはCGO + OpenJTalkが必要です。詳細はGo APIサーバーの記事を参照してください。

4実装間の互換性保証

Python、npm、Rust、Goの4実装は、PUA互換テーブル(96エントリ)をCIで一致検証しています。どのSDKを使っても同じテキストに対して同じ音素列が生成されます。

所感

piper-plus-g2pはTTSを使わずに音素変換だけを利用したいユースケース(発音辞書生成、語学アプリ、音声検索のインデックス構築など)に適しています。eSpeak-ng(GPL)を排除してMITライセンスで8言語のG2Pが使える点は、商用プロダクトへの組み込みにも有利です。4つのSDKでCIレベルの互換性が保証されている点も実用的です。