Text normalization for Indian-language TTS — pure Python, zero dependencies.
Turns raw, messy text into clean spoken-form words for the 22 scheduled languages of India (+ English): numbers, dates, currency, measures, IDs, LaTeX math — while stripping the artifacts that hurt synthesis.
from indic_normalizer import normalize
normalize("Flight 6E204 to Delhi departs at 10:30 am on 15/08/2025.")
# 'Flight six E two zero four to Delhi departs at ten thirty am
# on fifteenth August twenty twenty five.'Why it exists — TTS front-ends mispronounce ₹5 lakh, 1947, +91 98765 43210,
and ∂u/∂t in different, subtle ways. This normalizes them before synthesis,
with no model to load and nothing to install.
| Zero runtime dependencies | standard library only — no pynini, no models |
| 23 languages | all 22 scheduled languages of India, plus English |
| Context-aware | 1947 → nineteen forty seven, but 1,947 / 1947 kg → one thousand nine hundred and forty seven |
| Script-aware | native digits read regionally, ASCII digits read in English — the rules |
| LaTeX-to-speech | math, physics, and chemistry, written from scratch |
| Fast | ~0.2 ms per sentence, ~350k chars/sec on one core |
Documentation · Quick start · What it normalizes · Configuration · Languages · Contributing · Changelog
Requires Python 3.10+ (the vendored number engine uses PEP 604 unions).
python -m pip install . # local install from the repo root
python -m pip install -e ".[test]" # editable development installfrom indic_normalizer import Normalizer, normalize
normalize("India became independent in 1947.", lang="en")
# 'India became independent in nineteen forty seven.'
normalize("भारत 1947 में स्वतंत्र हुआ।", lang="hi")
# 'भारत एक हज़ार नौ सौ सैंतालीस में स्वतंत्र हुआ।' (numbers follow lang)
normalize("यह १९४७ की बात है।", lang="hi")
# 'यह एक हज़ार नौ सौ सैंतालीस की बात है।' (native digits, same reading)
normalize("भारत 1947 में स्वतंत्र हुआ।", lang="hi", number_lang="en")
# 'भारत nineteen forty seven में स्वतंत्र हुआ।' (numbers overridden to English)For repeated calls, reuse a configured instance:
from indic_normalizer import Normalizer, NormalizerConfig
config = NormalizerConfig(lang="en", detect_years=False)
normalizer = Normalizer(config=config)
normalizer.normalize("India became independent in 1947.")
# 'India became independent in one thousand nine hundred and forty seven.'Numbers are spoken in the sentence language unless you say otherwise, so a number phrase is native throughout or English throughout — never a mix of English digits and native unit words.
There are only two language knobs:
lang |
the sentence language — picks the lexicon (the glue words: "दशमलव", "प्रतिशत", रुपये, month names) and reads native-script digits |
number_lang |
the language numbers are spoken in — "auto" (default) means "same as lang", or name any language code to override |
Digit script does not change the reading: 1947 and १९४७ in a Hindi document
both read एक हज़ार नौ सौ सैंतालीस. Override when you want something else:
normalize("भारत 1947 में", lang="hi") # एक हज़ार नौ सौ सैंतालीस
normalize("भारत 1947 में", lang="hi", number_lang="en") # nineteen forty seven
normalize("It costs 50.5%", lang="en", number_lang="ta") # Tamil numbers in English textlang also selects the glue words — decimal point, percent, currency units,
month names — so an English document says "percent" even if its numbers are
read in another language. Year style follows the number language instead
(English digits pair as "nineteen forty seven" even inside Hindi text).
| Class | Example (en) | Output |
|---|---|---|
| Year (context) | in 1947 |
nineteen forty seven |
| Cardinal | 1,947 / 1947 kg |
one thousand nine hundred and forty seven … |
| Leading zeros | agent 007, 0091 |
zero zero seven, zero zero nine one (digit-wise) |
| Decimal | 3.14 |
three point one four |
| Signed values | -5°C, +42, −42 |
minus five degrees celsius, plus forty two, … |
| Currency | ₹1,234.50 / ₹1 / ₹0.50 |
… rupees and fifty paise / one rupee / fifty paise |
| Currency scales | ₹5 lakh, $1.5 billion |
five lakh rupees, one point five billion dollars |
| Suffix currency | 100₹, 250 rs |
one hundred rupees, two hundred and fifty rupees |
| Percent | 12.5% |
twelve point five percent |
| Measure | 37.5°C, 5000 mAh, 2.4 GHz, 3000 rpm |
… degrees celsius, … milliamp hours, … gigahertz, … revolutions per minute |
| Range | 1939-1945, pages 10-15, 10-15%, 5-10 kg |
nineteen thirty nine to nineteen forty five, ten to fifteen … |
| Ratio / score | won 3:2, 16:9 |
three to two, sixteen to nine |
| Date (numeric) | 15/08/1947, 2024-03-05, 15.8.1947, 08/15/1947 |
fifteenth August nineteen forty seven (US order auto-detected) |
| Date (textual) | 15 August 1947, Aug 15, 1947, 5 June |
fifteenth August nineteen forty seven, August fifteenth …, fifth June |
| Decade | the 1990s, the 90s |
the nineteen nineties, the nineties |
| Time | 10:30 am, 18:45 |
ten thirty am, eighteen forty five |
| Phone | +91 98765 43210, 011-2345-6789 |
plus nine one nine eight … (digit-by-digit, shape-gated) |
| Indian IDs | PAN ABCDE1234F, SBIN0001234, KA 01 AB 1234, PIN 560001 |
spelled letter-by-letter, digits digit-wise |
| Email / URL | test123@gmail.com, www.example.com/page2 |
test one two three at gmail dot com, w w w dot example dot com slash page two |
| Version / IP | Python 3.11.4, 192.168.1.1 |
three point eleven point four, one nine two dot one six eight dot one dot one |
| Scientific | 1.5e10 |
one point five times ten to the power ten |
| Ordinal | 21st, 5वाँ, 2ਵਾਂ |
twenty first, पाँचवाँ, ਦੂਜਾ |
| Fraction | 3/4 |
three quarters |
| Blood pressure | BP 120/80, 140/90 mmHg |
one hundred and twenty over eighty (millimeters of mercury) |
| Cricket score | 287/5 in 50 overs |
two hundred and eighty seven for five … (context-gated) |
| Abbreviations | Dr. Rao, Pvt. Ltd., etc., vs., No. 5, Main St. |
Doctor Rao, Private Limited, et cetera, versus, Number five, Main Street |
| Acronyms | U.S.A., A.P.J. Kalam |
U S A, A P J Kalam |
| Roman (context) | Chapter IV, Class X, World War II |
Chapter four, Class ten, World War two (always on) |
| Alphanumeric | COVID19, 5G, seat 32A |
COVID nineteen, five G, seat thirty two A (Latin kept as-is) |
| Codes | AB123CD, 6E204 |
A B one two three C D, six E two zero four (≥2 letter/digit transitions) |
| Position numbers | room 225, flight 6204 |
room two twenty five, flight sixty two oh four (after room/page/flight/gate/bus) |
| Symbols | 2 + 2 = 4, 5 < 10, A & B, @ |
plus / equals / less than / and / at (standalone only) |
| LaTeX | $x=\frac{-b\pm\sqrt{b^2-4ac}}{2a}$ |
x equals minus b plus or minus square root of b squared minus four a c over two a |
| Chemistry | \ce{2H2 + O2 -> 2H2O} |
two H two plus O two yields two H two O |
A bare $…$ span is only treated as math when its content looks mathy —
I paid $5 and she paid $10 reads as two dollar amounts, never as LaTeX.
Handlers run in a fixed priority order (specific → general); each rewrites its spans into words, so later handlers never re-match them:
web → abbrev → time → ratio → date → textdate → money → range → percent → ids → bp → measure → native_scale → scientific → code → decade → ordinal → alphanumeric → phone → dotted → cricket → fraction → decimal → position → number → symbol → roman_ctx → roman (opt-in)
See indic_normalizer/classes/__init__.py for the registry and
classes/handlers.py for each handler.
- Escape sequences (
\n,\t,\uXXXX) and control/zero-width chars are stripped; Unicode minus (−) is normalized to-, and output is always NFC-normalized (sonormalizeis idempotent). (...)parentheticals are removed (balanced pairs only — an unmatched(can never swallow the rest of the utterance).[...]and tag-shaped<...>are preserved verbatim — they are SSML / prosody markers ([emphasis],<break/>). Bare comparisons (5 < 10) are NOT treated as tags and read asfive less than ten.
from indic_normalizer import latex_to_speech
latex_to_speech(r"\int_0^1 x^2\,dx") # 'integral from zero to one of x squared d x'
latex_to_speech(r"\frac{a}{b}", verbosity="explicit") # 'the fraction a over b'Auto-detected inside mixed text for the delimiters $…$, $$…$$, \(…\),
\[…\], \begin{env}…\end{env}, and \ce{…}.
Normalizer(lang=..., **opts) / normalize(text, lang=..., **opts) accept:
| Option | Default | Meaning |
|---|---|---|
lang |
"en" |
sentence/regional language (drives lexicon + native-digit reading) |
number_lang |
"auto" |
"auto" resolves by digit script; any language code forces every number into it |
strip_parentheses |
True |
remove (...) |
keep_square_brackets / keep_angle_brackets |
True |
preserve [...] / <...> |
strip_escapes |
True |
strip escape-sequence artifacts |
latex |
True |
convert LaTeX spans |
latex_verbosity |
"natural" |
"natural" or "explicit" |
detect_years |
True |
enable year-style reading |
detect_positions |
True |
pairing style after room/page/flight/gate/bus (room 225 → two twenty five) |
year_range |
(1100, 2099) |
candidate year range |
detect_roman |
False |
convert bare roman numerals everywhere (opt-in; risky). Context-gated romans (Chapter IV, Class X) are always on. |
emit_variations |
False |
reserved; currently does not change normalize output. Use numbers.cardinal_variations directly. |
as bn brx doi en gu hi kn ks kok mai ml mni mr ne or pa sa sat sd ta te ur
Cardinals work for all of them via the vendored engine. The extra glue-word lexicon (decimal point, percent, currency, months, ordinals, range/"and" connectors) is populated for English, Hindi, and the other major scripts — Gregorian month names ship for 19 languages, suffix ordinals for 10 (Dravidian ordinals are deliberately left out: they need stem sandhi, not concatenation). Best-effort or missing entries are flagged for native review:
from indic_normalizer.lexicon import list_review_flags
list_review_flags() # ['brx: decimal_point', 'brx: percent', ...] (44 entries)See data/review/REVIEW.md. Contributions of verified
native wording are welcome.
Note: Sindhi (
sd) has upstream gaps in the number engine for some values; those fall back to a digit-by-digit reading rather than failing.
The Sphinx site is the complete reference:
docs/getting-started.rst— install, one-shot calls, reusable instances, configuration objects, and batch integration.docs/normalization-cases.rst— every supported normalization class, its guards, and executable input/output examples.docs/languages.rst— all language codes and number-language resolution.docs/latex.rst— LaTeX math, physics, chemistry, and environments.docs/configuration.rst— every option, artifact handling, and pipeline precedence.docs/api.rst— top-level and lower-level Python APIs.
Build the HTML site (autodoc + napoleon + viewcode, RTD theme):
python -m pip install sphinx sphinx_rtd_theme # docs-only dependencies
python -m sphinx -W -b html docs docs/_build/html
# open docs/_build/html/index.htmlThe usage examples are executable doctests:
python -m sphinx -W -b doctest docs docs/_build/doctestpython -m pytest -q # 614 tests: per-handler suites, a 132-pin golden
# corpus (each pin also idempotency-checked), and
# deterministic fuzz nets (pipeline + LaTeX)
python examples/demo.py # showcaseSee CONTRIBUTING.md for the development workflow and
CHANGELOG.md for release history.
MIT-licensed. Built from scratch, but borrowing (not depending on):
- Number-word tables & cardinal logic vendored from AI4Bharat/indic-numtowords (MIT).
- Semiotic-class taxonomy inspired by Kenpath/indic-text-normalization (Apache-2.0).
See NOTICE for details.