From 68919ed87aa6a097a4d21414ec7c438883f5fe22 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 2 Nov 2026 06:38:18 +0100 Subject: [PATCH] Read decades like 1990s as plural years in TTS normalizer (#3930) * Read decades like 1990s as plural years in TTS normalizer Found while testing crates/tts-text/src/normalize.rs:~654 (cardinal/year branch) and parse_continuation ~line 680 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 * tts-text: decade tests in the it binary, with boundary cases Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Haiku 5.5 Co-authored-by: Brandon Thomas --- crates/tts-text/src/normalize.rs | 22 ++++++++++++++++++++++ crates/tts-text/tests/it/decades.rs | 27 +++++++++++++++++++++++++++ crates/tts-text/tests/it/main.rs | 1 + 3 files changed, 50 insertions(+) create mode 100644 crates/tts-text/tests/it/decades.rs create mode 100644 crates/tts-text/tests/it/main.rs diff --git a/crates/tts-text/src/normalize.rs b/crates/tts-text/src/normalize.rs index d925b086b..834a9c01a 100644 --- a/crates/tts-text/src/normalize.rs +++ b/crates/tts-text/src/normalize.rs @@ -654,9 +654,31 @@ fn parse_number_core(rest: &str, lang: Lang) -> Option<(Vec, usize)> { if !grouped && digits.len() == 4 && (1100..=2099).contains(&value) && year_is_standalone(rest, used) { return Some((year_words(value, lang), used)); } + // A decade such as "1990s" reads as a plural year: "nineteen nineties". + if !grouped + && digits.len() == 4 + && (1100..=2099).contains(&value) + && value.is_multiple_of(10) + && matches!(peek(rest, used), Some('s')) + && !peek(rest, used + 1).is_some_and(char::is_alphanumeric) + { + let mut words = year_words(value, lang); + if let Some(Token::Word(last)) = words.last_mut() { + *last = pluralize_decade(last); + } + return Some((words, used + 1)); + } Some((english_cardinal(value).into_iter().map(Token::Word).collect(), used)) } +/// Plural of the last word of a round year: ninety → nineties, hundred → hundreds. +fn pluralize_decade(word: &str) -> String { + match word.strip_suffix('y') { + Some(stem) => format!("{stem}ies"), + None => format!("{word}s"), + } +} + fn has_leading_zero_str(digits: &str) -> bool { digits.len() > 1 && digits.starts_with('0') } diff --git a/crates/tts-text/tests/it/decades.rs b/crates/tts-text/tests/it/decades.rs new file mode 100644 index 000000000..1316bcf4e --- /dev/null +++ b/crates/tts-text/tests/it/decades.rs @@ -0,0 +1,27 @@ +use filmcraft_tts_text::{Lang, Token, normalize}; + +fn spoken(text: &str) -> String { + let words: Vec = normalize(text, Lang::EnUs).unwrap().into_iter().filter_map(|t| if let Token::Word(w) = t { Some(w) } else { None }).collect(); + words.join(" ") +} + +#[test] +fn bare_year_is_unchanged() { + assert_eq!(spoken("in 1990"), "in nineteen ninety"); +} + +#[test] +fn decade_reads_as_plural_year() { + assert_eq!(spoken("the 1990s"), "the nineteen nineties"); + assert_eq!(spoken("the 2010s."), "the twenty tens"); + assert_eq!(spoken("the 1900s"), "the nineteen hundreds"); + assert_eq!(spoken("the 1100s"), "the eleven hundreds"); + assert_eq!(spoken("the 2090s"), "the twenty nineties"); +} + +#[test] +fn only_round_years_followed_by_a_lone_s_are_decades() { + assert_ne!(spoken("the 1995s"), "the nineteen ninety fives"); + assert!(!spoken("the 1990sx").contains("nineties")); + assert!(!spoken("the 3000s").contains("thousands")); +} diff --git a/crates/tts-text/tests/it/main.rs b/crates/tts-text/tests/it/main.rs new file mode 100644 index 000000000..f45cbb417 --- /dev/null +++ b/crates/tts-text/tests/it/main.rs @@ -0,0 +1 @@ +mod decades;