speakable 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- speakable/__init__.py +4 -0
- speakable/normalize.py +248 -0
- speakable/numbers.py +104 -0
- speakable-1.0.0.dist-info/METADATA +159 -0
- speakable-1.0.0.dist-info/RECORD +8 -0
- speakable-1.0.0.dist-info/WHEEL +5 -0
- speakable-1.0.0.dist-info/licenses/LICENSE +21 -0
- speakable-1.0.0.dist-info/top_level.txt +1 -0
speakable/__init__.py
ADDED
speakable/normalize.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Turn LLM output into text a TTS engine can speak correctly.
|
|
2
|
+
|
|
3
|
+
The problem this solves: an LLM happily returns "**Total: $1,234.56** due on
|
|
4
|
+
2026-11-11 - call Dr. Rao at (555) 123-4567". Feed that straight to TTS and the
|
|
5
|
+
caller hears "asterisk asterisk total dollar one comma two three four".
|
|
6
|
+
"""
|
|
7
|
+
import re
|
|
8
|
+
|
|
9
|
+
from .numbers import (MONTHS, decimal_to_words, digits_to_words, int_to_words,
|
|
10
|
+
ordinal_to_words, year_to_words)
|
|
11
|
+
|
|
12
|
+
CURRENCIES = {
|
|
13
|
+
"$": ("dollar", "cent"),
|
|
14
|
+
"₹": ("rupee", "paisa"),
|
|
15
|
+
"£": ("pound", "penny"),
|
|
16
|
+
"€": ("euro", "cent"),
|
|
17
|
+
"¥": ("yen", "sen"),
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
ABBREVIATIONS = {
|
|
21
|
+
"Dr": "Doctor", "Mr": "Mister", "Mrs": "Missus", "Ms": "Miss",
|
|
22
|
+
"Prof": "Professor", "Jr": "Junior", "Sr": "Senior", "St": "Street",
|
|
23
|
+
"Ave": "Avenue", "Rd": "Road", "Blvd": "Boulevard", "Ln": "Lane",
|
|
24
|
+
"Apt": "Apartment", "Ste": "Suite", "Inc": "Incorporated",
|
|
25
|
+
"Corp": "Corporation", "Ltd": "Limited", "Dept": "Department",
|
|
26
|
+
"approx": "approximately", "vs": "versus", "etc": "et cetera",
|
|
27
|
+
}
|
|
28
|
+
PHRASES = {
|
|
29
|
+
"e.g.": "for example", "i.e.": "that is",
|
|
30
|
+
"a.m.": "AM", "p.m.": "PM",
|
|
31
|
+
"&": " and ", "%": " percent ", "=": " equals ",
|
|
32
|
+
"<": " less than ", ">": " greater than ",
|
|
33
|
+
}
|
|
34
|
+
UNITS = {
|
|
35
|
+
"kg": "kilograms", "mg": "milligrams", "lbs": "pounds", "lb": "pounds",
|
|
36
|
+
"oz": "ounces", "km": "kilometers", "cm": "centimeters",
|
|
37
|
+
"mm": "millimeters", "mi": "miles", "ft": "feet", "hrs": "hours",
|
|
38
|
+
"hr": "hours", "sec": "seconds", "ms": "milliseconds",
|
|
39
|
+
"kb": "kilobytes", "mb": "megabytes", "gb": "gigabytes",
|
|
40
|
+
"tb": "terabytes",
|
|
41
|
+
}
|
|
42
|
+
# Acronyms a TTS engine already says correctly as a word.
|
|
43
|
+
PRONOUNCEABLE = {"NASA", "RADAR", "LASER", "SCUBA", "JSON", "YAML", "SOAP",
|
|
44
|
+
"REST", "SIP", "RAM", "ROM", "AIDS", "OPEC", "NATO", "SIM"}
|
|
45
|
+
|
|
46
|
+
_EMOJI = re.compile(
|
|
47
|
+
"[\U0001F300-\U0001FAFF\U00002600-\U000027BF\U0001F1E6-\U0001F1FF"
|
|
48
|
+
"\U00002190-\U000021FF\U0000FE00-\U0000FE0F\U00002B00-\U00002BFF]+")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _say_number(raw):
|
|
52
|
+
"""'1,234.5' -> 'one thousand two hundred thirty-four point five'"""
|
|
53
|
+
raw = raw.replace(",", "")
|
|
54
|
+
if "." in raw:
|
|
55
|
+
whole, _, frac = raw.partition(".")
|
|
56
|
+
return decimal_to_words(whole, frac)
|
|
57
|
+
return int_to_words(int(raw))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
_FRACTIONS = {
|
|
61
|
+
(1, 2): "one half", (1, 3): "one third", (2, 3): "two thirds",
|
|
62
|
+
(1, 4): "one quarter", (3, 4): "three quarters", (1, 5): "one fifth",
|
|
63
|
+
(1, 8): "one eighth", (3, 8): "three eighths", (5, 8): "five eighths",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _say_fraction(num, den):
|
|
68
|
+
if (num, den) in _FRACTIONS:
|
|
69
|
+
return _FRACTIONS[(num, den)]
|
|
70
|
+
return "%s over %s" % (int_to_words(num), int_to_words(den))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _strip_markdown(text):
|
|
74
|
+
text = re.sub(r"```[\s\S]*?```", " ", text) # fenced code
|
|
75
|
+
text = re.sub(r"`([^`]*)`", r"\1", text) # inline code
|
|
76
|
+
text = re.sub(r"!\[[^\]]*\]\([^)]*\)", " ", text) # images
|
|
77
|
+
text = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", text) # links -> label
|
|
78
|
+
text = re.sub(r"^\s{0,3}#{1,6}\s*", "", text, flags=re.M)
|
|
79
|
+
text = re.sub(r"^\s{0,3}>\s?", "", text, flags=re.M)
|
|
80
|
+
text = re.sub(r"^\s*[-*+]\s+", "", text, flags=re.M) # bullets
|
|
81
|
+
text = re.sub(r"^\s*\|.*\|\s*$", " ", text, flags=re.M)
|
|
82
|
+
text = re.sub(r"^\s*[-*_]{3,}\s*$", " ", text, flags=re.M)
|
|
83
|
+
text = re.sub(r"(\*\*\*|___)(.+?)\1", r"\2", text, flags=re.S)
|
|
84
|
+
text = re.sub(r"(\*\*|__)(.+?)\1", r"\2", text, flags=re.S)
|
|
85
|
+
text = re.sub(r"(?<!\w)([*_])(?!\s)(.+?)(?<!\s)\1(?!\w)", r"\2", text)
|
|
86
|
+
text = re.sub(r"~~(.+?)~~", r"\1", text)
|
|
87
|
+
# Streaming or truncated LLM output leaves unpaired markers behind. Any
|
|
88
|
+
# asterisk that survives the rules above would be spoken aloud, so drop it.
|
|
89
|
+
text = re.sub(r"\*+", "", text)
|
|
90
|
+
return text
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _say_money(symbol, whole, frac):
|
|
94
|
+
major, minor = CURRENCIES[symbol]
|
|
95
|
+
amount = int(whole.replace(",", ""))
|
|
96
|
+
out = "%s %s%s" % (int_to_words(amount), major,
|
|
97
|
+
"" if amount == 1 else "s")
|
|
98
|
+
if frac and int(frac):
|
|
99
|
+
cents = int(frac.ljust(2, "0")[:2])
|
|
100
|
+
if minor == "penny":
|
|
101
|
+
word = "penny" if cents == 1 else "pence"
|
|
102
|
+
elif minor == "paisa":
|
|
103
|
+
word = "paisa" if cents == 1 else "paise"
|
|
104
|
+
else:
|
|
105
|
+
word = minor if cents == 1 else minor + "s"
|
|
106
|
+
out += " and %s %s" % (int_to_words(cents), word)
|
|
107
|
+
return out
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _say_date(y, m, d):
|
|
111
|
+
if not (1 <= m <= 12 and 1 <= d <= 31):
|
|
112
|
+
return ""
|
|
113
|
+
return "%s %s, %s" % (MONTHS[m - 1], ordinal_to_words(d), year_to_words(y))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _say_time(h, mi, meridiem):
|
|
117
|
+
if h > 23 or mi > 59:
|
|
118
|
+
return ""
|
|
119
|
+
if not meridiem and h >= 13:
|
|
120
|
+
# 24-hour input: nobody says "fourteen o'clock" out loud.
|
|
121
|
+
h, meridiem = h - 12, "PM"
|
|
122
|
+
elif not meridiem and h == 0:
|
|
123
|
+
h, meridiem = 12, "AM"
|
|
124
|
+
hour = int_to_words(h if h else 12)
|
|
125
|
+
if mi == 0:
|
|
126
|
+
body = hour + " o'clock"
|
|
127
|
+
elif mi < 10:
|
|
128
|
+
body = "%s oh %s" % (hour, int_to_words(mi))
|
|
129
|
+
else:
|
|
130
|
+
body = "%s %s" % (hour, int_to_words(mi))
|
|
131
|
+
tail = meridiem.upper().replace(".", "")
|
|
132
|
+
return (body + " " + tail).strip()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _month_name(token):
|
|
136
|
+
for name in MONTHS:
|
|
137
|
+
if name.lower().startswith(token[:3].lower()):
|
|
138
|
+
return name
|
|
139
|
+
return token
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def normalize(text, spell_acronyms=False, strip_emoji=True):
|
|
143
|
+
"""Rewrite `text` so a TTS engine speaks it the way a human would."""
|
|
144
|
+
if not text:
|
|
145
|
+
return ""
|
|
146
|
+
t = _strip_markdown(text)
|
|
147
|
+
if strip_emoji:
|
|
148
|
+
t = _EMOJI.sub(" ", t)
|
|
149
|
+
|
|
150
|
+
# Emails and URLs first, before anything else touches the dots.
|
|
151
|
+
t = re.sub(r"\b[\w.+-]+@[\w-]+\.[\w.]+\b",
|
|
152
|
+
lambda m: m.group(0).replace("@", " at ").replace(".", " dot "),
|
|
153
|
+
t)
|
|
154
|
+
t = re.sub(r"https?://\S+|\bwww\.\S+",
|
|
155
|
+
lambda m: re.sub(r"/\S*$", "",
|
|
156
|
+
re.sub(r"https?://|www\.", "", m.group(0)))
|
|
157
|
+
.replace(".", " dot "), t)
|
|
158
|
+
|
|
159
|
+
# Dotted numerics before everything numeric, or the phone matcher claims
|
|
160
|
+
# them: "192.168.1.1" is seven digits and three separators.
|
|
161
|
+
t = re.sub(r"\b(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\b",
|
|
162
|
+
lambda m: " dot ".join(int_to_words(int(g))
|
|
163
|
+
for g in m.groups()), t)
|
|
164
|
+
t = re.sub(r"\bv?(\d+)\.(\d+)\.(\d+)\b",
|
|
165
|
+
lambda m: " point ".join(int_to_words(int(g))
|
|
166
|
+
for g in m.groups()), t)
|
|
167
|
+
|
|
168
|
+
# Dates before phone numbers: 2026-11-11 is digits-and-hyphens too.
|
|
169
|
+
t = re.sub(r"\b(\d{4})-(\d{2})-(\d{2})\b",
|
|
170
|
+
lambda m: _say_date(int(m.group(1)), int(m.group(2)),
|
|
171
|
+
int(m.group(3))) or m.group(0), t)
|
|
172
|
+
t = re.sub(r"\b(\d{1,2})/(\d{1,2})/(\d{4})\b",
|
|
173
|
+
lambda m: _say_date(int(m.group(3)), int(m.group(1)),
|
|
174
|
+
int(m.group(2))) or m.group(0), t)
|
|
175
|
+
month_re = "|".join(MONTHS + [m[:3] for m in MONTHS])
|
|
176
|
+
t = re.sub(r"\b(" + month_re + r")\.?\s+(\d{1,2})(?:st|nd|rd|th)?"
|
|
177
|
+
r"(?:,?\s*(\d{4}))?\b",
|
|
178
|
+
lambda m: _month_name(m.group(1)) + " "
|
|
179
|
+
+ ordinal_to_words(int(m.group(2)))
|
|
180
|
+
+ (", " + year_to_words(int(m.group(3))) if m.group(3) else ""),
|
|
181
|
+
t, flags=re.I)
|
|
182
|
+
|
|
183
|
+
# Note: the meridiem deliberately does not absorb a trailing dot, so that
|
|
184
|
+
# "at 3:30 PM." keeps the sentence-ending period.
|
|
185
|
+
t = re.sub(r"\b(\d{1,2}):(\d{2})(?:\s*([ap]\.?m))?",
|
|
186
|
+
lambda m: _say_time(int(m.group(1)), int(m.group(2)),
|
|
187
|
+
m.group(3) or "") or m.group(0),
|
|
188
|
+
t, flags=re.I)
|
|
189
|
+
|
|
190
|
+
def _phone(m):
|
|
191
|
+
raw = m.group(0).strip()
|
|
192
|
+
digits = re.sub(r"\D", "", raw)
|
|
193
|
+
if not 7 <= len(digits) <= 15:
|
|
194
|
+
return m.group(0)
|
|
195
|
+
# A digit run alone is not a phone number - "1000-2000" is a range.
|
|
196
|
+
# Require real phone shape: a country code, parens, two or more
|
|
197
|
+
# separators, or an unbroken run long enough to be a full number.
|
|
198
|
+
seps = len(re.findall(r"[\s\-.]", raw))
|
|
199
|
+
if not (raw.startswith("+") or "(" in raw or seps >= 2
|
|
200
|
+
or (seps == 0 and len(digits) >= 10)):
|
|
201
|
+
return m.group(0)
|
|
202
|
+
return digits_to_words(raw)
|
|
203
|
+
|
|
204
|
+
# The leading paren must be inside the match, or "(555) 123-4567" leaves a
|
|
205
|
+
# stray "(" behind once the digits are rewritten.
|
|
206
|
+
t = re.sub(r"(?<![\w$.])\+?\(?\d[\d\-.\s()]{6,18}\d\)?(?![\w])", _phone, t)
|
|
207
|
+
|
|
208
|
+
t = re.sub(r"#(?=\d)", "number ", t)
|
|
209
|
+
|
|
210
|
+
# Ranges, now that whatever is left is not a phone number.
|
|
211
|
+
t = re.sub(r"\b(\d[\d,]*)\s?-\s?(\d[\d,]*)\b",
|
|
212
|
+
lambda m: _say_number(m.group(1)) + " to "
|
|
213
|
+
+ _say_number(m.group(2)), t)
|
|
214
|
+
# A bare leading minus is spoken, not silent. Must follow the range rule,
|
|
215
|
+
# whose hyphen is preceded by a digit and so is unaffected.
|
|
216
|
+
t = re.sub(r"(?<![\w\d])-\s?(?=\d)", "negative ", t)
|
|
217
|
+
|
|
218
|
+
t = re.sub(r"\b(\d+)/(\d+)\b",
|
|
219
|
+
lambda m: _say_fraction(int(m.group(1)), int(m.group(2))), t)
|
|
220
|
+
|
|
221
|
+
sym = "|".join(re.escape(s) for s in CURRENCIES)
|
|
222
|
+
t = re.sub("(" + sym + r")\s?(\d[\d,]*)(?:\.(\d{1,2}))?",
|
|
223
|
+
lambda m: _say_money(m.group(1), m.group(2), m.group(3)), t)
|
|
224
|
+
t = re.sub(r"(\d[\d,]*(?:\.\d+)?)\s?%",
|
|
225
|
+
lambda m: _say_number(m.group(1)) + " percent", t)
|
|
226
|
+
t = re.sub(r"\b(\d+)(?:st|nd|rd|th)\b",
|
|
227
|
+
lambda m: ordinal_to_words(int(m.group(1))), t)
|
|
228
|
+
t = re.sub(r"\b(\d[\d,]*(?:\.\d+)?)\s?(" + "|".join(UNITS) + r")\b",
|
|
229
|
+
lambda m: _say_number(m.group(1)) + " " + UNITS[m.group(2)], t)
|
|
230
|
+
|
|
231
|
+
for abbr, full in ABBREVIATIONS.items():
|
|
232
|
+
t = re.sub(r"\b" + re.escape(abbr) + r"\.", full, t)
|
|
233
|
+
for src, dst in PHRASES.items():
|
|
234
|
+
t = t.replace(src, dst)
|
|
235
|
+
|
|
236
|
+
t = re.sub(r"\b\d[\d,]*(?:\.\d+)?\b", lambda m: _say_number(m.group(0)), t)
|
|
237
|
+
|
|
238
|
+
if spell_acronyms:
|
|
239
|
+
t = re.sub(r"\b([A-Z]{2,5})\b",
|
|
240
|
+
lambda m: m.group(1) if m.group(1) in PRONOUNCEABLE
|
|
241
|
+
else " ".join(m.group(1)), t)
|
|
242
|
+
|
|
243
|
+
# A bare "#" with no number after it would be read out as "hash".
|
|
244
|
+
t = re.sub(r"#", " ", t)
|
|
245
|
+
|
|
246
|
+
t = re.sub(r"[ \t]+", " ", t)
|
|
247
|
+
t = re.sub(r"\s+([,.!?;:])", r"\1", t)
|
|
248
|
+
return re.sub(r"\n{2,}", "\n", t).strip()
|
speakable/numbers.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""English number-to-words. No dependencies, so the API has no cold-start cost."""
|
|
2
|
+
|
|
3
|
+
_ONES = [
|
|
4
|
+
"zero", "one", "two", "three", "four", "five", "six", "seven", "eight",
|
|
5
|
+
"nine", "ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen",
|
|
6
|
+
"sixteen", "seventeen", "eighteen", "nineteen",
|
|
7
|
+
]
|
|
8
|
+
_TENS = ["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy",
|
|
9
|
+
"eighty", "ninety"]
|
|
10
|
+
_SCALES = [(10**12, "trillion"), (10**9, "billion"), (10**6, "million"),
|
|
11
|
+
(10**3, "thousand")]
|
|
12
|
+
|
|
13
|
+
_ORDINALS = {
|
|
14
|
+
"one": "first", "two": "second", "three": "third", "four": "fourth",
|
|
15
|
+
"five": "fifth", "six": "sixth", "seven": "seventh", "eight": "eighth",
|
|
16
|
+
"nine": "ninth", "ten": "tenth", "eleven": "eleventh", "twelve": "twelfth",
|
|
17
|
+
"thirteen": "thirteenth", "fourteen": "fourteenth", "fifteen": "fifteenth",
|
|
18
|
+
"sixteen": "sixteenth", "seventeen": "seventeenth",
|
|
19
|
+
"eighteen": "eighteenth", "nineteen": "nineteenth", "twenty": "twentieth",
|
|
20
|
+
"thirty": "thirtieth", "forty": "fortieth", "fifty": "fiftieth",
|
|
21
|
+
"sixty": "sixtieth", "seventy": "seventieth", "eighty": "eightieth",
|
|
22
|
+
"ninety": "ninetieth", "hundred": "hundredth", "thousand": "thousandth",
|
|
23
|
+
"million": "millionth", "billion": "billionth", "zero": "zeroth",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
MONTHS = ["January", "February", "March", "April", "May", "June", "July",
|
|
27
|
+
"August", "September", "October", "November", "December"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _under_hundred(n: int) -> str:
|
|
31
|
+
if n < 20:
|
|
32
|
+
return _ONES[n]
|
|
33
|
+
tens, ones = divmod(n, 10)
|
|
34
|
+
return _TENS[tens] + (f"-{_ONES[ones]}" if ones else "")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _under_thousand(n: int) -> str:
|
|
38
|
+
hundreds, rest = divmod(n, 100)
|
|
39
|
+
parts = []
|
|
40
|
+
if hundreds:
|
|
41
|
+
parts += [_ONES[hundreds], "hundred"]
|
|
42
|
+
if rest:
|
|
43
|
+
parts.append(_under_hundred(rest))
|
|
44
|
+
return " ".join(parts)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def int_to_words(n: int) -> str:
|
|
48
|
+
"""1234 -> 'one thousand two hundred thirty-four'"""
|
|
49
|
+
if n < 0:
|
|
50
|
+
return "negative " + int_to_words(-n)
|
|
51
|
+
if n == 0:
|
|
52
|
+
return "zero"
|
|
53
|
+
parts = []
|
|
54
|
+
for value, name in _SCALES:
|
|
55
|
+
if n >= value:
|
|
56
|
+
chunk, n = divmod(n, value)
|
|
57
|
+
parts += [int_to_words(chunk), name]
|
|
58
|
+
if n:
|
|
59
|
+
parts.append(_under_thousand(n))
|
|
60
|
+
return " ".join(parts)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def digits_to_words(s: str) -> str:
|
|
64
|
+
"""'555' -> 'five five five'. For phone numbers and codes."""
|
|
65
|
+
out = []
|
|
66
|
+
for ch in s:
|
|
67
|
+
if ch.isdigit():
|
|
68
|
+
out.append(_ONES[int(ch)])
|
|
69
|
+
elif ch == "+":
|
|
70
|
+
out.append("plus")
|
|
71
|
+
elif ch in "-.()":
|
|
72
|
+
continue
|
|
73
|
+
else:
|
|
74
|
+
out.append(ch)
|
|
75
|
+
return " ".join(out)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def decimal_to_words(whole: str, frac: str) -> str:
|
|
79
|
+
"""'3', '14' -> 'three point one four'"""
|
|
80
|
+
head = int_to_words(int(whole)) if whole else "zero"
|
|
81
|
+
return f"{head} point {digits_to_words(frac)}"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def ordinal_to_words(n: int) -> str:
|
|
85
|
+
"""3 -> 'third', 21 -> 'twenty-first'"""
|
|
86
|
+
words = int_to_words(n)
|
|
87
|
+
head, sep, last = words.rpartition("-")
|
|
88
|
+
if not sep:
|
|
89
|
+
head, sep, last = words.rpartition(" ")
|
|
90
|
+
if last in _ORDINALS:
|
|
91
|
+
return head + sep + _ORDINALS[last]
|
|
92
|
+
return words + "th"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def year_to_words(y: int) -> str:
|
|
96
|
+
"""2026 -> 'twenty twenty-six', 1905 -> 'nineteen oh five'"""
|
|
97
|
+
if 1100 <= y < 2000 or 2010 <= y < 2100:
|
|
98
|
+
hi, lo = divmod(y, 100)
|
|
99
|
+
if lo == 0:
|
|
100
|
+
return f"{int_to_words(hi)} hundred"
|
|
101
|
+
if lo < 10:
|
|
102
|
+
return f"{int_to_words(hi)} oh {_ONES[lo]}"
|
|
103
|
+
return f"{int_to_words(hi)} {_under_hundred(lo)}"
|
|
104
|
+
return int_to_words(y)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: speakable
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Text normalization for voice agents - turn LLM output into text a TTS engine speaks correctly.
|
|
5
|
+
Author: Divyajeet
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Divyajeet01/speakable
|
|
8
|
+
Project-URL: Issues, https://github.com/Divyajeet01/speakable/issues
|
|
9
|
+
Keywords: tts,text-to-speech,voice-agent,normalization,livekit,speech,nlp
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
|
|
14
|
+
Classifier: Topic :: Text Processing :: Linguistic
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Provides-Extra: api
|
|
19
|
+
Requires-Dist: fastapi>=0.110; extra == "api"
|
|
20
|
+
Requires-Dist: uvicorn[standard]>=0.27; extra == "api"
|
|
21
|
+
Requires-Dist: pydantic>=2.0; extra == "api"
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# Speakable
|
|
25
|
+
|
|
26
|
+
**Text normalization for voice agents.** Turn LLM output into text a TTS engine
|
|
27
|
+
speaks correctly.
|
|
28
|
+
|
|
29
|
+
Your LLM returns this:
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
**Invoice #42**: $1,234.56 due 2026-11-11. Call Dr. Rao at (555) 123-4567
|
|
33
|
+
before 3:30 PM. Save 15%! 🎉
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Your TTS engine says *"asterisk asterisk invoice hash forty two asterisk
|
|
37
|
+
asterisk colon dollar one comma two three four point five six..."*
|
|
38
|
+
|
|
39
|
+
Speakable returns this:
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
Invoice number forty-two: one thousand two hundred thirty-four dollars and
|
|
43
|
+
fifty-six cents due November eleventh, twenty twenty-six. Call Doctor Rao at
|
|
44
|
+
five five five one two three four five six seven before three thirty PM.
|
|
45
|
+
Save fifteen percent!
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Pure Python, zero dependencies in the core, ~1.5 ms for a typical utterance.
|
|
49
|
+
|
|
50
|
+
## What it handles
|
|
51
|
+
|
|
52
|
+
| Input | Output |
|
|
53
|
+
|---|---|
|
|
54
|
+
| `**bold**`, `# headings`, `` `code` ``, `[link](url)` | stripped to plain text |
|
|
55
|
+
| `$1,234.56` / `₹500` / `£20` / `€99` | `one thousand two hundred thirty-four dollars and fifty-six cents` |
|
|
56
|
+
| `2026-11-11`, `11/11/2026`, `Nov 11, 2026` | `November eleventh, twenty twenty-six` |
|
|
57
|
+
| `3:30 PM`, `9:05 a.m.`, `14:00` | `three thirty PM`, `nine oh five AM`, `two o'clock PM` |
|
|
58
|
+
| `(555) 123-4567`, `+1 555-123-4567` | digit-by-digit, the way a human reads a number aloud |
|
|
59
|
+
| `15%`, `2.5%` | `fifteen percent`, `two point five percent` |
|
|
60
|
+
| `1st`, `21st`, `3rd` | `first`, `twenty-first`, `third` |
|
|
61
|
+
| `5kg`, `10mi`, `3hrs` | `five kilograms`, `ten miles`, `three hours` |
|
|
62
|
+
| `Dr.`, `Inc.`, `vs.`, `etc.` | `Doctor`, `Incorporated`, `versus`, `et cetera` |
|
|
63
|
+
| `john@example.com`, `https://acme.io/help` | `john at example dot com`, `acme dot io` |
|
|
64
|
+
| 🎉 and friends | removed |
|
|
65
|
+
| `API` (opt-in) | `A P I`, skipping acronyms TTS already says right |
|
|
66
|
+
| `10-20`, `1000-2000` | `ten to twenty` (ranges, not phone numbers) |
|
|
67
|
+
| `-45` | `negative forty-five` |
|
|
68
|
+
| `192.168.1.1`, `v1.2.3` | `one hundred ninety-two dot ...`, `one point two point three` |
|
|
69
|
+
| `**unclosed bold` | unpaired markers dropped, not spoken |
|
|
70
|
+
| `3/4`, `5/16` | `three quarters`, `five over sixteen` |
|
|
71
|
+
|
|
72
|
+
## Library use
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from speakable import normalize
|
|
76
|
+
|
|
77
|
+
normalize("Pay $20 by 2026-01-05.")
|
|
78
|
+
# 'Pay twenty dollars by January fifth, twenty twenty-six.'
|
|
79
|
+
|
|
80
|
+
normalize("The API is up", spell_acronyms=True)
|
|
81
|
+
# 'The A P I is up'
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`normalize(text, spell_acronyms=False, strip_emoji=True)` is the whole API.
|
|
85
|
+
It is idempotent — running it twice changes nothing.
|
|
86
|
+
|
|
87
|
+
## Use it in a LiveKit agent
|
|
88
|
+
|
|
89
|
+
The one line that matters, right before synthesis:
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from speakable import normalize
|
|
93
|
+
|
|
94
|
+
async def on_llm_response(text: str):
|
|
95
|
+
await tts.say(normalize(text))
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## HTTP API
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
pip install -r requirements.txt
|
|
102
|
+
uvicorn app:app --reload # docs at http://127.0.0.1:8000/docs
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
curl -X POST http://127.0.0.1:8000/normalize \
|
|
107
|
+
-H 'Content-Type: application/json' \
|
|
108
|
+
-d '{"text": "Pay $20 by 2026-01-05."}'
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
```json
|
|
112
|
+
{
|
|
113
|
+
"text": "Pay twenty dollars by January fifth, twenty twenty-six.",
|
|
114
|
+
"original": "Pay $20 by 2026-01-05.",
|
|
115
|
+
"chars_in": 22,
|
|
116
|
+
"chars_out": 54,
|
|
117
|
+
"took_ms": 0.9
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`POST /normalize/batch` takes `{"texts": [...]}` for up to 100 strings at once.
|
|
122
|
+
`GET /health` for liveness.
|
|
123
|
+
|
|
124
|
+
Set `RAPIDAPI_PROXY_SECRET` to reject any traffic that didn't come through the
|
|
125
|
+
marketplace proxy. `MAX_CHARS` and `MAX_BATCH` bound request size.
|
|
126
|
+
|
|
127
|
+
## Docker
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
docker build -t speakable .
|
|
131
|
+
docker run -p 8000:8000 speakable
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Tests
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
python tests/test_normalize.py # 28 cases, no pytest required
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Why this exists
|
|
141
|
+
|
|
142
|
+
Every voice agent hits this. The LLM is trained on written English and emits
|
|
143
|
+
markdown, digits and abbreviations; TTS engines are built for prose. The gap
|
|
144
|
+
between them is where callers hear "asterisk" out loud. Most teams end up with
|
|
145
|
+
a pile of ad-hoc regexes that handle their own demo and break on the next one.
|
|
146
|
+
|
|
147
|
+
This is that pile, written once, tested, and ordered correctly — the ordering
|
|
148
|
+
is most of the difficulty. Dates have to run before phone numbers, because
|
|
149
|
+
`2026-11-11` is also digits-and-hyphens. Emails have to run before anything
|
|
150
|
+
touches a dot. Currency has to run before plain integers.
|
|
151
|
+
|
|
152
|
+
## Not handled (yet)
|
|
153
|
+
|
|
154
|
+
Non-English locales, Indian digit grouping (lakh/crore), roman numerals, and
|
|
155
|
+
mathematical expressions. PRs welcome.
|
|
156
|
+
|
|
157
|
+
## License
|
|
158
|
+
|
|
159
|
+
MIT.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
speakable/__init__.py,sha256=2uidMrukGHK_TU7T-GG14sUQ6RwfjwmsskQ8hVXZ2TY,80
|
|
2
|
+
speakable/normalize.py,sha256=eT5NCX0Is1uSAjoCqDtiQW009F9zgyBs2A7tk-EIUUM,10211
|
|
3
|
+
speakable/numbers.py,sha256=PiBiUQtiuGJqoxlxpLcXhR02SKKzwmYQGhK-jWtOHAY,3527
|
|
4
|
+
speakable-1.0.0.dist-info/licenses/LICENSE,sha256=ko7FedI8XAMhFmALSmsLDTDh6b32syvJ0oYIejnIPdM,1087
|
|
5
|
+
speakable-1.0.0.dist-info/METADATA,sha256=nkGbRskKEmDwTBA-QnrRaTTvMb9bvoUPcJVGM2WWWOg,5369
|
|
6
|
+
speakable-1.0.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
7
|
+
speakable-1.0.0.dist-info/top_level.txt,sha256=GFKQ2FXkJ7uv0Zbudrly4S9NCZ8L5cfHOtggZBiNKu0,10
|
|
8
|
+
speakable-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Divyajeet
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
speakable
|