Speakable
Text normalization for voice agents. Turn LLM output into text a TTS engine speaks correctly.
Your LLM returns this:
**Invoice #42**: $1,234.56 due 2026-11-11. Call Dr. Rao at (555) 123-4567
before 3:30 PM. Save 15%! 🎉
Your TTS engine says "asterisk asterisk invoice hash forty two asterisk asterisk colon dollar one comma two three four point five six..."
Speakable returns this:
Invoice number forty-two: one thousand two hundred thirty-four dollars and
fifty-six cents due November eleventh, twenty twenty-six. Call Doctor Rao at
five five five one two three four five six seven before three thirty PM.
Save fifteen percent!
Pure Python, zero dependencies in the core, ~1.5 ms for a typical utterance.
What it handles
| Input | Output |
|---|---|
**bold**, # headings, `code`, [link](url) |
stripped to plain text |
$1,234.56 / ₹500 / £20 / €99 |
one thousand two hundred thirty-four dollars and fifty-six cents |
2026-11-11, 11/11/2026, Nov 11, 2026 |
November eleventh, twenty twenty-six |
3:30 PM, 9:05 a.m., 14:00 |
three thirty PM, nine oh five AM, two o'clock PM |
(555) 123-4567, +1 555-123-4567 |
digit-by-digit, the way a human reads a number aloud |
15%, 2.5% |
fifteen percent, two point five percent |
1st, 21st, 3rd |
first, twenty-first, third |
5kg, 10mi, 3hrs |
five kilograms, ten miles, three hours |
Dr., Inc., vs., etc. |
Doctor, Incorporated, versus, et cetera |
john@example.com, https://acme.io/help |
john at example dot com, acme dot io |
| 🎉 and friends | removed |
API (opt-in) |
A P I, skipping acronyms TTS already says right |
10-20, 1000-2000 |
ten to twenty (ranges, not phone numbers) |
-45 |
negative forty-five |
192.168.1.1, v1.2.3 |
one hundred ninety-two dot ..., one point two point three |
**unclosed bold |
unpaired markers dropped, not spoken |
3/4, 5/16 |
three quarters, five over sixteen |
Library use
from speakable import normalize
normalize("Pay $20 by 2026-01-05.")
# 'Pay twenty dollars by January fifth, twenty twenty-six.'
normalize("The API is up", spell_acronyms=True)
# 'The A P I is up'
normalize(text, spell_acronyms=False, strip_emoji=True) is the whole API.
It is idempotent — running it twice changes nothing.
Use it in a LiveKit agent
The one line that matters, right before synthesis:
from speakable import normalize
async def on_llm_response(text: str):
await tts.say(normalize(text))
HTTP API
pip install -r requirements.txt
uvicorn app:app --reload # docs at http://127.0.0.1:8000/docs
curl -X POST http://127.0.0.1:8000/normalize \
-H 'Content-Type: application/json' \
-d '{"text": "Pay $20 by 2026-01-05."}'
{
"text": "Pay twenty dollars by January fifth, twenty twenty-six.",
"original": "Pay $20 by 2026-01-05.",
"chars_in": 22,
"chars_out": 54,
"took_ms": 0.9
}
POST /normalize/batch takes {"texts": [...]} for up to 100 strings at once.
GET /health for liveness.
Set RAPIDAPI_PROXY_SECRET to reject any traffic that didn't come through the
marketplace proxy. MAX_CHARS and MAX_BATCH bound request size.
Docker
docker build -t speakable .
docker run -p 8000:8000 speakable
Tests
python tests/test_normalize.py # 28 cases, no pytest required
Why this exists
Every voice agent hits this. The LLM is trained on written English and emits markdown, digits and abbreviations; TTS engines are built for prose. The gap between them is where callers hear "asterisk" out loud. Most teams end up with a pile of ad-hoc regexes that handle their own demo and break on the next one.
This is that pile, written once, tested, and ordered correctly — the ordering
is most of the difficulty. Dates have to run before phone numbers, because
2026-11-11 is also digits-and-hyphens. Emails have to run before anything
touches a dot. Currency has to run before plain integers.
Not handled (yet)
Non-English locales, Indian digit grouping (lakh/crore), roman numerals, and mathematical expressions. PRs welcome.
License
MIT.
Metadata
Release files for speakable 1.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| speakable-1.0.0.tar.gz | 12.5 kB | Without provenance | Details for speakable-1.0.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| speakable-1.0.0-py3-none-any.whl | Python 3 | none | any | Details for speakable-1.0.0-py3-none-any.whl |
Total release size: 22.5 kB
Release files / speakable-1.0.0.tar.gz
| Download URL | speakable-1.0.0.tar.gz |
|---|---|
| Size | 12.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
30a921f7792bd4768dd2f612f1dda3132554dd7f32e0ac163dee19b264037c06
|
|
BLAKE2b-256 checksum How to use checksums |
7634a60c11ea54632b55daaddb6c087777242b95040d4c9de088472098112ae2
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.1
|
Release files / speakable-1.0.0-py3-none-any.whl
| Download URL | speakable-1.0.0-py3-none-any.whl |
|---|---|
| Size | 9.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
f88dfedc63f85b67ca7275373f5ae1fdbebad0e704c1d722dbbfa49c1d0e03b3
|
|
BLAKE2b-256 checksum How to use checksums |
80315de64c67bca1ec8dca95428a9b47775fcdd80cd89fa0c1a5ee5d4be96d16
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.1
|
Security for speakable 1.0.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.