Files

404 lines
15 KiB
Python
Raw Permalink Normal View History

"""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