Skip to content

Go

The same engine as a Go module over github.com/yalue/onnxruntime_go, running the fp32 ONNX graphs exported by tools/export_onnx.py. No torch at runtime, only the graphs, the checkpoint’s embedding tables and the onnxruntime shared library.

Use Go 1.25.13 or newer. The patch floor includes the current standard-library security fixes; golang.org/x/text v0.40 sets the Go 1.25 language floor.

This guide assumes you have:

  • the synthesis checkpoint (loudr-1.safetensors);
  • the exported graphs beside it (onnx/); the release ships them, and the fetch below gets them with everything else;
  • a voice profile (see guide 3);
  • the text tokenizer (tokenizer.json, ships beside the checkpoint);
  • a libonnxruntime shared library. The Go wrapper loads it at runtime. Point at it with LOUDKIT_ONNXRUNTIME_LIB. Any recent release works, including the venv’s onnxruntime/capi/libonnxruntime.*.dylib.

One fetch gets the whole set. The graphs alone cannot run: this port also reads the checkpoint’s embedding tables, the tokenizer and a voice. loudkit download --for onnx fetches all of it together, and nothing the torch path alone would need.

Terminal window
pip install "loudkit[hub]" # the fetch tool only; no torch
loudkit download loudreader/loudr-1 --for onnx --local-dir loudr-1

To clone a voice from this port, add --with-cloning. It fetches the three enrollment graphs this port enrols through, and not the torch enrollment weights, which only Python reads:

Terminal window
loudkit download loudreader/loudr-1 --for onnx --with-cloning --local-dir loudr-1

Exporting instead needs a Python checkout with torch installed, once, on any machine. Nothing in this module links torch, and the machine that runs it never needs torch. If that machine cannot have torch even once, export on another one and copy the onnx/ directory across. The graphs are plain files with no host affinity.

Terminal window
cd go
go build ./...
Terminal window
go get github.com/loudreader/loudkit/go

The module path is the full repository URL. go get cannot resolve any other form: a bare name builds inside this repository but cannot be fetched from outside it.

package main
import (
"fmt"
"log"
"github.com/loudreader/loudkit/go/engine"
"github.com/loudreader/loudkit/go/onnx"
"github.com/loudreader/loudkit/go/voice"
)
func main() {
onnx.SetSharedLibraryPath("/path/to/libonnxruntime.dylib")
if err := onnx.InitializeEnvironment(); err != nil {
log.Fatal(err)
}
defer onnx.DestroyEnvironment()
eng, err := engine.Load(
"loudr-1/loudr-1.safetensors",
"loudr-1/onnx",
"loudr-1/tokenizer.json",
)
if err != nil {
log.Fatal(err)
}
defer eng.Close()
v, err := voice.Load("loudr-1/voices/joe.safetensors")
if err != nil {
log.Fatal(err)
}
// SynthesizeLong, not Synthesize: Synthesize renders one window and returns
// an error for anything longer rather than clipping it.
//
// The parameters after the seed are language, speed, previousTokens and
// shouldCancel. Go has no default arguments, so each one is written out.
// An empty language means the voice's own: the argument, then v.Language,
// then "en". Name one only to read text in a language the voice was not
// enrolled in. 1.0 is normal speed and an exact bypass. nil previousTokens
// means this utterance continues nothing. nil shouldCancel means no
// barge-in; pass a closure and the decode loop stops within one forward
// pass.
audio, tokens, _, chunks, sr, capped, err := eng.SynthesizeLong(
"Hello from loudkit.", v, 7, "", 1.0, nil, nil)
if err != nil {
log.Fatal(err)
}
if capped {
fmt.Println("(truncated: generation stopped at the token cap)")
}
fmt.Printf("%d tokens, %.2fs audio, %d chunks\n",
len(tokens), float64(len(audio))/float64(sr), len(chunks))
}

Same seed, same tokens as the Python engine. The conformance fixture is verified by pytest, swift test, the JS suite and this one, down to exact Philox bits, sampler choices, frontend ids and free-running tokens.

eng.Synthesize renders exactly one window. It returns an error for anything longer rather than clipping the end of a paragraph. eng.SynthesizeLong is the long-form path. It runs the speech funnel over the whole text, splits on the manifest’s chunking recipe, gives each chunk its own derived seed, and carries a token prefix across the joins so the pitch contour does not restart at every boundary. Same algorithm as Python’s Engine.synthesize_long, same fingerprint.

speed is the video-player control: 1.5x is the same voice, sooner, with the pitch left where it was (timestretch.TimeStretch, WSOLA; see speed.md). The range is [0.5, 2.0], and anything outside it is refused, not clamped. 1.0 is an exact bypass, so the waveform is the vocoder’s own slice and every existing caller keeps their bytes. The CLI takes -speed.

The fourth return value is []timing.ChunkTiming, one entry per chunk, in order and adjacent. Chunk k’s End is the same float64 as chunk k+1’s Start, because offsets accumulate as integer samples and are divided by the rate once. Text is the post-funnel text, the text that was tokenised, and it is what a highlight should be matched against.

Each entry also carries Words, and those are an estimate: the chunk’s real duration shared out in proportion to each word’s length in code points. There is no alignment model here. See timestamps.md before building anything that depends on them. -timestamps prints both tiers.

A chunk handed to eng.Stream carries its own Timing starting at zero, because a streamed chunk cannot know what preceded it. A caller stitching the stream adds the offsets with ChunkTiming.Shifted. SynthesizeLong has the whole passage in hand and adds them in samples instead.

previousTokens takes the tokens an earlier call returned. It conditions the first window on their tail exactly as an interior chunk is conditioned on its predecessor, so a second request does not restart the pitch contour like a fresh sentence. Pass the whole previous result. Only the last chunking.prefix_tokens are used, and the slice happens inside the engine.

engine.Load and enroll.LoadEnroller take the best provider the shared library offers. engine.LoadWith and enroll.LoadEnrollerWith name one instead. The five values are the same in every loudkit port.

eng, err := engine.LoadWith(ckpt, onnxDir, tokPath, config.ExecutionConfig{
ONNXProvider: config.ProviderCPU, // auto, cpu, cuda, coreml, directml
})
fmt.Println(eng.Provider()) // the provider that ran, never "auto"
fmt.Println(eng.Describe()) // ... | exec[onnx provider=cpu]

The CLI carries the same knob as -provider.

auto is the default. It takes cuda where the shared library carries it, and cpu otherwise; it reaches neither coreml nor directml. A named provider the shared library does not carry is an error, never a quiet fall back to cpu.

The shared library alone decides which providers exist. Nothing is compiled in, so go get cannot add one. Point the binding through onnx.SetSharedLibraryPath, not the binding’s own setter, so a refusal can name the library that decided the answer.

coreml runs the renderer, not the whole engine. It puts the three renderer graphs on CoreML with ModelFormat=MLProgram and keeps the generator on CPU, because the generator has no winning CoreML configuration. The speech tokens are therefore identical to a cpu run, index for index; the waveform is not bit-identical, which is what ../reference/IDENTITY-CONTRACT.md says about running the renderer elsewhere.

The first run on a machine compiles those graphs, which takes about two minutes. The result is cached in ~/Library/Caches/loudkit/coreml, about 1.6 GB, and later runs open in about 25 s against 3 s for cpu. $LOUDKIT_COREML_CACHE moves the directory. That startup cost is why auto does not pick coreml. Measured RTF is in ../benchmarks.md.

CUDA measured 2.68x on an RTX 3090, against 0.67x for the CPU provider on the same host. See the benchmark report for the shared passage and runtime details.

The weight-free vectors (RNG, sampler, frontend, seed derivation) run anywhere:

Terminal window
just go-test # or: cd go && go test ./conformance/ -run 'TestPhilox|…'

The full engine (tokens + render band) needs the assets and the shared library and skips without them:

Terminal window
LOUDKIT_CKPT=…/loudr-1/loudr-1.safetensors \
LOUDKIT_ONNX_DIR=…/loudr-1/onnx \
LOUDKIT_VOICE=…/tests/data/reference/testvoice.voice.safetensors \
LOUDKIT_ONNXRUNTIME_LIB=…/libonnxruntime.dylib \
go test ./conformance/

The ported pieces live in go/: rng/ (Philox-4x32-10, native uint32), sampler/ (LR-SAMPLER-v1), tokenizer/ (grapheme BPE), windowing/, noise/, config/, checkpoint/, voice/, safetensors/, timing/, timestretch/, engine/. Each mirrors a Python or JS module 1:1 so a fix on any side is a one-line diff on the others.

  • Enrollment. Ported, and held to the shared enrollment fixture (prompt and conditioning tokens exact, embeddings cosine > 0.9999).

Not ported:

  • fp16 / int8. The graphs are fp32, the registered and measured configuration. int8 stays blocked; nothing int8 is produced.
  • The server. engine.Stream delivers chunks in process; only Python streams over a transport. Wrap it in your own server if you need one over the wire.