Why Mr. Hyde Became Mimar Hyde and How to Fix It

The first time I ran a full book through the narrator-tts pipeline, I spotted something odd in the QA logs. One of the characters was listed as "Mimar Hyde" in the fidelity report.

His name is not Mimar Hyde. His name is Mr. Hyde. The model had seen "Mr." in the text, did not know what to do with it, and produced something that sounded vaguely like "mirror." Whisper transcribed it back as "Mimar."

Abbreviations are invisible to you. They are very visible to a TTS model.

This is the story of fixing that — and why the obvious fix makes everything worse.

Building planning-first AI systems, one pipeline at a time. Get weekly deep dives into local AI, Rust, and agent orchestration — no fluff, no hype.

The Problem

TTS models are trained on phonetic text. They learn to map characters to sounds. When they encounter "Mr.," they do not automatically produce "Mister." They produce whatever the raw character sequence maps to, which is usually garbage.

Common abbreviations that break:

  • Mr. → "mirror," "mister-r," or a stumble
  • Dr. → "doctor" (sometimes correct), "dur," or silence
  • St. → "street," "saint," or "stuh" depending on context
  • vs. → "vee ess" instead of "versus"
  • etc. → "et cetera" (sometimes), "etcuh" (often)
  • No. → "no" (number, not the abbreviation for "Number")

The model is not broken. It is doing exactly what it was trained to do — reading characters. The abbreviation is a human convention that maps to a spoken word, and that mapping has to happen before the text reaches the model.

The Naive Fix

The obvious solution is string replacement. Before sending text to the model, replace "Mr." with "Mister," "Dr." with "Doctor," and so on.

fn expand_abbreviations(text: &str) -> String {
    text.replace("Mr.", "Mister")
       .replace("Dr.", "Doctor")
       .replace("St.", "Saint")
       .replace("vs.", "versus")
       .replace("etc.", "et cetera")
}

Run it, ship it, done. Except this is where things get worse, not better.

The Naive Fix Eats Your Words

The problem with replace is that it does not respect word boundaries. It operates on raw substrings. And abbreviations like "Dr." and "St." are substrings of perfectly normal words.

Here is what happens:

Original Text After Naive Expansion What Happened
Draught of wind Doctor-aught "Dr" matched inside "Draught"
He started walking He Saint-arted "St" matched inside "started"
The stream widened The Saint-ream widened "St" in "stream"
Mr. Hyde said Mister Hyde said Correct!

The last row works. The first three are now broken in new and creative ways. And these are not edge cases — they show up constantly in a 120,000-word manuscript.

String replacement does not fail on the abbreviation. It fails on every word that happens to contain the same characters. The fix creates more bugs than the original problem.

The Right Fix: Word Boundaries

The solution is to expand abbreviations only when they appear as complete words, not as substrings inside other words. This means matching on word boundaries.

In Rust, the regex crate provides \b (word boundary) anchors. The expansion layer becomes a set of word-boundary-anchored replacements:

use regex::Regex;

fn expand_abbreviations(text: &str) -> String {
    let rules = [
        (r"\bMr\.\s", "Mister "),
        (r"\bDr\.\s", "Doctor "),
        (r"\bSt\.\s", "Saint "),
        (r"\bvs\.\s", "versus "),
        (r"\betc\.\s", "et cetera "),
        (r"\bNo\.\s", "Number "),
    ];

    let mut result = text.to_string();
    for (pattern, replacement) in &rules {
        let re = Regex::new(pattern).unwrap();
        result = re.replace_all(&result, *replacement).to_string();
    }
    result
}

The key differences from naive replacement:

Word boundary anchors (\b). The regex \bDr\.\s only matches when "Dr." appears as a complete token — preceded by a word boundary and followed by whitespace. It will not match "Draught" because the "dr" there is part of a longer word.

Trailing whitespace in the pattern. Abbreviations are always followed by either a space or the end of a sentence. Requiring \s in the match prevents the pattern from matching inside other punctuation sequences.

Consistent replacement spacing. The replacement includes the trailing space, so you do not accidentally collapse "Mr. Hyde" into "MisterHyde."

Context Matters: St. Is a Demon

The abbreviation "St." is uniquely evil. It has two valid expansions:

  • Saint — as in "St. Mary's Hospital"
  • Street — as in "Baker St."

A word-boundary regex catches both. Expanding both to "Saint" turns Baker Street into Baker Saint. Expanding both to "Street" turns Saint Mary into Street Mary.

There is no pure syntactic way to disambiguate. "St." before a proper noun that is a person's name is usually "Saint." "St." after a proper noun that is a road name is usually "Street."

In practice, I handle this with a context heuristic:

  • If "St." is followed by a capitalized name → "Saint"
  • If "St." is preceded by a capitalized name or compass direction → "Street"
  • If ambiguous → default to "Saint" (more common in fiction)

This is not perfect. But it is correct ~95% of the time, and the remaining 5% produces audio that is wrong but not incomprehensible. That is an acceptable tradeoff for a preprocessing layer.

The Expansion Table

After auditing the manuscript corpus, here is the full set of abbreviations that needed expansion:

Abbreviation Expansion Notes
Mr. Mister Unambiguous
Mrs. Missus Unambiguous
Dr. Doctor Unambiguous
St. Saint / Street Context-dependent
vs. versus Unambiguous
etc. et cetera Unambiguous
No. Number Capital N, followed by digit
Jan. – Dec. Full month names Twelve patterns
A.M. / P.M. "A M" / "P M" Spelled out, model handles rest
Inc. Incorporated Unambiguous
Corp. Corporation Unambiguous
Lt. Lieutenant Unambiguous
Sgt. Sergeant Unambiguous
Capt. Captain Unambiguous
Gen. General Military rank, unambiguous in context

Military ranks were a surprise. The manuscript had dialogue like "Lt. Aldridge" and "Gen. Morrison" that the model was reading as "L T Aldridge" and "Gen Morrison." These are not abbreviations you encounter in technical documentation, but they are everywhere in fiction.

Where This Lives in the Pipeline

The abbreviation expansion runs as part of the preprocessing stage, after text normalization but before segmentation. The full preprocessing chain looks like:

  1. Unicode normalization — curly quotes to straight, smart dashes to ASCII
  2. Number expansion — "1,119" → "one thousand one hundred nineteen"
  3. Abbreviation expansion — the layer described here
  4. Punctuation cleanup — remove double spaces, fix orphaned periods
  5. Segmentation — split into narration-sized chunks

Each step is a pure function. The input is text, the output is text. No side effects, no state. This makes the preprocessing chain easy to test — each transformation gets its own unit tests, and the full chain can be verified against known inputs.

The Principle

The broader lesson is one that shows up everywhere in the TTS pipeline:

Every human convention that compresses text for readers must be decompressed before the text reaches the model.

Abbreviations are compressed words. Numbers are compressed quantities. Em dashes are compressed pauses. Ellipses are compressed timing instructions. The model does not know these conventions exist. It reads characters.

The preprocessing layer's job is to be the translator between human writing conventions and phonetic model input. The more faithfully it decompresses, the better the model performs — without changing the model at all.

You can spend weeks tuning a TTS model and get a 2% improvement. Or you can fix your preprocessing and get 15%. The preprocessing layer is where the easy wins live.

That "Mimar Hyde" bug? The model was fine. The preprocessing was just missing a step.

One regex pattern later, and Mr. Hyde got his name back.