feat(tts): numbers, times, and abbreviations are spoken correctly in every engine (#1049)

New conservative, idempotent pre-TTS normalization pass
(services/text_normalization.py): strips zero-width/control junk, caps
pathological repeat runs, expands digits/times/ordinals/currency via
num2words (29 locales) and per-language abbreviation maps (EN/DE/ES/FR).
Wired once at each text-to-engine choke point — /generate, dub segments
(+ preview), and longform chapters — BEFORE the pronunciation dictionary
so user respellings stay the final say. Pref-gated
(text_normalization_enabled, default ON) with OMNIVOICE_TEXT_NORMALIZATION
env override; num2words promoted to a direct dependency.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Palash Debnath
2026-07-10 18:13:08 +05:30
committed by GitHub
co-authored by mergetest Claude Fable 5
parent 04d6d0cb7a
commit 60b29b4006
8 changed files with 921 additions and 8 deletions
+5
View File
@@ -69,6 +69,11 @@ hiddenimports = [
# Pipeline
'yt_dlp', 'demucs', 'demucs.separate',
# Numbers→words for the pre-TTS text normalization pass
# (services/text_normalization.py). Imported inside a function (lazy),
# so pin it explicitly rather than trusting the tracer.
'num2words',
# OmniVoice's own package
'omnivoice', 'omnivoice.models', 'omnivoice.models.omnivoice',
]
+21 -7
View File
@@ -311,7 +311,8 @@ async def _prepare_synth(default_voice: str | None, language: str | None = None)
return info["synth"], info["sample_rate"], resolve, engine_id
def _render_chapter_cached(chapter, synth, sr, engine_id, resolve, cache_dir, lexicon=None):
def _render_chapter_cached(chapter, synth, sr, engine_id, resolve, cache_dir, lexicon=None,
language=None):
"""Render one chapter, content-addressed so a re-run reuses it (resume).
Returns ``(wav_path, duration_s, was_cached, seg_stats)``. Two cache
@@ -330,19 +331,29 @@ def _render_chapter_cached(chapter, synth, sr, engine_id, resolve, cache_dir, le
moment it renders (an interrupted chapter resumes from them).
``seg_stats`` is ``{"total": spoken_spans, "cached": reused}``.
Span text is normalized (``services.text_normalization``) up front — BEFORE
either cache key and BEFORE ``synthesize_chapter``'s lexicon pass, so the
per-project dictionary operates on normalized text and toggling / changing
normalization output naturally invalidates cached chapters and segments.
Runs in the GPU-pool executor.
"""
import json
import wave
from services.audio_io import atomic_save_wav
from services.audiobook import Span
from services.longform_render import SegmentCache, chapter_cache_key
from services.pronunciation import normalize_lexicon
from services.text_normalization import normalize_for_tts
spans = [Span(voice_id=s.voice_id, text=normalize_for_tts(s.text, language),
pause_ms_after=s.pause_ms_after, speed=getattr(s, "speed", None))
for s in chapter.spans]
spans_tuples = [(s.voice_id, s.text, s.pause_ms_after, getattr(s, "speed", None))
for s in chapter.spans]
for s in spans]
voice_sigs: dict = {}
for s in chapter.spans:
for s in spans:
k = s.voice_id or ""
if k not in voice_sigs:
v = resolve(s.voice_id)
@@ -367,7 +378,7 @@ def _render_chapter_cached(chapter, synth, sr, engine_id, resolve, cache_dir, le
seg_cache = SegmentCache(cache_dir, sample_rate=sr, engine_id=engine_id,
voice_sig=voice_sigs, extra_sig=lex_sig)
audio, dur = synthesize_chapter(chapter.spans, synth, sr, lexicon=lexicon,
audio, dur = synthesize_chapter(spans, synth, sr, lexicon=lexicon,
segment_cache=seg_cache)
atomic_save_wav(wav_path, audio, sr)
return wav_path, dur, False, {"total": seg_cache.hits + seg_cache.misses,
@@ -402,14 +413,15 @@ async def audiobook_preview(req: AudiobookPreviewRequest) -> dict:
chapter = plan.chapters[req.chapter_index]
cache_dir = os.path.join(OUTPUTS_DIR, "longform_cache") # shared with _render_longform_sse
os.makedirs(cache_dir, exist_ok=True)
resolved_lang = _resolve_default_language(req.language, req.default_voice)
synth, sr, resolve, engine_id = await _prepare_synth(
req.default_voice,
language=_resolve_default_language(req.language, req.default_voice),
language=resolved_lang,
)
loop = asyncio.get_running_loop()
wav_path, dur, was_cached, _seg_stats = await loop.run_in_executor(
_gpu_pool, _render_chapter_cached, chapter, synth, sr, engine_id, resolve, cache_dir,
req.lexicon,
req.lexicon, resolved_lang,
)
return {
"output": os.path.relpath(wav_path, OUTPUTS_DIR), # served via /audio
@@ -514,8 +526,9 @@ async def _render_longform_sse(
loop = asyncio.get_running_loop()
try:
resolved_lang = _resolve_default_language(language, default_voice)
synth, sr, resolve, engine_id = await _prepare_synth(
default_voice, language=_resolve_default_language(language, default_voice)
default_voice, language=resolved_lang
)
total = len(plan.chapters)
@@ -530,6 +543,7 @@ async def _render_longform_sse(
wav_path, dur, was_cached, seg_stats = await loop.run_in_executor(
_gpu_pool, _render_chapter_cached,
chapter, synth, sr, engine_id, resolve, cache_dir, lexicon,
resolved_lang,
)
except Exception: # isolate a bad chapter — keep going
logger.warning("[%s] chapter %d (%s) failed to render",
+10 -1
View File
@@ -429,6 +429,12 @@ async def dub_generate(job_id: str, req: DubRequest):
continue
def _gen(text, lang, instruct_str, dur_s, nstep, cfg, spd, profile_id, effect_preset):
# Normalize once at the segment's text→engine choke point
# (covers the OOM-retry generate below too, which reuses this
# closure's `text`). Pref-gated, idempotent, never raises.
from services.text_normalization import normalize_for_tts
text = normalize_for_tts(text, lang)
ref_audio = None
ref_text = None
used_seed = None
@@ -1246,8 +1252,11 @@ async def preview_segment(job_id: str, req: SegmentPreviewRequest):
instruct_str = row["instruct"]
lang = req.language if req.language != "Auto" else None
# Same normalization as the full dub render above, so a preview
# sounds exactly like the final segment. Pref-gated, never raises.
from services.text_normalization import normalize_for_tts
audio_out = backend.generate(
text=req.text,
text=normalize_for_tts(req.text, lang),
language=lang,
ref_audio=ref_audio,
ref_text=ref_text,
+9
View File
@@ -877,6 +877,15 @@ async def generate_speech(
if used_seed is None:
used_seed = random.randint(0, 2**31 - 1)
# Engine-agnostic text normalization (junk strip, numbers→words,
# abbreviations) — AFTER `language` is fully resolved, and BEFORE the
# pronunciation dictionary so user dictionary entries operate on
# normalized text and respellings are never re-mangled (ordering rationale
# in services/text_normalization.py). Pref-gated (default ON), idempotent,
# never raises; applied exactly once per request, at this choke point.
from services.text_normalization import normalize_for_tts
text = normalize_for_tts(text, language)
# Expressive-TTS Spec 01: apply the user pronunciation dictionary + inline
# [[…]] one-off overrides to the text, here — AFTER `language` is fully
# resolved (a profile may fill it above) so per-language entries match the
+492
View File
@@ -0,0 +1,492 @@
"""Engine-agnostic text normalization — a conservative pre-pass before TTS.
Raw user text trips TTS engines: digits, clock times, and title abbreviations
mispronounce; zero-width junk and pathological repeat runs cause hallucinations
and long dead air. This module cleans text *once*, at the point where each
pipeline hands text to an engine (single-shot /generate, dub segments,
longform chapters), so every engine benefits equally.
Design rules (load-bearing):
* **Conservative.** A false negative (digits left alone) is fine; a false
positive (mangled meaning) is not. Anything ambiguous — thousands-grouped
numbers ("1,000"), ranges ("3-5"), version strings ("v2", "3.5.1"),
leading-zero codes ("007"), 7+-digit IDs — is left unchanged. Roman
numerals are out of scope entirely ("I" is a pronoun).
* **Idempotent.** ``normalize_text(normalize_text(x)) == normalize_text(x)``:
number/abbreviation output contains no digits or matchable tokens and the
safety filters are fixed-point by construction, so an accidental second
pass through a pipeline is harmless.
* **Per-language.** Numbers go through ``num2words`` only for languages it
supports (``_NUM2WORDS_LANGS``; the request's ``language`` is a full
display name from frontend/src/languages.json or an ISO-ish code — both
resolve via :func:`_num2words_lang`). Everything else keeps its digits.
Clock times / ordinals / currency are English-only (their spoken form is
language-specific); decimals only for locales whose num2words rendering
was vetted. CJK scripts pass through the safety filters untouched — no
CJK punctuation is stripped and no words are injected into unsegmented
text.
* **Markup-safe.** The single-bracket grammar (``[voice:…]``, ``[pause …]``,
SSML-lite) and inline ``[[…]]`` pronunciation overrides are never touched:
the language passes skip every ``[…]`` span (same shape as chunked_tts's
``_BRACKET_TAG_RE``), so ``[pause 300ms]`` / ``[rate 0.9]`` stay parseable.
Ordering vs. the pronunciation dictionary (audited 2026-07-10): normalization
runs **BEFORE** ``services.pronunciation.apply_pronunciation`` (and before the
audiobook ``apply_lexicon`` overlay). Rationale from the code:
1. Dictionary respellings are the user's explicit, final say. If
normalization ran second it would re-process them — a respelling that
deliberately contains digits or an abbreviation must reach the engine
verbatim.
2. Users already write lexicon entries against display text (the lexicon
docstring's own example is ``{"Dr": "Doctor"}``); entries keyed on
normalized words keep firing, and the dictionary stays the override for
anything the normalizer produced.
3. Inline ``[[…]]`` overrides resolve last inside ``apply_pronunciation``
(and their bracketed content is masked here), so the user retains a
per-occurrence override over any normalizer output.
Pinned by ``tests/test_text_normalization.py`` (dictionary-order test).
Gate: prefs key ``text_normalization_enabled`` (default ON) with env override
``OMNIVOICE_TEXT_NORMALIZATION`` — the same env-wins contract as
``OMNIVOICE_PRONUNCIATION`` ("0"/"false"/"no"/"off" disable).
:func:`normalize_for_tts` is the gated entry point every pipeline calls; it
never raises — normalization is never allowed to break synthesis.
"""
from __future__ import annotations
import logging
import os
import re
from typing import Callable, Optional
logger = logging.getLogger("omnivoice.text_normalization")
ENV_VAR = "OMNIVOICE_TEXT_NORMALIZATION"
PREF_KEY = "text_normalization_enabled"
# ── Language resolution ───────────────────────────────────────────────────────
#
# The `language` kwarg across the app is normally a full display name from
# frontend/src/languages.json ("English", "German", …) — see
# resolve_kokoro_lang_code in services/tts_backend.py — but ISO-ish codes
# ("en", "pt-BR") also flow through dub/API callers. Map both to a num2words
# locale; anything unmapped keeps its digits (false negatives are fine).
_FULL_NAME_TO_CODE = {
"english": "en",
"german": "de",
"spanish": "es",
"french": "fr",
"italian": "it",
"portuguese": "pt",
"dutch": "nl",
"russian": "ru",
"ukrainian": "uk",
"polish": "pl",
"turkish": "tr",
"czech": "cs",
"danish": "da",
"finnish": "fi",
"swedish": "sv",
"norwegian": "no",
"norwegian bokmål": "no",
"norwegian nynorsk": "no",
"romanian": "ro",
"hungarian": "hu",
"indonesian": "id",
"lithuanian": "lt",
"latvian": "lv",
"slovenian": "sl",
"serbian": "sr",
"hebrew": "he",
"persian": "fa",
"azerbaijani": "az",
"vietnamese": "vi",
"kazakh": "kz",
"standard arabic": "ar",
}
# ISO codes whose num2words locale name differs.
_ISO_ALIASES = {"kk": "kz"}
# Locales verified against the pinned num2words (cardinal + basic rendering).
# zh/ja/ko/th are deliberately absent: unsegmented scripts where injecting
# space-delimited words is wrong, and their engines read digits natively.
_NUM2WORDS_LANGS = frozenset({
"en", "de", "es", "fr", "it", "pt", "nl", "ru", "uk", "pl", "tr", "cs",
"da", "fi", "sv", "no", "ro", "hu", "id", "lt", "lv", "sl", "sr", "ar",
"he", "fa", "az", "vi", "kz",
})
# Locales whose num2words decimal rendering was vetted ("drei Komma fünf",
# "три целых пять десятых", …). tr/vi are excluded on purpose: their 0.5
# renders as "fifty" (wrong), so decimals keep their digits there.
_DECIMAL_LANGS = frozenset({
"en", "de", "es", "fr", "it", "pt", "nl", "ru", "uk", "pl", "cs", "da",
"no", "sv", "fi", "ro", "hu", "id",
})
# "50%" → "fifty <word>" only where the spoken percent word is unambiguous.
_PERCENT_WORD = {
"en": "percent",
"de": "Prozent",
"es": "por ciento",
"fr": "pour cent",
"it": "per cento",
"pt": "por cento",
"nl": "procent",
}
_ISO_CODE_RE = re.compile(r"^([a-z]{2,3})(?:[-_]|$)")
def _num2words_lang(language: Optional[str]) -> Optional[str]:
"""Resolve a request language (display name or ISO-ish code) to a
num2words locale, or ``None`` when digits should be left alone."""
if not language:
return None
s = str(language).strip().lower()
if not s or s == "auto":
return None
code = _FULL_NAME_TO_CODE.get(s)
if code:
return code
m = _ISO_CODE_RE.match(s)
if m:
c = _ISO_ALIASES.get(m.group(1), m.group(1))
if c in _NUM2WORDS_LANGS:
return c
return None
# ── Universal safety filters (all languages) ─────────────────────────────────
# Zero-width & bidi controls, C0/C1 controls (except \t \n \r), BOM, U+FFFD.
_ZW_CONTROL_RE = re.compile(
"[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f"
"\u200b-\u200f\u202a-\u202e\u2060-\u2064\ufeff\ufffd]"
)
# A tiny, unambiguous HTML-entity leftover set. `&amp;` is decoded only when
# NOT followed by a letter/`#` — so double-encoded junk ("&amp;nbsp;") is left
# alone rather than decoded one layer per pass (idempotency).
_ENTITIES = {
"&nbsp;": " ",
"&quot;": '"',
"&#39;": "'",
"&apos;": "'",
"&hellip;": "",
"&mdash;": "",
"&ndash;": "",
}
_ENTITY_RE = re.compile(
"(?:" + "|".join(re.escape(k) for k in _ENTITIES) + "|&amp;(?![a-zA-Z#]))"
)
# Same ASCII punctuation char repeated more than 3 times → capped at 3
# ("!!!!!!!!" / "........." cause dead air and babble). CJK punctuation and
# letters are deliberately untouched ("Nooooo" is expressive).
_REPEAT_RE = re.compile(r"([!?.,;:~_*#=-])\1{3,}")
_HSPACE_RE = re.compile(r"[^\S\n]+") # horizontal whitespace runs → one space
_NEWLINE_RE = re.compile(r"\n{3,}") # blank-line floods → one blank line
def _safety_filters(text: str) -> str:
out = _ZW_CONTROL_RE.sub("", text)
out = _ENTITY_RE.sub(lambda m: _ENTITIES.get(m.group(0), "&"), out)
out = _REPEAT_RE.sub(lambda m: m.group(1) * 3, out)
out = _HSPACE_RE.sub(" ", out)
out = _NEWLINE_RE.sub("\n\n", out)
return out.strip()
# ── Bracket masking ──────────────────────────────────────────────────────────
#
# Language passes must never rewrite `[…]` spans: `[pause 300ms]` /
# `[rate 0.9]` / `[voice:NAME]` are grammar, and `[[term|replacement]]`
# belongs to the pronunciation layer. Bounded repetition keeps it linear.
_BRACKET_SPAN_RE = re.compile(r"\[[^\][\n]{0,128}\]")
def _outside_brackets(text: str, fn: Callable[[str], str]) -> str:
if "[" not in text:
return fn(text)
parts: list[str] = []
last = 0
for m in _BRACKET_SPAN_RE.finditer(text):
parts.append(fn(text[last:m.start()]))
parts.append(m.group(0))
last = m.end()
parts.append(fn(text[last:]))
return "".join(parts)
# ── Abbreviation expansion ────────────────────────────────────────────────────
#
# Per-language (key, expansion, guard) triples. Matching is case-sensitive
# (a lowercase "st." is NOT the title "St."); lowercase connective keys
# ("e.g.") get an auto-added sentence-initial variant. Guards:
# "cap" — only before a capitalized word (titles precede names; leaves
# street-suffix "Elm St." / "Elm Dr." untouched).
# "digit" — only before a number ("No. 5"; leaves the word "No." alone).
_ABBREVIATIONS: dict[str, list[tuple[str, str, Optional[str]]]] = {
"en": [
("Dr.", "Doctor", "cap"),
("Mr.", "Mister", "cap"),
("Mrs.", "Missus", "cap"),
("Prof.", "Professor", "cap"),
("St.", "Saint", "cap"),
("Mt.", "Mount", "cap"),
("Jr.", "Junior", None),
("Sr.", "Senior", None),
("vs.", "versus", None),
("etc.", "et cetera", None),
("e.g.", "for example", None),
("i.e.", "that is", None),
("approx.", "approximately", None),
("No.", "number", "digit"),
],
"de": [
("Dr.", "Doktor", "cap"),
("Prof.", "Professor", "cap"),
("Nr.", "Nummer", "digit"),
("z.B.", "zum Beispiel", None),
("z. B.", "zum Beispiel", None),
("d.h.", "das heißt", None),
("d. h.", "das heißt", None),
("usw.", "und so weiter", None),
("bzw.", "beziehungsweise", None),
("ca.", "circa", None),
],
"es": [
("Sr.", "Señor", "cap"),
("Sra.", "Señora", "cap"),
("Srta.", "Señorita", "cap"),
("Dr.", "Doctor", "cap"),
("Dra.", "Doctora", "cap"),
("Ud.", "usted", None),
("Uds.", "ustedes", None),
("etc.", "etcétera", None),
("núm.", "número", "digit"),
],
"fr": [
# "M." is deliberately absent: indistinguishable from a middle initial.
("Mme", "Madame", "cap"),
("Mmes", "Mesdames", "cap"),
("Mlle", "Mademoiselle", "cap"),
("Mlles", "Mesdemoiselles", "cap"),
("etc.", "et cetera", None),
("", "numéro", "digit"),
("", "Numéro", "digit"),
],
}
_GUARD_LOOKAHEAD = {
None: "",
"cap": r"(?=\s+[A-ZÀ-ÖØ-Þ])",
"digit": r"(?=\s*\d)",
}
def _compile_abbreviations() -> dict[str, tuple[re.Pattern, dict[str, str]]]:
compiled: dict[str, tuple[re.Pattern, dict[str, str]]] = {}
for lang, entries in _ABBREVIATIONS.items():
entries = list(entries)
# Sentence-initial variants for lowercase connectives ("E.g." → …).
for key, expansion, guard in list(entries):
if key[:1].islower():
cap_key = key[0].upper() + key[1:]
if not any(k == cap_key for k, _, _ in entries):
entries.append((cap_key, expansion[0].upper() + expansion[1:], guard))
entries.sort(key=lambda e: len(e[0]), reverse=True) # longest key wins
lookup = {key: expansion for key, expansion, _ in entries}
alts = []
for key, _, guard in entries:
suffix = r"(?!\w)" if key[-1:].isalnum() else ""
alts.append(f"{re.escape(key)}{suffix}{_GUARD_LOOKAHEAD[guard]}")
# Literal alternation with per-key guards; no nested quantifiers.
pattern = re.compile(r"(?<![\w.])(?:" + "|".join(alts) + ")")
compiled[lang] = (pattern, lookup)
return compiled
_ABBREV_COMPILED = _compile_abbreviations()
def _expand_abbreviations(text: str, lang: str) -> str:
entry = _ABBREV_COMPILED.get(lang)
if entry is None:
return text
pattern, lookup = entry
def _repl(m: re.Match) -> str:
return lookup.get(m.group(0), m.group(0))
return pattern.sub(_repl, text)
# ── Numbers → words ──────────────────────────────────────────────────────────
#
# Every pattern requires clean word boundaries: digits glued to letters
# ("MP3", "v2"), separators ("1,000", "3-5", "1/2", "12:34:56"), leading
# zeros ("007") or 7+ digits (IDs, phone numbers) are all left alone.
# EN-only clock time: H:MM, 0-23 hours. Rejects H:MM:SS (durations).
_TIME_RE = re.compile(r"(?<![\d:.,])([01]?\d|2[0-3]):([0-5]\d)(?![\d:])")
# EN-only ordinal, suffix verified in the callback ("2th" stays as-is).
_ORDINAL_RE = re.compile(r"(?<![\w.,])(\d{1,4})(st|nd|rd|th)\b")
# EN-only dollars: $N or $N.CC. "$1,000" is blocked by the lookahead.
_CURRENCY_RE = re.compile(r"(?<!\w)\$(\d{1,6})(?:\.(\d{2}))?(?![\d.,])")
_PERCENT_RE = re.compile(r"(?<![\w.,])(\d{1,6}(?:\.\d{1,4})?)\s?%")
_DECIMAL_RE = re.compile(
r"(?<![\w.,:/$%-])(\d{1,6})\.(\d{1,6})(?![\w:/%-])(?![.,]\d)"
)
_INTEGER_RE = re.compile(
r"(?<![\w.,:/$%-])(?!0\d)(\d{1,6})(?![\w:/%-])(?![.,]\d)"
)
_ORDINAL_SUFFIX = {1: "st", 2: "nd", 3: "rd"}
def _correct_ordinal_suffix(n: int) -> str:
if 10 <= n % 100 <= 13:
return "th"
return _ORDINAL_SUFFIX.get(n % 10, "th")
def _numbers_to_words(text: str, lang: str) -> str:
try:
from num2words import num2words
except ImportError: # pragma: no cover — direct dependency; belt & braces
return text
def _safe(m: re.Match, render: Callable[[re.Match], str]) -> str:
# Any num2words hiccup leaves this occurrence untouched.
try:
return render(m)
except Exception: # noqa: BLE001 — conservative: never mangle
return m.group(0)
if lang == "en":
def _time(m: re.Match) -> str:
h, mm = int(m.group(1)), int(m.group(2))
hw = num2words(h, lang="en")
if mm == 0:
return f"{hw} o'clock"
if mm < 10:
return f"{hw} oh {num2words(mm, lang='en')}"
return f"{hw} {num2words(mm, lang='en')}"
text = _TIME_RE.sub(lambda m: _safe(m, _time), text)
def _ordinal(m: re.Match) -> str:
n = int(m.group(1))
if m.group(2) != _correct_ordinal_suffix(n):
return m.group(0)
return num2words(n, lang="en", to="ordinal")
text = _ORDINAL_RE.sub(lambda m: _safe(m, _ordinal), text)
def _currency(m: re.Match) -> str:
dollars = int(m.group(1))
if m.group(2) is not None:
amount = float(f"{m.group(1)}.{m.group(2)}")
return num2words(amount, lang="en", to="currency", currency="USD")
unit = "dollar" if dollars == 1 else "dollars"
return f"{num2words(dollars, lang='en')} {unit}"
text = _CURRENCY_RE.sub(lambda m: _safe(m, _currency), text)
percent_word = _PERCENT_WORD.get(lang)
if percent_word:
def _percent(m: re.Match) -> str:
raw = m.group(1)
if "." in raw:
if lang not in _DECIMAL_LANGS:
return m.group(0)
value: object = float(raw)
else:
value = int(raw)
return f"{num2words(value, lang=lang)} {percent_word}"
text = _PERCENT_RE.sub(lambda m: _safe(m, _percent), text)
if lang in _DECIMAL_LANGS:
def _decimal(m: re.Match) -> str:
return num2words(float(f"{m.group(1)}.{m.group(2)}"), lang=lang)
text = _DECIMAL_RE.sub(lambda m: _safe(m, _decimal), text)
def _integer(m: re.Match) -> str:
raw = m.group(1)
n = int(raw)
if len(raw) == 4 and 1500 <= n <= 2099:
# Bare 4-digit numbers in this range read as years
# ("nineteen eighty-four"); fall back to cardinal where the
# locale has no year form (sv, vi).
try:
return num2words(n, lang=lang, to="year")
except Exception: # noqa: BLE001
pass
return num2words(n, lang=lang)
return _INTEGER_RE.sub(lambda m: _safe(m, _integer), text)
# ── Public API ───────────────────────────────────────────────────────────────
def normalize_text(text: str, language: Optional[str] = None) -> str:
"""Pure, idempotent normalization pass (no pref gate — see
:func:`normalize_for_tts` for the gated entry point pipelines call)."""
if not text:
return text or ""
out = _safety_filters(text)
lang = _num2words_lang(language)
if lang:
if lang in _ABBREV_COMPILED:
out = _outside_brackets(out, lambda t: _expand_abbreviations(t, lang))
out = _outside_brackets(out, lambda t: _numbers_to_words(t, lang))
return out
def normalization_enabled() -> bool:
"""Env wins (power-user override, mirrors OMNIVOICE_PRONUNCIATION);
otherwise the ``text_normalization_enabled`` pref, default ON."""
env = os.environ.get(ENV_VAR)
if env is not None:
return env.strip().lower() not in ("0", "false", "no", "off", "")
try:
from core import prefs
return bool(prefs.get(PREF_KEY, True))
except Exception: # noqa: BLE001 — prefs unreadable → default ON
return True
def normalize_for_tts(text: str, language: Optional[str] = None) -> str:
"""Gated + hardened entry point: pref/env toggle, never raises.
Every TTS pipeline calls this exactly once, at its text→engine choke
point, BEFORE the pronunciation dictionary (see module docstring).
"""
if not text:
return text or ""
if not normalization_enabled():
return text
try:
return normalize_text(text, language)
except Exception: # noqa: BLE001 — normalization must never break synth
logger.warning("text normalization failed; using raw text", exc_info=True)
return text
+4
View File
@@ -163,6 +163,10 @@ dependencies = [
# Python, MIT, PyPA-maintained, zero transitive deps — same class of fix
# as socksio above, identical on macOS/Windows/Linux.
"truststore>=0.9",
# Numbers→words for the pre-TTS text normalization pass
# (services/text_normalization.py). Was already installed transitively;
# promoted to a direct dependency because we now import it ourselves.
"num2words>=0.5.14",
]
[project.optional-dependencies]
+378
View File
@@ -0,0 +1,378 @@
"""Pre-TTS text normalization (services/text_normalization.py).
Covers the conservative per-language pre-pass: table-driven change /
leave-unchanged cases, idempotency (f(f(x)) == f(x)), digit preservation for
unsupported languages, bracket-grammar safety, the pref/env toggle, the
normalization-BEFORE-pronunciation-dictionary ordering, and the /generate
integration (applied exactly once, at the text→engine choke point).
The engine layer is stubbed (no real model loads), matching
test_generate_engine.py.
"""
import os
os.environ.setdefault("OMNIVOICE_MODEL", "test")
os.environ.setdefault("OMNIVOICE_DISABLE_FILE_LOG", "1")
import importlib
import pytest
import torch
from services import text_normalization
from services.text_normalization import (
normalization_enabled,
normalize_for_tts,
normalize_text,
)
# ── Table-driven: text that SHOULD change ────────────────────────────────────
_CHANGE_CASES = [
# (language, input, expected)
# EN numbers / times / ordinals / currency / percent
("English", "I have 2 cats", "I have two cats"),
("English", "It is 3:30 now", "It is three thirty now"),
("English", "At 3:00 sharp", "At three o'clock sharp"),
("English", "At 12:05 sharp", "At twelve oh five sharp"),
("English", "the 21st of May", "the twenty-first of May"),
("English", "It costs $5", "It costs five dollars"),
("English", "Just $1 more", "Just one dollar more"),
("English", "It costs $5.99 now", "It costs five dollars, ninety-nine cents now"),
("English", "rated 3.5 stars", "rated three point five stars"),
("English", "battery at 50%", "battery at fifty percent"),
("English", "back in 1984", "back in nineteen eighty-four"),
("English", "I have 3.", "I have three."),
# EN abbreviations (guards: titles need a following capitalized word)
("English", "Dr. Smith is here", "Doctor Smith is here"),
("English", "Mr. Jones and Mrs. Jones", "Mister Jones and Missus Jones"),
("English", "St. Louis is far", "Saint Louis is far"),
("English", "cats, dogs, etc. are pets", "cats, dogs, et cetera are pets"),
("English", "fruit, e.g. apples", "fruit, for example apples"),
("English", "good vs. evil", "good versus evil"),
("English", "exhibit No. 5", "exhibit number five"),
# ISO-ish codes resolve like display names
("en", "I have 2 cats", "I have two cats"),
("en-US", "I have 2 cats", "I have two cats"),
# Other languages
("German", "Er hat 42 Katzen", "Er hat zweiundvierzig Katzen"),
("German", "z.B. Dr. Meier", "zum Beispiel Doktor Meier"),
("German", "Nr. 5 gewinnt", "Nummer fünf gewinnt"),
("German", "etwa 50%", "etwa fünfzig Prozent"),
("Spanish", "tengo 42 gatos", "tengo cuarenta y dos gatos"),
("Spanish", "el Sr. García", "el Señor García"),
("French", "il a 42 chats", "il a quarante-deux chats"),
("French", "Mme Dupont arrive", "Madame Dupont arrive"),
("Russian", "у меня 42 кота", "у меня сорок два кота"),
# Universal safety filters (language-independent)
(None, "hello world", "hello world"),
(None, "too many\t spaces", "too many spaces"),
(None, "wow!!!!!!!!", "wow!!!"),
(None, "wait.........", "wait..."),
(None, "ab", "ab"),
(None, "Tom &amp; Jerry", "Tom & Jerry"),
(None, "one&nbsp;space", "one space"),
]
@pytest.mark.parametrize("language,raw,expected", _CHANGE_CASES)
def test_normalizes(language, raw, expected):
assert normalize_text(raw, language) == expected
# ── Table-driven: conservatism — text that must NOT change ───────────────────
_UNCHANGED_CASES = [
# (language, input) — ambiguous constructs keep their digits/shape
("English", "version v2 shipped"), # digit glued to a letter
("English", "order 1,000 units"), # thousands separator: ambiguous
("English", "pages 3-5 tonight"), # range
("English", "agent 007 reporting"), # leading-zero code
("English", "see 3.5.1 in the docs"), # version string
("English", "call 5551234567 now"), # 7+ digits: an ID, not a number
("English", "12:34:56 elapsed"), # H:MM:SS duration, not a time
("English", "the ratio is 1/2 there"), # fraction: ambiguous
("English", "MP3 files and A4 paper"), # alphanumeric tokens
("English", "I met her. I agree."), # "I" is a pronoun, not a numeral
("English", "Chapter II and III stand"), # roman numerals out of scope
("English", "on Elm St. tonight"), # street suffix: no name follows
("English", "I said no. Fine."), # the word "no.", not "number"
("English", "down main st. Anyway"), # lowercase "st." is not Saint
("German", "es kostet 3,5 Euro"), # decimal comma: ambiguous
# Unsupported languages keep every digit (num2words unmapped)
("Japanese", "42 cats and 3.5 stars at 3:30"),
("Thai", "42 cats"),
("Chinese", "42 cats"),
("Korean", "42 cats"),
(None, "42 cats"), # no language pin → no digits pass
("Auto", "42 cats"),
# Bracket grammar is never rewritten
("English", "[pause 300ms] then [rate 0.9] speech [voice:Alice]"),
("English", "[[term|5]] stays bracket-managed"),
# Plain text is byte-identical
("English", "The quick brown fox jumps."),
(None, "already clean text"),
]
@pytest.mark.parametrize("language,raw", _UNCHANGED_CASES)
def test_leaves_unchanged(language, raw):
assert normalize_text(raw, language) == raw
def test_cjk_passthrough_untouched():
# CJK sentences (incl. CJK punctuation and full-width forms) pass through
# the safety filters untouched — no punctuation stripped, no words injected.
# Functional CJK test fixture: tests/ is allowlisted in
# tests/test_no_hardcoded_cjk.py.
cases = [
"今日は3時30分に会いましょう。よろしく!",
"我有42只猫,真的!?……",
"안녕하세요. 42마리의 고양이가 있어요!",
]
for s in cases:
assert normalize_text(s, "Japanese") == s
assert normalize_text(s, None) == s
# ── Idempotency: f(f(x)) == f(x) for every table case ───────────────────────
@pytest.mark.parametrize(
"language,raw",
[(lang, raw) for lang, raw, _ in _CHANGE_CASES]
+ [(lang, raw) for lang, raw in _UNCHANGED_CASES],
)
def test_idempotent(language, raw):
once = normalize_text(raw, language)
assert normalize_text(once, language) == once
def test_idempotent_double_encoded_entity():
# "&amp;nbsp;" must not decode one layer per pass.
once = normalize_text("bad &amp;nbsp; soup", None)
assert normalize_text(once, None) == once
# ── Toggle: pref (default ON) + env override ─────────────────────────────────
def test_enabled_by_default(monkeypatch):
monkeypatch.delenv(text_normalization.ENV_VAR, raising=False)
assert normalization_enabled() is True
assert normalize_for_tts("I have 2 cats", "English") == "I have two cats"
def test_pref_off_bypasses(monkeypatch):
monkeypatch.delenv(text_normalization.ENV_VAR, raising=False)
import core.prefs as prefs_mod
monkeypatch.setattr(
prefs_mod, "get",
lambda key, default=None: False if key == text_normalization.PREF_KEY else default,
)
raw = "I have 2 cats!!!!!!"
assert normalize_for_tts(raw, "English") == raw # byte-identical bypass
def test_env_off_bypasses(monkeypatch):
monkeypatch.setenv(text_normalization.ENV_VAR, "0")
raw = "I have 2 cats"
assert normalize_for_tts(raw, "English") == raw
def test_env_on_beats_pref_off(monkeypatch):
monkeypatch.setenv(text_normalization.ENV_VAR, "1")
import core.prefs as prefs_mod
monkeypatch.setattr(prefs_mod, "get", lambda key, default=None: False)
assert normalize_for_tts("I have 2 cats", "English") == "I have two cats"
def test_never_raises(monkeypatch):
# A crash inside the normalizer must degrade to raw text, never break synth.
monkeypatch.delenv(text_normalization.ENV_VAR, raising=False)
monkeypatch.setattr(
text_normalization, "normalize_text",
lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom")),
)
assert normalize_for_tts("I have 2 cats", "English") == "I have 2 cats"
def test_empty_and_none_text():
assert normalize_for_tts("", "English") == ""
assert normalize_for_tts(None, "English") == ""
assert normalize_text("", "English") == ""
# ── Ordering vs. the pronunciation dictionary ────────────────────────────────
def test_dictionary_operates_on_normalized_text():
"""Normalization runs FIRST: dictionary entries keyed on normalized words
fire, and respellings are the final say (never re-normalized)."""
from services.pronunciation import apply_pronunciation
entries = [
# Fires only if "2" was already normalized to "two".
{"term": "two", "replacement": "TWO-OH", "type": "respelling",
"language": "*", "enabled": 1},
# A respelling that deliberately contains a digit must reach the
# engine verbatim (normalization must NOT run after the dictionary).
{"term": "cats", "replacement": "c4ts", "type": "respelling",
"language": "*", "enabled": 1},
]
normalized = normalize_text("I have 2 cats", "English")
assert normalized == "I have two cats"
out = apply_pronunciation(normalized, entries, "English")
assert out == "I have TWO-OH c4ts" # entry fired + digit respelling intact
# ── /generate integration: applied exactly once, before the dictionary ──────
def _tts_mod():
"""Resolve services.tts_backend at RUN time (same rationale as
test_generate_engine.py — collection-time bindings can go stale)."""
return importlib.import_module("services.tts_backend")
def _norm_mod():
"""Resolve services.text_normalization at RUN time so monkeypatches land
on the same module object the routes import per-request."""
return importlib.import_module("services.text_normalization")
def _make_fake_engine():
class _FakeEngine(_tts_mod().TTSBackend):
id = "fake-norm-engine"
display_name = "Fake Norm Engine (test)"
gpu_compat = ("cpu",)
calls: list = []
@property
def sample_rate(self) -> int:
return 24000
@property
def supported_languages(self) -> list[str]:
return ["multi"]
@classmethod
def is_available(cls):
return True, "ready"
def generate(self, text, **kw) -> torch.Tensor:
type(self).calls.append((text, kw))
return torch.zeros(1, 24000)
return _FakeEngine
@pytest.fixture()
def client():
from fastapi.testclient import TestClient
from main import app
return TestClient(app, client=("127.0.0.1", 50000))
@pytest.fixture()
def fake_engine(monkeypatch):
fake = _make_fake_engine()
monkeypatch.setitem(_tts_mod()._REGISTRY, "fake-norm-engine", fake)
monkeypatch.delenv("OMNIVOICE_TTS_BACKEND", raising=False)
return fake
def test_generate_applies_normalization_exactly_once(client, monkeypatch, fake_engine):
monkeypatch.delenv(text_normalization.ENV_VAR, raising=False)
norm_mod = _norm_mod()
calls = []
real = norm_mod.normalize_for_tts
def spy(text, language=None):
calls.append(text)
return real(text, language)
monkeypatch.setattr(norm_mod, "normalize_for_tts", spy)
res = client.post("/generate", data={
"text": "Dr. Smith has 2 cats", "language": "English",
"engine": "fake-norm-engine", "seed": "42",
})
assert res.status_code == 200, res.text
assert len(calls) == 1 # exactly once, at the choke point
assert len(fake_engine.calls) == 1
assert fake_engine.calls[0][0] == "Doctor Smith has two cats"
def test_generate_normalizes_before_dictionary(client, monkeypatch, fake_engine):
"""Route-level pin of the ordering: the DB dictionary sees normalized
text, and its respellings are not re-normalized."""
monkeypatch.delenv(text_normalization.ENV_VAR, raising=False)
monkeypatch.delenv("OMNIVOICE_PRONUNCIATION", raising=False)
pron_mod = importlib.import_module("services.pronunciation")
monkeypatch.setattr(pron_mod, "load_entries_from_db", lambda: [
{"term": "two", "replacement": "TWO-OH", "type": "respelling",
"language": "*", "enabled": 1},
{"term": "cats", "replacement": "c4ts", "type": "respelling",
"language": "*", "enabled": 1},
])
res = client.post("/generate", data={
"text": "I have 2 cats", "language": "English",
"engine": "fake-norm-engine", "seed": "42",
})
assert res.status_code == 200, res.text
assert fake_engine.calls[-1][0] == "I have TWO-OH c4ts"
def test_generate_toggle_off_sends_raw_text(client, monkeypatch, fake_engine):
monkeypatch.setenv(text_normalization.ENV_VAR, "0")
res = client.post("/generate", data={
"text": "Dr. Smith has 2 cats", "language": "English",
"engine": "fake-norm-engine", "seed": "42",
})
assert res.status_code == 200, res.text
assert fake_engine.calls[-1][0] == "Dr. Smith has 2 cats"
# ── Longform: chapter render normalizes spans (and keys the cache on it) ────
def _render_chapter(tmp_path, text, language, monkeypatch, env=None):
if env is None:
monkeypatch.delenv(text_normalization.ENV_VAR, raising=False)
else:
monkeypatch.setenv(text_normalization.ENV_VAR, env)
from api.routers.audiobook import _render_chapter_cached
from services.audiobook import Chapter, Span
seen = []
def synth(t, voice_id, speed=None):
seen.append(t)
return torch.zeros(1, 2400)
chapter = Chapter(title="One", spans=[Span(voice_id=None, text=text)])
wav_path, dur, was_cached, _seg_stats = _render_chapter_cached(
chapter, synth, 24000, "stub-engine",
lambda vid: {"ref_audio": None, "ref_text": None, "instruct": None, "seed": None},
str(tmp_path), None, language,
)
return seen, wav_path, was_cached
def test_longform_chapter_normalizes_spans(tmp_path, monkeypatch):
seen, _, was_cached = _render_chapter(
tmp_path, "Dr. Smith has 2 cats", "English", monkeypatch)
assert not was_cached
assert seen == ["Doctor Smith has two cats"]
def test_longform_cache_key_tracks_normalization_toggle(tmp_path, monkeypatch):
# Normalization ON and OFF must not share a cached WAV: the key is built
# over the normalized span text, so toggling re-renders.
_, path_on, _ = _render_chapter(
tmp_path, "Dr. Smith has 2 cats", "English", monkeypatch)
seen_off, path_off, was_cached_off = _render_chapter(
tmp_path, "Dr. Smith has 2 cats", "English", monkeypatch, env="0")
assert path_on != path_off
assert not was_cached_off
assert seen_off == ["Dr. Smith has 2 cats"] # raw text with the toggle off
Generated
+2
View File
@@ -3226,6 +3226,7 @@ dependencies = [
{ name = "mcp" },
{ name = "mlx-audio", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" },
{ name = "mlx-whisper", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" },
{ name = "num2words" },
{ name = "numpy" },
{ name = "openai" },
{ name = "pedalboard" },
@@ -3305,6 +3306,7 @@ requires-dist = [
{ name = "mcp", specifier = ">=1.2" },
{ name = "mlx-audio", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'", specifier = ">=0.3.0" },
{ name = "mlx-whisper", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'", specifier = ">=0.2.1" },
{ name = "num2words", specifier = ">=0.5.14" },
{ name = "numpy" },
{ name = "openai", specifier = ">=1.40" },
{ name = "pedalboard", specifier = ">=0.9.14" },