diff --git a/Cargo.lock b/Cargo.lock index 8051905..6bb65e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -93,6 +93,8 @@ name = "espeak-synth" version = "0.1.0" dependencies = [ "espeak-sys", + "hound", + "thiserror", ] [[package]] @@ -115,6 +117,12 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "hound" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" + [[package]] name = "itertools" version = "0.13.0" @@ -248,6 +256,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "unicode-ident" version = "1.0.22" diff --git a/Cargo.toml b/Cargo.toml index 2d65a23..75515b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,3 +17,7 @@ license.workspace = true [dependencies] espeak-sys = { path = "espeak-sys" } +thiserror = "2.0.18" + +[dev-dependencies] +hound = "3.5.1" diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..e2bde25 --- /dev/null +++ b/build.rs @@ -0,0 +1,6 @@ +fn main() { + let data_dir = + std::env::var("DEP_ESPEAK_SYS_DATA_DIR").expect("espeak-sys should export data-dir"); + + println!("cargo:rustc-env=ESPEAK_NG_DATA_DIR={}", data_dir); +} diff --git a/espeak-sys/Cargo.toml b/espeak-sys/Cargo.toml index d1d6523..aeb32ba 100644 --- a/espeak-sys/Cargo.toml +++ b/espeak-sys/Cargo.toml @@ -5,6 +5,8 @@ edition.workspace = true authors.workspace = true license.workspace = true +links = "espeak-sys" + [dependencies] [build-dependencies] diff --git a/espeak-sys/build.rs b/espeak-sys/build.rs index 57f9cc7..47172ab 100644 --- a/espeak-sys/build.rs +++ b/espeak-sys/build.rs @@ -7,6 +7,10 @@ fn main() { let espeak_src = manifest_dir.join("espeak-ng"); let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); + // espeak-ng-data/ will contain the Espeak voices + let data_dir = out_dir.join("share/espeak-ng-data"); + println!("cargo:data-dir={}", data_dir.display()); + // for faster builds unsafe { env::set_var( @@ -29,10 +33,6 @@ fn main() { .define("USE_LIBSONIC", "OFF") .define("USE_LIBPCAUDIO", "OFF") .define("USE_SPEECHPLAYER", "OFF") - .define("COMPILE_INTONATIONS", "OFF") - .define("COMPILE_PHONEMES", "OFF") - .define("COMPILE_DICTIONARIES", "OFF") - .always_configure(false) .very_verbose(env::var("CMAKE_VERBOSE").is_ok()) .build(); @@ -65,8 +65,9 @@ fn main() { println!("cargo:rustc-link-lib=c++"); } - if cfg!(all(debug_assertions, windows)) { - println!("cargo:rustc-link-lib=dylib=msvcrtd"); + if cfg!(target_os = "windows") { + // needed to find the data path + println!("cargo:rustc-link-lib=advapi32"); } // Generate bindings diff --git a/espeak-sys/src/lib.rs b/espeak-sys/src/lib.rs index 15bf66a..4908bae 100644 --- a/espeak-sys/src/lib.rs +++ b/espeak-sys/src/lib.rs @@ -2,5 +2,7 @@ #![allow(non_camel_case_types)] #![allow(non_snake_case)] #![allow(unnecessary_transmutes)] +#![allow(clippy::ptr_offset_with_cast)] +#![allow(clippy::missing_safety_doc)] include!(concat!(env!("OUT_DIR"), "/bindings.rs")); diff --git a/src/callback.rs b/src/callback.rs new file mode 100644 index 0000000..109ce18 --- /dev/null +++ b/src/callback.rs @@ -0,0 +1,77 @@ +use espeak_sys::espeak_EVENT; +use std::ffi::{c_int, c_short}; + +pub(crate) unsafe extern "C" fn synth_callback( + wav: *mut c_short, + num_samples: c_int, + events: *mut espeak_EVENT, +) -> c_int { + if wav.is_null() || num_samples <= 0 || events.is_null() { + return 0; + } + + let user_data = unsafe { (*events).user_data }; + if user_data.is_null() { + return 0; + } + + let buffer = unsafe { &mut *(user_data as *mut Vec) }; + let slice = unsafe { std::slice::from_raw_parts(wav, num_samples as usize) }; + buffer.extend_from_slice(slice); + + 0 +} + +#[cfg(test)] +mod tests { + use super::*; + use std::ptr; + + fn create_mock_event(user_data: *mut std::ffi::c_void) -> espeak_EVENT { + espeak_EVENT { + type_: 0, + unique_identifier: 0, + text_position: 0, + length: 0, + audio_position: 0, + sample: 0, + user_data, + id: espeak_sys::espeak_EVENT__bindgen_ty_1 { number: 0 }, + } + } + + #[test] + fn appends_samples_to_buffer() { + let mut buffer: Vec = Vec::new(); + let mut wav_data: Vec = vec![1, 2, 3, 4, 5, 6]; + let mut event = create_mock_event(&mut buffer as *mut Vec as *mut _); + + let result = + unsafe { synth_callback(wav_data.as_mut_ptr(), wav_data.len() as c_int, &mut event) }; + + assert_eq!(result, 0); + assert_eq!(buffer, vec![1, 2, 3, 4, 5, 6]); + } + + #[test] + fn null_wav_returns_zero() { + let mut buffer: Vec = Vec::new(); + let mut event = create_mock_event(&mut buffer as *mut Vec as *mut _); + + let result = unsafe { synth_callback(ptr::null_mut(), 10, &mut event) }; + + assert_eq!(result, 0); + assert!(buffer.is_empty()); + } + + #[test] + fn null_user_data_returns_zero() { + let mut wav_data: Vec = vec![1, 2, 3]; + let mut event = create_mock_event(ptr::null_mut()); + + let result = + unsafe { synth_callback(wav_data.as_mut_ptr(), wav_data.len() as c_int, &mut event) }; + + assert_eq!(result, 0); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..50da08f --- /dev/null +++ b/src/error.rs @@ -0,0 +1,84 @@ +use std::{ffi::NulError, str::Utf8Error}; + +use espeak_sys::{ + espeak_ERROR, espeak_ERROR_EE_BUFFER_FULL as BUFFER_FULL, + espeak_ERROR_EE_INTERNAL_ERROR as INTERNAL_ERROR, espeak_ERROR_EE_NOT_FOUND as NOT_FOUND, +}; + +use super::EspeakParam; + +#[derive(Clone, Debug, thiserror::Error)] +pub enum Error { + #[error("espeak operation failed: {}", espeak_error_msg(*.0))] + Espeak(espeak_ERROR), + + #[error("no voices available")] + NoVoicesAvailable, + + #[error("invalid value for '{0:?}': {1}")] + InvalidParamValue(EspeakParam, u32), + + #[error(transparent)] + NullPointer(#[from] NulError), + + #[error(transparent)] + InvalidUtf8(#[from] Utf8Error), +} + +fn espeak_error_msg(code: espeak_ERROR) -> &'static str { + match code { + INTERNAL_ERROR => "internal error", + BUFFER_FULL => "buffer full", + NOT_FOUND => "not found", + _ => "unknown error", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn espeak_variant_to_string_returns_expected() { + assert_eq!( + Error::Espeak(INTERNAL_ERROR).to_string(), + "espeak operation failed: internal error" + ); + assert_eq!( + Error::Espeak(BUFFER_FULL).to_string(), + "espeak operation failed: buffer full" + ); + assert_eq!( + Error::Espeak(NOT_FOUND).to_string(), + "espeak operation failed: not found" + ); + assert_eq!( + Error::Espeak(10).to_string(), + "espeak operation failed: unknown error" + ); + } + + #[test] + fn invalid_param_value_variant_to_string_returns_expected() { + assert_eq!( + Error::InvalidParamValue(EspeakParam::Amplitude, 666).to_string(), + "invalid value for 'Amplitude': 666" + ); + assert_eq!( + Error::InvalidParamValue(EspeakParam::Pitch, 666).to_string(), + "invalid value for 'Pitch': 666" + ); + assert_eq!( + Error::InvalidParamValue(EspeakParam::PitchRange, 666).to_string(), + "invalid value for 'PitchRange': 666" + ); + assert_eq!( + Error::InvalidParamValue(EspeakParam::Speed, 666).to_string(), + "invalid value for 'Speed': 666" + ); + assert_eq!( + Error::InvalidParamValue(EspeakParam::WordGap, 666).to_string(), + "invalid value for 'WordGap': 666" + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index b93cf3f..6c894f0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,14 +1,238 @@ -pub fn add(left: u64, right: u64) -> u64 { - left + right +use std::ffi::{CStr, CString, c_void}; +use std::num::NonZeroU32; +use std::path::Path; +use std::ptr; + +use espeak_sys::*; + +mod callback; +mod error; +mod parameter; + +pub use error::*; +pub use parameter::*; + +pub struct EspeakSynth { + sample_rate: NonZeroU32, +} + +impl Default for EspeakSynth { + fn default() -> Self { + let data_dir = env!("ESPEAK_NG_DATA_DIR"); + Self::new(Path::new(data_dir)) + } +} + +impl Drop for EspeakSynth { + fn drop(&mut self) { + unsafe { espeak_Terminate() }; + } +} + +impl EspeakSynth { + pub fn new(data_dir: &Path) -> Self { + if !data_dir.exists() { + panic!( + "espeak-ng-data directory does not exist: {}", + data_dir.display() + ) + } + + let data_dir = CString::new(data_dir.to_str().unwrap()).unwrap(); + let sample_rate = unsafe { + espeak_Initialize( + espeak_AUDIO_OUTPUT_AUDIO_OUTPUT_SYNCHRONOUS, + 0, + data_dir.as_ptr(), + 0, + ) + }; + + assert!( + sample_rate > 0, + "Espeak initialization failed with EE_INTERNAL_ERROR" + ); + + unsafe { + espeak_SetSynthCallback(Some(callback::synth_callback)); + }; + + Self { + sample_rate: NonZeroU32::new(sample_rate as u32).unwrap(), + } + } + + pub fn sample_rate(&self) -> NonZeroU32 { + self.sample_rate + } + + pub fn synthesize(&self, text: &str, audio_buffer: &mut Vec) -> Result<(), Error> { + let text = CString::new(text)?; + let result = unsafe { + espeak_Synth( + text.as_ptr().cast(), + text.as_bytes_with_nul().len(), + 0, + espeak_POSITION_TYPE_POS_WORD, + 0, + 0, + ptr::null_mut(), + audio_buffer as *mut Vec as *mut c_void, + ) + }; + + if result != espeak_ERROR_EE_OK { + return Err(Error::Espeak(result)); + } + + Ok(()) + } + + pub fn available_voices(&self) -> Result, Error> { + let voices_ptr = unsafe { espeak_ListVoices(ptr::null_mut()) }; + if voices_ptr.is_null() { + return Err(Error::NoVoicesAvailable); + } + + let mut voices: Vec = Vec::new(); + let mut i = 0; + + loop { + let voice = unsafe { *voices_ptr.add(i) }; + if voice.is_null() { + break; + } + + unsafe { + let voice = &*voice; + if !voice.name.is_null() { + let name = CStr::from_ptr(voice.name).to_str()?.to_string(); + voices.push(name); + } + } + + i += 1; + } + + Ok(voices) + } + + pub fn set_voice(&self, voice: &str) -> Result<(), Error> { + let s = CString::new(voice)?; + + let result = unsafe { espeak_SetVoiceByName(s.as_ptr()) }; + if result != espeak_ERROR_EE_OK { + return Err(Error::Espeak(result)); + } + + Ok(()) + } + + pub fn set_parameter(&self, param: EspeakParam, value: u32) -> Result<(), Error> { + validate_param_value(param, value)?; + + let result = unsafe { espeak_SetParameter(param as _, value as _, 0) }; + if result != espeak_ERROR_EE_OK { + return Err(Error::Espeak(result)); + } + + Ok(()) + } } #[cfg(test)] mod tests { use super::*; + use hound::WavReader; + + const REFERENCE_OUTPUT_WAV: &str = "testdata/dies_ist_ein_test.wav"; + const REFERENCE_TEXT: &str = "Dies ist ein Test"; + const REFERENCE_VOICE: &str = "German"; + const REFERENCE_PITCH: u32 = 40; + const REFERNECE_SPEED: u32 = 80; + + #[test] + fn default_initializes_espeak() { + let espeak = EspeakSynth::default(); + assert!(espeak.sample_rate.get() >= 22050); + } + + #[test] + #[should_panic = "espeak-ng-data directory does not exist: ./invalid"] + fn new_with_non_existent_data_dir_panics() { + let non_existent = Path::new("./invalid"); + let _ = EspeakSynth::new(non_existent); + } + + #[test] + fn available_voices_valid_data_dir_result_contains_expected_voices() { + let espeak = EspeakSynth::default(); + let voices = espeak.available_voices().unwrap(); + assert!(voices.contains(&"German".to_owned())); + assert!(voices.contains(&"English (Great Britain)".to_owned())); + } + + #[test] + fn set_voice_valid_returns_ok() { + let espeak = EspeakSynth::default(); + let result = espeak.set_voice("German"); + assert!(result.is_ok()); + } #[test] - fn it_works() { - let result = add(2, 2); - assert_eq!(result, 4); + fn set_voice_invalid_returns_ee_not_found_err() { + let espeak = EspeakSynth::default(); + let err = espeak.set_voice("Invalid").unwrap_err(); + assert!(matches!(err, Error::Espeak(code) if code == espeak_ERROR_EE_NOT_FOUND)); + } + + #[test] + fn set_parameter_valid_case_returns_ok() { + let espeak = EspeakSynth::default(); + let result = espeak.set_parameter(EspeakParam::Amplitude, 100); + assert!(result.is_ok()); + } + + #[test] + fn set_parameter_invalid_returns_err() { + let espeak = EspeakSynth::default(); + let err = espeak + .set_parameter(EspeakParam::Amplitude, 101) + .unwrap_err(); + assert!( + matches!(err, Error::InvalidParamValue(p, v) if p == EspeakParam::Amplitude && v == 101) + ); + } + + #[test] + fn synthesize_with_default_settings_works() { + let espeak = EspeakSynth::default(); + let mut buffer = Vec::new(); + + espeak.synthesize("test", &mut buffer).unwrap(); + assert!(!buffer.is_empty()); + } + + #[test] + fn synthesize_known_settings_result_matches_reference() { + let espeak = EspeakSynth::default(); + let mut buffer = Vec::new(); + + espeak.set_voice(REFERENCE_VOICE).unwrap(); + espeak + .set_parameter(EspeakParam::Pitch, REFERENCE_PITCH) + .unwrap(); + espeak + .set_parameter(EspeakParam::Speed, REFERNECE_SPEED) + .unwrap(); + + espeak.synthesize(REFERENCE_TEXT, &mut buffer).unwrap(); + assert!(!buffer.is_empty()); + + let reference_wav = WavReader::open(REFERENCE_OUTPUT_WAV).unwrap(); + let reference_samples: Vec = + reference_wav.into_samples().map(|s| s.unwrap()).collect(); + + assert_eq!(buffer, reference_samples); } } diff --git a/src/parameter.rs b/src/parameter.rs new file mode 100644 index 0000000..34eeaa1 --- /dev/null +++ b/src/parameter.rs @@ -0,0 +1,91 @@ +use super::Error; + +pub const MAX_AMPLITUDE: u32 = 100; + +pub const MAX_PITCH: u32 = 100; + +pub const MAX_PITCH_RANGE: u32 = 100; + +pub const MAX_WORD_GAP: u32 = 100; + +pub const MIN_SPEED: u32 = 80; + +pub const MAX_SPEED: u32 = 450; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EspeakParam { + Amplitude = 2, + Pitch = 3, + PitchRange = 4, + Speed = 1, + WordGap = 7, +} + +pub(crate) fn validate_param_value(param: EspeakParam, value: u32) -> Result<(), Error> { + let (min, max) = match param { + EspeakParam::Amplitude => (0, MAX_AMPLITUDE), + EspeakParam::Pitch => (0, MAX_PITCH), + EspeakParam::PitchRange => (0, MAX_PITCH_RANGE), + EspeakParam::Speed => (MIN_SPEED, MAX_SPEED), + EspeakParam::WordGap => (0, MAX_WORD_GAP), + }; + + if !(min..=max).contains(&value) { + return Err(Error::InvalidParamValue(param, value)); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_param_value_valid_cases_return_ok() { + let test_cases = vec![ + (EspeakParam::Amplitude, 0), + (EspeakParam::Amplitude, 50), + (EspeakParam::Amplitude, 100), + (EspeakParam::Pitch, 0), + (EspeakParam::Pitch, 50), + (EspeakParam::Pitch, 100), + (EspeakParam::PitchRange, 0), + (EspeakParam::PitchRange, 50), + (EspeakParam::PitchRange, 100), + (EspeakParam::Speed, 80), + (EspeakParam::Speed, 200), + (EspeakParam::Speed, 450), + (EspeakParam::WordGap, 0), + (EspeakParam::WordGap, 50), + (EspeakParam::WordGap, 100), + ]; + + for (param, val) in test_cases { + assert!( + validate_param_value(param, val).is_ok(), + "({param:?}, {val}) returned err" + ); + } + } + + #[test] + fn validate_param_value_invalid_cases_return_err() { + let test_cases = vec![ + (EspeakParam::Amplitude, 101), + (EspeakParam::Pitch, 101), + (EspeakParam::PitchRange, 101), + (EspeakParam::Speed, 79), + (EspeakParam::Speed, 451), + (EspeakParam::WordGap, 101), + ]; + + for (param, val) in test_cases { + let result = validate_param_value(param, val); + assert!(result.is_err(), "({param:?}, {val}) returned err"); + assert!( + matches!(result.unwrap_err(), Error::InvalidParamValue(p, v) if p == param && v == val) + ); + } + } +} diff --git a/testdata/dies_ist_ein_test.wav b/testdata/dies_ist_ein_test.wav new file mode 100644 index 0000000..7c1b80c Binary files /dev/null and b/testdata/dies_ist_ein_test.wav differ