use thiserror::Error;
#[cfg(feature = "espeak")]
use std::sync::Mutex;
#[cfg(feature = "espeak")]
use espeak_rs::text_to_phonemes;
#[cfg(feature = "espeak")]
static ESPEAK_MUTEX: Mutex<()> = Mutex::new(());
#[derive(Error, Debug)]
pub enum FallbackError {
#[error("espeak error for '{word}': {error}")]
Espeak {
word: String,
error: String,
},
#[error("no phonemes matched for '{word}'")]
NoPhonemes {
word: String,
},
#[error("mutex poisoned: {0}")]
MutexPoisoned(String),
}
pub trait Fallback: Send + Sync {
fn phonemize(&self, word: &str) -> Result<String, FallbackError>;
}
#[cfg(feature = "espeak")]
pub struct EspeakFallback {
british: bool,
}
#[cfg(feature = "espeak")]
impl EspeakFallback {
pub fn new(british: bool) -> Result<Self, String> {
Ok(Self { british })
}
fn convert_espeak_to_misaki(&self, espeak_ipa: &str) -> String {
let mut result = espeak_ipa.to_string();
result = result.replace("iː", "ˈi").replace("i:", "ˈi");
result = result.replace("uː", "ˈu").replace("u:", "ˈu");
result = result.replace("ɜː", "ɜ").replace("ɜ:", "ɜ");
result = result.replace("ɔː", "ɔ").replace("ɔ:", "ɔ");
result = result.replace("ɑː", "ɑ").replace("ɑ:", "ɑ");
result = result.replace("ː", ""); result = result.replace("_", "");
result.replace("ˈˈ", "ˈ").replace("ˌˌ", "ˌ")
}
}
#[cfg(feature = "espeak")]
impl Fallback for EspeakFallback {
fn phonemize(&self, word: &str) -> Result<String, FallbackError> {
let _lock = ESPEAK_MUTEX
.lock()
.map_err(|e| FallbackError::MutexPoisoned(format!("{:?}", e)))?;
let voice = if self.british { "en" } else { "en-us" };
match text_to_phonemes(word, voice, None, true, false) {
Ok(phonemes) => {
if phonemes.is_empty() {
return Err(FallbackError::NoPhonemes {
word: word.to_string(),
});
}
let cleaned = self.convert_espeak_to_misaki(&phonemes.join(""));
Ok(cleaned)
}
Err(e) => Err(FallbackError::Espeak {
word: word.to_string(),
error: format!("{:?}", e),
}),
}
}
}
#[cfg(all(test, feature = "espeak"))]
mod tests {
use super::*;
#[test]
fn test_espeak_fallback() {
let fallback = EspeakFallback::new(false).expect("espeak should initialize");
let phonemes = fallback.phonemize("ilili").unwrap();
assert!(!phonemes.is_empty());
assert!(!phonemes.contains("ˈɛl")); }
#[test]
fn test_espeak_nonsense_word() {
let fallback = EspeakFallback::new(false).unwrap();
let phonemes = fallback.phonemize("xyzqwop").unwrap();
assert!(!phonemes.is_empty(), "espeak should phonemize nonsense words");
}
#[test]
fn test_espeak_phonemes_beat() {
let fallback = EspeakFallback::new(false).unwrap();
let phonemes = fallback.phonemize("beat").unwrap();
assert!(
phonemes.contains("ˈi"),
"Should contain stressed i, got: {}",
phonemes
);
}
#[test]
fn test_espeak_american_vs_british() {
let us = EspeakFallback::new(false).unwrap();
let gb = EspeakFallback::new(true).unwrap();
let us_phonemes = us.phonemize("schedule").unwrap();
let gb_phonemes = gb.phonemize("schedule").unwrap();
assert!(!us_phonemes.is_empty());
assert!(!gb_phonemes.is_empty());
assert!(us_phonemes.contains("sk"), "US schedule usually has 'sk', got: {}", us_phonemes);
assert!(gb_phonemes.contains("ʃ"), "GB schedule usually starts with 'ʃ', got: {}", gb_phonemes);
}
}