"""Language-safe text sanitizer for the OmniVoice TTS wrapper.
Strips artifacts that are wrong to read aloud in ANY language — LLM
reasoning/thinking blocks, markdown, HTML/XML tags, model control/special
tokens, stray control characters — and normalizes Unicode (NFKC) + whitespace.
DELIBERATELY does NOT do English-specific normalization (number / phone /
email / currency expansion). OmniVoice is a 600+-language model; those rewrites
are correct only for English and would corrupt non-English input. The fork we
took the idea from (groxaxo/omnivoice-streaming) chains that English-only step
in — we keep only the language-neutral subset (eshpfi session 2026-06-19).
OmniVoice's own inline non-verbal symbols ([laughter], [sigh], [breath], …) are
PRESERVED — they use square brackets and contain no "](...)" link tail, so none
of the markdown/HTML rules below touch them.
"""
from __future__ import annotations
import re
import unicodedata
# LLM reasoning/thinking blocks: …, …, etc.
# Tag + content both removed (the content is private chain-of-thought, not speech).
_THINK_RE = re.compile(
r"<(think|thinking|reasoning|reflection|scratchpad)>.*?\1>",
re.IGNORECASE | re.DOTALL,
)
# Markdown — applied while line breaks still exist (the (?m)^ anchors need them).
_CODE_FENCE_RE = re.compile(r"```.*?```", re.DOTALL) # fenced code block → drop
_IMAGE_RE = re.compile(r"!\[[^\]]*\]\([^)]*\)") #  → drop
_LINK_RE = re.compile(r"\[([^\]]+)\]\([^)]*\)") # [text](url) → text
_INLINE_CODE_RE = re.compile(r"`([^`]*)`") # `code` → code
# Emphasis: ***, **, *, ~~, __ (single _ deliberately EXCLUDED so snake_case
# words like "voice_clone_prompt" survive intact).
_EMPHASIS_RE = re.compile(r"(\*\*\*|\*\*|\*|~~|__)(.*?)\1", re.DOTALL)
_HEADING_RE = re.compile(r"(?m)^[ \t]{0,3}#{1,6}[ \t]+") # "## H" → ""
_BLOCKQUOTE_RE = re.compile(r"(?m)^[ \t]{0,3}>[ \t]?") # "> q" → "q"
_LIST_MARKER_RE = re.compile(r"(?m)^[ \t]*(?:[-*+]|\d+[.)])[ \t]+") # "- x" / "1. x" → "x"
_HR_RE = re.compile(r"(?m)^[ \t]{0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$") # "---" rule → drop
# Model / chat-template control + special tokens.
_CONTROL_TOKEN_RE = re.compile(
r"<\|[^>]*?\|>" # <|im_start|>, <|eot_id|>, …
r"|?s>" #
r"|<(?:pad|unk|mask|bos|eos|sep|cls)>" # common HF special tokens
r"|\[/?INST\]" # [INST] [/INST]
r"|<?SYS>>", # <> <>
re.IGNORECASE,
)
# Any remaining HTML/XML tags (run AFTER think-blocks so their content is gone).
_HTML_TAG_RE = re.compile(r"<[^>]+>")
# Stray control characters (keep \n and \t; collapsed in the final whitespace pass).
_CTRL_CHARS_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
def sanitize_tts_text(text: str) -> str:
"""Return ``text`` cleaned of artifacts that shouldn't be spoken, language-safe.
Idempotent and Unicode-aware. Collapses all whitespace to single spaces at the
end (the scheduler's splitter does the same, so paragraph structure is moot for
synthesis). Returns ``""`` for falsy input.
"""
if not text:
return ""
# 1. LLM reasoning/thinking blocks (tag + content).
text = _THINK_RE.sub(" ", text)
# 2. Markdown constructs (order matters: fences/images/links before inline/emphasis).
text = _CODE_FENCE_RE.sub(" ", text)
text = _IMAGE_RE.sub(" ", text)
text = _LINK_RE.sub(r"\1", text)
text = _INLINE_CODE_RE.sub(r"\1", text)
text = _EMPHASIS_RE.sub(r"\2", text)
text = _HEADING_RE.sub("", text)
text = _BLOCKQUOTE_RE.sub("", text)
text = _LIST_MARKER_RE.sub("", text)
text = _HR_RE.sub(" ", text)
# 3. Model control/special tokens, then any leftover HTML/XML tags.
text = _CONTROL_TOKEN_RE.sub(" ", text)
text = _HTML_TAG_RE.sub(" ", text)
# 4. Unicode normalize (NFKC) + drop stray control characters.
text = unicodedata.normalize("NFKC", text)
text = _CTRL_CHARS_RE.sub("", text)
# 5. Collapse all whitespace (incl. newlines) to single spaces, trim.
return " ".join(text.split())