supported_languages
def supported_languages()->tuple[__main__.LanguageSupport, ...]:Return the language registry in ISO-code order.
Return the language registry in ISO-code order.
Normalize text for one language: Normalizer("hi", tts_mode=True)("...").
language accepts ISO codes ("hi"), BCP-47 tags ("hi-IN", "zh-Hans") and English names ("hindi"). Options such as tts_mode=True are validated against the selected language and raise ValueError if it does not accept them, instead of being ignored. Normalizer.for_language("hi") is an equivalent spelling.
A supported language and the normalization level available for it.
tier is one of:
"dedicated": a normalizer written and reviewed for this language."script": no dedicated class yet, but the language shares a script with a dedicated normalizer that is reused as-is (for example Marathi reuses the Devanagari normalizer, without Hindi’s number/TTS tuning)."generic": only conservative, script-safe cleanup is applied; no language-specific rules exist.Conservative, script-safe normalization for languages without bespoke rules.
assert len(supported_languages()) == 101
tiers = {"dedicated": 0, "script": 0, "generic": 0}
for entry in supported_languages():
tiers[entry.tier] += 1
assert tiers == {"dedicated": 15, "script": 4, "generic": 82}
# BCP-47 region/script subtags are accepted; only the primary language subtag
# drives selection.
assert Normalizer("french")("Vingt et un euros.") == "21 euros"
assert Normalizer("es-ES")("El total es 1.234,50.") == "el total es 1234.50"
assert Normalizer("Russian")("Privet, mir!") == "privet mir"
assert Normalizer("hi-IN")("नमस्ते, दुनिया!") == Normalizer("hi")("नमस्ते, दुनिया!")
assert Normalizer("en-IN")("Colour and favour.") == Normalizer("en")("Colour and favour.")
# ar/zh/ru must use their dedicated normalizers (from international.py), not
# the generic fallback: dedicated Chinese/Arabic normalization differs from
# the generic tier by converting written numerals and removing all
# inter-character whitespace, which the generic normalizer does not do.
assert Normalizer("zh")("你好,世界!") == "你好世界"
assert Normalizer("zh")("我有二十一個蘋果。") == "我有21個蘋果"
assert Normalizer("ar")("أَهْلًا، وَسَهْلًا!") == "اهلا وسهلا"
# Odia ("or") is a dedicated normalizer even though it isn't one of the ~99
# languages Whisper itself documents. "od-IN" and "odia" are accepted as
# aliases for the same "or" code.
assert LANGUAGE_REGISTRY["or"].tier == "dedicated"
assert Normalizer("od-IN")("2024") == Normalizer("or")("2024")
assert Normalizer("odia")("2024") == Normalizer("or")("2024")
# Script tier: Marathi (mr) and Assamese (as) now support tts_mode via
# indic_numtowords. Nepali (ne) and Sanskrit (sa) remain script-only (not
# covered by indic_numtowords).
assert LANGUAGE_REGISTRY["mr"].tier == "script"
assert isinstance(Normalizer("mr").normalizer, DevanagariNormalizer)
assert Normalizer("mr")("क: ख") == Normalizer("hi")("क: ख") # shared Devanagari visarga rule
# Marathi now supports tts_mode via DevanagariNormalizer (indic_numtowords supports 'mr').
assert Normalizer("mr", tts_mode=True).tts_mode is True
assert LANGUAGE_REGISTRY["as"].tier == "script"
assert isinstance(Normalizer("as").normalizer, BengaliNormalizer)
assert Normalizer("as").normalizer.lang == "as"
# tts_mode is validated against the selected normalizer instead of being
# silently ignored or forwarded to a constructor that doesn't accept it.
assert Normalizer("hi", tts_mode=True).tts_mode is True
assert Normalizer("bn", tts_mode=True).tts_mode is True
# French now supports tts_mode via _LatinTextNormalizer (num2words converts digits to words).
assert Normalizer("fr", tts_mode=True).tts_mode is True
try:
Normalizer("xx")
except ValueError as error:
assert "Unsupported language" in str(error)
else:
raise AssertionError("unknown languages must be rejected")
# Generic-tier smoke test: every generic-tier code must lowercase, strip
# punctuation and symbols (including emoji, which are Unicode Symbol
# category), collapse whitespace, and stay safe on empty/mixed-script input,
# without bespoke per-language fixtures.
generic_codes = [entry.code for entry in supported_languages() if entry.tier == "generic"]
assert len(generic_codes) == 82
for code in generic_codes:
normalizer = Normalizer(code)
assert normalizer("") == ""
assert normalizer("HELLO, World!!") == "hello world"
assert normalizer("Café 😀 مرحبا") == "café مرحبا"# Per-language regression fixtures — dedicated and script tier
# Marathi (script tier): Devanagari normalisation + correct number words
mr = Normalizer('mr')
assert mr('क: ख') == Normalizer('hi')('क: ख') # visarga rule shared
mr_tts = Normalizer('mr', tts_mode=True)
assert mr_tts.tts_mode is True
result_mr = mr_tts('मला 42 रुपये द्या')
assert '42' not in result_mr, f'MR TTS: digit not converted: {result_mr}'
# Marathi uses 'बेचाळीस', not Hindi 'बयालीस'
assert 'बेचाळीस' in result_mr, f'MR: wrong number word: {result_mr}'
# Assamese (script tier): Bengali normaliser + Assamese char remapping + TTS
as_norm = Normalizer('as')
assert as_norm.language == 'as'
as_tts = Normalizer('as', tts_mode=True)
result_as = as_tts('আমি 10 টকা')
assert '10' not in result_as, f'AS TTS: digit not converted: {result_as}'
# Nepali / Sanskrit: tts_mode unsupported
try:
Normalizer('ne', tts_mode=True)
raise AssertionError('ne should raise ValueError')
except ValueError as e:
assert 'tts_mode' in str(e) or 'not supported' in str(e)
try:
Normalizer('sa', tts_mode=True)
raise AssertionError('sa should raise ValueError')
except ValueError as e:
assert 'tts_mode' in str(e) or 'not supported' in str(e)
# Generic tier: smoke coverage for a representative sample
for code, sample, expected in [
('de', 'Guten Tag!', 'guten tag'),
('tr', 'Merhaba dünya!', 'merhaba dünya'),
('ko', '안녕하세요!', '안녕하세요'),
('th', 'สวัสดี!', 'สวัสดี'),
]:
result = Normalizer(code)(sample)
assert result == expected, f'{code}: got {result!r}, expected {expected!r}'
print('Per-language regression fixtures passed')# Generic-tier TTS regression tests
import importlib
if importlib.util.find_spec('num2words') is not None:
de_tts = Normalizer('de', tts_mode=True)
result = de_tts('42 Euro')
assert '42' not in result, f'DE TTS: digits not converted: {result}'
nl_tts = Normalizer('nl', tts_mode=True)
result = nl_tts('42 euro')
assert '42' not in result, f'NL TTS: digits not converted: {result}'
# Verify lang attr is injected
assert de_tts.language == 'de', 'lang not injected for generic tier'
print('Generic TTS tests passed')
else:
print('num2words not installed; skipping generic TTS tests')Generic TTS tests passed
Normalizer: the single public entry pointNormalizer("hi") and Normalizer.for_language("hi") are equivalent. The tests below use only that public interface; expected values are literals.
# Normalizer(language) selects the normalizer for a language and is callable. One case per tier.
CASES = [
("hi", "नमस्ते, दुनिया!", "नमस्ते, दुनिया!"), # dedicated, Indic
("en", "Colour and favour.", "color and favor"), # dedicated, English
("fr", "Vingt et un euros.", "21 euros"), # dedicated, international
("zh", "我有二十一個蘋果。", "我有21個蘋果"), # dedicated, non-Latin
("es", "El total es 1.234,50.", "el total es 1234.50"), # locale numbers
("de", "Guten Tag!", "guten tag"), # generic tier
]
bad = {}
for code, text, expected in CASES:
out = Normalizer(code)(text)
if out != expected:
bad[code] = (out, expected)
assert not bad, bad# Metadata: the language is resolved once, from a code, a BCP-47 tag or an English name.
for query, code, name, tier in [
("hi", "hi", "Hindi", "dedicated"),
("hi-IN", "hi", "Hindi", "dedicated"),
("Hindi", "hi", "Hindi", "dedicated"),
("zh-Hans", "zh", "Chinese", "dedicated"),
("odia", "or", "Odia", "dedicated"),
("mr", "mr", "Marathi", "script"),
("de", "de", "German", "generic"),
]:
n = Normalizer(query)
assert (n.language, n.name, n.tier) == (code, name, tier), (query, n.language, n.name, n.tier)
# Capabilities tell you what a language supports before you ask for it.
assert "tts_mode" in Normalizer("hi").capabilities
assert "tts_mode" in Normalizer("en").capabilities
assert Normalizer("en", tts_mode=True)("It costs $20 for 3 items") == "it costs twenty dollars for three items"
assert Normalizer("en-US", tts_mode=True).tts_mode is True# Options: tts_mode is off by default, is reported back, and reaches the language normalizer.
assert Normalizer("hi").tts_mode is False
n = Normalizer("hi", tts_mode=True)
assert n.tts_mode is True and n.options == {"tts_mode": True}
assert Normalizer("mr", tts_mode=True)("मला 42 रुपये द्या") == "मला बेचाळीस रुपये द्या"
assert Normalizer("mr")("मला 42 रुपये द्या") == "मला बेचाळीस रुपये द्या" # Indic digit spelling is not gated on tts_mode
# repr shows how to recreate it, using the resolved language code.
assert repr(Normalizer("hi-IN", tts_mode=True)) == "Normalizer('hi', tts_mode=True)"
assert repr(Normalizer("Hindi")) == "Normalizer('hi')"# Errors are loud and specific, for both spellings, and never fall back to a generic normalizer.
for make in (Normalizer, Normalizer.for_language):
try:
make("xx")
raise AssertionError("unsupported language should raise")
except ValueError as e:
assert "Unsupported language: 'xx'" in str(e) and "Supported codes:" in str(e) and "hi" in str(e)
try:
make("en", bogus=True)
raise AssertionError("unsupported option should raise")
except ValueError as e:
assert str(e) == "English normalizer does not support option(s): bogus"
for code in ("ne", "sa"): # script-tier languages without number words
try:
make(code, tts_mode=True)
raise AssertionError(f"{code} tts_mode should raise")
except ValueError as e:
assert "tts_mode" in str(e) or "not supported" in str(e)# The package exports Normalizer (and the language listing), and nothing else for selection.
import whisper_normalizer
from whisper_normalizer import Normalizer as ExportedNormalizer, supported_languages as exported_supported_languages
assert ExportedNormalizer("hi-IN", tts_mode=True).language == "hi"
assert ExportedNormalizer.for_language("fr")("Vingt et un euros.") == "21 euros"
assert len(exported_supported_languages()) == 101
assert not hasattr(whisper_normalizer, "get_normalizer")
assert sorted(whisper_normalizer.__all__) == ["Normalizer", "__version__", "supported_languages"]The registry’s capability metadata is checked against real behaviour for every language, so the documented table cannot drift from what the normalizers do.
# tts_mode is advertised exactly when it can be constructed, and then it spells digits as words.
bad = {}
for entry in supported_languages():
try:
n = Normalizer(entry.code, tts_mode=True)
except ValueError:
constructible = False
else:
constructible = True
spoken = n("5")
if any(ch.isdigit() for ch in spoken):
bad[entry.code] = f"tts_mode left a digit in {spoken!r}"
if constructible != ("tts_mode" in entry.capabilities):
bad[entry.code] = f"advertises tts_mode={'tts_mode' in entry.capabilities} but constructible={constructible}"
assert not bad, bad# digits_to_words says when digits become words, and matches what default mode really does for all languages:
# "always" (Indic family), "tts_mode" (only with tts_mode=True) or "never" (tts_mode unavailable).
bad = {}
for entry in supported_languages():
spelled_by_default = not any(ch.isdigit() for ch in Normalizer(entry.code)("5"))
if (entry.digits_to_words == "always") != spelled_by_default:
bad[entry.code] = (entry.digits_to_words, "spelled by default" if spelled_by_default else "digits kept")
# Outside the Indic family, "never" is exactly "tts_mode unavailable".
if entry.digits_to_words != "always" and (entry.digits_to_words == "never") != ("tts_mode" not in entry.capabilities):
bad[entry.code] = (entry.digits_to_words, entry.capabilities)
assert not bad, bad
assert {e.code for e in supported_languages() if e.digits_to_words == "always"} == {
"as", "bn", "gu", "hi", "kn", "ml", "mr", "ne", "or", "pa", "sa", "ta", "te"}
assert Normalizer("en").tts_mode is False and [e for e in supported_languages() if e.code == "en"][0].digits_to_words == "tts_mode"# marks says whether combining marks survive normalization ("kept") or are removed ("stripped"); it is checked against
# real behaviour for every dedicated and script-tier language, plus the generic tier.
import unicodedata
def count_marks(s):
return sum(unicodedata.category(ch).startswith("M") for ch in unicodedata.normalize("NFD", s))
INDIC_BASE = {"hi": 0x0900, "mr": 0x0900, "ne": 0x0900, "sa": 0x0900, "bn": 0x0980, "as": 0x0980, "pa": 0x0A00,
"gu": 0x0A80, "or": 0x0B00, "ta": 0x0B80, "te": 0x0C00, "kn": 0x0C80, "ml": 0x0D00}
MARK_SAMPLES = {code: chr(b + 0x15) + chr(b + 0x3F) for code, b in INDIC_BASE.items()} # KA + vowel sign I
MARK_SAMPLES.update({"en": "café", "fr": "café", "es": "niño", "ar": "أَهْلًا", "ru": "йод", "de": "café", "th": "ที่"})
NO_MARKS_IN_SCRIPT = {"zh"} # Chinese has no combining marks to keep or strip
bad = {}
checked_marks = set()
for entry in supported_languages():
# Every generic-tier language runs the same script-safe cleanup, so one accented sample covers each of them.
sample = MARK_SAMPLES.get(entry.code) or ("café" if entry.tier == "generic" else None)
if sample is None:
assert entry.code in NO_MARKS_IN_SCRIPT, f"no marks sample for {entry.code}"
continue
checked_marks.add(entry.code)
out = Normalizer(entry.code)(sample)
expected_marks = 0 if entry.marks == "stripped" else count_marks(sample)
if count_marks(out) != expected_marks:
bad[entry.code] = (entry.marks, sample, out)
assert not bad, bad
assert {e.code for e in supported_languages() if e.marks == "stripped"} == {"ar", "en"}
# Every language except Chinese (no combining marks) is checked: 100 of 101.
assert len(checked_marks) == 100 and checked_marks == {e.code for e in supported_languages()} - NO_MARKS_IN_SCRIPTThe table in README.md and nbs/index.ipynb is generated from the registry by capability_section() below; the test pins both files to it, so it cannot go stale.
import json
from pathlib import Path
from indic_numtowords import supported_langs as _numtowords_langs
def capability_section():
"""Markdown for the supported-languages table: one row per dedicated/script language, plus a generic-tier summary."""
rows = ["| Language | Code | Normalizer | Tier | Digits → words | Marks | `tts_mode` |", "|---|---|---|---|---|---|---|"]
entries = sorted((e for e in supported_languages() if e.tier != "generic"), key=lambda e: (e.tier != "dedicated", e.name))
for e in entries:
cls = getattr(e.factory, "func", e.factory).__name__
digits = e.digits_to_words
if digits == "always" and e.code not in _numtowords_langs:
digits += " (Hindi number words)"
tts = "yes" if "tts_mode" in e.capabilities else "no"
rows.append(f"| {e.name} | `{e.code}` | `{cls}` | {e.tier} | {digits} | {e.marks} | {tts} |")
generic = [e for e in supported_languages() if e.tier == "generic"]
with_tts = sum("tts_mode" in e.capabilities for e in generic)
summary = (
f"The other {len(generic)} languages use the generic tier: Unicode NFC, brackets and punctuation removed, "
f"case folded, combining marks kept. Digits become words only with `tts_mode=True`, which {with_tts} of them support."
)
return "\n".join(rows) + "\n\n" + summary
root = next(p for p in (Path("."), Path("..")) if (p / "README.md").exists())
section = capability_section()
assert section.count("\n| ") >= 19 # 15 dedicated + 4 script rows below the header
assert "| English | `en` | `EnglishTextNormalizer` | dedicated | tts_mode | stripped | yes |" in section
assert "| Hindi | `hi` | `HindiNormalizer` | dedicated | always | kept | yes |" in section
assert "| Nepali | `ne` | `DevanagariNormalizer` | script | always (Hindi number words) | kept | no |" in section
assert section in (root / "README.md").read_text(encoding="utf-8"), "README.md is out of date with the registry"
index_nb = json.load(open(root / "nbs" / "index.ipynb", encoding="utf-8"))
index_text = "\n".join("".join(c["source"]) for c in index_nb["cells"])
assert section in index_text, "nbs/index.ipynb is out of date with the registry"# Boolean options must be real bools: "no" is truthy, so a string would silently mean the opposite of what was meant.
for make in (Normalizer, Normalizer.for_language):
for bad_value in ("no", "false", 1, None):
try:
make("hi", tts_mode=bad_value)
raise AssertionError(f"tts_mode={bad_value!r} should raise")
except TypeError as e:
assert str(e) == f"tts_mode must be a bool, got {type(bad_value).__name__}: {bad_value!r}", str(e)
try:
make("ta", do_normalize_chandras="yes")
raise AssertionError("other boolean options are validated too")
except TypeError as e:
assert "do_normalize_chandras must be a bool" in str(e)
assert Normalizer("hi", tts_mode=False).tts_mode is False
assert Normalizer("hi", tts_mode=True).tts_mode is True
# Non-boolean options are not affected.
assert Normalizer("hi", nasals_mode="to_anusvaara_relaxed").options == {"nasals_mode": "to_anusvaara_relaxed"}