voxnorm
Text normalization for TTS: written forms into spoken forms, multilingual, pure Python. Currently speaks Traditional Chinese (zh-TW), Simplified Chinese (zh-CN), English, Japanese, and Korean.
Language models and databases write for the eye — 12:00, NT$1,200,
0912-345-678 — and a small TTS frontend handed that text verbatim has to
guess, and guesses badly. voxnorm rewrites the written forms into what a
speaker would actually say, and leaves everything else character-for-character
alone:
>>> import voxnorm
>>> voxnorm.normalize("會議在12:00開始")
'會議在十二點開始'
>>> voxnorm.normalize("The meeting starts at 12:00")
"The meeting starts at twelve o'clock"
>>> voxnorm.normalize("会議は12:00からです") # kana → Japanese
'会議は十二時からです'
>>> voxnorm.normalize("회의는 12:00에 시작합니다") # hangul → Korean (native-numeral hours)
'회의는 열두 시에 시작합니다'
>>> voxnorm.normalize("電話是0912-345-678。")
'電話是零九一二三四五六七八。'
>>> voxnorm.normalize("NT$1,200", lang="zh")
'新台幣一千兩百元'
>>> voxnorm.normalize("会议在12:00开始") # simplified glyphs → zh-CN
'会议在十二点开始'
Design
Two layers, split where languages stop sharing:
- Detection is language-neutral (
voxnorm/tokens.py): one scanner finds the spans — times, ISO dates, phone numbers, currency, percentages, room numbers, letter-glued identifiers, decimals, integers — and names their kind. - Verbalisation is per-language (
voxnorm/zh.py,cn.py,en.py,ja.py,ko.py): a new language is a new module and one registry entry. English, Japanese and Korean ride on num2words for their number words, each adding the readings the library cannot know — a Japanese year without the era default, Korean native-numeral hours (한 시, never 일 시), the irregular Korean months 유월/시월. Chinese is spelled out in-repo, Traditional first, because the 兩/二 reading choice (一千兩百, never 一千二百 in zh-TW speech) is exactly what general-purpose converters get wrong; the Simplified variant re-spells that one output vocabulary (兆→万亿, and¥reads as renminbi rather than yen).
Language is decided per call: an explicit hint wins
(normalize(text, lang="zh-TW")), otherwise script evidence decides — kana
means Japanese, hangul Korean, a Han character Chinese (Simplified when
distinctive simplified glyphs appear, Traditional otherwise), else English. (A
Japanese sentence written entirely in kanji is script-identical to Chinese
and lands on the Chinese reading — pass lang="ja" when you know better.)
The charter is structural only, never guess: a form whose reading is
ambiguous does not convert (1/2 the fraction and 8/17 the date are the
same slash string, so no slash form converts at all; 7-11 the shop and
3-5天 the range are the same hyphen string, so no hyphen form does either).
What is not converted is simply spoken as written — which is where it started.
Codes the text does not mark
Structure settles most readings: a leading zero, a letter glued on the left
(A123456789), a 號 after three or more digits all say "code, digit by
digit". What structure cannot settle is vocabulary — that the 4820 after
車牌 or plate is a plate tail, not a quantity. That word list is open-ended
and belongs to the caller, so the package takes no guess and instead accepts
the words that settle it:
>>> voxnorm.normalize("車牌4820的車款已逾期12天。", code_words=["車牌"])
'車牌四八二零的車款已逾期十二天。'
>>> voxnorm.normalize("請問您4820的車款繳了嗎?", code_words=["的車款"])
'請問您四八二零的車款繳了嗎?'
>>> voxnorm.normalize("plate 4820 is overdue", code_words=["plate"])
'plate four eight two zero is overdue'
Each word is matched literally, directly before the digit run (whitespace
allowed) or directly after it; include any connective in the word itself
(末四碼是, not 末四碼). The words are per-call and per-language — the
same API carries 車牌, plate, 会員番号 and 계좌 — and a run that touches a
larger form (電話0912-345-678) still takes its structural reading.
Why not an FST toolkit
nemo_text_processing and WeTextProcessing were measured before this package
was written. Both depend on pynini, which publishes no aarch64 wheel — on an
ARM box they need OpenFst compiled from source and a permanently vendored
libfst.so — and both mis-read the very classes this package exists for:
phone numbers as quantities, 302號房 as a date, and (WeTextProcessing)
force-simplifying every Traditional character in the sentence it touches. The
classes a spoken product needs are a bounded set; implementing them directly
costs ~3 MB of pure Python and stays debuggable.
Install
uv add voxnorm # or: pip install voxnorm
Development
uv sync
uv run pytest
uv run ruff check .
Apache-2.0.
Metadata
Release files for voxnorm 0.2.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 | |
|---|---|---|---|
| voxnorm-0.2.0.tar.gz | 42.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| voxnorm-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 67.2 kB
Release files / voxnorm-0.2.0.tar.gz
| Download URL | voxnorm-0.2.0.tar.gz |
|---|---|
| Size | 42.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
a08aacd8c79862e3f92e2f36c0b8cb8801e67838f1b4842c7a57c1c0a98bd1af
|
|
BLAKE2b-256 checksum How to use checksums |
816cbf144475d457f6ce6b3168e6f7093dc10136206e200005adf270f924bbb8
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
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.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 9, 2026.
Transparency logRelease files / voxnorm-0.2.0-py3-none-any.whl
| Download URL | voxnorm-0.2.0-py3-none-any.whl |
|---|---|
| Size | 25.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
bfd6eb07145b7958e03f49d4bbbcedb45d846707ebd9330872c2966236fc020b
|
|
BLAKE2b-256 checksum How to use checksums |
2239e9608c79f999d92eca007d343be385966f88af58e847a72b60dfdeaa4fe6
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
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.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 9, 2026.
Transparency log