Skip to main content

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)

Source distribution for voxnorm 0.2.0
File Size Uploaded
voxnorm-0.2.0.tar.gz 42.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for voxnorm 0.2.0
File Interpreter ABI Platform
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 log

Release 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

Release history Release notifications | RSS feed

This release

0.2.0 This release

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page