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