Source code for speaker_helper.spoken_numbers

"""
Numbers, dates, times and amounts, said as words before an engine reads them.

Module summary
--------------
A neural engine trained on prose reads "le 14/11 à 9h30, 3 200 €" however its
training data happened to: measured on 2026-11-04, Audio8 said "14/11" as an
hour and swallowed the colon after "Correction". Whisper hearing the right
words back does not prove the listener did. The robust fix is the oldest one in
speech synthesis: hand the engine words, not digits.

This module is that step for **French and English**, deterministic and
dependency-free. Other languages pass through untouched — a wrong verbalisation
is worse than the engine's own guess. What it rewrites:

* dates ``14/11``, ``14/11/2026`` (fr: day/month; en: month/day unless the
  first number cannot be a month);
* times ``9h30``, ``14 h``, ``9:30``, ``2:30 p.m.``;
* amounts ``3 200 €``, ``12,50 €``, ``3 M€``, ``$3,200``, ``€12.50``, ``£5``;
* percentages ``7,94 %``, ``12%``;
* ordinals ``1er``, ``2e``, ``3ème``, ``1st``, ``14th``;
* grouped and decimal numbers ``3 200``, ``7,94`` (fr) / ``3,200``, ``2.91``
  (en), negatives, leading zeros (``06`` → "zéro six"), English years
  (``2026`` → "twenty twenty-six").

It is applied by :class:`~speaker_helper.speaker.Speaker` to what is *spoken*,
never to what a page *shows*: a narration's cues keep the document's digits.
``settings.numbers_to_words = False`` turns it off.

Usage example
-------------
>>> from speaker_helper.spoken_numbers import verbalize
>>> verbalize("Rendez-vous le 14/11 à 9h30 pour 3 200 €.", "fr")
'Rendez-vous le quatorze novembre à neuf heures trente pour trois mille deux cents euros.'
>>> verbalize("Meet on July 12th at 2:30 p.m., $3,200.", "en")
'Meet on July twelfth at two thirty p.m., three thousand two hundred dollars.'

Author
------
Warith HARCHAOUI — https://linkedin.com/in/warith-harchaoui
"""

from __future__ import annotations

import re
from collections.abc import Callable

#: Languages this module verbalises; anything else is returned unchanged.
SUPPORTED_LANGUAGES: tuple[str, ...] = ("fr", "en")

#: Beyond this the digits are read one by one — nobody says a 13-digit number.
_MAX_CARDINAL = 999_999_999_999

# ── French cardinals ───────────────────────────────────────────────────────

_FR_UNITS = (
    "zéro un deux trois quatre cinq six sept huit neuf dix onze douze treize quatorze quinze seize"
).split()
_FR_TENS = {2: "vingt", 3: "trente", 4: "quarante", 5: "cinquante", 6: "soixante"}


def _fr_below_100(n: int) -> str:
    """French words for ``0 <= n < 100`` (traditional spelling, "et un")."""
    if n <= 16:
        return _FR_UNITS[n]
    if n < 20:
        return "dix-" + _FR_UNITS[n - 10]
    tens, unit = divmod(n, 10)
    if tens in (7, 9):
        # 70–79 = soixante + 10–19, 90–99 = quatre-vingt + 10–19.
        base = "soixante" if tens == 7 else "quatre-vingt"
        rest = n - (60 if tens == 7 else 80)
        joiner = " et " if (tens == 7 and rest == 11) else "-"
        return base + joiner + _fr_below_100(rest)
    if tens == 8:
        return "quatre-vingts" if unit == 0 else "quatre-vingt-" + _FR_UNITS[unit]
    word = _FR_TENS[tens]
    if unit == 0:
        return word
    if unit == 1:
        return word + " et un"
    return word + "-" + _FR_UNITS[unit]


def _fr_below_1000(n: int, *, final: bool) -> str:
    """French words for ``0 <= n < 1000``.

    ``final`` says whether nothing multiplies this group (``cents`` and
    ``quatre-vingts`` keep their plural *s* only when they end the number).
    """
    hundreds, rest = divmod(n, 100)
    parts: list[str] = []
    if hundreds:
        if hundreds == 1:
            parts.append("cent")
        else:
            plural = rest == 0 and final
            parts.append(_FR_UNITS[hundreds] + (" cents" if plural else " cent"))
    if rest or not hundreds:
        words = _fr_below_100(rest)
        if not final and words == "quatre-vingts":
            words = "quatre-vingt"
        parts.append(words)
    return " ".join(parts)


[docs] def fr_cardinal(n: int) -> str: """Spell a non-negative integer in French. Examples -------- >>> fr_cardinal(71) 'soixante et onze' >>> fr_cardinal(3200) 'trois mille deux cents' >>> fr_cardinal(80_000) 'quatre-vingt mille' >>> fr_cardinal(2_000_001) 'deux millions un' """ if n < 1000: return _fr_below_1000(n, final=True) parts: list[str] = [] for value, singular, plural in ( (1_000_000_000, "milliard", "milliards"), (1_000_000, "million", "millions"), ): count, n = divmod(n, value) if count: # Million and milliard are nouns: they take "un" and a plural. parts.append(f"{fr_cardinal(count)} {singular if count == 1 else plural}") thousands, n = divmod(n, 1000) if thousands: # Mille is invariable, and takes no "un" before it. parts.append( "mille" if thousands == 1 else _fr_below_1000(thousands, final=False) + " mille" ) if n: parts.append(_fr_below_1000(n, final=True)) return " ".join(parts)
[docs] def fr_ordinal(n: int) -> str: """Spell a French ordinal (``1`` → "premier"). Examples -------- >>> fr_ordinal(1), fr_ordinal(5), fr_ordinal(9), fr_ordinal(21) ('premier', 'cinquième', 'neuvième', 'vingt et unième') """ if n == 1: return "premier" word = fr_cardinal(n) if word.endswith("cinq"): return word + "uième" if word.endswith("neuf"): return word[:-1] + "vième" if word.endswith(("cents", "vingts")): word = word[:-1] if word.endswith("e"): word = word[:-1] return word + "ième"
# ── English cardinals ────────────────────────────────────────────────────── _EN_UNITS = ( "zero one two three four five six seven eight nine ten eleven twelve " "thirteen fourteen fifteen sixteen seventeen eighteen nineteen" ).split() _EN_TENS = "_ _ twenty thirty forty fifty sixty seventy eighty ninety".split() def _en_below_1000(n: int) -> str: """English words for ``0 <= n < 1000`` (no "and", the US reading).""" hundreds, rest = divmod(n, 100) parts: list[str] = [] if hundreds: parts.append(_EN_UNITS[hundreds] + " hundred") if rest or not hundreds: if rest < 20: parts.append(_EN_UNITS[rest]) else: tens, unit = divmod(rest, 10) parts.append(_EN_TENS[tens] + (f"-{_EN_UNITS[unit]}" if unit else "")) return " ".join(parts)
[docs] def en_cardinal(n: int) -> str: """Spell a non-negative integer in English. Examples -------- >>> en_cardinal(3200) 'three thousand two hundred' >>> en_cardinal(1_000_001) 'one million one' """ if n < 1000: return _en_below_1000(n) parts: list[str] = [] for value, name in ((1_000_000_000, "billion"), (1_000_000, "million"), (1000, "thousand")): count, n = divmod(n, value) if count: parts.append(f"{en_cardinal(count)} {name}") if n: parts.append(_en_below_1000(n)) return " ".join(parts)
_EN_ORDINAL_IRREGULAR = { "one": "first", "two": "second", "three": "third", "five": "fifth", "eight": "eighth", "nine": "ninth", "twelve": "twelfth", }
[docs] def en_ordinal(n: int) -> str: """Spell an English ordinal. Examples -------- >>> en_ordinal(1), en_ordinal(12), en_ordinal(14), en_ordinal(21), en_ordinal(40) ('first', 'twelfth', 'fourteenth', 'twenty-first', 'fortieth') """ word = en_cardinal(n) # Only the last word (or the part after the last hyphen) takes the suffix. cut = max(word.rfind(" "), word.rfind("-")) head, last = word[: cut + 1], word[cut + 1 :] if last in _EN_ORDINAL_IRREGULAR: last = _EN_ORDINAL_IRREGULAR[last] elif last.endswith("y"): last = last[:-1] + "ieth" else: last += "th" return head + last
[docs] def en_year(n: int) -> str: """Read a year the way English speakers do. Examples -------- >>> en_year(2026), en_year(2000), en_year(2005) ('twenty twenty-six', 'two thousand', 'two thousand five') >>> en_year(1999), en_year(1900) ('nineteen ninety-nine', 'nineteen hundred') """ century, rest = divmod(n, 100) if 2000 <= n <= 2009: return en_cardinal(n) if rest == 0: return en_cardinal(century) + " hundred" if rest < 10: return f"{en_cardinal(century)} oh {en_cardinal(rest)}" return f"{en_cardinal(century)} {en_cardinal(rest)}"
# ── Shared reading of a digit string ─────────────────────────────────────── _FR_MONTHS = ( "janvier février mars avril mai juin juillet août septembre octobre novembre décembre" ).split() _EN_MONTHS = ( "January February March April May June July August September October November December" ).split() def _cardinal(lang: str) -> Callable[[int], str]: return fr_cardinal if lang == "fr" else en_cardinal def _integer(digits: str, lang: str) -> str: """Read a bare digit string: leading zeros and huge numbers digit by digit.""" say = _cardinal(lang) zero = "zéro" if lang == "fr" else "zero" if len(digits) > 1 and digits.startswith("0"): stripped = digits.lstrip("0") zeros = " ".join([zero] * (len(digits) - len(stripped))) return f"{zeros} {_integer(stripped, lang)}" if stripped else zeros value = int(digits) if value > _MAX_CARDINAL: return " ".join(say(int(d)) for d in digits) return say(value) def _decimal(int_part: str, frac: str, lang: str) -> str: """Read ``int_part`` [,.] ``frac``; a fraction starting with 0 digit by digit.""" point = "virgule" if lang == "fr" else "point" head = _integer(int_part, lang) if lang == "en" or frac.startswith("0"): # English reads decimals digit by digit ("two point nine one"); French # does too when the fraction starts with a zero ("sept virgule zéro cinq"). tail = " ".join(_integer(d, lang) for d in frac) else: tail = _integer(frac, lang) return f"{head} {point} {tail}" # ── Patterns ─────────────────────────────────────────────────────────────── _SP = "[   ]" # space, no-break space, narrow no-break space _FR_NUM = rf"\d{{1,3}}(?:{_SP}\d{{3}})+|\d+" _EN_NUM = r"\d{1,3}(?:,\d{3})+|\d+" def _digits(raw: str) -> str: return re.sub(rf"[{_SP[1:-1]},]", "", raw) def _fr_number(int_raw: str, frac: str | None) -> str: digits = _digits(int_raw) return _decimal(digits, frac, "fr") if frac else _integer(digits, "fr") def _en_number(int_raw: str, frac: str | None) -> str: digits = _digits(int_raw) return _decimal(digits, frac, "en") if frac else _integer(digits, "en") def _fr_hour(h: int) -> str: """ "une heure", "vingt et une heures" — heure is feminine.""" word = fr_cardinal(h) if word.endswith("un"): word = word[:-2] + "une" return f"{word} {'heure' if h in (0, 1) else 'heures'}" def _verbalize_fr(text: str) -> str: # Dates: 14/11, 14/11/2026, 1/1/26. def date(m: re.Match) -> str: day, month, year = int(m["d"]), int(m["m"]), m["y"] if not (1 <= day <= 31 and 1 <= month <= 12): return m[0] out = f"{fr_ordinal(1) if day == 1 else fr_cardinal(day)} {_FR_MONTHS[month - 1]}" if year: out += " " + fr_cardinal(int(year) + (2000 if len(year) == 2 else 0)) return out text = re.sub( r"(?<![\d/])(?P<d>\d{1,2})/(?P<m>\d{1,2})(?:/(?P<y>\d{4}|\d{2}))?(?![\d/])", date, text ) # Times: 9h30, 14 h, 9 h 05, 9:30. def hour(m: re.Match) -> str: h, minutes = int(m["h"]), m["min"] if h > 24 or (minutes and int(minutes) > 59): return m[0] out = _fr_hour(h) return out + (f" {fr_cardinal(int(minutes))}" if minutes and int(minutes) else "") text = re.sub(r"(?<![\d,.])(?P<h>\d{1,2}) ?h ?(?P<min>\d{2})?(?![\wÀ-ÿ])", hour, text) text = re.sub(r"(?<![\d,.:])(?P<h>\d{1,2}):(?P<min>\d{2})(?![\d:])", hour, text) # Amounts: 3 200 €, 12,50 €, 3 M€, 2 k€, €12, 5 $, $5, 5 £. currency = {"€": ("euro", "euros", "centime"), "EUR": ("euro", "euros", "centime"), "$": ("dollar", "dollars", "cent"), "USD": ("dollar", "dollars", "cent"), "£": ("livre", "livres", "penny")} # fmt: skip def money(m: re.Match) -> str: sym = m["sym"] or m["presym"] one, many, _ = currency[sym.upper() if sym.isalpha() else sym] scale = m["scale"] if scale: count = _fr_number(m["n"], m["f"]) if scale == "k": return f"{count} mille {many}" unit = "million" if count == "un" else "millions" return f"{count} {unit} d'{many}" if many[0] in "aeiou" else f"{count} {unit} de {many}" value = _digits(m["n"]) words = _fr_number(m["n"], None) noun = one if int(value) in (0, 1) else many frac = m["f"] if frac: cents = frac.ljust(2, "0")[:2] return f"{words} {noun} {fr_cardinal(int(cents))}" if int(cents) else f"{words} {noun}" return f"{words} {noun}" text = re.sub( rf"(?P<presym>[€$£])?(?P<n>{_FR_NUM})(?:,(?P<f>\d+))?{_SP}?(?:(?P<scale>[kM]){_SP}?)?" r"(?P<sym>€|EUR\b|\$|USD\b|£)", money, text, ) text = re.sub( rf"(?P<presym>[€$£])(?P<n>{_FR_NUM})(?:,(?P<f>\d+))?(?P<scale>)(?P<sym>)", lambda m: money(m), text, ) # Percentages. text = re.sub( rf"(?P<n>{_FR_NUM})(?:,(?P<f>\d+))?{_SP}?%", lambda m: _fr_number(m["n"], m["f"]) + " pour cent", text, ) # Ordinals: 1er, 1re, 2e, 3ème, 2nd(e). def ordinal(m: re.Match) -> str: n, suffix = int(m["n"]), m["s"] if n == 1: return "première" if suffix.startswith("r") else "premier" if suffix.startswith("nd"): return "seconde" if suffix.endswith("e") else "second" return fr_ordinal(n) text = re.sub(r"\b(?P<n>\d+)(?P<s>er|re|ère|e|ème|è|nde|nd)\b", ordinal, text) # Plain numbers last, with negatives and decimals. def number(m: re.Match) -> str: words = _fr_number(m["n"], m["f"]) return ("moins " if m["neg"] else "") + words return re.sub( rf"(?P<neg>(?<![\w\d])[-−](?=\d))?(?<![\w])(?P<n>{_FR_NUM})(?:,(?P<f>\d+))?(?![\w])", number, text, ) def _verbalize_en(text: str) -> str: # Dates: 11/14/2026 (US), or day-first when the first part cannot be a month. def date(m: re.Match) -> str: a, b, year = int(m["a"]), int(m["b"]), m["y"] month, day = (a, b) if a <= 12 else (b, a) if not (1 <= day <= 31 and 1 <= month <= 12): return m[0] out = f"{_EN_MONTHS[month - 1]} {en_ordinal(day)}" if year: out += ", " + en_year(int(year) + (2000 if len(year) == 2 else 0)) return out text = re.sub( r"(?<![\d/])(?P<a>\d{1,2})/(?P<b>\d{1,2})(?:/(?P<y>\d{4}|\d{2}))?(?![\d/])", date, text ) # Times: 2:30, 2:05 p.m., 14:00. def clock(m: re.Match) -> str: h, minutes = int(m["h"]), int(m["min"]) if h > 24 or minutes > 59: return m[0] if minutes == 0 and h > 12 and not m["ampm"]: out = en_cardinal(h) + " hundred" # 24-hour clock: "fourteen hundred" elif minutes == 0: out = en_cardinal(h) + ("" if m["ampm"] else " o'clock") elif minutes < 10: out = f"{en_cardinal(h)} oh {en_cardinal(minutes)}" else: out = f"{en_cardinal(h)} {en_cardinal(minutes)}" return out + (m["sp"] + m["ampm"] if m["ampm"] else "") text = re.sub( r"(?<![\d.:])(?P<h>\d{1,2}):(?P<min>\d{2})(?![\d:])(?P<sp>\s?)(?P<ampm>[ap]\.?m\.?(?!\w))?", clock, text, ) # Amounts: $3,200, $12.50, €5, £5, 5 euros stays as is (already words). currency = {"$": ("dollar", "dollars", "cent", "cents"), "€": ("euro", "euros", "cent", "cents"), "£": ("pound", "pounds", "penny", "pence")} # fmt: skip def money(m: re.Match) -> str: one, many, sub_one, sub_many = currency[m["sym"]] value = int(_digits(m["n"])) out = f"{_en_number(m['n'], None)} {one if value == 1 else many}" scale = m["scale"] if scale: return ( f"{_en_number(m['n'], m['f'])} {'million' if scale in 'mM' else 'thousand'} {many}" ) if m["f"]: cents = int(m["f"].ljust(2, "0")[:2]) if cents: out += f" and {en_cardinal(cents)} {sub_one if cents == 1 else sub_many}" return out text = re.sub( rf"(?P<sym>[$€£])(?P<n>{_EN_NUM})(?:\.(?P<f>\d+))?(?P<scale>[kKmM](?![a-zA-Z]))?", money, text, ) text = re.sub( rf"(?P<n>{_EN_NUM})(?:\.(?P<f>\d+))? ?(?P<sym>[€£$])(?P<scale>)", money, text, ) # Percentages. text = re.sub( rf"(?P<n>{_EN_NUM})(?:\.(?P<f>\d+))? ?%", lambda m: _en_number(m["n"], m["f"]) + " percent", text, ) # Ordinals: 1st, 2nd, 3rd, 14th. text = re.sub(r"\b(?P<n>\d+)(?:st|nd|rd|th)\b", lambda m: en_ordinal(int(m["n"])), text) # Plain numbers; a bare four-digit 1100–2099 reads as a year. def number(m: re.Match) -> str: raw, frac = m["n"], m["f"] if not frac and re.fullmatch(r"(1[1-9]|20)\d\d", raw): words = en_year(int(raw)) else: words = _en_number(raw, frac) return ("minus " if m["neg"] else "") + words return re.sub( rf"(?P<neg>(?<![\w\d])[-−](?=\d))?(?<![\w])(?P<n>{_EN_NUM})(?:\.(?P<f>\d+))?(?![\w])", number, text, )
[docs] def verbalize(text: str, language: str) -> str: """Rewrite the digits in ``text`` as the words a speaker would say. Parameters ---------- text : str Text about to be synthesised. language : str ISO-639-1 code (``"fr-FR"`` is fine); anything but French or English returns ``text`` unchanged. Returns ------- str The text with dates, times, amounts, percentages, ordinals and numbers spelled out; unchanged when it holds no digit. Examples -------- >>> verbalize("Correction : non pas le 14, mais le 15.", "fr") 'Correction : non pas le quatorze, mais le quinze.' >>> verbalize("Un WER de 7,94 %.", "fr") 'Un WER de sept virgule quatre-vingt-quatorze pour cent.' >>> verbalize("Hola 12", "es") 'Hola 12' """ lang = (language or "").strip().lower().split("-")[0] if lang not in SUPPORTED_LANGUAGES or not any(ch.isdigit() for ch in text): return text return _verbalize_fr(text) if lang == "fr" else _verbalize_en(text)