pub mod aligner;
pub mod classifier;
pub mod cmudict;
pub(crate) fn apostrophe_elided_recorded_word_at(
chars: &[char],
run_start: usize,
run_end: usize,
) -> bool {
if run_start >= run_end
|| run_end > chars.len()
|| !chars[run_start..run_end]
.iter()
.all(|ch| ch.is_ascii_alphabetic())
{
return false;
}
let is_apostrophe = |ch: char| matches!(ch, '\'' | '\u{2019}');
let is_member = |ch: char| ch.is_ascii_alphabetic() || is_apostrophe(ch);
let mut start = run_start;
while start > 0 && is_member(chars[start - 1]) {
start -= 1;
}
let mut end = run_end;
while end < chars.len() && is_member(chars[end]) {
end += 1;
}
let segment = &chars[start..end];
if !segment.iter().any(|ch| is_apostrophe(*ch)) {
return false;
}
if segment.iter().enumerate().any(|(index, ch)| {
is_apostrophe(*ch)
&& (index == 0
|| index + 1 == segment.len()
|| !segment[index - 1].is_ascii_alphabetic()
|| !segment[index + 1].is_ascii_alphabetic())
}) {
return false;
}
let normalized: String = segment
.iter()
.filter(|ch| ch.is_ascii_alphabetic())
.map(|ch| ch.to_ascii_lowercase())
.collect();
cmudict::is_recorded_word(&normalized)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Phoneme {
pub base: String,
pub stress: Option<u8>,
}
impl Phoneme {
pub fn is_vowel(&self) -> bool {
self.stress.is_some()
}
}
pub fn parse_phoneme(tok: &str) -> Phoneme {
if let Some(d @ b'0'..=b'2') = tok.as_bytes().last().copied() {
return Phoneme {
base: tok[..tok.len() - 1].to_string(),
stress: Some(d - b'0'),
};
}
let base = tok.to_string();
Phoneme { base, stress: None }
}
pub trait PronunciationProvider: Send + Sync {
fn pronunciations(&self, word: &str) -> Vec<Vec<Phoneme>>;
}
#[cfg(test)]
pub struct NoPronunciationProvider;
#[cfg(test)]
impl PronunciationProvider for NoPronunciationProvider {
fn pronunciations(&self, _word: &str) -> Vec<Vec<Phoneme>> {
Vec::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[rstest::rstest]
#[case::vowel_primary("AH1", "AH", Some(1))]
#[case::vowel_unstressed("IH0", "IH", Some(0))]
#[case::vowel_secondary("EH2", "EH", Some(2))]
#[case::digit_not_stress("AH3", "AH3", None)]
#[case::consonant_b("B", "B", None)]
#[case::consonant_ng("NG", "NG", None)]
fn parses_phoneme_base_and_stress(
#[case] tok: &str,
#[case] base: &str,
#[case] stress: Option<u8>,
) {
let ph = parse_phoneme(tok);
assert_eq!(ph.base, base);
assert_eq!(ph.stress, stress);
assert_eq!(ph.is_vowel(), stress.is_some());
}
#[test]
fn no_provider_yields_no_pronunciations() {
assert!(NoPronunciationProvider.pronunciations("become").is_empty());
}
#[test]
fn parses_runtime_consonant_token_without_stress() {
let token = std::hint::black_box("NG");
let ph = parse_phoneme(token);
assert_eq!(ph.base, "NG");
assert_eq!(ph.stress, None);
assert!(!ph.is_vowel());
}
#[rstest::rstest]
#[case::straight_apostrophe("O'PENing", 2, 8, true)]
#[case::curly_apostrophe("O\u{2019}PENing", 2, 8, true)]
#[case::no_join("PENing", 0, 6, false)]
#[case::unknown_elision("rock'n", 5, 6, false)]
#[case::empty_requested_run("abc", 1, 1, false)]
#[case::run_end_out_of_bounds("abc", 0, 4, false)]
#[case::requested_run_contains_nonletter("a1c", 0, 3, false)]
fn classifies_apostrophe_elided_lexical_words(
#[case] text: &str,
#[case] run_start: usize,
#[case] run_end: usize,
#[case] expected: bool,
) {
let chars: Vec<char> = text.chars().collect();
assert_eq!(
apostrophe_elided_recorded_word_at(&chars, run_start, run_end),
expected
);
}
}