Skip to content

Go

The same engine as a Go module over onnxruntime. No Python, no torch.

Go 1.25 or newer, and one shared library that cannot be vendored: brew install onnxruntime on macOS, apt install libonnxruntime-dev on Linux, the onnxruntime-win-x64 archive on Windows. loudkit.Load finds it; set LOUDKIT_ONNXRUNTIME_LIB if yours is somewhere unusual.

Terminal window
mkdir hello && cd hello
go mod init hello
go get github.com/loudreader/loudkit/go@v0.1.1
package main
import (
"log"
loudkit "github.com/loudreader/loudkit/go"
)
func main() {
eng, err := loudkit.Load("loudreader/loudr-1")
if err != nil {
log.Fatal(err)
}
defer eng.Close()
v, err := eng.Voice("joe")
if err != nil {
log.Fatal(err)
}
out, err := eng.Synthesize("Hello from loudkit.", v, loudkit.Options{Seed: 7})
if err != nil {
log.Fatal(err)
}
if err := out.SaveWav("hello.wav"); err != nil {
log.Fatal(err)
}
}

The first run downloads the model files into ~/Library/Caches/loudkit/loudreader--loudr-1 on macOS and ~/.cache/loudkit/loudreader--loudr-1 on Linux ($LOUDKIT_CACHE moves it), the directory the Rust, JS and Swift ports share, and checks every file against the release’s own SHA256SUMS; later runs read what is there. eng.Voices() names the 28 voices. The snippets need loudkit 0.1.1; from a checkout, go run ./examples/hello in go/.

Both loudr-1 and loudr-1-turbo use this API in 0.1.1. Change the model name to switch; keep the same voice profile. A local release directory works as well as a published model name.

dir, err := loudkit.DownloadWith("loudreader/loudr-1", "loudr-1", loudkit.Fetch{Revision: "v0.1.1"})
eng, err := loudkit.Load(dir)

Download writes a receipt, .loudkit-release.json; a later call whose revision still resolves to the same commit fetches and hashes nothing, a moved revision keeps every file that still hashes to the new SHA256SUMS and fetches the rest, and an interrupted fetch resumes. Pin Revision for anything reproducible. Under loudreader/, release.json must say the bundle passed the builder’s gate, and it is checked before any weight moves.

Synthesize takes text of any length: it splits at sentence boundaries, gives each chunk its own seed, carries the pitch contour across the joins and returns one Result. The zero Options is every default.

out, err := eng.Synthesize(text, v, loudkit.Options{
Seed: 7, // 0 when omitted
Language: "pl", // the voice's own when empty
Speed: 1.25, // [0.5, 2.0], pitch preserved; 0 or 1.0 is an exact bypass
PreviousTokens: earlier.Tokens, // continue an earlier result's pitch contour
})
out.Audio // []float32 at out.SampleRate
out.Tokens // the speech tokens
out.Chunks // where each chunk lands, and an estimate of each word
out.HitTokenCap // generation stopped at the token cap: probably cut off
out.SaveWav(path); out.WriteWav(w)

SynthesizeWindow renders exactly one model window and returns an error on longer text; it is for the conformance harness.

err := eng.Stream(text, v, loudkit.Options{Seed: 7, ShouldCancel: stopped}, func(c loudkit.Chunk) bool {
play(c.Audio) // c.Timing starts at zero; c.Timing.Shifted adds your offset
return true // false stops at the next chunk
})

Stream hands out chunks as they are made, so playback starts before the passage is finished. ShouldCancel is polled on every decode step, and the chunk being generated is discarded.

out.Chunks is exact at the chunk level and an estimate at the word level; read timestamps.md before building on the word times. Speed is refused outside [0.5, 2.0]; see speed.md.

mine, err := eng.Enroll("me.wav", "mine", "en")
if err != nil { log.Fatal(err) }
if err := mine.Save("mine.safetensors"); err != nil { log.Fatal(err) }

The first Enroll on an engine loaded by repo id fetches the three enrollment graphs into the same cache directory, ~/Library/Caches/loudkit/loudreader--loudr-1 on macOS and ~/.cache/loudkit/loudreader--loudr-1 on Linux; later calls read them from there. An engine loaded from a directory of your own needs them fetched with loudkit.DownloadWith(repo, dir, loudkit.Fetch{Cloning: true}). Five to ten seconds of clean speech is the input this was tuned for. Enroll reads 8, 16, 24 and 32-bit PCM and 32-bit float WAVs at any rate; EnrollPCM takes samples. voice.Load(path) reads the profile back.

Load takes the best provider the shared library offers. To name one, build the engine yourself:

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"

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

coreml runs the renderer on CoreML and keeps the generator on CPU, so the speech tokens are identical to a cpu run and the waveform is not bit-identical. The first run compiles the graphs, about two minutes, cached under ~/Library/Caches/loudkit/coreml ($LOUDKIT_COREML_CACHE moves it).

CUDA measured 2.68x on an RTX 3090 (measured on 0.1.0), against 0.67x for the CPU provider on the same host. On an Apple M3 Pro the CPU provider runs the shared passage at 1.18x with loudr-1 and 1.70x with loudr-1-turbo, measured on 0.1.1. See the benchmark page for the passage and the runtime details.

loudkit.LoadPaths(checkpoint, onnxDir, tokenizer) opens three paths you assembled yourself, and loudkit.Open(dir) reads a release’s paths without loading it.

Terminal window
cd go && go test ./conformance/ # weight-free vectors
LOUDKIT_CKPT=… LOUDKIT_ONNX_DIR=… LOUDKIT_VOICE=… LOUDKIT_ONNXRUNTIME_LIB=… go test ./conformance/

The second needs the checkpoint, the graphs, the reference voice and the shared library, and holds Synthesize and SynthesizeWindow to the fixture’s tokens, chunk by chunk.