"""Make Whisper output speakable for a character-level TTS (MMS-VITS has no digits). "Alarm F007 on line 3, VFD tripped" -> "alarm eff zero zero seven on line three, vee eff dee tripped" Codes (letters+digits) and digit strings with leading zeros are read digit by digit, which is how operators read alarm and part numbers; plain numbers < 10000 are read as words; ALL-CAPS acronyms are spelled out. """ from __future__ import annotations import re _ONES = ["zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen", "sixteen", "seventeen", "eighteen", "nineteen"] _TENS = ["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety"] _SYMBOLS = {"%": " percent", "&": " and ", "+": " plus ", "°": " degrees ", "#": " number ", "/": " slash ", "@": " at "} def _under_100(n: int) -> str: if n < 20: return _ONES[n] t, o = divmod(n, 10) return _TENS[t] + ("" if o == 0 else " " + _ONES[o]) def number_to_words(n: int) -> str: if n < 100: return _under_100(n) if n < 1000: h, r = divmod(n, 100) return _ONES[h] + " hundred" + ("" if r == 0 else " " + _under_100(r)) th, r = divmod(n, 1000) return _under_100(th) + " thousand" + ("" if r == 0 else " " + number_to_words(r)) # Letter names: a character-level TTS reads a lone "f" as a sound, not as the letter # (measured: "f zero zero seven" came back from Whisper as "007"). _LETTERS = {"a": "ay", "b": "bee", "c": "see", "d": "dee", "e": "ee", "f": "eff", "g": "jee", "h": "aitch", "i": "eye", "j": "jay", "k": "kay", "l": "el", "m": "em", "n": "en", "o": "oh", "p": "pee", "q": "cue", "r": "ar", "s": "ess", "t": "tee", "u": "you", "v": "vee", "w": "double you", "x": "ex", "y": "why", "z": "zed"} def _spell(letters: str) -> str: return " ".join(_LETTERS[ch] for ch in letters.lower()) def _digits(s: str) -> str: return " ".join(_ONES[int(d)] for d in s) def _read_number(s: str) -> str: if len(s) > 1 and s.startswith("0") or len(s) > 4: return _digits(s) return number_to_words(int(s)) def _token(tok: str) -> str: if re.fullmatch(r"\d+(\.\d+)?", tok): whole, _, frac = tok.partition(".") out = _read_number(whole) return out + (" point " + _digits(frac) if frac else "") if re.search(r"\d", tok) and re.search(r"[A-Za-z]", tok): # code like F007, M6x20, PN-4471A parts = re.findall(r"[A-Za-z]+|\d+", tok) return " ".join(_spell(p) if p.isalpha() and len(p) <= 3 else (p.lower() if p.isalpha() else _digits(p)) for p in parts) if re.fullmatch(r"[A-Z]{2,5}s?", tok): # VFD, PLC, HMIs letters = tok[:-1] if tok.endswith("s") else tok return _spell(letters) + ("s" if tok.endswith("s") else "") if re.fullmatch(r"\d+", re.sub(r"[^\d]", "", tok) or "x") and "-" in tok: # 12-34 return " ".join(_digits(p) for p in tok.split("-") if p) return tok def speakable(text: str) -> str: for sym, rep in _SYMBOLS.items(): text = text.replace(sym, rep) out = [] for tok in re.findall(r"[A-Za-z0-9][A-Za-z0-9.\-']*[A-Za-z0-9]|[A-Za-z0-9]|[^\sA-Za-z0-9]", text): if tok[-1] == "." and len(tok) > 1: tok = tok[:-1] out.append(_token(tok)) s = " ".join(out) s = re.sub(r"\s+([,.!?;:])", r"\1", s) s = re.sub(r"[^a-zA-Z' ,.!?;:\-]", " ", s) return re.sub(r"\s+", " ", s).strip().lower() def split_sentences(text: str, max_chars: int = 200) -> list[str]: """Split into sentence-sized pieces so the TTS can start speaking early.""" parts = [p.strip() for p in re.split(r"(?<=[.!?;])\s+", text) if p.strip()] out: list[str] = [] for p in parts: while len(p) > max_chars: cut = p.rfind(",", 0, max_chars) cut = cut if cut > 0 else p.rfind(" ", 0, max_chars) cut = cut if cut > 0 else max_chars out.append(p[:cut + 1].strip()) p = p[cut + 1:].strip() if p: out.append(p) return out