Horus.F5Tts.Onnx
Here's what landed in v0.3.0:
Streaming: hear the first sentence sooner
For a paragraph, SynthesizeLongAsync gives you nothing until every sentence is done. The wait that actually hurts in an interactive app is time-to-first-audio — and that's the one v0.3.0 fixes.
await foreach (var chunk in model.SynthesizeStreamAsync(reference, refText, paragraph))
player.Write(chunk.Samples); // sentence chunk.Index + 1 of chunk.Count
The bit I care about: it's not a second rendering. The chunks are the same sentence-level pieces, cross-faded the same way, so concatenating every streamed chunk is byte-for-byte identical to SynthesizeLongAsync for the same inputs and seed. I proved that twice — as pure cross-fade math with no models, and end-to-end against the model — and then confirmed it live: the streamed and non-streamed WAVs share one SHA-256.
It's chunk-granularity, not frame-level (F5 renders each chunk as a whole), so the win is the first chunk arriving early, and it grows with text length.
Text normalizers: numbers that read right
Checkpoints are trained on normalized text. Feed the model a raw 50 %, 1.000 €, z.B., 3.8.2026 or 14:30 and it swallows or mumbles them — they're out-of-distribution. v0.3.0 ships ready normalizers for German and English:
options.TextNormalizer = GermanTextNormalizer.Normalize;
// "am 3.8.2026 um 14:30 Uhr" -> "am dritten August zweitausendsechsundzwanzig um vierzehn Uhr dreißig"
options.TextNormalizer = EnglishTextNormalizer.Normalize;
// "I saved $1,000 (50%) by the 3rd" -> "I saved one thousand dollars (fifty percent) by the third"
They cover cardinal numbers, percent, currency with cents, decimals and thousands (the ,/. convention is swapped between the two languages), dates, clock times, ordinals (German inflects from the leading word — am → -ten, der → -te, none → -ter), abbreviations and a few symbols. Only recognized patterns are touched; prose — and the name "Max." — is left alone.
The difference is audible. The same German sentence ran 5.2 s raw vs 8.3 s normalized — the extra seconds are the numbers actually being spoken instead of skipped.
And where a reading would be a guess, I left it alone on purpose: German bare ordinals at a sentence boundary stay cardinals, and English numeric dates (3/8/2026 — is that March 8 or 3 August?) are untouched. A wrong reading is worse than a plain one.
PreparedVoice — and the feature I measured away
When one voice speaks many lines, you can now bind the reference once:
var voice = model.PrepareVoiceFromWav("reference.wav", refText);
var a = await voice.SynthesizeAsync("First line.");
var b = await voice.SynthesizeLongAsync(wholeParagraph);
I originally wanted this as a performance feature — cache the reference processing across calls. Then I measured one synthesis (NFE 32):
| phase | time | share |
|---|---|---|
| preprocess (the only reference-dependent step) | 50 ms | 0.3 % |
| transformer denoising loop (31 steps) | 16,156 ms | 99.5 % |
| decode | 36 ms | 0.2 % |
Caching the reference would save ~0.3 % — and it can't even be cached, because F5 fuses the reference into the same graph pass as the generated text. So PreparedVoice ships as ergonomics only, and its docs say exactly that. The genuine latency win this release is streaming (time-to-first-audio), not throughput.
That's the rule I keep for this library: measure before you build, and don't sell a number you don't have.
Also in 0.3.0
IProgress<F5TtsProgress> progress reporting that spans the whole request, and 238 tests (the streaming guarantee and ~124 normalizer cases included).
Feedback, issues and PRs welcome. 🙌
Top comments (0)