rlx-inflect-nano
Inflect-Nano-v1 English text-to-speech for RLX — a ~4.6M-parameter two-stage TTS, ported to Rust with full numeric parity to the reference PyTorch implementation and a fully standalone text frontend (no Python at runtime).
Architecture
text ─▶ frontend ─▶ phone/tone/lang ids ─▶ MicroFastSpeech ─▶ 80-bin log-mel ─▶ Snake HiFi-GAN ─▶ 24 kHz wav
- Acoustic (
MicroFastSpeech, 3.47M params): phone/tone/lang/speaker embeddings → 5 ConvFFN encoder blocks → duration/energy/bright/pitch prosody heads → length regulation → 6 ConvFFN decoder blocks → bidirectional GRU → mel head → postnet. Fully deterministic (no sampling). - Vocoder (Snake HiFi-GAN
snake_v2mid, 1.16M params): conv_pre → 4× (Snake → ConvTranspose1d → 3 ResBlocks) → Snake → conv_post → tanh. Weight-norm folded at export. - Frontend (standalone Rust):
clean→normalize(a faithfulinflectnumber/time/currency- abbreviation port) → BERT WordPiece word grouping → CMUdict →
g2p_enneural OOV (single-layer GRU seq2seq, greedy) + nltk averaged-perceptron POS tagger for homographs → phoneme ids → blanks.
- abbreviation port) → BERT WordPiece word grouping → CMUdict →
Backends
The vocoder (the compute core) builds as an rlx-ir graph compiled per device, so it runs on every
RLX backend. 1-D convs are NCHW convs with time in H; the transposed convs are rewritten as
zero-insertion + regular conv (so no backend needs a native ConvTranspose kernel); Snake α is
folded to constants. Runtime-validated on CPU, Metal, MLX, and wgpu (this Apple-Silicon host);
CUDA/ROCm flags are wired but need their toolkits/hardware to build.
| Backend | --device |
Path | Status |
|---|---|---|---|
| CPU | cpu |
rlx graph / host | ✅ graph parity 2.2e-4; host is the reference |
| Apple Metal | metal |
rlx graph | ✅ runs, parity 2.1e-4 |
| Apple MLX | mlx |
rlx graph | ✅ runs (fastest — 48× RTF) |
| wgpu (Metal/Vulkan/DX12) | gpu |
rlx graph | ✅ runs |
| Apple CoreML / ANE | coreml |
ONNX Runtime EP | ✅ runs on ANE/GPU (--features coreml); warm 10.9× RTF (~1.8× host) |
| CUDA / ROCm | cuda / rocm |
rlx graph | flags wired; not buildable without toolkit |
CoreML is reached through ONNX Runtime's CoreML execution provider (the workspace's standard CoreML
route), not the native rlx-graph compiler. CoreML's MIL requires bounded dims, so the dynamic-axis
vocoder.onnx would silently fall back to CPU — the CoreML path instead loads a static-shape
vocoder_static.onnx and chunks the mel into fixed-length segments (with overlap; bit-exact stitch),
so it genuinely executes on the ANE/GPU (fp16; ≈4% vs the f32 host, inaudible on a tanh-bounded
waveform). The ORT session is cached (InflectNano::synthesize_coreml), so the one-time CoreML
model compile (~4.5 s) is paid on first call and warm runs are ~1.8× faster than the host CPU
pipeline. The acoustic stage stays host-eager like the other backends.
Feature flags mirror the other RLX model crates:
cargo run -p rlx-inflect-nano --features metal -- --device metal --text "..." # Apple GPU
cargo run -p rlx-inflect-nano --features mlx -- --device mlx --text "..." # Apple MLX
cargo build -p rlx-inflect-nano --features all-backends # wire all
A host-eager (ndarray, matmul-based) CPU path is always available and is the numeric reference.
The acoustic stage runs host-eager (it is tiny); synthesize_on(device) runs the vocoder graph on
the chosen backend.
Speed (2.77 s utterance, this Apple-Silicon host)
| Path | Time | RTF |
|---|---|---|
| PyTorch reference (CPU, 10 threads, MKL) | 1.08 s | 2.56× |
| rlx-inflect-nano host-eager (CPU, 1 thread) | 0.66 s | 4.2× |
| rlx-inflect-nano vocoder graph (Metal) | 0.099 s (vocoder only) | ~28× |
The Rust CPU path (frontend + acoustic + vocoder) is ~1.6× faster than the 10-thread PyTorch reference; the vocoder graph on Metal is far faster again.
Asset bundle
The PyTorch checkpoints + frontend assets are converted to an RLX bundle by the export script (weights/dicts are gitignored; regenerate locally):
python3 -m venv --system-site-packages .venv-inflect
.venv-inflect/bin/pip install g2p_en inflect nltk safetensors
git clone https://huggingface.co/owensong/Inflect-Nano-v1 /tmp/inflect-nano
.venv-inflect/bin/python scripts/export_inflect_nano.py --repo /tmp/inflect-nano --out weights/inflect-nano-rlx
Bundle layout: acoustic.safetensors, vocoder.safetensors, config.json, and frontend/
(CMUdict, g2p_en checkpoint, homographs, perceptron tagger, BERT tokenizer, symbol table).
Samples
Generated by the example below (CPU path, this crate, 24 kHz):
| Audio (MP4/AAC, 24 kHz) | Text |
|---|---|
assets/sample_weather.mp4 |
"The weather is nice today, and I feel very relaxed." |
assets/sample_numbers.mp4 |
"Dr. Smith paid 42 dollars and 50 cents at 3:15 pm." |
assets/sample_rlx.mp4 |
"RLX runs this tiny speech model on every backend, as fast or faster." |
assets/sample_coreml.mp4 |
"This speech was generated on Apple CoreML." (vocoder on the CoreML EP / ANE) |
Regenerate (the example writes WAV; ffmpeg -i out.wav -c:a aac out.mp4 to repackage):
cargo run -p rlx-inflect-nano --release --example synthesize -- \
--data weights/inflect-nano-rlx --text "The weather is nice today, and I feel very relaxed." \
--out out.wav
Usage
cargo run -p rlx-inflect-nano --release -- --data weights/inflect-nano-rlx \
--text "The weather is nice today, and I feel very relaxed." --out out.wav
let model = load_from_dir?;
let wav = model.synthesize?;
write_wav?;
Execution modes
synthesize_mode(text, &opts, mode) (CLI --mode …) picks the compute path. The acoustic stage is
tiny and always runs host-eager; the mode selects how the vocoder (the compute core) runs:
Mode (ExecutionMode) |
--mode |
Path |
|---|---|---|
Latency |
latency |
Vocoder on the fastest accelerator (Metal → MLX → wgpu); CPU fallback if none |
Precision (default) |
precision |
Pure host-eager f32 — the deterministic parity reference |
MemoryFootprint |
memory |
Host-eager only — no graph compile, AOT cache, or compiled-graph residency |
Hybrid |
hybrid |
iOS-style CPU+GPU split: acoustic on CPU, vocoder graph on the GPU |
cargo run -p rlx-inflect-nano --features apple-silicon --release -- \
--data weights/inflect-nano-rlx --mode hybrid --text "Hello!" --out out.wav
let wav = model.synthesize_mode?;
Latency/Hybrid fall back to the (fast) CPU path when no GPU backend is compiled in or available,
so they are always safe to request.
Real-time streaming (1 s of audio in < 1 s of compute)
synthesize_stream (CLI --stream [--chunk-secs 1.0]) emits the waveform in fixed-length chunks so
playback can start after the first chunk and a long utterance sustains real-time with bounded
latency. The acoustic model runs once; the vocoder runs per chunk over a small overlap context and
the trimmed chunks concatenate bit-identically to a full-utterance vocode (validated in
tests/streaming_parity.rs, max-diff 0.0).
cargo run -p rlx-inflect-nano --release -- --data weights/inflect-nano-rlx --stream --chunk-secs 1.0 \
--text "Streaming one-second chunks in real time." --out out.wav
let report = model.synthesize_stream?;
assert!; // every 1 s chunk produced in < 1 s
Each 1-second chunk is produced in ~0.26 s on CPU alone (worst-chunk RTF ~3.8×), so the stream stays
comfortably real-time with margin to spare (and far more on MLX/Metal). Streamed chunks are the raw
tanh-bounded vocoder output — the whole-clip RMS normalization in synthesize needs the full signal
and is intentionally skipped when streaming.
Per-mode benchmark
cargo run --features <backend> --release --example bench_modes — 9.27 s utterance, warmed iters,
on this Apple-Silicon host. RTF (audio-seconds / wall-seconds), higher is better:
| Mode | Path | CPU | wgpu | Metal | MLX |
|---|---|---|---|---|---|
precision |
host CPU f32 | 6.1× | 6.1× | 5.8× | 5.4× |
memory |
host CPU | 6.7× | 6.7× | 5.8× | 5.5× |
latency |
GPU vocoder | — | 6.9× | ~10–14× | 48× |
hybrid |
CPU + GPU vocoder | — | 6.1× | ~16× | 47× |
- The acoustic stage always runs host (it's tiny), so
latency/hybridonly accelerate the vocoder. MLX gives a ~9× vocoder speedup (48× RTF); Metal ~2–3× (kernel JIT warmup adds variance — best-case ~17×); wgpu's dispatch/readback overhead makes it marginal for a model this small. - Peak RSS is ~300 MB in every mode (297–302 MB), dominated by the text-frontend dictionaries + model weights rather than the backend — so mode choice barely affects memory here.
- CoreML / ANE: supported via ONNX Runtime's CoreML EP (
--features coreml,--device coreml) — not the native rlx-graph compiler (rlx-runtimeonly has an unimplementedanestub), so it runs the exportedvocoder.onnxrather than the rlx graph.
Parity
Validated against Python reference fixtures (scripts/inflect_nano_reference.py,
scripts/inflect_nano_frontend_fixtures.py):
| Stage | Metric | Result |
|---|---|---|
| Frontend normalization | exact string match | 2166/2166 |
| Frontend full pipeline (text→ids) | exact id match | 18/18 (incl. OOV, numbers, abbrev) |
| Acoustic (ids→mel) | max abs diff | ~2.3e-5 |
| Vocoder (mel→wav, host) | max abs diff | ~4.4e-4 |
| Vocoder graph (CPU) vs host | max abs diff | ~2.2e-4 |
| Vocoder graph (Metal) vs host | max abs diff | ~2.1e-4 |
| Durations | exact integer match | all cases |
Run the tests (the bundle dir defaults to weights/inflect-nano-rlx, override with
RLX_INFLECT_NANO_DATA):
cargo test -p rlx-inflect-nano --release