← All articles

Voice AI engineering

Text normalization is on the critical path: keeping streaming TTS fast and correct

Design a streaming TTS normalization layer for money, dates, codes, URLs, and mixed-language text without adding a new wall of dead air during real calls.

Streaming model tokens pass through a fast text-normalization boundary before text-to-speech audio begins

Take it with you

MarkdownFree and ungated

Streaming normalization architecture checklist

A Markdown review sheet for entity classes, ambiguity policy, chunk boundaries, SSML support, latency evidence, and spoken-output tests.

streaming-normalization-architecture-checklist.md

Download Markdown

The voice sounds gorgeous, but it just read $1,250.50 as “one dollar, two hundred fifty point five zero.”

Text normalization is the part of voice AI that everybody assumes somebody else solved. Then a real customer says a date, an IFSC code, a room number, or a coupon, and the system discovers that written text is not a pronunciation plan.

This guide is for engineers building a streaming text-to-speech path. You will get a fast normalization architecture, rules for ambiguous values, and a way to test correctness without hiding the added latency.

The ugly boundary between meaning and audio

An April 2026 r/MachineLearning discussion focused on dates, URLs, acronyms, prices, phone numbers, and promo codes. The thread also argued over the cost of adding preprocessing before streaming TTS. The useful conclusion is not “add one more LLM call.” The job is narrower:

  1. Detect text that may be spoken incorrectly.
  2. Resolve the intended reading from known context.
  3. Produce a TTS-safe form.
  4. Preserve streaming boundaries.
  5. Refuse to guess when the reading changes the business meaning.

Scroll diagram horizontally on smaller screens.

A streaming text-normalization boundary classifies stable text, resolves structured entities, blocks ambiguity, and feeds TTS in safe chunks

Put deterministic work first

You do not need a model to recognize every currency symbol, ISO date, phone number, or uppercase identifier. Start with a fast classifier for:

  • Explicit numeric entities such as currency with a code or locale, dates with a declared format, times with a timezone, and measurements.
  • Contact and address-like strings such as telephone numbers, long digit strings, emails, and URLs.
  • Domain text such as product codes and acronyms from a known lexicon.

Use the workflow state to remove ambiguity. If the booking tool returns an ISO date in a typed field called departure_date, you already know what it is. Do not ask a language model whether 2026-08-04 looks date-ish.

Normalize typed values once, close to the data boundary, and carry the spoken form with the structured value before either is blended into free-form model text. If a pricing service returns { amount: 1250.50, currency: "USD" }, the application can create a reviewed spoken representation directly. Waiting until the model has turned the value into a sentence throws away useful type information and makes the normalizer guess.

Model output still needs inspection because it can introduce new written forms, but the system should prefer annotated segments over reparsing the whole response. One practical representation carries plain speech alongside protected entities:

[
  { "type": "text", "value": "Your total is " },
  {
    "type": "currency",
    "canonical": "USD 1250.50",
    "locale": "en-US",
    "spoken": "one thousand two hundred fifty dollars and fifty cents"
  }
]

This structure also keeps display text and spoken text from becoming accidental substitutes for the business value. The UI can show $1,250.50, TTS can receive the reviewed spoken form, and the payment service still receives the canonical amount.

Validate any model-generated reading

A model can still corrupt a value after the typed boundary is correct. A prompt that says “write all numbers as words” can turn 1.05 into 1.5, turn AB01 into a phrase, choose the wrong date locale, or rewrite a code that must still appear on screen.

If a model is needed for an unusual reading, constrain its input and validate its output against the canonical value. Remove permitted words such as currency units, normalize number words back to a numeric representation, and compare with the source. For codes, strip approved separators and confirm that the character sequence is unchanged. A failure should fall back to a deterministic reading or clarification, not pass through because the generated phrase sounds natural.

Streaming changes the design

Batch normalization gets the whole sentence, while streaming may receive a structured value as fragments:

Your total is $
1,
250
.50

Sending those fragments straight to speech loses the value before it is complete. Use a small, semantic holdback buffer around risky token classes: stable words can stream, while an opening currency symbol, slash-heavy date, URL prefix, or alphanumeric run waits until the entity boundary closes. A fixed character limit is not enough because ten characters may be plenty for $42.00 and useless for a long booking reference.

Implement the buffer as a small state machine. In ordinary text, emit stable phrases at punctuation or prosodic boundaries. When a risky token begins, switch into an entity state and hold only that segment. Close it when the parser sees a valid boundary, the typed annotation ends, or a configured maximum forces a safe fallback. URLs, decimal numbers, abbreviations, and alphanumeric identifiers need different closing rules.

The fallback must avoid inventing a reading. A long unresolved code can be spelled after confirmation, returned to the orchestrator for clarification, or routed through a prebuilt phrase. Flushing an incomplete entity just to protect first-audio latency turns a timing target into a correctness bug.

Do not send filler merely to make the latency chart look better. “Let me check that” is useful when a real tool is running and the workflow permits an acknowledgement. It is irritating when inserted before every amount because the normalizer cannot keep up. Measure when the caller receives information that advances the task, not only when any waveform starts.

Measure:

  • Time from first model token to first safe speech chunk.
  • Time each risky entity spends in the buffer.
  • Wrong-boundary count.
  • Re-synthesis count.
  • First useful audio, not filler audio.

If the normalization layer adds 12 milliseconds but prevents a wrong payment amount, that is an easy trade. If it adds 400 milliseconds to every sentence because it waits for a large model, the architecture is wrong.

Look at tail behavior by entity class. A normalizer can appear cheap at the median because most replies contain no risky text, while URLs or mixed-language codes add a second at p95. Record the buffered entity, token arrival pattern, selected rule, output form, and time spent waiting. Those fields make it possible to tell whether the delay came from model tokenization, the entity parser, a lexicon lookup, or the TTS provider.

SSML helps, but support differs

Speech Synthesis Markup Language gives TTS engines hints such as “read this as currency” or “spell these characters.” Google Cloud Text-to-Speech documents say-as handling for dates, times, acronyms, and currency. Microsoft Speech supports content types including characters, alphanumeric strings, digits, dates, telephone numbers, and currency, with locale-dependent coverage. Amazon Polly supports its own set and warns that some tags behave differently across engine and language choices.

Build a capability table per provider, engine, voice, and locale, then render audio and listen. An SSML tag supported in US English may not behave the same way in Hindi, German, or Indian English.

Ambiguity needs policy

03/04/26 could mean March fourth or the third of April. The normalizer cannot recover missing business context by being confident, so choose one of four explicit paths rather than letting a prompt improvise.

Resolve from typed context when the source system stores an ISO date and the locale is known. Speak from that authoritative value instead of reinterpreting the display string.

Ask when two meanings remain plausible: “Do you mean March fourth or April third?” The extra turn is slower and far cheaper than booking the wrong day.

Rephrase without interpretation when the characters themselves are the value. Spell a code in short groups instead of turning it into a word that sounds natural but cannot be reconstructed.

Block speech when a sensitive value remains ambiguous. Do not read a guessed account number or payment amount. Move to the approved recovery or human path and preserve the unresolved input for review.

The policy belongs to the entity class and workflow risk, and locale is one of its inputs rather than a decorative parameter. Locale may come from the workflow, the customer’s approved preference, the current voice lane, or a value’s source system, so write down the precedence. A caller speaking English in Bengaluru may still expect an Indian date order and rupee grouping, while a hotel booking tool may return a destination-local date that needs an explicit month name to avoid confusion.

When signals conflict, prefer a form that removes ambiguity. “The fourth of March, 2026” costs a few more syllables than “three slash four,” but it keeps the caller from decoding punctuation over a phone line. For confirmation codes, grouping characters into short chunks can improve recall without changing the canonical value.

Mixed-language calls are not a corner case

Consider a fictional food-delivery call in Mumbai:

“Your order total is ₹1,450 and coupon SAVE20 was applied.”

The conversation is mostly Hindi, while the menu item and coupon are English. The amount should follow the voice and locale conventions approved for that lane, and the coupon should be spelled or read using the product’s known pronunciation. A generic English normalizer may say the rupee amount oddly, turn SAVE20 into “save twenty,” or switch voices around the code. Keep token language, entity type, source value, and desired spoken form separate, then test each code-switch point with the actual voice. This scenario is fictional, but the language and entity boundaries are the ones the production test must preserve.

Cache what repeats

Restaurant menus, product names, airline codes, medication names, street names, and policy phrases repeat, so store them in a reviewed pronunciation lexicon keyed by text, locale, voice family, and version. Keep the source and approval date with every entry.

Treat the lexicon and rule set as versioned release inputs, and rerun them when a voice provider changes a model because “same API” does not promise identical audio. A restaurant may change a product name, a bank may introduce a new card tier, or a carrier may add an airport code that collides with an ordinary word. Record which normalization version produced each audio sample so an incident can be replayed after the table changes.

Assign ownership before release. The workflow owner decides what a value means and when clarification is required. The speech owner decides how the approved value should sound in each locale and voice. The platform owner keeps the streaming path fast, observable, and consistent. When nobody owns meaning, pronunciation, and latency separately, every failure lands in a prompt backlog.

Test the actual stream

A plain-text unit test proves only the rewrite, so the release suite should:

  1. Feed token fragments at realistic boundaries.
  2. Capture the SSML or normalized text sent to TTS.
  3. Render audio with the production voice and settings.
  4. Transcribe or grade the audio against the intended spoken form.
  5. Human-review sensitive and ambiguous classes.
  6. Record latency per entity class.
  7. Verify the canonical business value never changed.

Run the new normalizer in shadow before routing its output to callers. Compare the proposed spoken form with the current path, render a sample of changed cases, and review the entity classes with the highest business risk. Shadowing also reveals how often the component blocks for missing context. A high clarification rate may point to an upstream schema problem rather than a normalization bug.

Roll out by workflow and locale, not to every voice at once. Keep the previous rules available for rollback, and watch semantic failures alongside latency. A faster rule set that misreads one payment amount is not an improvement; a safer rule set that adds a small, measured buffer only to ambiguous entities may be.

Pair this architecture check with the spoken-data torture test and a segmented latency trace: one catches wrong audio, while the other shows what the protection cost. Voxeval’s streaming normalization architecture checklist covers the boundary conditions, provider matrix, ambiguity policy, evidence, and release checks.

Normalization is not polish. It is a correctness service sitting on the audio critical path, and it should be tested and owned like one. For more voice AI engineering guides and test assets, subscribe to Voxeval.

Reference list

Sources

  1. I can't believe text normalization is so underdiscussed in streaming text-to-speech
  2. Google Cloud Text-to-Speech: Speech Synthesis Markup Language
  3. Microsoft Speech service: Pronunciation with SSML
  4. Amazon Polly: Controlling how special types of words are spoken