TTS reads numbers, dates and acronyms wrong
Normalization mangles currency, ranges, dates and initialisms before synthesis. A preprocessing pass that writes the script the way it should be spoken.
The voice did not "misread" $47,345.67. A preprocessor decided what those characters meant, then the synthesizer spoke that decision. Text normalization is the silent stage in front of every TTS engine: it expands numbers, dates, currency, abbreviations, and initialisms into words. When it guesses right, you never notice. When it guesses wrong, you get "one thousand thousand dollars" for a million, a date in the wrong locale, a range spoken as a subtraction, or an acronym spoken as a word that is not a word.
ElevenLabs documents the mechanism plainly: normalization is on by default, smaller models generalize it less well than larger ones, and the phrase $1,000,000 is read as "one million dollars" on Eleven Multilingual v2 and as "one thousand thousand dollars" on Eleven Flash v2.5. That is not a voice problem and it is not a prompt-for-emotion problem. The written form never made it to the mouth. Write the spoken form yourself.
Normalisation happens before the voice
W3C SSML 1.1 describes the same split: orthographic text is converted into a spoken form, and markup such as say-as exists for the cases where that conversion is ambiguous. Most TTS UIs never show you that stage. You paste $50–70, 01/02/2026, FAQ, and you hear whatever the front-end emitted.
Two consequences follow.
First, switching voices will not save a bad expansion. Timbre changes. The tokens going into the vocoder do not, unless the new engine’s normalizer happens to disagree. Cartesia Sonic 3.5 and ElevenLabs Multilingual can sound like different people and still both say "minus" for a hyphenated range.
Second, you cannot prompt the style field out of it. emotion and style_instructions on Versely’s generate_speech steer delivery. They do not tell the normalizer that 2024-01-01 is a date. On engines that take clean text only, SSML say-as is not available either, so the portable fix is the same one that works for brand names: put the spoken words in the script.
ElevenLabs’ own mitigation list is the right order even if you never open their API: prefer a model that generalizes numbers, expand in an LLM pass if a model is writing the script, and preprocess with deterministic rules when it still fails. For brand-critical copy, skip the first two and always do the third.
The failure patterns
These are the constructions that break in production copy. The spoken column is US-English; lock a locale before you ship.
| Written | Typical bad reading | Write this instead |
|---|---|---|
$1,000,000 |
"one thousand thousand dollars" on smaller models | one million dollars |
$47.50 |
"forty-seven point five zero", or "dollars forty-seven fifty" | forty-seven dollars and fifty cents |
$50–70 |
"fifty minus seventy dollars" | fifty to seventy dollars |
2019–2024 |
"two thousand nineteen minus two thousand twenty-four", or digit soup | twenty nineteen to twenty twenty-four |
01/02/2026 |
January or February, depending on locale | February first, twenty twenty-six (or January, if that is what you meant) |
2024-01-01 |
"two thousand twenty-four dash zero one dash zero one" | January first, twenty twenty-four |
14:30 |
"fourteen thirty", "one four colon three zero" | two thirty P M |
555-0100 |
"five hundred fifty-five minus one hundred" | five five five, zero one zero zero |
2nd |
"two N D" | second |
100% |
"one hundred", "one zero zero percent" | one hundred percent |
Ctrl + Z |
"control plus zed", "C T R L plus Z" | control Z |
FAQ |
"fack", "faq" as a word | F A Q (or frequently asked questions the first time) |
SQL |
"sequel" when you wanted letters, or the reverse | sequel or S Q L, pick one |
AI |
"ay", "eye", "A I" | A I if you want letters |
example.com/pricing |
"example dot com pricing" with a slash swallowed | example dot com slash pricing |
Dates and ranges are the ones that survive review because they look correct on the page. 01/02/2026 is unambiguous in print inside one country and a defect in audio the moment a US voice reads a UK script, or the reverse. Do not leave a numeric date in a spoken script. Spell the month.
Hyphens are the other silent defect. In text they mean a range, a minus sign, a phone break, or a compound adjective. Normalisers pick one. If you mean a range, write to or through. If you mean a minus, write minus. If you mean a phone number, write the digits as words in groups.
Write the spoken form
The preprocessing pass is a mechanical rewrite, not a style pass. Do it after the script is approved for meaning and before it goes to generate_speech.
- Highlight every token that is not a plain English (or target-language) word. Digits, symbols, slashes, hyphens between numbers, all-caps strings, URLs, SKUs, units.
- Decide the locale.
01/02and$versus£versus€are locale questions. ElevenLabs’ own example prompt treats01/02/2023as either January second or the first of February depending on the user. Pick one per series and stop arguing per take. - Expand in place. Replace the token with the words you want spoken. Do not leave the written form "for clarity" next to the expansion. The engine will try to say both.
- Expand abbreviations that are not pronounced as written.
Dr.→Doctor,Ave.→Avenue,St.→Streetexcept inSt. Patrick.TB→terabyteif that is the unit, notT B. - Freeze acronym policy. Letter-by-letter (
F A Q,C T A,R O I) versus word (NASA,FIFA,NATO). Write it the way it should be said, every time. Do not rely on the engine to remember your last take. - Generate, then listen at 1x for the expanded tokens only. You are not reviewing performance yet. You are checking that
$47.50became "forty-seven dollars and fifty cents" and not "dollars forty-seven point five".
A before/after for a real ad line:
Written: "From 01/02/2026, the starter plan is $49/mo or $50–70/yr on the annual plan. See example.com/pricing. FAQ: ROI in Q3."
Spoken: "From February first, twenty twenty-six, the starter plan is forty-nine dollars a month, or fifty to seventy dollars a year on the annual plan. See example dot com slash pricing. F A Q: return on investment in quarter three."
That spoken paragraph is what you paste into text-to-speech. The written paragraph is what you put on screen. If captions are generated from the TTS script, map the spoken forms back to the written forms before burn-in, or the captions will say "forty-nine dollars a month" under a title card that shows $49/mo, which is fine for accessibility and wrong if you needed the glyph.
A preprocessing pass you can reuse
For one-off scripts, the rewrite is manual. For a series, make it a checklist the writer cannot skip, or a small function that handles the cases you actually ship.
If an LLM writes the spoken form, still read the result. Models will expand $1,001.32 correctly and then invent a locale for 01/02.
A minimum deterministic pass, if you would rather not send copy through another model:
- Currency:
$1,234.56→ words +dollars+and+ cents. - Phone-shaped
\d{3}-\d{3}-\d{4}→ digit words in three groups. - ISO dates
YYYY-MM-DD→{month} {ordinal}, {year-in-words}. - Slashed dates: refuse to guess; require a locale flag.
- En-dashes or hyphens between two numbers:
to. %→percent;km→kilometers(orkilometresif that is your locale).- All-caps tokens of 2–4 letters: insert spaces (
F A Q) unless they are on an allowlist of spoken words (NASA,NATO).
Then send the cleaned text to generate_speech via adding a voiceover or by asking the agent to write and generate a voiceover. Delivery tags and style_instructions go on after the expansion. A [excited] $1,000,000 still has to survive normalization.
For a full read that also has a product name problem, do numbers first, then respell the names in the same spoken script. Mixing both rewrites in one unstructured pass is how a price becomes the wrong words and the brand name gets accidentally phoneticised into something you cannot search for in the glossary.
The text-to-speech glossary is the mechanism. This pass is the input it actually needs.
FAQ
Why did changing the voice not fix "one thousand thousand"?
Because the expansion happened before the voice. ElevenLabs documents $1,000,000 reading as "one thousand thousand dollars" on Flash v2.5 and as "one million dollars" on Multilingual v2. That is a model-size / normalizer difference, not a speaker difference. Write one million dollars and every voice will say it.
Should I use SSML say-as instead of rewriting?
Only on engines that implement it. W3C SSML 1.1 defines say-as for exactly these constructs. Versely’s Cartesia, Gemini, and Qwen 3 paths want clean text; markup in those scripts gets read aloud. The portable method is to write the spoken words. Use say-as when you are on a stack that documents it and you have tested that the tag is stripped, not spoken.
How should I handle a range like "pages 12-18"?
Write pages twelve through eighteen (or to). A hyphen between digits is the most common "minus" misfire in tutorial scripts. The same rule applies to years, prices, and time windows.
Do I expand NASA, FIFA, and NASA-style acronyms into letters?
No. Expand the ones that are said as letters (F A Q, C T A, R O I, S Q L if you do not want "sequel"). Leave the ones that are said as words (NASA, FIFA, NATO). The defect is inconsistency, not letters versus words. Pick one reading per token and write that reading into the script every time.