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:
co-authored by
mergetest
Claude Fable 5
parent
04d6d0cb7a
commit
60b29b4006
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. `&` is decoded only when
|
||||
# NOT followed by a letter/`#` — so double-encoded junk ("&nbsp;") is left
|
||||
# alone rather than decoded one layer per pass (idempotency).
|
||||
_ENTITIES = {
|
||||
" ": " ",
|
||||
""": '"',
|
||||
"'": "'",
|
||||
"'": "'",
|
||||
"…": "…",
|
||||
"—": "—",
|
||||
"–": "–",
|
||||
}
|
||||
_ENTITY_RE = re.compile(
|
||||
"(?:" + "|".join(re.escape(k) for k in _ENTITIES) + "|&(?![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),
|
||||
("n°", "numéro", "digit"),
|
||||
("N°", "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
|
||||
@@ -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]
|
||||
|
||||
@@ -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, "a�b", "ab"),
|
||||
(None, "Tom & Jerry", "Tom & Jerry"),
|
||||
(None, "one 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():
|
||||
# "&nbsp;" must not decode one layer per pass.
|
||||
once = normalize_text("bad &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
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user