- 初めに
- 開発環境
- アーキテクチャ概要
- 環境構築
- Go APIの基本的な使い方
- HTTP APIサーバー
- VoicePool: 並行セッション管理
- ストリーミング合成
- GPU推論
- Dockerデプロイ
- 実行結果
- 所感
初めに
今回は、piper-plusのGo SDKを使ったHTTP APIのTTSサーバー構築方法を解説します。Go SDKはシングルバイナリでデプロイでき、VoicePoolによる並行合成、6言語対応のG2P、ストリーミング合成、GPU推論に対応しています。
開発環境
- 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.mod に replace ディレクティブを含めているため、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のクエリパラメータは lang、speaker を使います(POSTのJSONボディでは language、speaker_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でパッケージングすればデプロイの問題にはなりません。
