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 ADDED
@@ -0,0 +1,4 @@
1
+ from .normalize import normalize
2
+
3
+ __version__ = "1.0.0"
4
+ __all__ = ["normalize"]
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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