mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 05:40:26 +02:00
feat: Add year pronunciation handling and roman numeral normalization to text processing
This commit is contained in:
@@ -49,6 +49,7 @@ class ApostropheConfig:
|
|||||||
protect_cultural_names: bool = True # Always keep O'Brien, D'Angelo, etc.
|
protect_cultural_names: bool = True # Always keep O'Brien, D'Angelo, etc.
|
||||||
convert_numbers: bool = True # Convert grouped numbers such as 12,500 to words
|
convert_numbers: bool = True # Convert grouped numbers such as 12,500 to words
|
||||||
number_lang: str = "en" # num2words language code
|
number_lang: str = "en" # num2words language code
|
||||||
|
year_pronunciation_mode: str = "american" # off|american (extend if needed)
|
||||||
contraction_categories: Dict[str, bool] = field(default_factory=lambda: dict(CONTRACTION_CATEGORY_DEFAULTS))
|
contraction_categories: Dict[str, bool] = field(default_factory=lambda: dict(CONTRACTION_CATEGORY_DEFAULTS))
|
||||||
|
|
||||||
def is_contraction_enabled(self, category: str) -> bool:
|
def is_contraction_enabled(self, category: str) -> bool:
|
||||||
@@ -430,6 +431,105 @@ _ROMAN_COMPOSE_ORDER = [
|
|||||||
|
|
||||||
_ROMAN_PREFIX_RE = re.compile(r"^(?P<roman>[IVXLCDM]+)(?P<sep>[\s\.:,;\-–—]*)", re.IGNORECASE)
|
_ROMAN_PREFIX_RE = re.compile(r"^(?P<roman>[IVXLCDM]+)(?P<sep>[\s\.:,;\-–—]*)", re.IGNORECASE)
|
||||||
|
|
||||||
|
_ROMAN_TOKEN_RE = re.compile(r"^[IVXLCDM]+$")
|
||||||
|
_ROMAN_CARDINAL_CONTEXTS = {
|
||||||
|
"act",
|
||||||
|
"appendix",
|
||||||
|
"article",
|
||||||
|
"battle",
|
||||||
|
"book",
|
||||||
|
"campaign",
|
||||||
|
"chapter",
|
||||||
|
"episode",
|
||||||
|
"film",
|
||||||
|
"final",
|
||||||
|
"fantasy",
|
||||||
|
"game",
|
||||||
|
"installment",
|
||||||
|
"lesson",
|
||||||
|
"level",
|
||||||
|
"mission",
|
||||||
|
"movement",
|
||||||
|
"opus",
|
||||||
|
"operation",
|
||||||
|
"page",
|
||||||
|
"part",
|
||||||
|
"phase",
|
||||||
|
"psalm",
|
||||||
|
"round",
|
||||||
|
"scene",
|
||||||
|
"season",
|
||||||
|
"section",
|
||||||
|
"series",
|
||||||
|
"song",
|
||||||
|
"super",
|
||||||
|
"bowl",
|
||||||
|
"stage",
|
||||||
|
"step",
|
||||||
|
"track",
|
||||||
|
"volume",
|
||||||
|
"war",
|
||||||
|
"world",
|
||||||
|
}
|
||||||
|
_ROMAN_NAME_TITLES = {
|
||||||
|
"baron",
|
||||||
|
"baroness",
|
||||||
|
"captain",
|
||||||
|
"cardinal",
|
||||||
|
"count",
|
||||||
|
"countess",
|
||||||
|
"duchess",
|
||||||
|
"duke",
|
||||||
|
"emperor",
|
||||||
|
"empress",
|
||||||
|
"general",
|
||||||
|
"governor",
|
||||||
|
"king",
|
||||||
|
"lord",
|
||||||
|
"lady",
|
||||||
|
"major",
|
||||||
|
"pope",
|
||||||
|
"president",
|
||||||
|
"prince",
|
||||||
|
"princess",
|
||||||
|
"queen",
|
||||||
|
"saint",
|
||||||
|
"sir",
|
||||||
|
}
|
||||||
|
_ROMAN_NAME_CONNECTORS = {
|
||||||
|
"de",
|
||||||
|
"del",
|
||||||
|
"della",
|
||||||
|
"der",
|
||||||
|
"di",
|
||||||
|
"dos",
|
||||||
|
"la",
|
||||||
|
"le",
|
||||||
|
"of",
|
||||||
|
"the",
|
||||||
|
"van",
|
||||||
|
"von",
|
||||||
|
}
|
||||||
|
_ROMAN_BREAK_TOKENS = {
|
||||||
|
",",
|
||||||
|
".",
|
||||||
|
"!",
|
||||||
|
"?",
|
||||||
|
";",
|
||||||
|
":",
|
||||||
|
"(",
|
||||||
|
")",
|
||||||
|
"[",
|
||||||
|
"]",
|
||||||
|
"{",
|
||||||
|
"}",
|
||||||
|
"—",
|
||||||
|
"–",
|
||||||
|
"-",
|
||||||
|
"'",
|
||||||
|
'"',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _roman_to_int(token: str) -> Optional[int]:
|
def _roman_to_int(token: str) -> Optional[int]:
|
||||||
if not token:
|
if not token:
|
||||||
@@ -463,6 +563,100 @@ def _int_to_roman(value: int) -> str:
|
|||||||
return "".join(parts)
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_titlecase_token(token: str) -> bool:
|
||||||
|
cleaned = token.replace("'", "").replace("-", "")
|
||||||
|
if not cleaned:
|
||||||
|
return False
|
||||||
|
if not cleaned[0].isalpha() or not cleaned[0].isupper():
|
||||||
|
return False
|
||||||
|
tail = cleaned[1:]
|
||||||
|
return not tail or tail.islower()
|
||||||
|
|
||||||
|
|
||||||
|
def _token_is_cardinal_context(token: str) -> bool:
|
||||||
|
return token.lower() in _ROMAN_CARDINAL_CONTEXTS
|
||||||
|
|
||||||
|
|
||||||
|
def _should_render_ordinal(
|
||||||
|
tokens: Sequence[Tuple[str, int, int]],
|
||||||
|
index: int,
|
||||||
|
value: int,
|
||||||
|
) -> bool:
|
||||||
|
# Treat trailing roman numerals in name-like sequences as ordinals while
|
||||||
|
# leaving enumerated headings or series labels as cardinals.
|
||||||
|
if value <= 0:
|
||||||
|
return False
|
||||||
|
if index <= 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
uppercase_count = 0
|
||||||
|
title_count = 0
|
||||||
|
j = index - 1
|
||||||
|
while j >= 0:
|
||||||
|
token, *_ = tokens[j]
|
||||||
|
lowered = token.lower()
|
||||||
|
|
||||||
|
if lowered in _ROMAN_CARDINAL_CONTEXTS:
|
||||||
|
return False
|
||||||
|
if lowered in _ROMAN_BREAK_TOKENS or token.isdigit():
|
||||||
|
break
|
||||||
|
if lowered in _ROMAN_NAME_CONNECTORS:
|
||||||
|
j -= 1
|
||||||
|
continue
|
||||||
|
if _is_titlecase_token(token):
|
||||||
|
uppercase_count += 1
|
||||||
|
if lowered in _ROMAN_NAME_TITLES:
|
||||||
|
title_count += 1
|
||||||
|
j -= 1
|
||||||
|
continue
|
||||||
|
break
|
||||||
|
|
||||||
|
if not uppercase_count:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if title_count:
|
||||||
|
return value <= 50
|
||||||
|
|
||||||
|
if uppercase_count >= 2:
|
||||||
|
return value <= 20
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_roman_numerals(text: str, language: str) -> str:
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
|
||||||
|
tokens = tokenize_with_spans(text)
|
||||||
|
if not tokens:
|
||||||
|
return text
|
||||||
|
|
||||||
|
parts: List[str] = []
|
||||||
|
cursor = 0
|
||||||
|
|
||||||
|
for index, (token, start, end) in enumerate(tokens):
|
||||||
|
parts.append(text[cursor:start])
|
||||||
|
replacement = token
|
||||||
|
|
||||||
|
if len(token) >= 2 and token.isupper() and _ROMAN_TOKEN_RE.match(token):
|
||||||
|
numeric_value = _roman_to_int(token)
|
||||||
|
if numeric_value is not None:
|
||||||
|
if _should_render_ordinal(tokens, index, numeric_value):
|
||||||
|
ordinal = _int_to_ordinal_words(numeric_value, language)
|
||||||
|
if ordinal:
|
||||||
|
replacement = f"the {ordinal}"
|
||||||
|
else:
|
||||||
|
words = _int_to_words(numeric_value, language)
|
||||||
|
if words:
|
||||||
|
replacement = words
|
||||||
|
|
||||||
|
parts.append(replacement)
|
||||||
|
cursor = end
|
||||||
|
|
||||||
|
parts.append(text[cursor:])
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
def normalize_roman_numeral_titles(
|
def normalize_roman_numeral_titles(
|
||||||
titles: Sequence[str],
|
titles: Sequence[str],
|
||||||
*,
|
*,
|
||||||
@@ -890,6 +1084,105 @@ def _normalize_grouped_numbers(text: str, cfg: ApostropheConfig) -> str:
|
|||||||
|
|
||||||
language = (cfg.number_lang or "en").strip() or "en"
|
language = (cfg.number_lang or "en").strip() or "en"
|
||||||
|
|
||||||
|
def _year_mode() -> str:
|
||||||
|
mode = (cfg.year_pronunciation_mode or "").strip().lower()
|
||||||
|
if mode in {"", "none", "off", "disabled"}:
|
||||||
|
return "off"
|
||||||
|
if mode not in {"american"}:
|
||||||
|
return "off"
|
||||||
|
return mode
|
||||||
|
|
||||||
|
year_mode = _year_mode()
|
||||||
|
|
||||||
|
def _format_year_tail(value: int, *, allow_oh: bool = True) -> Optional[str]:
|
||||||
|
if value == 0:
|
||||||
|
return ""
|
||||||
|
if value < 10:
|
||||||
|
if allow_oh:
|
||||||
|
return f"oh {_DIGIT_WORDS[value]}"
|
||||||
|
return _DIGIT_WORDS[value]
|
||||||
|
words = _int_to_words(value, language)
|
||||||
|
if not words:
|
||||||
|
return None
|
||||||
|
return words
|
||||||
|
|
||||||
|
def _format_year_like(token: str, value: int) -> Optional[str]:
|
||||||
|
if year_mode == "off" or num2words is None:
|
||||||
|
return None
|
||||||
|
if len(token) != 4 or not token.isdigit():
|
||||||
|
return None
|
||||||
|
if value < 1000 or value > 9999:
|
||||||
|
return None
|
||||||
|
style = year_mode
|
||||||
|
|
||||||
|
def _words(value_to_convert: int) -> Optional[str]:
|
||||||
|
words = _int_to_words(value_to_convert, language)
|
||||||
|
return words
|
||||||
|
|
||||||
|
if style == "american":
|
||||||
|
if value % 1000 == 0:
|
||||||
|
thousands = value // 1000
|
||||||
|
thousands_words = _words(thousands)
|
||||||
|
if thousands_words:
|
||||||
|
return f"{thousands_words} thousand"
|
||||||
|
return None
|
||||||
|
|
||||||
|
first_two = value // 100
|
||||||
|
last_two = value % 100
|
||||||
|
|
||||||
|
# Special handling to match common American pronunciations.
|
||||||
|
if first_two == 20:
|
||||||
|
if last_two == 0:
|
||||||
|
return "two thousand"
|
||||||
|
if last_two < 10:
|
||||||
|
tail_word = _words(last_two)
|
||||||
|
if tail_word:
|
||||||
|
return f"two thousand {tail_word}"
|
||||||
|
return None
|
||||||
|
prefix = _words(first_two)
|
||||||
|
tail = _words(last_two)
|
||||||
|
if prefix and tail:
|
||||||
|
return f"{prefix} {tail}"
|
||||||
|
return prefix
|
||||||
|
|
||||||
|
prefix = _words(first_two)
|
||||||
|
if not prefix:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if first_two == 10:
|
||||||
|
if last_two == 0:
|
||||||
|
return "one thousand"
|
||||||
|
tail = _format_year_tail(last_two)
|
||||||
|
if tail:
|
||||||
|
return f"{prefix} {tail}"
|
||||||
|
return prefix
|
||||||
|
|
||||||
|
if first_two <= 12:
|
||||||
|
if last_two == 0:
|
||||||
|
return f"{prefix} hundred"
|
||||||
|
tail = _format_year_tail(last_two)
|
||||||
|
if tail:
|
||||||
|
return f"{prefix} hundred {tail}"
|
||||||
|
return f"{prefix} hundred"
|
||||||
|
|
||||||
|
if first_two <= 19:
|
||||||
|
if last_two == 0:
|
||||||
|
return f"{prefix} hundred"
|
||||||
|
tail = _format_year_tail(last_two)
|
||||||
|
if tail:
|
||||||
|
return f"{prefix} {tail}"
|
||||||
|
return prefix
|
||||||
|
|
||||||
|
if last_two == 0:
|
||||||
|
return f"{prefix} hundred"
|
||||||
|
|
||||||
|
tail = _format_year_tail(last_two)
|
||||||
|
if tail:
|
||||||
|
return f"{prefix} {tail}"
|
||||||
|
return prefix
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
def _replace_grouped(match: re.Match[str]) -> str:
|
def _replace_grouped(match: re.Match[str]) -> str:
|
||||||
token = match.group(1)
|
token = match.group(1)
|
||||||
value = _coerce_int_token(token)
|
value = _coerce_int_token(token)
|
||||||
@@ -927,6 +1220,9 @@ def _normalize_grouped_numbers(text: str, cfg: ApostropheConfig) -> str:
|
|||||||
value = _coerce_int_token(token)
|
value = _coerce_int_token(token)
|
||||||
if value is None:
|
if value is None:
|
||||||
return token
|
return token
|
||||||
|
year_like = _format_year_like(token, value)
|
||||||
|
if year_like:
|
||||||
|
return year_like
|
||||||
if num2words is None:
|
if num2words is None:
|
||||||
return str(value)
|
return str(value)
|
||||||
words = _int_to_words(value, language)
|
words = _int_to_words(value, language)
|
||||||
@@ -985,6 +1281,7 @@ def _normalize_grouped_numbers(text: str, cfg: ApostropheConfig) -> str:
|
|||||||
normalized = _DECIMAL_NUMBER_RE.sub(_replace_decimal, normalized)
|
normalized = _DECIMAL_NUMBER_RE.sub(_replace_decimal, normalized)
|
||||||
normalized = _NUMBER_WITH_GROUP_RE.sub(_replace_grouped, normalized)
|
normalized = _NUMBER_WITH_GROUP_RE.sub(_replace_grouped, normalized)
|
||||||
normalized = _PLAIN_NUMBER_RE.sub(_replace_plain, normalized)
|
normalized = _PLAIN_NUMBER_RE.sub(_replace_plain, normalized)
|
||||||
|
normalized = _normalize_roman_numerals(normalized, language)
|
||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
# ---------- Optional phoneme hint post-processing ----------
|
# ---------- Optional phoneme hint post-processing ----------
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ _SETTINGS_DEFAULTS: Dict[str, Any] = {
|
|||||||
"llm_prompt": DEFAULT_LLM_PROMPT,
|
"llm_prompt": DEFAULT_LLM_PROMPT,
|
||||||
"llm_context_mode": "sentence",
|
"llm_context_mode": "sentence",
|
||||||
"normalization_numbers": True,
|
"normalization_numbers": True,
|
||||||
|
"normalization_numbers_year_style": "american",
|
||||||
"normalization_titles": True,
|
"normalization_titles": True,
|
||||||
"normalization_terminal": True,
|
"normalization_terminal": True,
|
||||||
"normalization_phoneme_hints": True,
|
"normalization_phoneme_hints": True,
|
||||||
@@ -170,6 +171,7 @@ def build_apostrophe_config(
|
|||||||
) -> ApostropheConfig:
|
) -> ApostropheConfig:
|
||||||
config = replace(base or ApostropheConfig())
|
config = replace(base or ApostropheConfig())
|
||||||
config.convert_numbers = bool(settings.get("normalization_numbers", True))
|
config.convert_numbers = bool(settings.get("normalization_numbers", True))
|
||||||
|
config.year_pronunciation_mode = str(settings.get("normalization_numbers_year_style", "american") or "").strip().lower()
|
||||||
config.add_phoneme_hints = bool(settings.get("normalization_phoneme_hints", True))
|
config.add_phoneme_hints = bool(settings.get("normalization_phoneme_hints", True))
|
||||||
config.contraction_mode = "expand" if settings.get("normalization_apostrophes_contractions", True) else "keep"
|
config.contraction_mode = "expand" if settings.get("normalization_apostrophes_contractions", True) else "keep"
|
||||||
config.plural_possessive_mode = (
|
config.plural_possessive_mode = (
|
||||||
|
|||||||
@@ -101,6 +101,48 @@ def test_space_separated_numbers_become_ranges() -> None:
|
|||||||
assert "pages twelve to fourteen" in normalized.lower()
|
assert "pages twelve to fourteen" in normalized.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_year_like_numbers_use_common_pronunciation() -> None:
|
||||||
|
normalized = _normalize_for_pipeline("In 1924 the journey began")
|
||||||
|
folded = normalized.lower().replace("-", " ")
|
||||||
|
assert "nineteen twenty four" in folded
|
||||||
|
|
||||||
|
|
||||||
|
def test_early_century_years_use_hundred_format() -> None:
|
||||||
|
normalized = _normalize_for_pipeline("In 1204 the city fell")
|
||||||
|
assert "twelve hundred" in normalized.lower()
|
||||||
|
assert "oh four" in normalized.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_roman_numerals_in_titles_are_converted() -> None:
|
||||||
|
normalized = _normalize_for_pipeline("Chapter IV begins now")
|
||||||
|
assert "chapter four" in normalized.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_roman_numeral_suffixes_use_ordinals() -> None:
|
||||||
|
normalized = _normalize_for_pipeline("Bob Smith II arrived late")
|
||||||
|
assert "bob smith the second" in normalized.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_recent_years_split_twenty_style() -> None:
|
||||||
|
normalized = _normalize_for_pipeline("In 2025 we planned ahead")
|
||||||
|
folded = normalized.lower().replace("-", " ")
|
||||||
|
assert "twenty twenty five" in folded
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_thousands_use_two_thousand_prefix() -> None:
|
||||||
|
normalized = _normalize_for_pipeline("In 2005 we celebrated")
|
||||||
|
assert "two thousand five" in normalized.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_year_style_can_be_disabled() -> None:
|
||||||
|
normalized = _normalize_for_pipeline(
|
||||||
|
"In 2025 we planned ahead",
|
||||||
|
normalization_overrides={"normalization_numbers_year_style": "off"},
|
||||||
|
)
|
||||||
|
folded = normalized.lower().replace("-", " ")
|
||||||
|
assert "twenty twenty five" not in folded
|
||||||
|
|
||||||
|
|
||||||
def test_contractions_can_be_kept_when_override_disabled() -> None:
|
def test_contractions_can_be_kept_when_override_disabled() -> None:
|
||||||
normalized = _normalize_for_pipeline(
|
normalized = _normalize_for_pipeline(
|
||||||
"It's a good day.",
|
"It's a good day.",
|
||||||
|
|||||||
Reference in New Issue
Block a user