Skip to content

Repository files navigation

indic_normalizer

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.

CI Docs Python License Dependencies Code style: black

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 1947nineteen forty seven, but 1,947 / 1947 kgone 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

Install

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 install

Quick start

from 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.'

How numbers pick their language

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 text

lang 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).

What it normalizes

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.

Handler pipeline

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.

Artifacts & tags

  • Escape sequences (\n, \t, \uXXXX) and control/zero-width chars are stripped; Unicode minus () is normalized to -, and output is always NFC-normalized (so normalize is 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 as five less than ten.

LaTeX-to-speech (standalone)

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{…}.

Configuration

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 225two 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.

Languages

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.

Documentation

The Sphinx site is the complete reference:

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.html

The usage examples are executable doctests:

python -m sphinx -W -b doctest docs docs/_build/doctest

Tests

python -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  # showcase

See CONTRIBUTING.md for the development workflow and CHANGELOG.md for release history.

Credits & license

MIT-licensed. Built from scratch, but borrowing (not depending on):

See NOTICE for details.

Releases

Packages

Contributors

Languages