use std::path::{Path, PathBuf};
use anyhow::Result;
#[cfg(not(feature = "espeak"))]
use anyhow::anyhow;
use once_cell::sync::OnceCell;
static DATA_PATH: OnceCell<PathBuf> = OnceCell::new();
pub fn set_data_path(path: &Path) {
let _ = DATA_PATH.set(path.to_path_buf());
}
#[cfg(feature = "espeak")]
mod inner {
use std::ffi::{CStr, CString};
use std::os::raw::{c_char, c_int, c_void};
use std::sync::Mutex;
use anyhow::{anyhow, Result};
use once_cell::sync::OnceCell;
use super::DATA_PATH;
extern "C" {
fn espeak_ng_InitializePath(path: *const c_char);
fn espeak_ng_Initialize(context: *mut c_void) -> c_int;
fn espeak_ng_SetVoiceByName(name: *const c_char) -> c_int;
fn espeak_TextToPhonemes(
textptr: *mut *const c_void,
textmode: c_int,
phonememode: c_int,
) -> *const c_char;
}
const CHARS_UTF8: c_int = 1;
const PHONEMES_IPA: c_int = 0x02;
pub(super) static LOCK: Mutex<()> = Mutex::new(());
pub(super) static INIT: OnceCell<std::result::Result<(), String>> = OnceCell::new();
pub(super) fn do_init() -> std::result::Result<(), String> {
unsafe {
let path_cstr: Option<CString> = DATA_PATH.get().map(|p| {
CString::new(p.to_string_lossy().as_bytes())
.expect("espeak data path contains a null byte")
});
let path_ptr: *const c_char =
path_cstr.as_ref().map_or(std::ptr::null(), |c| c.as_ptr());
espeak_ng_InitializePath(path_ptr);
let status = espeak_ng_Initialize(std::ptr::null_mut());
if status != 0 {
return Err(format!(
"espeak_ng_Initialize failed (status {:#010x})",
status
));
}
let voice = CString::new("en-us").unwrap();
let rc = espeak_ng_SetVoiceByName(voice.as_ptr());
if rc != 0 {
return Err(format!(
"espeak_ng_SetVoiceByName(\"en-us\") failed (rc {})",
rc
));
}
}
Ok(())
}
pub(super) fn is_available() -> bool {
let _guard = LOCK.lock().unwrap_or_else(|p| p.into_inner());
INIT.get_or_init(do_init).is_ok()
}
pub(super) fn run_phonemize(text: &str) -> Result<String> {
let _guard = LOCK.lock().unwrap_or_else(|p| p.into_inner());
INIT.get_or_init(do_init)
.as_ref()
.map_err(|e| anyhow!("espeak-ng: {}", e))?;
let text_c = CString::new(text)
.map_err(|_| anyhow!("phonemize: text contains a null byte"))?;
let mut current: *const c_void = text_c.as_ptr() as *const c_void;
let mut parts: Vec<String> = Vec::new();
unsafe {
while !current.is_null() {
let phonemes_ptr =
espeak_TextToPhonemes(&mut current, CHARS_UTF8, PHONEMES_IPA);
if phonemes_ptr.is_null() {
continue;
}
let chunk = CStr::from_ptr(phonemes_ptr)
.to_str()
.map_err(|_| anyhow!("espeak-ng returned non-UTF-8 phonemes"))?
.trim()
.to_owned();
if !chunk.is_empty() {
parts.push(chunk);
}
}
}
Ok(parts.join(" "))
}
}
pub fn is_espeak_available() -> bool {
#[cfg(feature = "espeak")]
{
inner::is_available()
}
#[cfg(not(feature = "espeak"))]
{
false
}
}
pub fn phonemize(text: &str) -> Result<String> {
#[cfg(feature = "espeak")]
{
inner::run_phonemize(text)
}
#[cfg(not(feature = "espeak"))]
{
let _ = text;
Err(anyhow!(
"phonemize() requires the `espeak` Cargo feature.\n\
Enable it with: kittentts = {{ features = [\"espeak\"] }}\n\
Or use generate_from_ipa() to bypass phonemisation."
))
}
}
#[cfg(all(test, feature = "espeak"))]
mod tests {
use super::*;
#[test]
fn test_availability() {
assert!(
is_espeak_available(),
"espeak-ng library initialised but is_espeak_available() returned false"
);
}
#[test]
fn test_phonemize_hello() {
let ipa = phonemize("Hello world").expect("phonemize failed");
assert!(!ipa.is_empty(), "IPA output should not be empty");
assert!(
ipa.contains('h') || ipa.contains('ɛ') || ipa.contains('l'),
"unexpected IPA for 'Hello world': {ipa}"
);
println!("IPA: {ipa}");
}
#[test]
fn test_phonemize_punctuation() {
let ipa = phonemize("Hello, world.").expect("phonemize failed");
assert!(!ipa.is_empty());
}
#[test]
fn test_phonemize_empty() {
let ipa = phonemize("").expect("phonemize failed");
assert!(ipa.trim().is_empty(), "expected empty IPA for empty input, got: {ipa}");
}
}