Files
vllm-omni/examples/online_serving/voxtral_tts/text_preprocess.py
T
2026-03-25 01:28:00 +00:00

405 lines
15 KiB
Python

"""Text preprocessing utilities for the Voxtral TTS demo.
Provides text normalization and cleanup before sending input to the TTS model,
including:
- Removal of invisible Unicode characters (zero-width spaces, bidi marks, etc.)
- Flattening of Markdown and HTML formatting
- Verbalization of numbers and currency ($1.50 -> "one dollar and fifty cents")
- Normalization of punctuation and dashes
- Insertion of terminal punctuation when missing
"""
import re
from html import unescape
# Invisible Unicode chars often introduced by copy/paste.
_INVISIBLE_UNICODE_RE = re.compile(
"["
"\u200b" # zero-width space
"\u200e-\u200f" # bidi marks
"\u2028-\u2029" # line/paragraph separators
"\u2060-\u2064" # word joiner, invisible math operators
"\u00ad" # soft hyphen
"\u180e" # mongolian vowel separator
"\ufeff" # BOM / zero-width no-break space
"\ufff9-\ufffb" # interlinear annotations
"]"
)
# Keeps U+200C (ZWNJ) and U+200D (ZWJ).
_LINE_BREAK_RE = re.compile(r"(?:\r\n|\r|\n)+")
_REPEATED_PUNCT_RE = re.compile(r"([!?])\1+")
_ASCII_ELLIPSIS_RUN_RE = re.compile(r"\.{3,}")
_UNICODE_HYPHEN_TO_ASCII_RE = re.compile("[\u2010\u2011]")
_DASH_LIKE_CHARS = "\u002d\u2013\u2212"
_DASH_LIKE_CLASS = re.escape(_DASH_LIKE_CHARS)
_CURRENCY_CHARS = "$€£¥₹¢"
_MULTI_HYPHEN_RE = re.compile(rf"[{_DASH_LIKE_CLASS}]{{2,}}")
_STANDALONE_HYPHEN_RE = re.compile(rf"(?<=\s)[{_DASH_LIKE_CLASS}](?=\s)")
_TERMINAL_PUNCT = ".!?\u2026\u061f\u3002\uff01\uff1f"
_PARENTHETICAL_RE = re.compile(r"\s*\(([^()]+)\)\s*")
_SIMPLE_NUMERIC_PAREN_CONTENT_RE = re.compile(r"^\s*[$€£¥₹¢]?\s*[+\-−–]?\d+(?:[.,]\d+)?\s*%?\s*$")
_MD_FENCE_RE = re.compile(r"```[\s\S]*?```|~~~[\s\S]*?~~~")
_MD_IMAGE_LINK_RE = re.compile(r"!\[([^\]]*)\]\(([^)]*)\)")
_MD_LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)]*)\)")
_MD_INLINE_CODE_RE = re.compile(r"`([^`]+)`")
_MD_HEADING_RE = re.compile(r"^\s{0,3}#{1,6}\s*", re.MULTILINE)
_MD_UNORDERED_RE = re.compile(r"^\s*[-*+]\s+", re.MULTILINE)
_MD_ORDERED_RE = re.compile(r"^\s*\d{1,4}[.)]\s+", re.MULTILINE)
_MD_BLOCKQUOTE_RE = re.compile(r"^\s{0,3}>\s?", re.MULTILINE)
_MD_BR_TAG_RE = re.compile(r"(?i)<br\s*/?>")
_MD_HTML_TAG_RE = re.compile(r"</?[a-zA-Z][^>]*>")
_MD_AUTOLINK_RE = re.compile(r"<https?://[^>]+>")
_MD_URL_RE = re.compile(r"https?://\S+")
_ONES = {
0: "zero",
1: "one",
2: "two",
3: "three",
4: "four",
5: "five",
6: "six",
7: "seven",
8: "eight",
9: "nine",
}
_TEENS = {
10: "ten",
11: "eleven",
12: "twelve",
13: "thirteen",
14: "fourteen",
15: "fifteen",
16: "sixteen",
17: "seventeen",
18: "eighteen",
19: "nineteen",
}
_TENS = {
20: "twenty",
30: "thirty",
40: "forty",
50: "fifty",
60: "sixty",
70: "seventy",
80: "eighty",
90: "ninety",
}
_SCALES = ["", "thousand", "million", "billion", "trillion", "quadrillion"]
_TOKEN_RE = re.compile(
r"""
(?P<currency>
(?P<symbol>[$£€¥₹¢])
(?P<amount>\d[\d,]*(?:\.\d+)?)
)
|
(?P<number>
(?<!\w)-?\d[\d,]*(?:\.\d+)?\b
)
""",
re.VERBOSE,
)
_CURRENCY_INFO = {
"$": ("dollar", "dollars", "cent", "cents"),
"£": ("pound", "pounds", "penny", "pence"),
"€": ("euro", "euros", "cent", "cents"),
"¥": ("yen", "yen", None, None),
"₹": ("rupee", "rupees", "paise", "paise"),
"¢": ("cent", "cents", None, None),
}
def _flatten_markdown_for_tts(text: str) -> str:
text = _MD_FENCE_RE.sub(" Code example omitted. ", text)
text = unescape(text)
text = _MD_BR_TAG_RE.sub(" ", text)
text = _MD_IMAGE_LINK_RE.sub(lambda match: match.group(1).strip() or "image", text)
text = _MD_LINK_RE.sub(lambda match: match.group(1), text)
text = _MD_AUTOLINK_RE.sub("link", text)
text = _MD_URL_RE.sub("link", text)
text = _MD_INLINE_CODE_RE.sub(lambda match: match.group(1), text)
text = _MD_HEADING_RE.sub("", text)
text = _MD_UNORDERED_RE.sub("", text)
text = _MD_ORDERED_RE.sub("", text)
text = _MD_BLOCKQUOTE_RE.sub("", text)
text = _MD_HTML_TAG_RE.sub(" ", text)
return text
def _next_non_space_char(text: str, start_index: int) -> str | None:
index = start_index
while index < len(text) and text[index].isspace():
index += 1
if index >= len(text):
return None
return text[index]
def _replace_standalone_hyphens(text: str) -> str:
def replace(match: re.Match[str]) -> str:
next_char = _next_non_space_char(text, match.end())
if next_char is None:
return match.group(0)
if next_char.isdigit() or next_char in _CURRENCY_CHARS:
return match.group(0)
return "\u2014"
return _STANDALONE_HYPHEN_RE.sub(replace, text)
def _replace_textual_parentheticals(text: str) -> str:
def normalize_parenthetical(inner: str) -> str:
normalized = inner
normalized = re.sub(r"(?i)\bN\s*/\s*A\b", "not available", normalized)
normalized = re.sub(r"\s*(?:\+/-|±)\s*", " plus or minus ", normalized)
normalized = re.sub(r"\s*>=\s*", " greater than or equal to ", normalized)
normalized = re.sub(r"\s*<=\s*", " less than or equal to ", normalized)
normalized = re.sub(r"\s*>\s*", " greater than ", normalized)
normalized = re.sub(r"\s*<\s*", " less than ", normalized)
normalized = re.sub(r"\s*~\s*", " about ", normalized)
normalized = re.sub(r"#\s*(\d+)\b", r"number \1", normalized)
normalized = re.sub(r"(\d+(?:\.\d+)?)\s*ms\b", r"\1 milliseconds", normalized, flags=re.IGNORECASE)
normalized = re.sub(r"(\d+(?:\.\d+)?)\s*h\b", r"\1 hours", normalized, flags=re.IGNORECASE)
normalized = re.sub(r"(\d+(?:\.\d+)?)\s*%", r"\1 percent", normalized)
normalized = re.sub(r"\s*\+\s*", " plus ", normalized)
normalized = re.sub(r"\s*=\s*", " equals ", normalized)
normalized = re.sub(r"\s+", " ", normalized).strip()
return normalized
def replace(match: re.Match[str]) -> str:
inner = match.group(1).strip()
if not inner:
return match.group(0)
if _SIMPLE_NUMERIC_PAREN_CONTENT_RE.fullmatch(inner):
return match.group(0)
normalized = normalize_parenthetical(inner)
return f"\u2014{normalized}\u2014"
return _PARENTHETICAL_RE.sub(replace, text)
def _verbalize_sub_thousand(n: int, *, use_and: bool = False) -> str:
if not 0 <= n <= 999:
raise ValueError(f"Expected sub-thousand integer in [0, 999], got {n=}")
parts: list[str] = []
hundreds = n // 100
rem = n % 100
if hundreds:
parts.append(f"{_ONES[hundreds]} hundred")
if rem and use_and:
parts.append("and")
if rem:
if rem < 10:
parts.append(_ONES[rem])
elif rem < 20:
parts.append(_TEENS[rem])
else:
tens = (rem // 10) * 10
ones = rem % 10
if ones:
parts.append(f"{_TENS[tens]}-{_ONES[ones]}")
else:
parts.append(_TENS[tens])
return " ".join(parts) if parts else "zero"
def _verbalize_integer_en(num_str: str, *, use_and: bool = False) -> str:
s = num_str.replace(",", "").strip()
if not re.fullmatch(r"\d+", s):
raise ValueError(f"Not a plain integer: {num_str}")
n = int(s)
if n == 0:
return "zero"
groups: list[int] = []
while n > 0:
groups.append(n % 1000)
n //= 1000
if len(groups) > len(_SCALES):
raise ValueError(f"Integer magnitude too large to verbalize safely: {num_str}")
parts: list[str] = []
for scale_idx in range(len(groups) - 1, -1, -1):
group_val = groups[scale_idx]
if group_val == 0:
continue
group_words = _verbalize_sub_thousand(
group_val,
use_and=use_and and scale_idx == 0 and len(groups) > 1,
)
scale_word = _SCALES[scale_idx]
parts.append(f"{group_words} {scale_word}".strip())
return " ".join(parts)
def _verbalize_decimal_en(num_str: str, *, use_and: bool = False) -> str:
s = num_str.replace(",", "").strip()
match = re.fullmatch(r"(\d+)\.(\d+)", s)
if not match:
raise ValueError(f"Not a plain decimal: {num_str}")
int_part, frac_part = match.groups()
int_words = _verbalize_integer_en(int_part, use_and=use_and)
frac_words = "-".join(_ONES[int(ch)] for ch in frac_part)
return f"{int_words} point {frac_words}"
def _verbalize_number_en(num_str: str, *, use_and: bool = False) -> str:
s = num_str.strip()
if s.startswith("-"):
inner = s[1:]
if not inner:
raise ValueError(f"Unsupported numeric format: {num_str}")
return f"negative {_verbalize_number_en(inner, use_and=use_and)}"
if re.fullmatch(r"\d{4}", s):
year = int(s)
if 1400 <= year < 2100:
if year == 2000:
return "two thousand"
first_two = year // 100
last_two = year % 100
if 1400 <= year <= 1999:
if last_two == 0:
return f"{_verbalize_integer_en(str(first_two), use_and=use_and)} hundred"
return f"{_verbalize_integer_en(str(first_two), use_and=use_and)} {_verbalize_sub_thousand(last_two)}"
if 2001 <= year <= 2009:
return f"two thousand {_ONES[last_two]}"
if 2010 <= year <= 2099:
return f"twenty {_verbalize_sub_thousand(last_two)}"
if re.fullmatch(r"\d[\d,]*", s):
return _verbalize_integer_en(s, use_and=use_and)
if re.fullmatch(r"\d[\d,]*\.\d+", s):
return _verbalize_decimal_en(s, use_and=use_and)
raise ValueError(f"Unsupported numeric format: {num_str}")
def _parse_currency_token(token: str) -> tuple[str, int, str | None]:
match = re.fullmatch(r"([$£€¥₹¢])(\d[\d,]*)(?:\.(\d+))?", token.strip())
if not match:
raise ValueError(f"Not a supported currency amount: {token}")
symbol, whole_part, frac_part = match.groups()
whole = int(whole_part.replace(",", ""))
return symbol, whole, frac_part
def _verbalize_currency_decimal_en(symbol: str, whole: int, frac_part: str) -> str:
singular_major, plural_major, _, _ = _CURRENCY_INFO[symbol]
major_unit = singular_major if whole == 1 else plural_major
whole_words = _verbalize_integer_en(str(whole), use_and=False)
frac_words = "-".join(_ONES[int(ch)] for ch in frac_part)
return f"{whole_words} point {frac_words} {major_unit}"
def _verbalize_currency_en(token: str, *, use_and: bool = True, short: bool = False) -> str:
symbol, whole, frac_part = _parse_currency_token(token)
singular_major, plural_major, singular_minor, plural_minor = _CURRENCY_INFO[symbol]
if short and symbol != "$":
short = False
if frac_part is not None and len(frac_part) > 2:
return _verbalize_currency_decimal_en(symbol, whole, frac_part)
minor = None
if frac_part is not None:
frac_2 = (frac_part + "00")[:2]
minor = int(frac_2)
if not short:
whole_words = _verbalize_integer_en(str(whole), use_and=use_and)
major_unit = singular_major if whole == 1 else plural_major
if symbol in {"¥", "¢"}:
if frac_part is None or set(frac_part) == {"0"}:
return f"{whole_words} {major_unit}"
return _verbalize_currency_decimal_en(symbol, whole, frac_part)
if minor is None or minor == 0:
return f"{whole_words} {major_unit}"
if whole == 0:
minor_words = _verbalize_integer_en(str(minor), use_and=False)
minor_unit = singular_minor if minor == 1 else plural_minor
return f"{minor_words} {minor_unit}"
minor_words = _verbalize_integer_en(str(minor), use_and=False)
minor_unit = singular_minor if minor == 1 else plural_minor
return f"{whole_words} {major_unit} and {minor_words} {minor_unit}"
if minor is None or minor == 0:
whole_words = _verbalize_integer_en(str(whole), use_and=False)
major_unit = singular_major if whole == 1 else plural_major
return f"{whole_words} {major_unit}"
if whole == 0:
minor_words = _verbalize_integer_en(str(minor), use_and=False)
minor_unit = singular_minor if minor == 1 else plural_minor
return f"{minor_words} {minor_unit}"
if 0 <= minor <= 9:
return f"{_verbalize_integer_en(str(whole), use_and=False)} oh {_ONES[minor]}"
return f"{_verbalize_integer_en(str(whole), use_and=False)} {_verbalize_integer_en(str(minor), use_and=False)}"
def _should_verbalize_plain_number(token: str) -> bool:
s = token.strip()
if s.startswith("-"):
s = s[1:]
raw_int_part = s.split(".", 1)[0]
int_no_commas = raw_int_part.replace(",", "")
if not re.fullmatch(r"\d+", int_no_commas):
return False
value = int(int_no_commas)
# Only rewrite large comma-separated values (e.g. 1,234,567).
return 1_000_000 <= value < 1e18 and "," in raw_int_part
def _auto_verbalize_safe_numbers_in_text(text: str) -> str:
matches = list(_TOKEN_RE.finditer(text))
currency_symbols = [m.group("symbol") for m in matches if m.group("currency") is not None]
only_dollar_repetitions = currency_symbols and set(currency_symbols) == {"$"} and len(currency_symbols) > 1
first_dollar_done = False
def repl(match: re.Match[str]) -> str:
nonlocal first_dollar_done
if match.group("currency") is not None:
token = match.group("currency")
symbol = match.group("symbol")
try:
short = False
if only_dollar_repetitions and symbol == "$":
if first_dollar_done:
short = True
else:
first_dollar_done = True
return _verbalize_currency_en(token, use_and=True, short=short)
except (ValueError, IndexError):
return token
token = match.group("number")
try:
if not _should_verbalize_plain_number(token):
return token
return _verbalize_number_en(token, use_and=True)
except (ValueError, IndexError):
return token
return _TOKEN_RE.sub(repl, text)
def sanitize_tts_input_text_for_demo(text: str) -> str:
"""Normalize text before sending it to TTS."""
raw_text = text
text = _flatten_markdown_for_tts(text)
text = _INVISIBLE_UNICODE_RE.sub("", text)
text = _LINE_BREAK_RE.sub(" ", text)
text = _auto_verbalize_safe_numbers_in_text(text)
text = _replace_textual_parentheticals(text)
text = _UNICODE_HYPHEN_TO_ASCII_RE.sub("-", text)
text = _ASCII_ELLIPSIS_RUN_RE.sub("...", text)
text = _REPEATED_PUNCT_RE.sub(r"\1", text)
text = _MULTI_HYPHEN_RE.sub("\u2014", text)
text = _replace_standalone_hyphens(text)
text = re.sub(r"\s+", " ", text).strip()
if text and text[-1] not in _TERMINAL_PUNCT:
text += "."
if not text:
raise ValueError(f"Speech input is empty after sanitization, got {raw_text=}")
return text