Files
esh-pfi-infrastructure/stacks/omnivoice/sanitize.py
T
vh 288d085236 feat(omnivoice): streaming /tts + language-safe sanitizer
Add a live-consumer streaming path and text sanitation to the OmniVoice
wrapper, so it can front speech-to-speech chat engines (not just the
asset-engine's batch WAV use).

- POST /tts: chunked 24 kHz mono s16le PCM (or open-ended WAV), driven by
  the adaptive buffer-ratchet scheduler. Emits the first sentence
  immediately, then ratchets chunk size up on OmniVoice's ~40x realtime
  headroom -> sub-second time-to-first-audio. Wire-compatible with
  chatterbox-fast /tts (both 24 kHz mono PCM). Batch /v1/audio/speech is
  unchanged for asset/file callers.

- scheduler.py: VENDORED byte-faithful copy of chatterbox-fast's pure-
  Python (torch-free) scheduler, pinned to commit 7631462 (v0.1.0/v0.1.1).
  Vendor-copy over a shared package (operator call 2026-06-19): the module
  has no GPU deps, so reuse it without dragging chatterbox-fast's torch
  tree into this image. Promote to a shared package only on a 3rd consumer
  or real drift.

- sanitize.py: language-safe TTS sanitizer run on both endpoints. Strips
  markdown, <think> blocks, HTML, and model control tokens; deliberately
  SKIPS the fork's English-only number/phone normalization that would
  corrupt OmniVoice's 600-language input. Preserves [laughter]-style tags.

- Refactor: shared GenParams base for SpeechRequest + TTSStreamRequest;
  single GEN_LOCK serializes generation (single-stream interactive).

- Dockerfile/playbook: copy + upload the two new modules; build-time
  `import app` smoke; correct stale "Gradio demo / no FastAPI" comments.
2026-06-19 22:47:15 -07:00

93 lines
4.2 KiB
Python

"""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: <think>…</think>, <reasoning>…</reasoning>, 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"!\[[^\]]*\]\([^)]*\)") # ![alt](url) → 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>" # <s> </s>
r"|<(?:pad|unk|mask|bos|eos|sep|cls)>" # common HF special tokens
r"|\[/?INST\]" # [INST] [/INST]
r"|<</?SYS>>", # <<SYS>> <</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())