use dengjen_tts::{AudioOutputConfig, DengjenSpeechSynthesizer, StreamMode, SYNTHESIS_THREAD_POOL};
use dengjen_tts_core::{
AudioSamples, CancellationToken, DengjenError, DengjenModel, DengjenResult,
};
use ffi_support::{call_with_result, define_string_destructor, ErrorCode, ExternError, FfiStr};
use std::ops::Deref;
use std::os::raw::c_void;
use std::panic::AssertUnwindSafe;
use std::path::PathBuf;
use std::sync::{Arc, Mutex, Once};
#[repr(transparent)]
struct UserDataPtr(*mut c_void);
unsafe impl Send for UserDataPtr {}
pub type SpeechSynthesisCallback = extern "C" fn(SynthesisEvent, *mut c_void) -> u8;
#[inline]
fn invoke_callback(cb: SpeechSynthesisCallback, event: SynthesisEvent, user_data: UserDataPtr) {
cb(event, user_data.0);
}
define_string_destructor!(_internal_libdengjenFreeString);
ffi_support::implement_into_ffi_by_pointer!(DengjenVoice);
ffi_support::define_box_destructor!(DengjenVoice, _internal_libdengjenUnloadDengjenVoice);
ffi_support::implement_into_ffi_by_pointer!(PiperSynthConfig);
ffi_support::define_box_destructor!(PiperSynthConfig, _internal_libdengjenFreePiperSynthConfig);
static INIT_ORT_ENVIRONMENT: Once = Once::new();
pub mod error_codes {
pub const INVALID_SYNTHESIS_MODE: i32 = 16;
pub const FAILED_TO_LOAD_RESOURCE: i32 = 17;
pub const PHONEMIZATION_ERROR: i32 = 18;
pub const OPERATION_ERROR: i32 = 19;
pub const INVALID_UTF8_SEQUENCE: i32 = 20;
pub const UNKNOWN_ERROR: i32 = 21;
pub const NULL_POINTER: i32 = 22;
pub const INFERENCE_ERROR: i32 = 23;
pub const INVALID_CONFIGURATION: i32 = 24;
pub const UNSUPPORTED_OPERATION: i32 = 25;
}
pub mod synth_event {
pub const SYNTH_EVENT_SPEECH: i32 = 0;
pub const SYNTH_EVENT_FINISHED: i32 = 1;
pub const SYNTH_EVENT_ERROR: i32 = 2;
}
pub mod synth_mode {
pub const SYNTH_MODE_LAZY: i32 = 0;
pub const SYNTH_MODE_PARALLEL: i32 = 1;
pub const SYNTH_MODE_REALTIME: i32 = 2;
}
pub const PIPER_SYNTH_CONFIG_NO_SPEAKER: u32 = u32::MAX;
pub struct DengjenVoice {
synth: AssertUnwindSafe<Arc<DengjenSpeechSynthesizer>>,
active_cancel_token: Arc<Mutex<Option<CancellationToken>>>,
}
impl DengjenVoice {
fn wrapping(synth: Arc<DengjenSpeechSynthesizer>) -> Self {
Self {
active_cancel_token: Arc::new(Mutex::new(None)),
synth: AssertUnwindSafe(synth),
}
}
}
impl From<DengjenSpeechSynthesizer> for DengjenVoice {
fn from(synthesizer: DengjenSpeechSynthesizer) -> Self {
Self::wrapping(Arc::new(synthesizer))
}
}
impl Deref for DengjenVoice {
type Target = DengjenSpeechSynthesizer;
fn deref(&self) -> &Self::Target {
&self.synth
}
}
impl<T> AsRef<T> for DengjenVoice
where
T: ?Sized,
<DengjenVoice as Deref>::Target: AsRef<T>,
{
fn as_ref(&self) -> &T {
(**self.synth).as_ref()
}
}
#[derive(Debug)]
pub struct DengjenFFIError(i32, String);
impl DengjenFFIError {
fn with_code(code: i32, message: impl Into<String>) -> Self {
Self(code, message.into())
}
fn invalid_utf8() -> Self {
Self::with_code(
error_codes::INVALID_UTF8_SEQUENCE,
"input string is not valid UTF-8",
)
}
fn invalid_synthesis_mode() -> Self {
Self::with_code(
error_codes::INVALID_SYNTHESIS_MODE,
"synthesis mode is not a recognized value",
)
}
fn null_pointer(param_name: &str) -> Self {
Self::with_code(
error_codes::NULL_POINTER,
format!("parameter `{param_name}` must not be null"),
)
}
}
impl From<DengjenError> for DengjenFFIError {
fn from(error: DengjenError) -> Self {
let code = match &error {
DengjenError::FailedToLoadResource(_) => error_codes::FAILED_TO_LOAD_RESOURCE,
DengjenError::PhonemizationError(_) => error_codes::PHONEMIZATION_ERROR,
DengjenError::InferenceError(_) => error_codes::INFERENCE_ERROR,
DengjenError::InvalidConfiguration(_) => error_codes::INVALID_CONFIGURATION,
DengjenError::UnsupportedOperation(_) => error_codes::UNSUPPORTED_OPERATION,
DengjenError::OperationError(_) => error_codes::OPERATION_ERROR,
};
let (DengjenError::FailedToLoadResource(message)
| DengjenError::PhonemizationError(message)
| DengjenError::InferenceError(message)
| DengjenError::InvalidConfiguration(message)
| DengjenError::UnsupportedOperation(message)
| DengjenError::OperationError(message)) = error;
Self::with_code(code, message)
}
}
impl From<DengjenFFIError> for ExternError {
fn from(error: DengjenFFIError) -> Self {
ExternError::new_error(ErrorCode::new(error.0), error.1)
}
}
pub type DengjenFFIResult<T> = Result<T, DengjenFFIError>;
#[repr(C)]
pub struct SynthesisEvent {
event_type: i32,
error_ptr: *mut ExternError,
len: i64,
data: *mut u8,
}
impl SynthesisEvent {
fn leak_bytes(bytes: Vec<u8>) -> (i64, *mut u8) {
let boxed: Box<[u8]> = bytes.into_boxed_slice();
let len = boxed.len() as i64;
let ptr = Box::into_raw(boxed) as *mut u8;
(len, ptr)
}
fn with_speech(speech: Vec<u8>) -> Self {
let (len, data) = Self::leak_bytes(speech);
Self {
event_type: synth_event::SYNTH_EVENT_SPEECH,
error_ptr: std::ptr::null_mut(),
len,
data,
}
}
fn with_error(error: impl Into<ExternError>) -> Self {
let (len, data) = Self::leak_bytes(Vec::new());
Self {
event_type: synth_event::SYNTH_EVENT_ERROR,
error_ptr: Box::into_raw(Box::new(error.into())),
len,
data,
}
}
fn with_finished() -> Self {
let (len, data) = Self::leak_bytes(Vec::new());
Self {
event_type: synth_event::SYNTH_EVENT_FINISHED,
error_ptr: std::ptr::null_mut(),
len,
data,
}
}
}
#[repr(C)]
pub struct AudioInfo {
sample_rate: u32,
num_channels: u32,
sample_width: u32,
}
#[derive(Clone)]
#[repr(C)]
pub struct SynthesisParams {
mode: i32,
rate: u8,
volume: u8,
pitch: u8,
appended_silence_ms: u32,
callback: Option<extern "C" fn(SynthesisEvent, *mut c_void) -> u8>,
nonblocking: u8,
user_data: *mut c_void,
}
unsafe impl Send for SynthesisParams {}
impl SynthesisParams {
fn as_synth_output_config(&self) -> AudioOutputConfig {
let &Self {
rate,
volume,
pitch,
appended_silence_ms,
..
} = self;
AudioOutputConfig {
appended_silence_ms: Some(appended_silence_ms),
pitch: Some(pitch),
rate: Some(rate),
volume: Some(volume),
}
}
}
#[repr(C)]
pub struct PiperSynthConfig {
speaker: u32,
length_scale: f32,
noise_scale: f32,
noise_w: f32,
}
impl PiperSynthConfig {
fn as_piper_synth_config(&self) -> dengjen_tts_piper::PiperSynthesisConfig {
let &Self {
speaker,
length_scale,
noise_scale,
noise_w,
} = self;
dengjen_tts_piper::PiperSynthesisConfig {
speaker: (speaker != PIPER_SYNTH_CONFIG_NO_SPEAKER).then(|| i64::from(speaker)),
length_scale,
noise_scale,
noise_w,
}
}
}
unsafe fn require_ref<'a, T>(
ptr: *const T,
param_name: &str,
out_error: &mut ExternError,
) -> Option<&'a T> {
match unsafe { ptr.as_ref() } {
Some(value) => Some(value),
None => {
*out_error = DengjenFFIError::null_pointer(param_name).into();
None
}
}
}
unsafe fn require_mut<'a, T>(
ptr: *mut T,
param_name: &str,
out_error: &mut ExternError,
) -> Option<&'a mut T> {
match unsafe { ptr.as_mut() } {
Some(value) => Some(value),
None => {
*out_error = DengjenFFIError::null_pointer(param_name).into();
None
}
}
}
#[no_mangle]
#[allow(non_snake_case)]
pub unsafe extern "C" fn libdengjenFreeString(string_ptr: *mut std::os::raw::c_char) {
unsafe { _internal_libdengjenFreeString(string_ptr) };
}
#[no_mangle]
#[allow(non_snake_case)]
pub unsafe extern "C" fn libdengjenFreePiperSynthConfig(synth_config: *mut PiperSynthConfig) {
unsafe { _internal_libdengjenFreePiperSynthConfig(synth_config) };
}
#[no_mangle]
#[allow(non_snake_case)]
pub unsafe extern "C" fn libdengjenFreeSynthesisEvent(event: SynthesisEvent) {
ffi_support::abort_on_panic::with_abort_on_panic(|| {
let SynthesisEvent {
error_ptr,
data,
len,
..
} = event;
drop(unsafe { Box::from_raw(std::ptr::slice_from_raw_parts_mut(data, len as usize)) });
if !error_ptr.is_null() {
let boxed_error = unsafe { Box::from_raw(error_ptr) };
unsafe { boxed_error.manually_release() };
}
});
}
#[no_mangle]
#[allow(non_snake_case)]
pub extern "C" fn libdengjenLoadVoiceFromConfigPath(
config_path_ptr: FfiStr,
out_error: &mut ExternError,
) -> *mut DengjenVoice {
let load_from_config = move || _load_voice(config_path_ptr);
call_with_result(out_error, load_from_config)
}
#[no_mangle]
#[allow(non_snake_case)]
pub unsafe extern "C" fn libdengjenUnloadDengjenVoice(voice_ptr: *mut DengjenVoice) {
unsafe { _internal_libdengjenUnloadDengjenVoice(voice_ptr) };
}
#[no_mangle]
#[allow(non_snake_case)]
pub unsafe extern "C" fn libdengjenGetAudioInfo(
voice_ptr: *mut DengjenVoice,
audio_info_ptr: *mut AudioInfo,
out_error: &mut ExternError,
) {
let Some(voice) = (unsafe { require_ref(voice_ptr, "voice_ptr", out_error) }) else {
return;
};
let Some(audio_info) = (unsafe { require_mut(audio_info_ptr, "audio_info_ptr", out_error) })
else {
return;
};
let mut out = AssertUnwindSafe(audio_info);
call_with_result(out_error, move || match voice.audio_output_info() {
Ok(info) => {
out.sample_rate = info.sample_rate as u32;
out.num_channels = info.num_channels as u32;
out.sample_width = info.sample_width as u32;
Ok(())
}
Err(e) => Err(DengjenFFIError::from(e)),
})
}
#[no_mangle]
#[allow(non_snake_case)]
pub unsafe extern "C" fn libdengjenGetPiperDefaultSynthConfig(
voice_ptr: *mut DengjenVoice,
out_error: &mut ExternError,
) -> *mut PiperSynthConfig {
let Some(voice) = (unsafe { require_ref(voice_ptr, "voice_ptr", out_error) }) else {
return std::ptr::null_mut();
};
call_with_result(out_error, move || {
let config = voice
.get_default_synthesis_config()
.map_err(DengjenFFIError::from)?
.ok_or_else(|| {
DengjenFFIError::with_code(
error_codes::INVALID_CONFIGURATION,
"voice has no default Piper synthesis config to return",
)
})?;
let piper_config = dengjen_tts_piper::PiperSynthesisConfig::from(&config);
Ok::<_, DengjenFFIError>(PiperSynthConfig {
speaker: piper_config
.speaker
.map_or(PIPER_SYNTH_CONFIG_NO_SPEAKER, |sid| sid as u32),
length_scale: piper_config.length_scale,
noise_scale: piper_config.noise_scale,
noise_w: piper_config.noise_w,
})
})
}
#[no_mangle]
#[allow(non_snake_case)]
pub unsafe extern "C" fn libdengjenSetPiperSynthConfig(
voice_ptr: *mut DengjenVoice,
synth_config: PiperSynthConfig,
out_error: &mut ExternError,
) {
let Some(voice) = (unsafe { require_ref(voice_ptr, "voice_ptr", out_error) }) else {
return;
};
let new_config = dengjen_tts_core::SynthesisConfig::from(&synth_config.as_piper_synth_config());
call_with_result(out_error, move || {
voice
.set_fallback_synthesis_config(&new_config)
.map_err(DengjenFFIError::from)
})
}
#[no_mangle]
#[allow(non_snake_case)]
pub unsafe extern "C" fn libdengjenSetSynthesisParameter(
voice_ptr: *mut DengjenVoice,
key_ptr: FfiStr,
value: f32,
out_error: &mut ExternError,
) {
let Some(voice) = (unsafe { require_ref(voice_ptr, "voice_ptr", out_error) }) else {
return;
};
call_with_result(out_error, move || {
let Some(key) = key_ptr.into_opt_string() else {
return Err(DengjenFFIError::invalid_utf8());
};
let mut config = voice
.get_fallback_synthesis_config()
.map_err(DengjenFFIError::from)?
.unwrap_or_default();
config.parameters.insert(key, value);
voice
.set_fallback_synthesis_config(&config)
.map_err(DengjenFFIError::from)
})
}
#[no_mangle]
#[allow(non_snake_case)]
pub unsafe extern "C" fn libdengjenGetSynthesisParameter(
voice_ptr: *mut DengjenVoice,
key_ptr: FfiStr,
out_value_ptr: *mut f32,
out_error: &mut ExternError,
) -> bool {
let Some(voice) = (unsafe { require_ref(voice_ptr, "voice_ptr", out_error) }) else {
return false;
};
let Some(out_value) = (unsafe { require_mut(out_value_ptr, "out_value_ptr", out_error) })
else {
return false;
};
let mut out_value = AssertUnwindSafe(out_value);
(call_with_result(out_error, move || {
let Some(key) = key_ptr.into_opt_string() else {
return Err(DengjenFFIError::invalid_utf8());
};
let config = voice
.get_fallback_synthesis_config()
.map_err(DengjenFFIError::from)?
.unwrap_or_default();
match config.parameters.get(&key) {
Some(value) => {
**out_value = *value;
Ok::<_, DengjenFFIError>(true)
}
None => Ok(false),
}
}) as u8)
!= 0
}
#[no_mangle]
#[allow(non_snake_case)]
pub unsafe extern "C" fn libdengjenSpeak(
voice_ptr: *mut DengjenVoice,
text_ptr: FfiStr,
params: SynthesisParams,
out_error: &mut ExternError,
) {
let Some(voice) = (unsafe { require_ref(voice_ptr, "voice_ptr", out_error) }) else {
return;
};
let owned_synth = AssertUnwindSafe(Arc::clone(&voice.synth));
let owned_cancel_slot = Arc::clone(&voice.active_cancel_token);
call_with_result(out_error, move || {
_synthesize(owned_synth, owned_cancel_slot, text_ptr, params)
})
}
#[no_mangle]
#[allow(non_snake_case)]
pub unsafe extern "C" fn libdengjenCancel(
voice_ptr: *mut DengjenVoice,
out_error: &mut ExternError,
) {
let Some(voice) = (unsafe { require_ref(voice_ptr, "voice_ptr", out_error) }) else {
return;
};
call_with_result(out_error, || _cancel(&voice.active_cancel_token))
}
#[no_mangle]
#[allow(non_snake_case)]
pub unsafe extern "C" fn libdengjenSpeakToFile(
voice_ptr: *mut DengjenVoice,
text_ptr: FfiStr,
params: SynthesisParams,
out_filename_ptr: FfiStr,
out_error: &mut ExternError,
) -> u8 {
let Some(voice) = (unsafe { require_ref(voice_ptr, "voice_ptr", out_error) }) else {
return 0;
};
let owned_synth = AssertUnwindSafe(Arc::clone(&voice.synth));
call_with_result(out_error, move || {
_synthesize_to_file(owned_synth, text_ptr, params, out_filename_ptr)?;
Ok::<u8, DengjenFFIError>(1)
})
}
fn init_ort_environment() {
INIT_ORT_ENVIRONMENT.call_once(|| {
#[cfg(target_os = "android")]
let execution_providers = vec![
ort::execution_providers::NNAPI::default().build(),
ort::execution_providers::CPU::default().build(),
];
#[cfg(target_os = "ios")]
let execution_providers = vec![
ort::execution_providers::CoreML::default().build(),
ort::execution_providers::CPU::default().build(),
];
#[cfg(not(any(target_os = "android", target_os = "ios")))]
let execution_providers = vec![ort::execution_providers::CPU::default().build()];
let committed = ort::init()
.with_name("dengjen")
.with_execution_providers(execution_providers)
.commit();
assert!(committed, "Failed to initialize onnxruntime");
});
}
fn load_voice(config_path: &std::path::Path) -> DengjenResult<Arc<dyn DengjenModel + Send + Sync>> {
let model_type = dengjen_tts::detect_model_type(config_path)?;
if model_type == "kokoro" {
return dengjen_tts_kokoro::from_config_path(config_path);
}
if model_type == "melotts" {
return dengjen_tts_melotts::from_config_path(config_path);
}
dengjen_tts_piper::from_config_path(config_path)
}
fn _load_voice(config_path_ptr: FfiStr) -> DengjenFFIResult<DengjenVoice> {
init_ort_environment();
let Some(config_path) = config_path_ptr.into_opt_string() else {
return Err(DengjenFFIError::invalid_utf8());
};
let model = load_voice(&PathBuf::from(config_path))?;
let synth = DengjenSpeechSynthesizer::new(model)?;
Ok(synth.into())
}
fn _cancel(cancel_slot: &Arc<Mutex<Option<CancellationToken>>>) -> DengjenFFIResult<()> {
let held_token = cancel_slot
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if let Some(active_token) = held_token.as_ref() {
active_token.cancel();
}
Ok(())
}
struct CancelSlotGuard {
slot: Arc<Mutex<Option<CancellationToken>>>,
token: CancellationToken,
}
impl Drop for CancelSlotGuard {
fn drop(&mut self) {
let mut held_token = self
.slot
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let still_owns_slot = held_token
.as_ref()
.is_some_and(|current| current.points_to_same_flag(&self.token));
if still_owns_slot {
*held_token = None;
}
}
}
fn _synthesize(
synth: AssertUnwindSafe<Arc<DengjenSpeechSynthesizer>>,
cancel_slot: Arc<Mutex<Option<CancellationToken>>>,
text_ptr: FfiStr,
params: SynthesisParams,
) -> DengjenFFIResult<()> {
let Some(text) = text_ptr.into_opt_string() else {
return Err(DengjenFFIError::invalid_utf8());
};
let Some(callback) = params.callback else {
return Err(DengjenFFIError::null_pointer("params.callback"));
};
if params.nonblocking == 0 {
return _do_synthesize(synth, cancel_slot, text, callback, params);
}
let report_to_caller = callback;
let report_user_data = UserDataPtr(params.user_data);
SYNTHESIS_THREAD_POOL.spawn(move || {
if let Err(error) = _do_synthesize(synth, cancel_slot, text, callback, params) {
invoke_callback(
report_to_caller,
SynthesisEvent::with_error(error),
report_user_data,
);
}
});
Ok(())
}
fn _do_synthesize(
synth: AssertUnwindSafe<Arc<DengjenSpeechSynthesizer>>,
cancel_slot: Arc<Mutex<Option<CancellationToken>>>,
text: String,
callback: SpeechSynthesisCallback,
params: SynthesisParams,
) -> DengjenFFIResult<()> {
const REALTIME_CHUNK_SIZE: usize = 72;
const REALTIME_CHUNK_PADDING: usize = 3;
let output_config = Some(params.as_synth_output_config());
let user_data = params.user_data;
let mut _release_slot_on_drop = None;
let mode = match params.mode {
synth_mode::SYNTH_MODE_LAZY => StreamMode::Lazy,
synth_mode::SYNTH_MODE_PARALLEL => StreamMode::Parallel,
synth_mode::SYNTH_MODE_REALTIME => {
let cancel_token = CancellationToken::new();
*cancel_slot
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(cancel_token.clone());
_release_slot_on_drop = Some(CancelSlotGuard {
slot: cancel_slot,
token: cancel_token.clone(),
});
StreamMode::Realtime {
chunk_size: REALTIME_CHUNK_SIZE,
chunk_padding: REALTIME_CHUNK_PADDING,
cancel_token,
}
}
_ => return Err(DengjenFFIError::invalid_synthesis_mode()),
};
let stream = synth
.synthesize_samples(text, output_config, mode)
.map_err(DengjenFFIError::from)?;
iterate_stream(stream, callback, user_data)
}
fn iterate_stream(
stream: impl Iterator<Item = DengjenResult<AudioSamples>> + Send + Sync + 'static,
callback: SpeechSynthesisCallback,
user_data: *mut c_void,
) -> DengjenFFIResult<()> {
for item in stream {
let audio = match item {
Ok(audio) => audio,
Err(error) => {
callback(
SynthesisEvent::with_error(DengjenFFIError::from(error)),
user_data,
);
return Ok(());
}
};
let caller_wants_more = callback(
SynthesisEvent::with_speech(audio.as_wave_bytes()),
user_data,
) == 0;
if !caller_wants_more {
return Ok(());
}
}
callback(SynthesisEvent::with_finished(), user_data);
Ok(())
}
fn _synthesize_to_file(
synth: AssertUnwindSafe<Arc<DengjenSpeechSynthesizer>>,
text_ptr: FfiStr,
params: SynthesisParams,
out_filename_ptr: FfiStr,
) -> DengjenFFIResult<()> {
let (Some(text), Some(out_filename)) = (
text_ptr.into_opt_string(),
out_filename_ptr.into_opt_string(),
) else {
return Err(DengjenFFIError::invalid_utf8());
};
synth
.synthesize_to_file(
&PathBuf::from(out_filename),
text,
Some(params.as_synth_output_config()),
)
.map_err(DengjenFFIError::from)
}
#[cfg(test)]
mod tests {
use super::*;
use ffi_support::ExternError;
extern "C" fn noop_callback(_event: SynthesisEvent, _user_data: *mut c_void) -> u8 {
1
}
fn synth_params() -> SynthesisParams {
SynthesisParams {
mode: synth_mode::SYNTH_MODE_LAZY,
rate: 50,
volume: 100,
pitch: 50,
appended_silence_ms: 0,
callback: Some(noop_callback),
nonblocking: 0,
user_data: std::ptr::null_mut(),
}
}
fn c_str(s: &str) -> std::ffi::CString {
std::ffi::CString::new(s).unwrap()
}
fn new_test_piper_voice() -> *mut DengjenVoice {
let dir = std::env::temp_dir().join("dengjen_capi_user_data_test");
std::fs::create_dir_all(&dir).unwrap();
let model_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../dengjen/models/piper/tests/fixtures/synthetic_piper_batch.onnx");
assert!(
model_path.exists(),
"Model path does not exist: {:?}",
model_path
);
std::fs::copy(&model_path, dir.join("piper_test.onnx")).unwrap();
let config_path = dir.join("piper_test.onnx.json");
std::fs::write(
&config_path,
r#"{
"key": null,
"language": {"code": "en-US"},
"audio": {"sample_rate": 22050, "quality": null},
"num_speakers": 1,
"speaker_id_map": {},
"streaming": false,
"espeak": {"voice": "en-us"},
"inference": {"noise_scale": 0.667, "length_scale": 1.0, "noise_w": 0.8},
"num_symbols": 8,
"phoneme_map": {},
"phoneme_id_map": {"^": [1], "$": [2], "_": [3], "t": [4]},
"phoneme_type": "text",
"hop_length": 256
}"#,
)
.unwrap();
let config_cstring = c_str(config_path.to_str().unwrap());
let mut out_error = ExternError::default();
unsafe {
libdengjenLoadVoiceFromConfigPath(
FfiStr::from_raw(config_cstring.as_ptr()),
&mut out_error,
)
}
}
#[test]
fn user_data_round_trips_unchanged_through_every_callback_invocation() {
use std::sync::atomic::{AtomicBool, AtomicPtr, Ordering};
use std::sync::OnceLock;
static LAST_USER_DATA: OnceLock<AtomicPtr<u8>> = OnceLock::new();
static MISMATCH: OnceLock<AtomicPtr<u8>> = OnceLock::new();
static SAW_FINISHED: OnceLock<AtomicBool> = OnceLock::new();
LAST_USER_DATA.get_or_init(|| AtomicPtr::new(std::ptr::null_mut()));
MISMATCH.get_or_init(|| AtomicPtr::new(std::ptr::null_mut()));
SAW_FINISHED.get_or_init(|| AtomicBool::new(false));
extern "C" fn recording_callback(event: SynthesisEvent, user_data: *mut c_void) -> u8 {
let expected = LAST_USER_DATA.get().unwrap().load(Ordering::SeqCst);
if user_data as *mut u8 != expected {
MISMATCH
.get()
.unwrap()
.store(user_data as *mut u8, Ordering::SeqCst);
}
if event.event_type == synth_event::SYNTH_EVENT_FINISHED {
SAW_FINISHED.get().unwrap().store(true, Ordering::SeqCst);
}
unsafe { libdengjenFreeSynthesisEvent(event) };
0
}
let mut sentinel: u8 = 0;
let token: *mut c_void = std::ptr::addr_of_mut!(sentinel).cast();
LAST_USER_DATA
.get()
.unwrap()
.store(token as *mut u8, Ordering::SeqCst);
let voice_ptr = new_test_piper_voice();
assert!(!voice_ptr.is_null(), "Failed to load test piper voice");
let mut params = synth_params();
params.callback = Some(recording_callback);
params.user_data = token;
let mut out_error = ExternError::default();
let text = c_str("t:_");
unsafe {
libdengjenSpeak(
voice_ptr,
FfiStr::from_raw(text.as_ptr()),
params,
&mut out_error,
)
};
assert_eq!(out_error.get_code(), ffi_support::ErrorCode::SUCCESS);
let mismatch = MISMATCH.get().unwrap().load(Ordering::SeqCst);
assert!(
mismatch.is_null(),
"callback received a user_data pointer ({mismatch:?}) that didn't match the one \
passed into libdengjenSpeak ({token:?})"
);
assert!(
SAW_FINISHED.get().unwrap().load(Ordering::SeqCst),
"callback never received SYNTH_EVENT_FINISHED; test did not exercise the \
terminal event path"
);
unsafe { libdengjenUnloadDengjenVoice(voice_ptr) };
}
#[test]
fn get_audio_info_null_voice_returns_null_pointer_error_without_panicking() {
let mut out_error = ExternError::default();
let mut audio_info = AudioInfo {
sample_rate: 0,
num_channels: 0,
sample_width: 0,
};
unsafe {
libdengjenGetAudioInfo(std::ptr::null_mut(), &mut audio_info, &mut out_error);
}
assert_eq!(out_error.get_code().code(), error_codes::NULL_POINTER);
unsafe { out_error.manually_release() };
}
#[test]
fn get_piper_default_synth_config_null_voice_returns_null_pointer_error_without_panicking() {
let mut out_error = ExternError::default();
let result =
unsafe { libdengjenGetPiperDefaultSynthConfig(std::ptr::null_mut(), &mut out_error) };
assert!(result.is_null());
assert_eq!(out_error.get_code().code(), error_codes::NULL_POINTER);
unsafe { out_error.manually_release() };
}
#[test]
fn set_piper_synth_config_null_voice_returns_null_pointer_error_without_panicking() {
let mut out_error = ExternError::default();
let synth_config = PiperSynthConfig {
speaker: 0,
length_scale: 1.0,
noise_scale: 1.0,
noise_w: 1.0,
};
unsafe {
libdengjenSetPiperSynthConfig(std::ptr::null_mut(), synth_config, &mut out_error);
}
assert_eq!(out_error.get_code().code(), error_codes::NULL_POINTER);
unsafe { out_error.manually_release() };
}
#[test]
fn set_synthesis_parameter_null_voice_returns_null_pointer_error_without_panicking() {
let mut out_error = ExternError::default();
let key = FfiStr::from_cstr(std::ffi::CStr::from_bytes_with_nul(b"noise_scale\0").unwrap());
unsafe {
libdengjenSetSynthesisParameter(std::ptr::null_mut(), key, 0.5, &mut out_error);
}
assert_eq!(out_error.get_code().code(), error_codes::NULL_POINTER);
unsafe { out_error.manually_release() };
}
#[test]
fn load_voice_errors_on_a_missing_config_path() {
let path = std::path::Path::new("/nonexistent-dengjen-capi-load-voice-test.json");
assert!(load_voice(path).is_err());
}
fn write_temp_config(dir: &std::path::Path, name: &str, contents: &str) -> std::path::PathBuf {
let path = dir.join(name);
std::fs::write(&path, contents).unwrap();
path
}
#[test]
fn load_voice_routes_kokoro_model_type_toward_the_kokoro_loader() {
let dir = std::env::temp_dir().join("dengjen_capi_load_voice_test_kokoro");
std::fs::create_dir_all(&dir).unwrap();
let path = write_temp_config(&dir, "config.json", r#"{"model_type": "kokoro"}"#);
let err = match load_voice(&path) {
Err(e) => format!("{}", e),
Ok(_) => panic!("expected an error for an incomplete Kokoro config"),
};
assert!(
err.contains("model_path"),
"expected a Kokoro-loader error naming the missing `model_path` field, got: {err}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn load_voice_routes_melotts_model_type_toward_the_melotts_loader() {
let dir = std::env::temp_dir().join("dengjen_capi_load_voice_test_melotts");
std::fs::create_dir_all(&dir).unwrap();
let path = write_temp_config(
&dir,
"config.json",
r#"{"model_type": "melotts", "audio": {"sample_rate": 24000}}"#,
);
let err = match load_voice(&path) {
Err(e) => format!("{}", e),
Ok(_) => panic!("expected an error for an incomplete MeloTTS config"),
};
assert!(
err.contains("phonemizer"),
"expected a MeloTTS-loader error naming the missing `phonemizer` field, got: {err}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn load_voice_routes_vits_model_type_toward_the_piper_loader() {
let dir = std::env::temp_dir().join("dengjen_capi_load_voice_test_vits");
std::fs::create_dir_all(&dir).unwrap();
let path = write_temp_config(&dir, "config.json", r#"{"model_type": "vits"}"#);
let err = match load_voice(&path) {
Err(e) => format!("{}", e),
Ok(_) => panic!("expected an error for an incomplete VITS config"),
};
assert!(
err.contains("audio"),
"expected a Piper-loader error naming the missing `audio` field, got: {err}"
);
std::fs::remove_dir_all(&dir).ok();
}
struct FakeModel {
fallback_config: Mutex<Option<dengjen_tts_core::SynthesisConfig>>,
}
impl DengjenModel for FakeModel {
fn audio_output_info(&self) -> DengjenResult<dengjen_tts_core::AudioInfo> {
Ok(dengjen_tts_core::AudioInfo {
sample_rate: 16000,
num_channels: 1,
sample_width: 2,
})
}
fn phonemize_text(&self, _text: &str) -> DengjenResult<dengjen_tts_core::Phonemes> {
Ok(dengjen_tts_core::Phonemes::from(Vec::<String>::new()))
}
fn speak_batch(
&self,
_phoneme_batches: Vec<String>,
) -> DengjenResult<Vec<dengjen_tts_core::Audio>> {
Ok(Vec::new())
}
fn speak_one_sentence(&self, _phonemes: String) -> dengjen_tts_core::DengjenAudioResult {
Ok(dengjen_tts_core::Audio::new(
AudioSamples::from(Vec::new()),
16000,
None,
))
}
fn get_default_synthesis_config(
&self,
) -> DengjenResult<Option<dengjen_tts_core::SynthesisConfig>> {
Ok(None)
}
fn get_fallback_synthesis_config(
&self,
) -> DengjenResult<Option<dengjen_tts_core::SynthesisConfig>> {
Ok(self.fallback_config.lock().unwrap().clone())
}
fn set_fallback_synthesis_config(
&self,
synthesis_config: &dengjen_tts_core::SynthesisConfig,
) -> DengjenResult<()> {
*self.fallback_config.lock().unwrap() = Some(synthesis_config.clone());
Ok(())
}
}
fn fake_voice() -> DengjenVoice {
let model: Arc<dyn DengjenModel + Send + Sync> = Arc::new(FakeModel {
fallback_config: Mutex::new(None),
});
DengjenVoice::from(DengjenSpeechSynthesizer::new(model).unwrap())
}
#[test]
fn set_synthesis_parameter_null_key_returns_invalid_utf8_error_without_panicking() {
let mut voice = fake_voice();
let mut out_error = ExternError::default();
let null_key = unsafe { FfiStr::from_raw(std::ptr::null()) };
unsafe {
libdengjenSetSynthesisParameter(&mut voice, null_key, 0.5, &mut out_error);
}
assert_eq!(
out_error.get_code().code(),
error_codes::INVALID_UTF8_SEQUENCE
);
unsafe { out_error.manually_release() };
}
#[test]
fn get_synthesis_parameter_null_voice_returns_null_pointer_error_without_panicking() {
let mut out_error = ExternError::default();
let key = FfiStr::from_cstr(std::ffi::CStr::from_bytes_with_nul(b"custom_knob\0").unwrap());
let mut value: f32 = 0.0;
let found = unsafe {
libdengjenGetSynthesisParameter(std::ptr::null_mut(), key, &mut value, &mut out_error)
};
assert!(!found);
assert_eq!(out_error.get_code().code(), error_codes::NULL_POINTER);
unsafe { out_error.manually_release() };
}
#[test]
fn get_synthesis_parameter_returns_false_for_a_key_that_was_never_set() {
let mut voice = fake_voice();
let mut out_error = ExternError::default();
let key = FfiStr::from_cstr(std::ffi::CStr::from_bytes_with_nul(b"custom_knob\0").unwrap());
let mut value: f32 = 0.0;
let found =
unsafe { libdengjenGetSynthesisParameter(&mut voice, key, &mut value, &mut out_error) };
assert!(!found);
assert!(out_error.get_code().is_success());
}
#[test]
fn get_synthesis_parameter_round_trips_a_value_set_via_set_synthesis_parameter() {
let mut voice = fake_voice();
let mut out_error = ExternError::default();
let key = FfiStr::from_cstr(std::ffi::CStr::from_bytes_with_nul(b"custom_knob\0").unwrap());
unsafe {
libdengjenSetSynthesisParameter(&mut voice, key, 1.25, &mut out_error);
}
assert!(out_error.get_code().is_success());
let mut value: f32 = 0.0;
let key2 =
FfiStr::from_cstr(std::ffi::CStr::from_bytes_with_nul(b"custom_knob\0").unwrap());
let found = unsafe {
libdengjenGetSynthesisParameter(&mut voice, key2, &mut value, &mut out_error)
};
assert!(found);
assert!(out_error.get_code().is_success());
assert_eq!(value, 1.25);
}
#[test]
fn speak_null_voice_returns_null_pointer_error_without_panicking() {
let mut out_error = ExternError::default();
let text = std::ffi::CString::new("hello").unwrap();
unsafe {
libdengjenSpeak(
std::ptr::null_mut(),
FfiStr::from_cstr(&text),
synth_params(),
&mut out_error,
);
}
assert_eq!(out_error.get_code().code(), error_codes::NULL_POINTER);
unsafe { out_error.manually_release() };
}
#[test]
fn speak_null_callback_returns_null_pointer_error_without_panicking() {
let mut voice = fake_voice();
let mut out_error = ExternError::default();
let text = std::ffi::CString::new("hello").unwrap();
let mut params = synth_params();
params.callback = None;
unsafe {
libdengjenSpeak(&mut voice, FfiStr::from_cstr(&text), params, &mut out_error);
}
assert_eq!(out_error.get_code().code(), error_codes::NULL_POINTER);
unsafe { out_error.manually_release() };
}
#[test]
fn speak_to_file_null_voice_returns_null_pointer_error_without_panicking() {
let mut out_error = ExternError::default();
let text = std::ffi::CString::new("hello").unwrap();
let filename = std::ffi::CString::new("out.wav").unwrap();
let result = unsafe {
libdengjenSpeakToFile(
std::ptr::null_mut(),
FfiStr::from_cstr(&text),
synth_params(),
FfiStr::from_cstr(&filename),
&mut out_error,
)
};
assert_eq!(result, 0);
assert_eq!(out_error.get_code().code(), error_codes::NULL_POINTER);
unsafe { out_error.manually_release() };
}
#[test]
fn speak_to_file_write_failure_populates_out_error_instead_of_reporting_success() {
let mut voice = fake_voice();
let mut out_error = ExternError::default();
let text = std::ffi::CString::new("hello").unwrap();
let filename =
std::ffi::CString::new("/nonexistent-dengjen-capi-test-dir-xyz/out.wav").unwrap();
let result = unsafe {
libdengjenSpeakToFile(
&mut voice,
FfiStr::from_cstr(&text),
synth_params(),
FfiStr::from_cstr(&filename),
&mut out_error,
)
};
assert_eq!(result, 0);
assert!(!out_error.get_code().is_success());
unsafe { out_error.manually_release() };
}
#[test]
fn cancel_null_voice_returns_null_pointer_error_without_panicking() {
let mut out_error = ExternError::default();
unsafe {
libdengjenCancel(std::ptr::null_mut(), &mut out_error);
}
assert_eq!(out_error.get_code().code(), error_codes::NULL_POINTER);
unsafe { out_error.manually_release() };
}
#[test]
fn cancel_cancels_the_token_held_in_the_slot() {
let token = CancellationToken::new();
let slot = Arc::new(Mutex::new(Some(token.clone())));
assert!(_cancel(&slot).is_ok());
assert!(token.is_cancelled());
}
#[test]
fn cancel_on_an_empty_slot_is_a_noop() {
let slot: Arc<Mutex<Option<CancellationToken>>> = Arc::new(Mutex::new(None));
assert!(_cancel(&slot).is_ok());
assert!(slot.lock().unwrap().is_none());
}
#[test]
fn cancel_slot_guard_clears_the_slot_when_it_still_holds_its_own_token() {
let slot = Arc::new(Mutex::new(None));
let token = CancellationToken::new();
*slot.lock().unwrap() = Some(token.clone());
drop(CancelSlotGuard {
slot: Arc::clone(&slot),
token,
});
assert!(slot.lock().unwrap().is_none());
}
#[test]
fn cancel_slot_guard_does_not_clobber_a_different_tokens_slot() {
let slot = Arc::new(Mutex::new(None));
let token_a = CancellationToken::new();
let token_b = CancellationToken::new();
*slot.lock().unwrap() = Some(token_a.clone());
let guard_a = CancelSlotGuard {
slot: Arc::clone(&slot),
token: token_a,
};
*slot.lock().unwrap() = Some(token_b.clone());
drop(guard_a);
let held = slot.lock().unwrap();
assert!(held.as_ref().unwrap().points_to_same_flag(&token_b));
}
#[test]
fn error_codes_round_trip_through_dengjen_ffi_error() {
let cases = [
(
DengjenError::FailedToLoadResource("x".into()),
error_codes::FAILED_TO_LOAD_RESOURCE,
),
(
DengjenError::PhonemizationError("x".into()),
error_codes::PHONEMIZATION_ERROR,
),
(
DengjenError::InferenceError("x".into()),
error_codes::INFERENCE_ERROR,
),
(
DengjenError::InvalidConfiguration("x".into()),
error_codes::INVALID_CONFIGURATION,
),
(
DengjenError::UnsupportedOperation("x".into()),
error_codes::UNSUPPORTED_OPERATION,
),
(
DengjenError::OperationError("x".into()),
error_codes::OPERATION_ERROR,
),
];
for (err, expected_code) in cases {
let ffi_err: DengjenFFIError = err.into();
assert_eq!(ffi_err.0, expected_code);
}
}
}
#[cfg(test)]
mod abi_struct_tests {
use super::*;
#[test]
fn synthesis_event_with_speech_carries_the_pcm_bytes_and_a_null_error_pointer() {
let event = SynthesisEvent::with_speech(vec![1, 2, 3, 4]);
assert_eq!(event.event_type, synth_event::SYNTH_EVENT_SPEECH);
assert!(event.error_ptr.is_null());
assert_eq!(event.len, 4);
let bytes = unsafe { std::slice::from_raw_parts(event.data, event.len as usize) };
assert_eq!(bytes, &[1, 2, 3, 4]);
unsafe { libdengjenFreeSynthesisEvent(event) };
}
#[test]
fn synthesis_event_with_error_carries_a_non_null_error_pointer_and_empty_data() {
let event = SynthesisEvent::with_error(DengjenFFIError::invalid_utf8());
assert_eq!(event.event_type, synth_event::SYNTH_EVENT_ERROR);
assert!(!event.error_ptr.is_null());
assert_eq!(event.len, 0);
assert_eq!(
unsafe { (*event.error_ptr).get_code().code() },
error_codes::INVALID_UTF8_SEQUENCE
);
unsafe { libdengjenFreeSynthesisEvent(event) };
}
#[test]
fn synthesis_event_with_finished_carries_a_null_error_pointer_and_empty_data() {
let event = SynthesisEvent::with_finished();
assert_eq!(event.event_type, synth_event::SYNTH_EVENT_FINISHED);
assert!(event.error_ptr.is_null());
assert_eq!(event.len, 0);
unsafe { libdengjenFreeSynthesisEvent(event) };
}
extern "C" fn noop_callback(_event: SynthesisEvent, _user_data: *mut c_void) -> u8 {
0
}
#[test]
fn as_synth_output_config_carries_over_all_fields() {
let params = SynthesisParams {
mode: synth_mode::SYNTH_MODE_LAZY,
rate: 60,
volume: 80,
pitch: 40,
appended_silence_ms: 250,
callback: Some(noop_callback),
nonblocking: 0,
user_data: std::ptr::null_mut(),
};
let config = params.as_synth_output_config();
assert_eq!(config.rate, Some(60));
assert_eq!(config.volume, Some(80));
assert_eq!(config.pitch, Some(40));
assert_eq!(config.appended_silence_ms, Some(250));
}
#[test]
fn as_piper_synth_config_carries_over_speaker_and_synthesis_tuning_fields() {
let synth_config = PiperSynthConfig {
speaker: 3,
length_scale: 1.2,
noise_scale: 0.5,
noise_w: 0.9,
};
let piper_config = synth_config.as_piper_synth_config();
assert_eq!(piper_config.speaker, Some(3));
assert_eq!(piper_config.length_scale, 1.2);
assert_eq!(piper_config.noise_scale, 0.5);
assert_eq!(piper_config.noise_w, 0.9);
}
#[test]
fn as_piper_synth_config_treats_speaker_zero_as_a_real_speaker_not_unset() {
let synth_config = PiperSynthConfig {
speaker: 0,
length_scale: 1.0,
noise_scale: 1.0,
noise_w: 1.0,
};
assert_eq!(synth_config.as_piper_synth_config().speaker, Some(0));
}
#[test]
fn as_piper_synth_config_maps_the_sentinel_to_no_speaker() {
let synth_config = PiperSynthConfig {
speaker: PIPER_SYNTH_CONFIG_NO_SPEAKER,
length_scale: 1.0,
noise_scale: 1.0,
noise_w: 1.0,
};
assert_eq!(synth_config.as_piper_synth_config().speaker, None);
}
}