Adding pronunciation control should have been the easiest feature in this codebase. No new model, no RunPod deployment, no license to check — just find a mispronounced word in a script and swap it for a phonetic respelling before the word reaches whichever TTS engine is speaking it. The whole implementation plan fit in a few lines: re.sub(rf"\b{word}\b", respelling, text).
It’s also wrong for four of the five Indian languages this project actually supports, and it fails in the specific way that’s hardest to catch — not with an error, but with silence.
The bug that passes a casual test
Kokoro speaks Hindi, Spanish, French, Italian, and Portuguese alongside English. Indic-Parler-TTS speaks Hindi, Tamil, Telugu, Bengali, and Marathi. A pronunciation-control feature that only works for English text isn’t really shipped — it’s shipped for one language out of a stack that now spans nine. So before writing any of the surrounding request-handling code, the substitution function itself got a real test: run it against a sentence in each of the five Indic-Parler-TTS languages and check that a target word actually gets replaced.
Hindi passed immediately:
That result is exactly what a reasonable person would expect from “add pronunciation control” — an unambiguous quick check, done in five minutes, passing cleanly. It would have been easy to stop there.
It was also the wrong test to stop on. Run the identical pattern against Tamil:
None. Not an exception, not a warning — just a match object that silently isn’t there, in a substring that’s unmistakably present:
The plain substring search finds it instantly. Wrapping it in \b...\b — the standard, textbook way to require a whole-word match — makes it disappear. Telugu, Bengali, and Marathi all reproduce the exact same failure. Only Hindi, out of the five, happens to work.
Why \b breaks here and not there
Python’s \b word boundary is defined in terms of \w, the “word character” class, built from Unicode’s character-property database. A boundary exists wherever the regex engine transitions between a \w character and a non-\w character. For English that’s intuitive: letters and digits are \w, spaces and punctuation aren’t, and the boundary lands exactly where a human would put it.
Indic scripts are abugidas — consonants carry an inherent vowel, other vowels attach as combining marks, and a virama character suppresses the inherent vowel when consonants cluster together. Devanagari (Hindi) classifies cleanly enough under Unicode’s word-character rules that \b lands where expected. Tamil, Telugu, Bengali, and Marathi’s scripts don’t — some of their combining/dependent vowel signs and virama-equivalent marks aren’t picked up as \w the same way, so the boundary the regex is looking for isn’t there at the edges of the word, even though the word itself is sitting right there in the string. The regex engine isn’t broken. It’s the assumption that \b means “word boundary, universally, in every script” that’s wrong.
This is the kind of bug that’s genuinely dangerous precisely because it doesn’t announce itself. A feature built and tested only in English would ship, work, get used, and would quietly do nothing for every Tamil, Telugu, Bengali, or Marathi override anyone ever tried — no crash, no log line, just a pronunciation request that mysteriously never took effect.
Stop asking Unicode what a word boundary is
The working replacement doesn’t try to get \b to behave — it sidesteps the whole question of Unicode word-character classification. Split the text into tokens on whitespace and punctuation, keeping the separators so the string can be reassembled exactly, then compare whole tokens via casefold() rather than \w-based boundaries:
This has nothing to do with \w classification, so it doesn’t inherit \b’s script-dependent blind spots. Re-run against all five languages:
It also fixes a real edge case a naive str.replace wouldn’t have: a word immediately followed by punctuation with no space (a period-equivalent danda with nothing between it and the word) still resolves to the right token, since the separator regex captures the punctuation as its own piece. And it still correctly leaves “readme” alone when the override targets “read” — token equality, not substring matching, so a word inside a longer word never collides.
Where it landed
apply_pronunciations() runs before moderation, not after — the substituted text is what actually reaches the model, so that’s what should be moderated, not the pre-substitution original. It’s wired into every entry point that accepts free text and a voice — /tts/generate, /tts/generate-local, /tts/dialogue, /tts/compare — with one deliberate exception: /tts/stream stays out of scope, since its query-parameter shape doesn’t carry a dictionary cleanly.
Verified against all five Indic-Parler-TTS languages plus the English edge cases, then end to end: a real Hindi generation through /tts/generate-local with a Devanagari override, confirmed via the actual synthesized request that the respelling — not the original word — is what reached the model. And in the browser: a “Custom pronunciations” row added to the Studio page, a script generated with a real override applied, the substituted text showing up in history exactly as typed into the override field, zero console errors.
“Add pronunciation control” and “add pronunciation control that works” were two different amounts of work, and the gap between them was invisible until the same five-minute check that passed for Hindi got run again against a different script.