diff --git a/abogen/kokoro_text_normalization.py b/abogen/kokoro_text_normalization.py index bb08e07..9517683 100644 --- a/abogen/kokoro_text_normalization.py +++ b/abogen/kokoro_text_normalization.py @@ -106,10 +106,15 @@ _FRACTION_SLASH_CLASS = re.escape(_FRACTION_SLASHES) _FRACTION_RE = re.compile( rf"(?-?\d+)\s*[{_FRACTION_SLASH_CLASS}]\s*(?P-?\d+)(?![\w{_FRACTION_SLASH_CLASS}])" ) +_DECIMAL_NUMBER_RE = re.compile( + rf"(?-?(?:\d{{1,3}}(?:,\d{{3}})+|\d+)\.(?P\d+))(?![\w{_NUMBER_RANGE_CLASS}/])" +) _PLAIN_NUMBER_RE = re.compile( rf"(?{_NUMBER_CORE_PATTERN})(?![\w{_NUMBER_RANGE_CLASS}/])" ) +_DIGIT_WORDS = ("zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine") + def _int_to_words(value: int, language: str) -> Optional[str]: """Convert integer to spelled-out words using configured language.""" @@ -927,10 +932,57 @@ def _normalize_grouped_numbers(text: str, cfg: ApostropheConfig) -> str: words = _int_to_words(value, language) return words or str(value) + def _replace_decimal(match: re.Match[str]) -> str: + token = match.group("number") + fraction_part = match.group("fraction") + start, end = match.span() + source = match.string + + if end < len(source) and source[end] == ".": + next_char = source[end + 1] if end + 1 < len(source) else "" + if next_char.isdigit(): + return token + + is_negative = token.startswith("-") + core = token[1:] if is_negative else token + if "." not in core: + return token + + integer_part, _, _ = core.partition(".") + if not integer_part or not fraction_part: + return token + + integer_value = _coerce_int_token(integer_part.replace(",", "")) + if integer_value is None: + return token + + trimmed_fraction = fraction_part.rstrip("0") + integer_words = _int_to_words(integer_value, language) + + if not trimmed_fraction: + if integer_words is None: + return token + spoken = integer_words + return f"minus {spoken}" if is_negative else spoken + + if integer_words is None: + fallback_core = core.replace(".", " point ") + return f"minus {fallback_core}" if is_negative else fallback_core + + digit_words: List[str] = [] + for digit in trimmed_fraction: + if not digit.isdigit(): + return token + digit_words.append(_DIGIT_WORDS[int(digit)]) + + spoken = f"{integer_words} point {' '.join(digit_words)}" + return f"minus {spoken}" if is_negative else spoken + normalized = text normalized = _NUMBER_RANGE_RE.sub(lambda m: _replace_number_range(m, language), normalized) normalized = _NUMBER_SPACE_RANGE_RE.sub(lambda m: _replace_space_separated_range(m, language), normalized) normalized = _FRACTION_RE.sub(lambda m: _replace_fraction(m, language), normalized) + normalized = _DECIMAL_NUMBER_RE.sub(_replace_decimal, normalized) normalized = _NUMBER_WITH_GROUP_RE.sub(_replace_grouped, normalized) normalized = _PLAIN_NUMBER_RE.sub(_replace_plain, normalized) return normalized diff --git a/tests/test_text_normalization.py b/tests/test_text_normalization.py index 31037e1..c4705e7 100644 --- a/tests/test_text_normalization.py +++ b/tests/test_text_normalization.py @@ -91,6 +91,11 @@ def test_plain_numbers_are_spelled_out() -> None: assert "forty-two" in normalized.lower() +def test_decimal_numbers_include_point() -> None: + normalized = _normalize_for_pipeline("Book 4.5 of the series.") + assert "four point five" in normalized.lower() + + def test_space_separated_numbers_become_ranges() -> None: normalized = _normalize_for_pipeline("Read pages 12 14 tonight.") assert "pages twelve to fourteen" in normalized.lower()