1#![allow(
20 clippy::missing_panics_doc,
21 clippy::not_unsafe_ptr_arg_deref,
22 clippy::cast_possible_truncation,
23 clippy::cast_possible_wrap,
24 clippy::cast_sign_loss,
25 clippy::ptr_as_ptr,
26 clippy::cast_ptr_alignment,
27 clippy::doc_markdown,
28 clippy::multiple_crate_versions,
29 clippy::field_reassign_with_default,
30 non_camel_case_types,
31 dead_code
32)]
33
34#[cfg(all(feature = "avsynth", target_os = "macos"))]
35mod avsynth_engine;
36#[cfg(test)]
37mod bcp_audit;
38pub mod boundaries;
39
40#[cfg(feature = "omnivoice-local")]
41pub mod omnivoice_local_engine;
42#[cfg(feature = "omnivoice-local")]
43pub mod omnivoice_local_support;
44#[cfg(feature = "ssml-expand")]
45pub mod ssml_expand;
46
47#[cfg(feature = "cloning")]
51pub mod cloning;
52#[cfg(any(feature = "cloud", feature = "cloud-core"))]
53mod cloud_engine;
54
55#[cfg(feature = "cloud-core")]
59pub mod cloud_core {
60 pub use crate::cloud_engine::{
61 authorization_header, build_gemini_request, build_google_request, edge_sec_ms_gec_at,
62 elevenlabs_smd_platform, parse_elevenlabs_alignment, parse_gemini_interaction_audio,
63 sha256_hex, ssml_to_dialect, CloudConfig, GeminiAudioBlock, SigV4Credentials, SignedUrl,
64 };
65}
66pub mod engine;
67pub mod factory;
68#[cfg(feature = "floravox")]
69pub mod floravox_engine;
70#[cfg(all(feature = "sapi", target_os = "windows"))]
71mod sapi_engine;
72#[cfg(feature = "sherpaonnx")]
73pub mod sherpaonnx_engine;
74
75pub mod qwen3_local_support;
78
79#[cfg(feature = "qwen3-local")]
84pub mod qwen3_local_engine;
85#[cfg(all(feature = "system", target_os = "linux"))]
86mod system_engine;
87pub mod timeline;
88pub mod types;
89pub mod word_search;
90
91#[cfg(feature = "pocket-timing")]
94pub mod pocket;
95
96pub use engine::TtsEngine;
98pub use factory::create_engine;
99#[cfg(feature = "sherpaonnx")]
100pub use sherpaonnx_engine::SherpaOnnxEngine;
101
102use std::ffi::{CStr, CString};
103use std::os::raw::c_char;
104use std::ptr;
105use std::sync::{Arc, Mutex};
106
107type BoxedEngine = Arc<dyn TtsEngine>;
113
114pub type CAudioCb = Option<extern "C" fn(*const u8, usize, *mut std::ffi::c_void)>;
116pub type CBoundaryCb =
130 Option<extern "C" fn(*const c_char, i32, i32, f32, f32, i32, *mut std::ffi::c_void)>;
131pub type CMarkCb = Option<extern "C" fn(*const c_char, i32, f32, f32, *mut std::ffi::c_void)>;
135pub type CVisemeCb = Option<extern "C" fn(i32, f32, *mut std::ffi::c_void)>;
136pub type CVoidCb = Option<extern "C" fn(*mut std::ffi::c_void)>;
137pub type CErrorCb = Option<extern "C" fn(*const c_char, *mut std::ffi::c_void)>;
138type BoxedAudioCb = Box<dyn FnMut(&[u8])>;
139type BoxedBoundaryCb = Box<dyn FnMut(&str, f32, f32, i32, i32, bool)>;
140type BoxedMarkCb = Box<dyn FnMut(&str, f32, f32, i32)>;
141
142pub struct tts_ctx {
143 engine: BoxedEngine,
149 voice_id: Mutex<Option<String>>,
150 rate: Mutex<f32>,
151 pitch: Mutex<f32>,
152 volume: Mutex<f32>,
153 last_error: Mutex<CString>,
161 on_audio: Mutex<AudioCallback>,
166 on_start: Mutex<VoidCallback>,
167 on_end: Mutex<VoidCallback>,
168 on_error: Mutex<ErrorCallback>,
169 on_boundary: Mutex<BoundaryCallback>,
170 on_mark: Mutex<MarkCallback>,
171 on_viseme: Mutex<VisemeCallback>,
172}
173
174#[derive(Clone, Copy)]
176struct AudioCallback {
177 cb: CAudioCb,
178 userdata: *mut std::ffi::c_void,
179}
180
181#[derive(Clone, Copy)]
183struct VoidCallback {
184 cb: CVoidCb,
185 userdata: *mut std::ffi::c_void,
186}
187
188#[derive(Clone, Copy)]
190struct ErrorCallback {
191 cb: CErrorCb,
192 userdata: *mut std::ffi::c_void,
193}
194
195#[derive(Clone, Copy)]
197struct BoundaryCallback {
198 cb: CBoundaryCb,
199 userdata: *mut std::ffi::c_void,
200}
201
202#[derive(Clone, Copy)]
203struct MarkCallback {
204 cb: CMarkCb,
205 userdata: *mut std::ffi::c_void,
206}
207
208#[derive(Clone, Copy)]
210struct VisemeCallback {
211 cb: CVisemeCb,
212 userdata: *mut std::ffi::c_void,
213}
214
215unsafe impl Send for AudioCallback {}
220unsafe impl Sync for AudioCallback {}
221unsafe impl Send for VoidCallback {}
222unsafe impl Sync for VoidCallback {}
223unsafe impl Send for ErrorCallback {}
224unsafe impl Sync for ErrorCallback {}
225unsafe impl Send for BoundaryCallback {}
226unsafe impl Sync for BoundaryCallback {}
227unsafe impl Send for VisemeCallback {}
228unsafe impl Sync for VisemeCallback {}
229
230static LAST_ERROR: Mutex<Option<CString>> = Mutex::new(None);
238
239fn set_error(msg: &str) {
240 let mut guard = LAST_ERROR
241 .lock()
242 .unwrap_or_else(std::sync::PoisonError::into_inner);
243 *guard = Some(safe_cstring(msg));
244}
245
246macro_rules! ffi_catch {
248 ($expr:expr) => {
249 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| $expr)).unwrap_or(-1)
250 };
251}
252
253fn safe_cstring(s: impl AsRef<str>) -> CString {
259 let s = s.as_ref();
260 if let Ok(cs) = CString::new(s) {
261 return cs;
262 }
263 let cleaned: String = s.chars().filter(|&c| c != '\0').collect();
265 CString::new(cleaned).unwrap_or_default()
266}
267
268#[no_mangle]
278pub extern "C" fn tts_create(
279 engine_id: *const c_char,
280 credentials_json: *const c_char,
281) -> *mut tts_ctx {
282 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
283 tts_create_inner(engine_id, credentials_json)
284 }));
285 if let Ok(ptr) = result {
286 ptr
287 } else {
288 set_error("engine creation panicked");
289 ptr::null_mut()
290 }
291}
292
293fn tts_create_inner(engine_id: *const c_char, credentials_json: *const c_char) -> *mut tts_ctx {
294 if engine_id.is_null() {
295 set_error("engine_id is null");
296 return ptr::null_mut();
297 }
298 let engine_id_str = unsafe { CStr::from_ptr(engine_id) }
299 .to_string_lossy()
300 .into_owned();
301 let creds = if credentials_json.is_null() {
302 String::new()
303 } else {
304 unsafe { CStr::from_ptr(credentials_json) }
305 .to_string_lossy()
306 .into_owned()
307 };
308
309 if let Some(engine) = create_engine(&engine_id_str, &creds) {
310 let ctx = Box::new(tts_ctx {
311 engine,
312 voice_id: Mutex::new(None),
313 rate: Mutex::new(1.0),
314 pitch: Mutex::new(1.0),
315 volume: Mutex::new(1.0),
316 last_error: Mutex::new(CString::new("").unwrap()),
317 on_audio: Mutex::new(AudioCallback {
318 cb: None,
319 userdata: ptr::null_mut(),
320 }),
321 on_start: Mutex::new(VoidCallback {
322 cb: None,
323 userdata: ptr::null_mut(),
324 }),
325 on_end: Mutex::new(VoidCallback {
326 cb: None,
327 userdata: ptr::null_mut(),
328 }),
329 on_error: Mutex::new(ErrorCallback {
330 cb: None,
331 userdata: ptr::null_mut(),
332 }),
333 on_mark: Mutex::new(MarkCallback {
334 cb: None,
335 userdata: std::ptr::null_mut(),
336 }),
337 on_boundary: Mutex::new(BoundaryCallback {
338 cb: None,
339 userdata: ptr::null_mut(),
340 }),
341 on_viseme: Mutex::new(VisemeCallback {
342 cb: None,
343 userdata: ptr::null_mut(),
344 }),
345 });
346 Box::into_raw(ctx)
347 } else {
348 set_error(&format!("Unknown engine: {engine_id_str}"));
349 ptr::null_mut()
350 }
351}
352
353#[no_mangle]
364pub extern "C" fn tts_destroy(ctx: *mut tts_ctx) {
365 if ctx.is_null() {
366 return;
367 }
368 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
369 let boxed = unsafe { Box::from_raw(ctx) };
371 let _ = boxed.engine.stop();
374 drop(boxed);
375 }));
376}
377
378#[no_mangle]
387pub extern "C" fn tts_speak(ctx: *mut tts_ctx, text: *const c_char) -> i32 {
388 tts_speak_impl(ctx, text, false)
389}
390
391#[no_mangle]
406pub extern "C" fn tts_speak_ssml(ctx: *mut tts_ctx, ssml: *const c_char) -> i32 {
407 tts_speak_impl(ctx, ssml, true)
408}
409
410fn tts_speak_impl(ctx: *mut tts_ctx, text: *const c_char, raw_ssml: bool) -> i32 {
411 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
412 tts_speak_impl_inner(ctx, text, raw_ssml)
413 }))
414 .unwrap_or(-1)
415}
416
417#[allow(clippy::too_many_lines)]
418fn tts_speak_impl_inner(ctx: *mut tts_ctx, text: *const c_char, raw_ssml: bool) -> i32 {
419 ffi_catch!({
420 if ctx.is_null() || text.is_null() {
421 return -1;
422 }
423 let ctx_ref = unsafe { &*ctx };
424 let text_str = unsafe { CStr::from_ptr(text) }
425 .to_string_lossy()
426 .into_owned();
427 let voice = ctx_ref
428 .voice_id
429 .lock()
430 .unwrap_or_else(std::sync::PoisonError::into_inner)
431 .clone();
432 let rate = if raw_ssml {
433 1.0
434 } else {
435 *ctx_ref
436 .rate
437 .lock()
438 .unwrap_or_else(std::sync::PoisonError::into_inner)
439 };
440 let pitch = if raw_ssml {
441 1.0
442 } else {
443 *ctx_ref
444 .pitch
445 .lock()
446 .unwrap_or_else(std::sync::PoisonError::into_inner)
447 };
448 let volume = if raw_ssml {
449 1.0
450 } else {
451 *ctx_ref
452 .volume
453 .lock()
454 .unwrap_or_else(std::sync::PoisonError::into_inner)
455 };
456
457 let audio = {
458 *ctx_ref
459 .on_audio
460 .lock()
461 .unwrap_or_else(std::sync::PoisonError::into_inner)
462 };
463 let boundary = {
464 *ctx_ref
465 .on_boundary
466 .lock()
467 .unwrap_or_else(std::sync::PoisonError::into_inner)
468 };
469 let mark = {
470 *ctx_ref
471 .on_mark
472 .lock()
473 .unwrap_or_else(std::sync::PoisonError::into_inner)
474 };
475
476 let mut on_audio_closure: Option<BoxedAudioCb> = match audio.cb {
477 Some(cb) => Some(Box::new(move |bytes: &[u8]| {
478 cb(bytes.as_ptr(), bytes.len(), audio.userdata);
479 })),
480 None => None,
481 };
482
483 let mut on_boundary_closure: Option<BoxedBoundaryCb> = boundary.cb.map(|cb| {
484 let ud = boundary.userdata;
485 let b: BoxedBoundaryCb = Box::new(
486 move |word: &str,
487 start: f32,
488 end: f32,
489 char_offset: i32,
490 char_len: i32,
491 estimated: bool| {
492 if let Ok(c_word) = CString::new(word) {
493 cb(
494 c_word.as_ptr(),
495 char_offset,
496 char_len,
497 start,
498 end,
499 i32::from(estimated),
500 ud,
501 );
502 }
503 },
504 );
505 b
506 });
507
508 let mut on_mark_closure: Option<BoxedMarkCb> = mark.cb.map(|cb| {
509 let ud = mark.userdata;
510 let b: BoxedMarkCb =
511 Box::new(move |name: &str, start: f32, end: f32, char_offset: i32| {
512 if let Ok(c_name) = CString::new(name) {
513 cb(c_name.as_ptr(), char_offset, start, end, ud);
514 }
515 });
516 b
517 });
518
519 let start_cb = {
520 *ctx_ref
521 .on_start
522 .lock()
523 .unwrap_or_else(std::sync::PoisonError::into_inner)
524 };
525 let end_cb = {
526 *ctx_ref
527 .on_end
528 .lock()
529 .unwrap_or_else(std::sync::PoisonError::into_inner)
530 };
531 let error_cb = {
532 *ctx_ref
533 .on_error
534 .lock()
535 .unwrap_or_else(std::sync::PoisonError::into_inner)
536 };
537
538 if let Some(cb) = start_cb.cb {
539 cb(start_cb.userdata);
540 }
541
542 #[cfg(feature = "cloud")]
543 {
544 let vis = {
545 *ctx_ref
546 .on_viseme
547 .lock()
548 .unwrap_or_else(std::sync::PoisonError::into_inner)
549 };
550 let vbox: Option<Box<dyn FnMut(i32, f32)>> = vis.cb.map(|cb| {
551 let ud = vis.userdata;
552 Box::new(move |id: i32, off: f32| {
553 cb(id, off, ud);
554 }) as Box<dyn FnMut(i32, f32)>
555 });
556 crate::cloud_engine::set_viseme_callback(vbox);
557 }
558
559 let engine = &ctx_ref.engine;
560 let result = engine.speak(
561 &text_str,
562 voice.as_deref(),
563 rate,
564 pitch,
565 volume,
566 on_audio_closure
567 .as_mut()
568 .map(|f| &mut **f as &mut dyn FnMut(&[u8])),
569 on_boundary_closure
570 .as_mut()
571 .map(|f| &mut **f as &mut dyn FnMut(&str, f32, f32, i32, i32, bool)),
572 on_mark_closure
573 .as_mut()
574 .map(|f| &mut **f as &mut dyn FnMut(&str, f32, f32, i32)),
575 );
576
577 match result {
578 Ok(()) => {
579 if let Some(cb) = end_cb.cb {
580 cb(end_cb.userdata);
581 }
582 0
583 }
584 Err(e) => {
585 let msg = e.to_string();
586 *ctx_ref
587 .last_error
588 .lock()
589 .unwrap_or_else(std::sync::PoisonError::into_inner) = safe_cstring(msg.clone());
590 if let Some(cb) = error_cb.cb {
591 if let Ok(c_msg) = CString::new(msg) {
592 cb(c_msg.as_ptr(), error_cb.userdata);
593 }
594 }
595 -1
596 }
597 }
598 })
599}
600
601#[no_mangle]
610#[allow(clippy::too_many_lines)]
611pub extern "C" fn tts_speak_sync(ctx: *mut tts_ctx, text: *const c_char) -> i32 {
612 ffi_catch!({
613 if ctx.is_null() || text.is_null() {
614 return -1;
615 }
616 let ctx_ref = unsafe { &*ctx };
617 let text_str = unsafe { CStr::from_ptr(text) }
618 .to_string_lossy()
619 .into_owned();
620 let voice = ctx_ref
621 .voice_id
622 .lock()
623 .unwrap_or_else(std::sync::PoisonError::into_inner)
624 .clone();
625 let rate = *ctx_ref
626 .rate
627 .lock()
628 .unwrap_or_else(std::sync::PoisonError::into_inner);
629 let pitch = *ctx_ref
630 .pitch
631 .lock()
632 .unwrap_or_else(std::sync::PoisonError::into_inner);
633 let volume = *ctx_ref
634 .volume
635 .lock()
636 .unwrap_or_else(std::sync::PoisonError::into_inner);
637
638 let audio = {
639 *ctx_ref
640 .on_audio
641 .lock()
642 .unwrap_or_else(std::sync::PoisonError::into_inner)
643 };
644 let boundary = {
645 *ctx_ref
646 .on_boundary
647 .lock()
648 .unwrap_or_else(std::sync::PoisonError::into_inner)
649 };
650 let mark = {
651 *ctx_ref
652 .on_mark
653 .lock()
654 .unwrap_or_else(std::sync::PoisonError::into_inner)
655 };
656
657 let mut on_mark_closure: Option<BoxedMarkCb> = mark.cb.map(|cb| {
658 let ud = mark.userdata;
659 let b: BoxedMarkCb =
660 Box::new(move |name: &str, start: f32, end: f32, char_offset: i32| {
661 if let Ok(c_name) = CString::new(name) {
662 cb(c_name.as_ptr(), char_offset, start, end, ud);
663 }
664 });
665 b
666 });
667
668 let mut on_audio_closure: Option<BoxedAudioCb> = match audio.cb {
669 Some(cb) => Some(Box::new(move |bytes: &[u8]| {
670 cb(bytes.as_ptr(), bytes.len(), audio.userdata);
671 })),
672 None => None,
673 };
674
675 let mut on_boundary_closure: Option<BoxedBoundaryCb> = boundary.cb.map(|cb| {
676 let ud = boundary.userdata;
677 let b: BoxedBoundaryCb = Box::new(
678 move |word: &str,
679 start: f32,
680 end: f32,
681 char_offset: i32,
682 char_len: i32,
683 estimated: bool| {
684 if let Ok(c_word) = CString::new(word) {
685 cb(
686 c_word.as_ptr(),
687 char_offset,
688 char_len,
689 start,
690 end,
691 i32::from(estimated),
692 ud,
693 );
694 }
695 },
696 );
697 b
698 });
699
700 let start_cb = {
702 *ctx_ref
703 .on_start
704 .lock()
705 .unwrap_or_else(std::sync::PoisonError::into_inner)
706 };
707 let end_cb = {
708 *ctx_ref
709 .on_end
710 .lock()
711 .unwrap_or_else(std::sync::PoisonError::into_inner)
712 };
713 let error_cb = {
714 *ctx_ref
715 .on_error
716 .lock()
717 .unwrap_or_else(std::sync::PoisonError::into_inner)
718 };
719
720 if let Some(cb) = start_cb.cb {
722 cb(start_cb.userdata);
723 }
724
725 #[cfg(feature = "cloud")]
726 {
727 let vis = {
728 *ctx_ref
729 .on_viseme
730 .lock()
731 .unwrap_or_else(std::sync::PoisonError::into_inner)
732 };
733 let vbox: Option<Box<dyn FnMut(i32, f32)>> = vis.cb.map(|cb| {
734 let ud = vis.userdata;
735 Box::new(move |id: i32, off: f32| {
736 cb(id, off, ud);
737 }) as Box<dyn FnMut(i32, f32)>
738 });
739 crate::cloud_engine::set_viseme_callback(vbox);
740 }
741
742 let engine = &ctx_ref.engine;
743 let result = engine.speak_sync(
744 &text_str,
745 voice.as_deref(),
746 rate,
747 pitch,
748 volume,
749 on_audio_closure
750 .as_mut()
751 .map(|f| &mut **f as &mut dyn FnMut(&[u8])),
752 on_boundary_closure
753 .as_mut()
754 .map(|f| &mut **f as &mut dyn FnMut(&str, f32, f32, i32, i32, bool)),
755 on_mark_closure
756 .as_mut()
757 .map(|f| &mut **f as &mut dyn FnMut(&str, f32, f32, i32)),
758 );
759
760 match result {
761 Ok(()) => {
762 if let Some(cb) = end_cb.cb {
763 cb(end_cb.userdata);
764 }
765 0
766 }
767 Err(e) => {
768 let msg = e.to_string();
769 *ctx_ref
770 .last_error
771 .lock()
772 .unwrap_or_else(std::sync::PoisonError::into_inner) = safe_cstring(msg.clone());
773 if let Some(cb) = error_cb.cb {
774 if let Ok(c_msg) = CString::new(msg) {
775 cb(c_msg.as_ptr(), error_cb.userdata);
776 }
777 }
778 -1
779 }
780 }
781 })
782}
783
784#[no_mangle]
790pub extern "C" fn tts_stop(ctx: *mut tts_ctx) {
791 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
792 if ctx.is_null() {
793 return;
794 }
795 let ctx_ref = unsafe { &*ctx };
796 let _ = ctx_ref.engine.stop();
797 }));
798}
799
800#[no_mangle]
811pub extern "C" fn tts_get_voices(
812 ctx: *mut tts_ctx,
813 out_voices: *mut *mut types::tts_voice,
814 out_count: *mut i32,
815) -> i32 {
816 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
817 tts_get_voices_inner(ctx, out_voices, out_count)
818 }));
819 result.unwrap_or(-1)
820}
821
822fn tts_get_voices_inner(
823 ctx: *mut tts_ctx,
824 out_voices: *mut *mut types::tts_voice,
825 out_count: *mut i32,
826) -> i32 {
827 if ctx.is_null() || out_voices.is_null() || out_count.is_null() {
828 return -1;
829 }
830 let ctx_ref = unsafe { &*ctx };
831 let engine = &ctx_ref.engine;
832 match engine.get_voices() {
833 Ok(voices) => {
834 let len = voices.len();
835 if len == 0 {
836 unsafe {
837 *out_voices = ptr::null_mut();
838 *out_count = 0;
839 }
840 return 0;
841 }
842 let Ok(layout) = std::alloc::Layout::array::<types::tts_voice>(len) else {
843 set_error(&format!("Voice array too large: {len} entries"));
844 unsafe {
845 *out_voices = ptr::null_mut();
846 *out_count = 0;
847 }
848 return -1;
849 };
850 let arr_ptr = unsafe { std::alloc::alloc(layout).cast::<types::tts_voice>() };
851 for (i, v) in voices.iter().enumerate() {
852 unsafe {
853 let entry = arr_ptr.add(i);
854 std::ptr::write(
855 entry,
856 types::tts_voice {
857 id: safe_cstring(&v.id).into_raw(),
858 name: safe_cstring(&v.name).into_raw(),
859 language: safe_cstring({
860 let lc = v.language_codes.first();
861 match lc {
862 Some(lc) if !lc.display.is_empty() => {
863 format!("{} [{}]", lc.display, lc.bcp47)
864 }
865 _ => v.primary_language().to_string(),
866 }
867 })
868 .into_raw(),
869 gender: safe_cstring(v.gender.to_string()).into_raw(),
870 engine: safe_cstring(&v.provider).into_raw(),
871 },
872 );
873 }
874 }
875 unsafe {
876 *out_voices = arr_ptr;
877 *out_count = len as i32;
878 }
879 0
880 }
881 Err(e) => {
882 *ctx_ref
883 .last_error
884 .lock()
885 .unwrap_or_else(std::sync::PoisonError::into_inner) = safe_cstring(e.to_string());
886 -1
887 }
888 }
889}
890
891#[no_mangle]
897pub extern "C" fn tts_free_voices(voices: *mut types::tts_voice, count: i32) {
898 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
899 if voices.is_null() || count <= 0 {
900 return;
901 }
902 for i in 0..count {
903 unsafe {
904 let v = voices.add(i as usize);
905 if !(*v).id.is_null() {
906 let _ = CString::from_raw((*v).id);
907 }
908 if !(*v).name.is_null() {
909 let _ = CString::from_raw((*v).name);
910 }
911 if !(*v).language.is_null() {
912 let _ = CString::from_raw((*v).language);
913 }
914 if !(*v).gender.is_null() {
915 let _ = CString::from_raw((*v).gender);
916 }
917 if !(*v).engine.is_null() {
918 let _ = CString::from_raw((*v).engine);
919 }
920 }
921 }
922 let Ok(layout) = std::alloc::Layout::array::<types::tts_voice>(count as usize) else {
923 return;
924 };
925 unsafe {
926 std::alloc::dealloc(voices.cast::<u8>(), layout);
927 }
928 }));
929}
930
931#[no_mangle]
937pub extern "C" fn tts_set_voice(ctx: *mut tts_ctx, voice_id: *const c_char) {
938 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
939 if ctx.is_null() || voice_id.is_null() {
940 return;
941 }
942 let ctx_ref = unsafe { &*ctx };
943 let id = unsafe { CStr::from_ptr(voice_id) }
944 .to_string_lossy()
945 .into_owned();
946 *ctx_ref
947 .voice_id
948 .lock()
949 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(id);
950 }));
951}
952
953#[no_mangle]
959pub extern "C" fn tts_set_rate(ctx: *mut tts_ctx, rate: f32) {
960 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
961 if ctx.is_null() {
962 return;
963 }
964 *unsafe { &*ctx }
965 .rate
966 .lock()
967 .unwrap_or_else(std::sync::PoisonError::into_inner) = rate;
968 }));
969}
970
971#[no_mangle]
977pub extern "C" fn tts_set_pitch(ctx: *mut tts_ctx, pitch: f32) {
978 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
979 if ctx.is_null() {
980 return;
981 }
982 *unsafe { &*ctx }
983 .pitch
984 .lock()
985 .unwrap_or_else(std::sync::PoisonError::into_inner) = pitch;
986 }));
987}
988
989#[no_mangle]
995pub extern "C" fn tts_set_volume(ctx: *mut tts_ctx, volume: f32) {
996 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
997 if ctx.is_null() {
998 return;
999 }
1000 *unsafe { &*ctx }
1001 .volume
1002 .lock()
1003 .unwrap_or_else(std::sync::PoisonError::into_inner) = volume;
1004 }));
1005}
1006#[no_mangle]
1011pub extern "C" fn tts_set_on_audio(
1012 ctx: *mut tts_ctx,
1013 cb: CAudioCb,
1014 userdata: *mut std::ffi::c_void,
1015) {
1016 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1017 if ctx.is_null() {
1018 return;
1019 }
1020 let ctx_ref = unsafe { &*ctx };
1021 *ctx_ref
1024 .on_audio
1025 .lock()
1026 .unwrap_or_else(std::sync::PoisonError::into_inner) = AudioCallback { cb, userdata };
1027 }));
1028}
1029
1030#[no_mangle]
1039pub extern "C" fn tts_set_on_boundary(
1040 ctx: *mut tts_ctx,
1041 cb: CBoundaryCb,
1042 userdata: *mut std::ffi::c_void,
1043) {
1044 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1045 if ctx.is_null() {
1046 return;
1047 }
1048 let ctx_ref = unsafe { &*ctx };
1049 *ctx_ref
1050 .on_boundary
1051 .lock()
1052 .unwrap_or_else(std::sync::PoisonError::into_inner) = BoundaryCallback { cb, userdata };
1053 }));
1054}
1055
1056#[no_mangle]
1062pub extern "C" fn tts_set_on_mark(ctx: *mut tts_ctx, cb: CMarkCb, userdata: *mut std::ffi::c_void) {
1063 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1064 if ctx.is_null() {
1065 return;
1066 }
1067 let ctx_ref = unsafe { &*ctx };
1068 *ctx_ref
1069 .on_mark
1070 .lock()
1071 .unwrap_or_else(std::sync::PoisonError::into_inner) = MarkCallback { cb, userdata };
1072 }));
1073}
1074
1075#[no_mangle]
1081pub extern "C" fn tts_set_on_viseme(
1082 ctx: *mut tts_ctx,
1083 cb: CVisemeCb,
1084 userdata: *mut std::ffi::c_void,
1085) {
1086 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1087 if ctx.is_null() {
1088 return;
1089 }
1090 let ctx_ref = unsafe { &*ctx };
1091 *ctx_ref
1092 .on_viseme
1093 .lock()
1094 .unwrap_or_else(std::sync::PoisonError::into_inner) = VisemeCallback { cb, userdata };
1095 }));
1096}
1097
1098#[no_mangle]
1103pub extern "C" fn tts_set_on_start(
1104 ctx: *mut tts_ctx,
1105 cb: CVoidCb,
1106 userdata: *mut std::ffi::c_void,
1107) {
1108 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1109 if ctx.is_null() {
1110 return;
1111 }
1112 let ctx_ref = unsafe { &*ctx };
1113 *ctx_ref
1114 .on_start
1115 .lock()
1116 .unwrap_or_else(std::sync::PoisonError::into_inner) = VoidCallback { cb, userdata };
1117 }));
1118}
1119
1120#[no_mangle]
1125pub extern "C" fn tts_set_on_end(ctx: *mut tts_ctx, cb: CVoidCb, userdata: *mut std::ffi::c_void) {
1126 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1127 if ctx.is_null() {
1128 return;
1129 }
1130 let ctx_ref = unsafe { &*ctx };
1131 *ctx_ref
1132 .on_end
1133 .lock()
1134 .unwrap_or_else(std::sync::PoisonError::into_inner) = VoidCallback { cb, userdata };
1135 }));
1136}
1137
1138#[no_mangle]
1146pub extern "C" fn tts_set_on_error(
1147 ctx: *mut tts_ctx,
1148 cb: CErrorCb,
1149 userdata: *mut std::ffi::c_void,
1150) {
1151 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1152 if ctx.is_null() {
1153 return;
1154 }
1155 let ctx_ref = unsafe { &*ctx };
1156 *ctx_ref
1157 .on_error
1158 .lock()
1159 .unwrap_or_else(std::sync::PoisonError::into_inner) = ErrorCallback { cb, userdata };
1160 }));
1161}
1162
1163#[no_mangle]
1165pub extern "C" fn tts_get_engine_count() -> i32 {
1166 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1167 factory::engine_count() as i32
1168 }))
1169 .unwrap_or(0)
1170}
1171#[no_mangle]
1182pub extern "C" fn tts_get_engines(
1183 out_engines: *mut *mut types::tts_engine_info,
1184 out_count: *mut i32,
1185) -> i32 {
1186 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1187 if out_engines.is_null() || out_count.is_null() {
1188 return -1;
1189 }
1190
1191 let engines = factory::engine_list();
1192 let count = engines.len();
1193
1194 if count == 0 {
1195 unsafe {
1196 *out_engines = ptr::null_mut();
1197 *out_count = 0;
1198 }
1199 return 0;
1200 }
1201
1202 let Ok(layout) = std::alloc::Layout::array::<types::tts_engine_info>(count) else {
1203 return -1;
1204 };
1205 let ptr = unsafe { std::alloc::alloc(layout) } as *mut types::tts_engine_info;
1206
1207 if ptr.is_null() {
1208 return -1;
1209 }
1210
1211 for (i, e) in engines.iter().enumerate() {
1212 unsafe {
1213 let entry = ptr.add(i);
1214 std::ptr::write(
1215 entry,
1216 types::tts_engine_info {
1217 id: safe_cstring(&e.id).into_raw(),
1218 name: safe_cstring(&e.name).into_raw(),
1219 needs_credentials: e.needs_credentials,
1220 credential_keys_json: safe_cstring(&e.credential_keys_json).into_raw(),
1221 },
1222 );
1223 }
1224 }
1225
1226 unsafe {
1227 *out_engines = ptr;
1228 *out_count = count as i32;
1229 }
1230
1231 0
1232 }))
1233 .unwrap_or(-1)
1234}
1235
1236#[no_mangle]
1242pub extern "C" fn tts_free_engines(engines: *mut types::tts_engine_info, count: i32) {
1243 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1244 if engines.is_null() || count <= 0 {
1245 return;
1246 }
1247 for i in 0..count {
1248 unsafe {
1249 let e = engines.add(i as usize);
1250 if !(*e).id.is_null() {
1251 let _ = CString::from_raw((*e).id);
1252 }
1253 if !(*e).name.is_null() {
1254 let _ = CString::from_raw((*e).name);
1255 }
1256 if !(*e).credential_keys_json.is_null() {
1257 let _ = CString::from_raw((*e).credential_keys_json);
1258 }
1259 }
1260 }
1261 let Ok(layout) = std::alloc::Layout::array::<types::tts_engine_info>(count as usize) else {
1262 return;
1263 };
1264 unsafe {
1265 std::alloc::dealloc(engines.cast::<u8>(), layout);
1266 }
1267 }));
1268}
1269
1270#[no_mangle]
1281pub extern "C" fn tts_get_last_error(ctx: *mut tts_ctx) -> *const c_char {
1282 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1283 if !ctx.is_null() {
1290 let ctx_ref = unsafe { &*ctx };
1291 if let Ok(guard) = ctx_ref.last_error.lock() {
1292 if !guard.is_empty() {
1293 return guard.as_ptr();
1294 }
1295 }
1296 }
1297
1298 let guard = LAST_ERROR
1301 .lock()
1302 .unwrap_or_else(std::sync::PoisonError::into_inner);
1303 match guard.as_ref() {
1304 Some(cs) => cs.as_ptr(),
1305 None => ptr::null(),
1306 }
1307 }))
1308 .unwrap_or(ptr::null())
1309}
1310#[no_mangle]
1315pub extern "C" fn tts_pause(ctx: *mut tts_ctx) {
1316 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1317 if ctx.is_null() {
1318 return;
1319 }
1320 let ctx_ref = unsafe { &*ctx };
1321 let _ = ctx_ref.engine.pause();
1322 }));
1323}
1324
1325#[no_mangle]
1330pub extern "C" fn tts_resume(ctx: *mut tts_ctx) {
1331 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1332 if ctx.is_null() {
1333 return;
1334 }
1335 let ctx_ref = unsafe { &*ctx };
1336 let _ = ctx_ref.engine.resume();
1337 }));
1338}
1339
1340#[no_mangle]
1348pub extern "C" fn tts_synth_to_bytes(
1349 ctx: *mut tts_ctx,
1350 text: *const c_char,
1351 out_bytes: *mut *mut u8,
1352 out_len: *mut usize,
1353) -> i32 {
1354 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1355 if ctx.is_null() || text.is_null() || out_bytes.is_null() || out_len.is_null() {
1356 return -1;
1357 }
1358 let ctx_ref = unsafe { &*ctx };
1359 let text_str = unsafe { CStr::from_ptr(text) }
1360 .to_string_lossy()
1361 .into_owned();
1362 let voice = ctx_ref
1363 .voice_id
1364 .lock()
1365 .unwrap_or_else(std::sync::PoisonError::into_inner)
1366 .clone();
1367 let rate = *ctx_ref
1368 .rate
1369 .lock()
1370 .unwrap_or_else(std::sync::PoisonError::into_inner);
1371 let pitch = *ctx_ref
1372 .pitch
1373 .lock()
1374 .unwrap_or_else(std::sync::PoisonError::into_inner);
1375 let volume = *ctx_ref
1376 .volume
1377 .lock()
1378 .unwrap_or_else(std::sync::PoisonError::into_inner);
1379
1380 let engine = &ctx_ref.engine;
1381 match engine.synth_to_bytes(&text_str, voice.as_deref(), rate, pitch, volume) {
1382 Ok(data) => {
1383 if data.is_empty() {
1384 unsafe {
1385 *out_bytes = ptr::null_mut();
1386 *out_len = 0;
1387 }
1388 return 0;
1389 }
1390 let len = data.len();
1391 let Ok(layout) = std::alloc::Layout::array::<u8>(len) else {
1392 set_error(&format!("Audio buffer too large: {len} bytes"));
1393 return -1;
1394 };
1395 let ptr = unsafe { std::alloc::alloc(layout) };
1396 unsafe {
1397 ptr::copy_nonoverlapping(data.as_ptr(), ptr, len);
1398 *out_bytes = ptr;
1399 *out_len = len;
1400 }
1401 0
1402 }
1403 Err(e) => {
1404 *ctx_ref
1405 .last_error
1406 .lock()
1407 .unwrap_or_else(std::sync::PoisonError::into_inner) =
1408 safe_cstring(e.to_string());
1409 -1
1410 }
1411 }
1412 }))
1413 .unwrap_or(-1)
1414}
1415
1416#[no_mangle]
1421pub extern "C" fn tts_free_bytes(bytes: *mut u8, len: usize) {
1422 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1423 if bytes.is_null() || len == 0 {
1424 return;
1425 }
1426 let Ok(layout) = std::alloc::Layout::array::<u8>(len) else {
1427 return;
1428 };
1429 unsafe {
1430 std::alloc::dealloc(bytes, layout);
1431 }
1432 }));
1433}
1434
1435#[cfg(test)]
1436mod poison_tests {
1437 use super::*;
1438
1439 #[test]
1440 fn poisoned_lock_does_not_break_subsequent_ffi_calls() {
1441 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1446 let _guard = LAST_ERROR.lock().unwrap();
1447 panic!("deliberate poison");
1448 }));
1449 assert!(LAST_ERROR.is_poisoned());
1450
1451 set_error("still alive");
1452 let p = tts_get_last_error(std::ptr::null_mut());
1453 assert!(
1454 !p.is_null(),
1455 "error must be readable through a poisoned lock"
1456 );
1457 let msg = unsafe { std::ffi::CStr::from_ptr(p) }.to_string_lossy();
1458 assert_eq!(msg, "still alive");
1459
1460 *LAST_ERROR
1463 .lock()
1464 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
1465 LAST_ERROR.clear_poison();
1466 }
1467}