288d085236
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.
93 lines
4.2 KiB
Python
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"!\[[^\]]*\]\([^)]*\)") #  → 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())
|