File size: 4,210 Bytes
36ba721
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
"""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