Skip to main content

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 through text_transforms. The "*" aggregation type runs it on every text frame regardless of how the text was aggregated:
No extra install: 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:
Available individually: 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.