use std::process::Command;
const E2M: &[(&str, &str)] = &[
("\u{0294}\u{02CC}n\u{0329}", "t\u{1D4A}n"), ("\u{0294}n", "t\u{1D4A}n"), ("\u{0259}\u{005E}l", "\u{1D4A}l"), ("a\u{005E}\u{026A}", "I"), ("a\u{005E}\u{028A}", "W"), ("d\u{005E}\u{0292}", "\u{02A4}"), ("e\u{005E}\u{026A}", "A"), ("t\u{005E}\u{0283}", "\u{02A7}"), ("\u{0254}\u{005E}\u{026A}", "Y"), ("\u{02B2}O", "jO"), ("\u{02B2}Q", "jQ"), ("\u{0303}", ""), ("e", "A"), ("r", "\u{0279}"), ("x", "k"), ("\u{00E7}", "k"), ("\u{0250}", "\u{0259}"), ("\u{025A}", "\u{0259}\u{0279}"), ("\u{026C}", "l"), ("\u{0294}", "t"), ("\u{02B2}", ""), ];
pub struct EspeakFallback {
british: bool,
espeak_path: String,
}
impl EspeakFallback {
pub fn new() -> Self {
Self {
british: false,
espeak_path: "espeak-ng".to_string(),
}
}
pub fn with_path(espeak_path: String) -> Self {
Self {
british: false,
espeak_path,
}
}
pub fn is_available(&self) -> bool {
Command::new(&self.espeak_path)
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
pub fn convert_word(&self, word: &str) -> Option<(String, u8)> {
let lang = if self.british { "en-gb" } else { "en-us" };
let output = Command::new(&self.espeak_path)
.args(["--ipa", "-q", "-v", lang, "--tie=^", word])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let raw = String::from_utf8_lossy(&output.stdout);
let ps = raw.trim();
if ps.is_empty() {
return None;
}
let mut ps = ps.to_string();
for &(old, new) in E2M {
ps = ps.replace(old, new);
}
ps = replace_syllabic_mark(&ps);
if self.british {
ps = ps.replace("e^ə", "\u{025B}\u{02D0}"); ps = ps.replace("i\u{0259}", "\u{026A}\u{0259}"); ps = ps.replace("\u{0259}^\u{028A}", "Q"); } else {
ps = ps.replace("o^\u{028A}", "O"); ps = ps.replace("\u{025C}\u{02D0}\u{0279}", "\u{025C}\u{0279}"); ps = ps.replace("\u{025C}\u{02D0}", "\u{025C}\u{0279}"); ps = ps.replace("\u{026A}\u{0259}", "i\u{0259}"); ps = ps.replace('\u{02D0}', ""); }
ps = ps.replace('^', "");
ps = ps.replace('\u{027E}', "T"); ps = ps.replace('\u{0294}', "t");
Some((ps, 2))
}
}
impl Default for EspeakFallback {
fn default() -> Self {
Self::new()
}
}
fn replace_syllabic_mark(input: &str) -> String {
let chars: Vec<char> = input.chars().collect();
let mut result = String::with_capacity(input.len());
let mut i = 0;
while i < chars.len() {
if i + 1 < chars.len() && chars[i + 1] == '\u{0329}' && !chars[i].is_whitespace() {
result.push('\u{1D4A}'); result.push(chars[i]);
i += 2; } else if chars[i] == '\u{0329}' {
i += 1;
} else {
result.push(chars[i]);
i += 1;
}
}
result
}
pub fn espeak_sentence(text: &str, espeak_path: &str) -> Option<String> {
let output = Command::new(espeak_path)
.args(["--ipa", "-q", "-v", "en-us", text])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let ipa = String::from_utf8_lossy(&output.stdout);
let joined: String = ipa
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty())
.collect::<Vec<_>>()
.join(" ");
if joined.is_empty() {
return None;
}
Some(crate::espeak_ipa_to_kokoro(&joined))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_syllabic_mark_replacement() {
let input = format!("n{}", '\u{0329}');
assert_eq!(replace_syllabic_mark(&input), "\u{1D4A}n");
}
#[test]
fn test_syllabic_mark_in_context() {
let input = format!("b\u{0251}tl{}", '\u{0329}');
assert_eq!(replace_syllabic_mark(&input), "b\u{0251}t\u{1D4A}l");
}
#[test]
fn test_e2m_affricate_tie() {
let mut s = "d^ʒ".to_string();
for &(old, new) in E2M {
s = s.replace(old, new);
}
assert_eq!(s, "\u{02A4}");
}
#[test]
fn test_e2m_diphthong_tie() {
let mut s = "a^ɪ".to_string();
for &(old, new) in E2M {
s = s.replace(old, new);
}
assert_eq!(s, "I");
}
#[test]
fn test_convert_word_available() {
let fb = EspeakFallback::new();
if !fb.is_available() {
eprintln!("Skipping test: espeak-ng not installed");
return;
}
let result = fb.convert_word("hello");
assert!(
result.is_some(),
"espeak-ng should produce output for 'hello'"
);
let (ps, rating) = result.unwrap();
assert_eq!(rating, 2);
assert!(!ps.is_empty());
assert!(
ps.contains('O'),
"Expected O diphthong in phonemes for 'hello': {}",
ps
);
}
#[test]
fn test_espeak_sentence_available() {
let fb = EspeakFallback::new();
if !fb.is_available() {
eprintln!("Skipping test: espeak-ng not installed");
return;
}
let result = espeak_sentence("Hello world", "espeak-ng");
assert!(result.is_some());
let ps = result.unwrap();
assert!(!ps.is_empty());
assert!(ps.contains('O'), "Expected O in: {}", ps);
}
}