DEV Community

Kasper
Kasper

Posted on

Shipping the first .NET F5-TTS library — and the ONNX bug I had to fix first

F5-TTS is one of the better open text-to-speech and voice-cloning models. There's just one catch if you live in the .NET world: running it means Python and PyTorch. No native story, no NuGet package, nothing you can drop into a desktop app without shipping an interpreter alongside it.

I wanted German (and any-language) TTS inside a native Windows app — so I set out to run F5-TTS on ONNX Runtime, which .NET already has first-class bindings for. This is the story of the bug that stood in the way, and the library that came out of fixing it.

The garbled-German problem

DakeQQ/F5-TTS-ONNX is an excellent project that exports F5-TTS to three ONNX graphs (preprocess → transformer → decode). It works great — for the default (v1) base checkpoint.

Point it at a non-English community fine-tune, though, and the output is fluent-but-wrong speech: the right voice, the right language, complete nonsense words. A classic sign that the text conditioning is broken while the acoustic model is fine.

Root cause: v0 vs v1

Many non-English fine-tunes (like hvoss-techfak/F5-TTS-German) are built on the older F5TTS_Base (v0) architecture, which differs from v1 in two config flags:

param v1 (default) v0
pe_attn_head None (RoPE on all heads) 1 (RoPE on the first head only)
text_mask_padding True False

The exporter's model reimplementation was hard-wired for v1: it applied RoPE to every attention head and always zeroed out padded text positions. On a v0 checkpoint, both corrupt the conditioning — and you get word-salad.

The fix was small and I contributed it upstream in PR #74 (merged): honor pe_attn_head in the attention processor, and honor mask_padding in the text embedding.

Wrapping it in a .NET API

With correct ONNX models in hand, the wrapper is tiny — all the heavy signal processing (STFT, the diffusion transformer, the vocoder) lives inside the graphs. The library just marshals tensors and runs the NFE loop:

using Horus.F5Tts.Onnx;

using var model = F5TtsModel.Load(
    "F5_Preprocess.onnx", "F5_Transformer.onnx", "F5_Decode.onnx", "vocab.txt",
    configureSession: o => o.AppendExecutionProvider_DML(0)); // CPU / DirectML / CUDA

var (reference, _) = WavAudio.ReadPcm16("voice.wav"); // 24 kHz mono
var result = model.Synthesize(reference, "Transcript of the clip.", "Hello from .NET!");
File.WriteAllBytes("out.wav", result.ToWav());
Enter fullscreen mode Exit fullscreen mode

Design choices worth calling out:

  • No forced runtime. The package only references the ONNX Runtime managed API; the consumer adds the CPU / DirectML / CUDA native package and picks the provider via a session hook.
  • Character-level tokenizer. For Latin-script languages you don't need jieba/pinyin at all — a plain char→vocab-index mapping is enough (verified end-to-end).
  • Dependency-free WAV helpers, so the surface stays tiny.

Verifying it actually works

Compiling isn't shipping. I ran the whole pipeline against real models and transcribed the output with faster-whisper large-v3: correct language, exact transcript, confidence 1.00. (The smoke test caught a real runtime bug too — the NFE loop ran one step too many and overran the transformer's time-step table. CI + a smoke test earn their keep.)

Shipping it

As far as I can tell, it's the first library that runs F5-TTS natively from .NET. If you're building something that needs local, offline, natural-sounding TTS on Windows/.NET, give it a spin — and open an issue if you hit anything.

Top comments (3)

Collapse
 
mrviduus profile image
Vasyl

Checking your TTS by transcribing it back with faster-whisper is a move I want to steal. It is a judge with a checkable answer, the transcript matches or it does not, no rubric to argue with. The garbled German bug is the scary failure class, nothing throws, the voice sounds fluent, only the meaning is broken. I hit the same shape in RAG work, output looks valid and only a comparison against a reference catches it. Does the whisper smoke test run in CI on every build, and how long does one round trip take on plain CPU?

Collapse
 
nibor1896 profile image
Kasper • Edited

Steal away — that's exactly why I like it. It turns "does this sound right?" into a
check with a ground truth: I know the sentence I asked for, so Whisper (large-v3 here)
either hands it back or it doesn't. No rubric, no vibes. The confidence score was an
extra tell — the broken runs didn't just mistranscribe, Whisper flipped the language
at rock-bottom confidence (German ref + German text coming back as "en, 0.26, 'Thank
you very much'"). That language-flip is a dead giveaway that something upstream is
broken, not just a hard sentence.

Your RAG parallel is the same shape, and it's the scary one precisely because nothing
throws. The stack is green, the audio is fluent, and the meaning is quietly wrong — the
only thing that catches it is a comparison against a reference you already trust.

On your two questions:

CI: the transcribe-back does not run on every build. CI is just restore/build/pack —
fast, no models. The Whisper check is a local gate I run against the real ~1.4 GB export
whenever I touch the pipeline or re-export a checkpoint. Two reasons it's not per-commit:
the weights aren't in the repo (~1.4 GB on Hugging Face, and the German ones are
CC-BY-NC), and full ONNX inference + Whisper on every push would dwarf the runner time
for a library that mostly changes in C#, not in the model. It still earns its keep — the
NFE off-by-one I wrote about only ever surfaced in that end-to-end run, never at compile
time.

CPU round trip: ~[X ms] -depends on the actual CPU- for one sentence (excluding the one-time model load), at the
default 32 NFE steps. CPU is the slow path — cost is dominated by 31 passes through the
~1.3 GB transformer, so it scales roughly linearly with NFE steps and text length. A
DirectML/CUDA provider brings that down a lot; CPU is the "works everywhere" fallback.

Collapse
 
nibor1896 profile image
Kasper

@mrviduus it's around 26s for one short sentence (~2.7 s of audio, synthesis only, excluding
the one-time model load)