Local text-to-speech in Rust. Three voice-cloning speech models ported from PyTorch, validated stage by stage against fp32 activations dumped from their references. One process, no Python at runtime.
Source on GitHub · Apache-2.0 · macOS on Apple silicon
1612 words of prose — examples/chapter.txt in the repo — rendered on one
M4 / 16 GB laptop, each engine in the configuration it ships in. These are the
complete chapters — eleven to thirteen minutes each — in two voices, cloned from two
reference clips of about ten seconds. Ordered by how they sound on this passage, best
first — a listening judgement, not a measured one, so trust your own ears over it.
--quant f16
A chapter becomes 11 minutes of speech in 3 minutes, on a laptop. A 16-hour book is about four hours of compute rather than twelve.
git clone https://github.com/drmhse/tts-rs && cd tts-rs
./scripts/bootstrap.sh
cargo run -p tts-cli --release -- speak --engine qwen3tts \
--voice voices/cosy-default-qwen3tts --quant f16 \
--text-file examples/chapter.txt --out chapter.wav
bootstrap.sh downloads and converts the checkpoints, fetches the fixtures for
the correctness gates, and builds — one command, nothing manual. Name the engines you want
(bootstrap.sh qwen3tts, ~4.3 GB) or take all three, at ~13 GB;
--list prints the ids and what each costs.
tts-serve loads one engine once and answers requests from it — no per-request
model load, which is where the 3–4 second start beats a Python service’s
15–17. It is wire-compatible with the FastAPI service it replaces, so an existing
client switches by pointing at a different port.
TTS_API_KEY=secret cargo run -p tts-serve --release -- --port 3003
curl -X POST localhost:3003/tts -H "X-API-Key: secret" \
-H 'content-type: application/json' \
-d '{"text":"Hello from Rust.","voice":"voices/cosy-default-male","seed":7}' \
-o out.wav -D headers.txt
| route | |
|---|---|
| POST /tts | WAV body, PCM s16le mono |
| POST /tts/stream | same, buffered rather than incremental |
| GET /v1/capabilities | engines, sample rates, weight formats each supports |
| GET /health | liveness |
| GET / | lists live routes and the unimplemented ones |
Two things worth knowing. Every response carries its own cost —
x-audio-seconds, x-wall-seconds, x-rtf, and
x-stages with the per-stage split, so a client can see where the time went
without a second request. And voice and seed are per request:
the first picks a voice asset without restarting, the second makes a render reproducible.
What it refuses is as deliberate as what it serves. speed other than 1.0,
mode=instruct, mode=cross_lingual and the durable job queue all
answer 501 with an explanation rather than quietly returning something
else — returning speed-1.0 audio to a client that asked for 1.5 would report the request
as honoured when it was not. Synthesis is serialised behind a semaphore, because two
requests interleaving on one Metal queue make both slower and neither faster.
| engine | RTF (both voices) | wall, fastest | batches | reach for it when |
|---|---|---|---|---|
| qwen3tts | 0.252–0.261 | 2m 40s | yes, at f16 | you are narrating something long |
| cosyvoice | 0.703–0.718 | 8m 15s | no | you want the widest language coverage |
| audio8 | 0.527–0.536 | 5m 47s | no | you want 44.1 kHz output |
Compare wall time, not just RTF: the three do not produce the same duration from the same
text — cosyvoice speaks slowest — and RTF divides by audio produced, which
flatters a slower-speaking engine. qwen3tts wins here only because it batches
across sections; on a short passage it is the slowest of the three, at 0.665.
Every engine is gated against per-stage fp32 activations dumped from its PyTorch
reference, so a failure localises to a stage rather than to “the audio sounds wrong”.
audio8’s greedy generation is bit-identical to its reference;
cosyvoice’s teacher-forced argmax matches 105/105.
Free-running sampled output is deliberately not gated on equality —
ras_sampling draws from torch’s generator, so a sampled sequence is not
reproducible across implementations. Quality is checked separately by word error rate.
Known limitations, including an undiagnosed regression in two convolution stages, are in
the README.