tts-rs

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

The same chapter, narrated by each engine

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.

qwen3tts

Qwen3-TTS-12Hz-1.7B-Base · 2.10 B · 24 kHz · --quant f16
female 0.261 · 3m 2s → 11:36 male 0.252 · 2m 40s → 10:34

cosyvoice

Fun-CosyVoice3-0.5B · 995 M · 24 kHz
female 0.718 · 9m 12s → 12:48 male 0.703 · 8m 15s → 11:44

audio8

Audio8-TTS-Preview-0.6b · 601 M · 44.1 kHz
female 0.536 · 6m 12s → 11:34 male 0.527 · 5m 47s → 10:59

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.

Try it

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.

Or run it as an HTTP API

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 /ttsWAV body, PCM s16le mono
POST /tts/streamsame, buffered rather than incremental
GET /v1/capabilitiesengines, sample rates, weight formats each supports
GET /healthliveness
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.

How they compare

engineRTF (both voices)wall, fastestbatchesreach for it when
qwen3tts0.252–0.2612m 40syes, at f16you are narrating something long
cosyvoice0.703–0.7188m 15snoyou want the widest language coverage
audio80.527–0.5365m 47snoyou 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.

What it is validated against

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.