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
libonnxruntimeshared library. The Go wrapper loads it at runtime. Point at it withLOUDKIT_ONNXRUNTIME_LIB. Any recent release works, including the venv’sonnxruntime/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.
pip install "loudkit[hub]" # the fetch tool only; no torchloudkit download loudreader/loudr-1 --for onnx --local-dir loudr-1To 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:
loudkit download loudreader/loudr-1 --for onnx --with-cloning --local-dir loudr-1Exporting 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.
cd gogo build ./...Use it from your own module
Section titled “Use it from your own module”go get github.com/loudreader/loudkit/goThe 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.
Passages longer than one window
Section titled “Passages longer than one window”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.
Where each chunk lands
Section titled “Where each chunk lands”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.
Continuing a previous call
Section titled “Continuing a previous call”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.
Choose the execution provider
Section titled “Choose the execution provider”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.
Verify against the shared fixture
Section titled “Verify against the shared fixture”The weight-free vectors (RNG, sampler, frontend, seed derivation) run anywhere:
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:
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.Streamdelivers chunks in process; only Python streams over a transport. Wrap it in your own server if you need one over the wire.