Overview
TTS services read text literally, which is wrong for anything written to be seen rather than said. Left alone, a service reads:VoiceFormatter rewrites these before synthesis, so they come out as “forty-two dollars and fifty cents”, “A P I”, and “March 15th, two thousand and twenty-five”.
It bundles the individual transforms in pipecat.utils.text.transforms behind one object, applying them in a deliberate order: structural cleanup first, language expansions second, your own replacements last.
Example Implementation
Runnable bot with voice formatting applied
Usage
Attach it to any TTS service throughtext_transforms. The "*" aggregation type runs it on every text frame regardless of how the text was aggregated:
num2words, which the number-expanding transforms depend on, is a base dependency.
Configuration
bool
default:"True"
Strip Markdown formatting symbols — bold, italic, headers, code spans.
bool
default:"True"
Space out phone number digits so they’re pronounced individually.
bool
default:"True"
Space out uppercase acronyms, so
"API" becomes "A P I".bool
default:"True"
Expand currency amounts, so
"$42.50" becomes "forty two dollars and fifty cents".bool
default:"True"
Expand percentages, so
"50%" becomes "fifty percent".bool
default:"True"
Expand unit abbreviations, so
"5km" becomes "5 kilometers".bool
default:"True"
Rewrite email addresses into spoken form.
bool
default:"True"
Expand date expressions into spoken form.
bool
default:"False"
Expand bare numeric digits into words. Off by default, because plenty of
numbers are clearer read as digits — a room number, a year, a version.
int | None
default:"None"
Numbers above this value are read digit by digit instead of as a quantity.
Only applies when
expand_numbers=True. None expands every number as words.list[tuple[str, str]] | None
default:"None"
(regex_pattern, replacement) pairs, applied after every other transform.Turning pieces off
Every option is independent, so a bundle can be narrowed to what a particular bot needs:number_digit_cutoff is the option worth knowing about: with expand_numbers=True and no cutoff, an account number is read as a single enormous quantity. Setting a cutoff keeps small numbers as words and switches longer ones to digit-by-digit.
Using transforms individually
VoiceFormatter is a convenience. Each transform is also an async callable you can register on its own:
strip_markdown, normalize_acronyms, expand_currency, expand_numbers, expand_percentages, expand_phone_numbers, expand_units, email_to_speech, normalize_dates, and replace_text.
A transform is any async callable with this signature, so your own slots in the same way:
Notes
- Order matters. The bundle applies structural cleanup first, then language expansions, then
custom_replacements. Registering transforms individually puts that order in your hands — list them in the order you want them applied. - Transforms run before synthesis, not before the context. What the bundle rewrites is what the TTS service is asked to say; the conversation context keeps the LLM’s original text.