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でパッケージングすればデプロイの問題にはなりません。