use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::OnceLock;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Pron {
pub syllables: usize,
pub stress: usize,
pub rhyme_key: String,
}
#[derive(Debug, Clone, Default)]
pub struct PhonemeDict {
map: HashMap<String, Pron>,
}
impl PhonemeDict {
pub fn get(&self, word: &str) -> Option<&Pron> {
self.map.get(&normalize(word))
}
pub fn len(&self) -> usize {
self.map.len()
}
pub fn is_empty(&self) -> bool {
self.map.is_empty()
}
}
fn normalize(word: &str) -> String {
word.trim()
.trim_matches(|c: char| !c.is_alphanumeric() && c != '\'')
.to_lowercase()
}
fn is_vowel_phoneme(p: &str) -> bool {
p.chars().last().is_some_and(|c| c.is_ascii_digit())
}
fn parse_line(line: &str) -> Option<(String, Pron)> {
let line = line.trim();
if line.is_empty() || line.starts_with(";;;") {
return None;
}
let mut parts = line.split_whitespace();
let raw_word = parts.next()?;
let word = raw_word.split('(').next()?.to_lowercase();
if word.is_empty() {
return None;
}
let phonemes: Vec<&str> = parts.collect();
let vowels: Vec<usize> = phonemes
.iter()
.enumerate()
.filter(|(_, p)| is_vowel_phoneme(p))
.map(|(i, _)| i)
.collect();
if vowels.is_empty() {
return None;
}
let syllables = vowels.len();
let stress = vowels
.iter()
.position(|&i| phonemes[i].ends_with('1'))
.unwrap_or(0);
let rhyme_key = phonemes[vowels[stress]..]
.iter()
.map(|p| p.trim_end_matches(|c: char| c.is_ascii_digit()))
.collect::<Vec<_>>()
.join(" ");
Some((normalize(&word), Pron { syllables, stress, rhyme_key }))
}
pub fn parse(text: &str) -> PhonemeDict {
let mut map = HashMap::new();
for line in text.lines() {
if let Some((w, p)) = parse_line(line) {
map.entry(w).or_insert(p);
}
}
PhonemeDict { map }
}
pub fn data_dir() -> Option<PathBuf> {
directories::ProjectDirs::from("dev", "inkhaven", "inkhaven")
.map(|d| d.data_dir().join("phonemes"))
}
pub fn en_path() -> Option<PathBuf> {
data_dir().map(|d| d.join("en.dict"))
}
static EN: OnceLock<Option<PhonemeDict>> = OnceLock::new();
pub fn en() -> Option<&'static PhonemeDict> {
EN.get_or_init(|| {
let text = std::fs::read_to_string(en_path()?).ok()?;
let dict = parse(&text);
if dict.is_empty() { None } else { Some(dict) }
})
.as_ref()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_syllables_stress_and_rhyme_key() {
let d = parse(
";;; a comment\n\
LOVE L AH1 V\n\
MOVE M UW1 V\n\
COMPARE K AH0 M P EH1 R\n\
MOTHER M AH1 DH ER0\n\
MOVE(2) M UW1 V ER0\n",
);
assert_eq!(d.get("compare").unwrap().syllables, 2);
assert_eq!(d.get("compare").unwrap().stress, 1); assert_eq!(d.get("mother").unwrap().syllables, 2);
assert_eq!(d.get("mother").unwrap().stress, 0);
assert_eq!(d.get("love").unwrap().rhyme_key, "AH V");
assert_eq!(d.get("move").unwrap().rhyme_key, "UW V");
assert_ne!(d.get("love").unwrap().rhyme_key, d.get("move").unwrap().rhyme_key);
assert_eq!(d.get("move").unwrap().syllables, 1);
assert!(d.get("Love,").is_some());
assert!(d.get("nonexistent").is_none());
}
#[test]
fn consonant_only_and_junk_lines_are_skipped() {
let d = parse("HMM HH M\n\n \nBAD");
assert!(d.is_empty()); }
}