Skip to main content

rust_tts_wrapper/
lib.rs

1//! # rust-tts-wrapper
2//!
3//! Cross-platform TTS (Text-to-Speech) wrapper with a C ABI.
4//! Mirrors [`js-tts-wrapper`] and `SwiftTTSWrapper`, supporting 25 engines:
5//! system (speech-dispatcher), AVSpeech (macOS), SAPI (Windows),
6//! Sherpa-ONNX + floravox (local ONNX voices — 1300+ models / ~1,100
7//! languages), and 20 cloud providers.
8//!
9//! [`js-tts-wrapper`]: https://github.com/AACTools/js-tts-wrapper
10//!
11//! ## Quick start (C)
12//!
13//! ```c
14//! tts_ctx* ctx = tts_create("system", NULL);
15//! tts_speak(ctx, "Hello world");
16//! tts_destroy(ctx);
17//! ```
18
19#![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/// Voice cloning: bank a voice once (Personal Voice export / LJSpeech
48/// corpus), enroll it with cloning-capable engines, speak it everywhere.
49/// Rust-only for now; enable the `cloning` feature.
50#[cfg(feature = "cloning")]
51pub mod cloning;
52#[cfg(any(feature = "cloud", feature = "cloud-core"))]
53mod cloud_engine;
54
55/// The pure cloud provider shapes shared by the native engine and the
56/// wasm/js crate: sigv4 signing, request builders, response parsers,
57/// provider configs. Enabled by `cloud-core` (implied by `cloud`).
58#[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
75/// Pure helpers for the qwen3-local engine — always compiled (and
76/// unit-tested in CI) even when the engine feature is off.
77pub mod qwen3_local_support;
78
79/// Local Qwen3-TTS via qwen3-tts.cpp (GGML). Requires the user-built C++
80/// library — see `scripts/build-qwen3-local.sh` and the feature docs in
81/// Cargo.toml. Zero-shot voice cloning: pass a reference WAV path (or an
82/// `emb:` handle from the cloning cloner) as the voice string.
83#[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/// Pocket-TTS with real word timings (own streaming loop over the tapped
92/// Kyutai ONNX graphs; attention-based alignment — no estimator).
93#[cfg(feature = "pocket-timing")]
94pub mod pocket;
95
96// Re-exports for user-friendly API
97pub 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
107/// Shared engine handle. Using `Arc<dyn TtsEngine>` instead of
108/// `Mutex<Box<dyn TtsEngine>>` means synthesis no longer blocks
109/// `tts_stop` / `tts_set_*` / `tts_destroy`. It also keeps the
110/// engine alive if `tts_destroy` is called while synthesis is still
111/// running — the Arc is cloned before speak() and dropped afterwards.
112type BoxedEngine = Arc<dyn TtsEngine>;
113
114/// Opaque context holding an engine instance and its per-instance settings.
115pub type CAudioCb = Option<extern "C" fn(*const u8, usize, *mut std::ffi::c_void)>;
116/// Word-boundary callback:
117/// cb(word, byte_offset, byte_len, start_s, end_s, estimated, userdata).
118/// byte_offset is a UTF-8 byte index into the spoken text, byte_len a
119/// byte length — pair them to slice the text safely. An unlocatable
120/// word holds the last known offset (0 if nothing matched) with
121/// byte_len -1. `estimated` is 1 when the timings are proportional
122/// estimates (unpatched voice, wpm model), 0 when measured (cloud
123/// provider timings). Exceptions: the Windows
124/// SAPI native-boundary path reports UTF-16 code-unit offsets/lengths
125/// (the units ISpVoice events provide), and the Azure/Edge WebSocket
126/// path passes Azure's own text.Offset/text.Length through unchanged
127/// when Azure supplies them (units per Azure's documentation; the
128/// wrapper's fallback arithmetic when they are absent is UTF-8 bytes).
129pub type CBoundaryCb =
130    Option<extern "C" fn(*const c_char, i32, i32, f32, f32, i32, *mut std::ffi::c_void)>;
131/// Mark/bookmark callback: cb(name, char_offset, start_s, end_s, userdata).
132/// char_offset is -1 when unknown; start/end are the measured (or
133/// estimated) audio position the mark fires at.
134pub 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    // The TtsEngine trait already requires Send + Sync, and every engine
144    // impl uses internal Mutexes (or atomic state) for thread safety. The
145    // outer Mutex that used to wrap the engine was held for the entire
146    // synthesis call, blocking concurrent tts_stop / tts_set_*. Removing
147    // it lets those calls proceed while synthesis runs.
148    engine: BoxedEngine,
149    voice_id: Mutex<Option<String>>,
150    rate: Mutex<f32>,
151    pitch: Mutex<f32>,
152    volume: Mutex<f32>,
153    // Cached CString of the last error message. We must return a *const c_char
154    // from tts_get_last_error whose backing allocation outlives the function
155    // call; the previous version constructed a fresh CString inside the
156    // getter and returned its as_ptr() while the CString was dropped at end
157    // of scope — use-after-free, surfaced as empty reads on Windows.
158    // Storing the CString here keeps the allocation alive until the next
159    // error replaces it.
160    last_error: Mutex<CString>,
161    // Callback + userdata are bundled into a single Mutex each so a reader
162    // can never observe a new callback paired with stale userdata (or vice
163    // versa). The `Send` wrapper below lets us ship the raw pointer across
164    // threads safely because access is mediated by the Mutex.
165    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/// Bundled audio callback + userdata so updates are atomic.
175#[derive(Clone, Copy)]
176struct AudioCallback {
177    cb: CAudioCb,
178    userdata: *mut std::ffi::c_void,
179}
180
181/// Bundled start/end callback (no payload, just a lifecycle signal).
182#[derive(Clone, Copy)]
183struct VoidCallback {
184    cb: CVoidCb,
185    userdata: *mut std::ffi::c_void,
186}
187
188/// Bundled error callback (error message + userdata).
189#[derive(Clone, Copy)]
190struct ErrorCallback {
191    cb: CErrorCb,
192    userdata: *mut std::ffi::c_void,
193}
194
195/// Extended boundary callback with source-text offsets.
196#[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/// Bundled viseme callback.
209#[derive(Clone, Copy)]
210struct VisemeCallback {
211    cb: CVisemeCb,
212    userdata: *mut std::ffi::c_void,
213}
214
215// SAFETY: the raw userdata pointers are only dereferenced via the C callback
216// signatures, and access is serialised by the surrounding Mutex on each
217// `tts_ctx` field. The host is responsible for the userdata lifetime, which
218// is the standard FFI callback contract.
219unsafe 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
230// All mutex locks in this file recover from poisoning
231// (`PoisonError::into_inner`): a poisoned lock must not turn every
232// subsequent FFI call into a panic (UB across the C boundary). The
233// realistic poison vector is an allocation failing mid-update (engines
234// and callbacks run after the guards are snapshot and dropped); the
235// guarded fields are all wholesale-replaced values, so recovery cannot
236// expose a half-written state.
237static 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
246/// Helper macro to wrap FFI functions with panic catching
247macro_rules! ffi_catch {
248    ($expr:expr) => {
249        std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| $expr)).unwrap_or(-1)
250    };
251}
252
253/// Create a CString from any string, replacing interior null bytes with a
254/// replacement character so it never panics. Used for all FFI string
255/// conversions where the source data comes from engine APIs (voice names,
256/// error messages, etc.) and should never contain nulls, but we must not
257/// panic across the FFI boundary if it does.
258fn 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    // Replace interior nulls and retry.
264    let cleaned: String = s.chars().filter(|&c| c != '\0').collect();
265    CString::new(cleaned).unwrap_or_default()
266}
267
268/// Create a new TTS engine instance.
269///
270/// Returns an opaque context pointer on success, or null on failure.
271/// Call [`tts_get_last_error`] to retrieve the error message on failure.
272///
273/// # Safety
274///
275/// `engine_id` must be a valid null-terminated C string.
276/// `credentials_json` may be null or a valid null-terminated JSON string.
277#[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/// Destroy a TTS context and free all associated resources.
354///
355/// Attempts to stop any in-progress speech before dropping the engine so the
356/// underlying resources (speech-dispatcher connection, COM objects, etc.) get
357/// a chance to clean up
358///
359/// # Safety
360///
361/// `ctx` must be a pointer previously returned by [`tts_create`],
362/// or null (no-op).
363#[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        // Safety: ctx is non-null and was produced by Box::into_raw above.
370        let boxed = unsafe { Box::from_raw(ctx) };
371        // Best-effort stop before drop. The engine's Drop impl is responsible
372        // for any additional cleanup (closing sockets, releasing COM refs).
373        let _ = boxed.engine.stop();
374        drop(boxed);
375    }));
376}
377
378/// Speak `text` asynchronously using the engine in `ctx`.
379///
380/// Returns 0 on success, -1 on failure.
381///
382/// # Safety
383///
384/// `ctx` must be a valid pointer from [`tts_create`].
385/// `text` must be a valid null-terminated C string.
386#[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/// Speak pre-built SSML using the engine in `ctx`.
392///
393/// The SSML bypasses rate/pitch/volume wrapping; callers are
394/// responsible for embedding all prosody in the SSML. Engines that
395/// parse SSML receive it directly; engines that do not (ElevenLabs)
396/// get it translated via SpeechMarkdown into their model-matched
397/// prompt dialect.
398///
399/// Returns 0 on success, -1 on failure.
400///
401/// # Safety
402///
403/// `ctx` must be a valid pointer from [`tts_create`].
404/// `ssml` must be a valid null-terminated C string.
405#[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/// Speak `text` synchronously (blocks until complete).
602///
603/// Returns 0 on success, -1 on failure.
604///
605/// # Safety
606///
607/// `ctx` must be a valid pointer from [`tts_create`].
608/// `text` must be a valid null-terminated C string.
609#[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        // Snapshot lifecycle callbacks atomically.
701        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        // Fire on_start before synthesis.
721        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/// Stop any in-progress speech.
785///
786/// # Safety
787///
788/// `ctx` must be a valid pointer from [`tts_create`].
789#[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/// Retrieve the list of available voices for the engine.
801///
802/// On success, writes a heap-allocated array to `*out_voices` and its length
803/// to `*out_count`. Caller must free with [`tts_free_voices`].
804///
805/// Returns 0 on success, -1 on failure.
806///
807/// # Safety
808///
809/// `ctx` must be valid. `out_voices` and `out_count` must be non-null.
810#[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/// Free a voice array previously returned by [`tts_get_voices`].
892///
893/// # Safety
894///
895/// `voices` must be a pointer from `tts_get_voices` with the matching `count`.
896#[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/// Set the voice for subsequent speak calls.
932///
933/// # Safety
934///
935/// `ctx` must be valid. `voice_id` must be a valid null-terminated C string.
936#[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/// Set the speech rate (1.0 = normal).
954///
955/// # Safety
956///
957/// `ctx` must be valid.
958#[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/// Set the speech pitch (1.0 = normal).
972///
973/// # Safety
974///
975/// `ctx` must be valid.
976#[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/// Set the speech volume (1.0 = normal).
990///
991/// # Safety
992///
993/// `ctx` must be valid.
994#[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/// Set the callback for streaming audio chunks.
1007///
1008/// # Safety
1009/// `ctx` must be valid.
1010#[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        // Single critical section: a reader can never observe a new cb
1022        // paired with stale userdata.
1023        *ctx_ref
1024            .on_audio
1025            .lock()
1026            .unwrap_or_else(std::sync::PoisonError::into_inner) = AudioCallback { cb, userdata };
1027    }));
1028}
1029
1030/// Set the word-boundary callback:
1031/// cb(word, byte_offset, byte_len, start_s, end_s, estimated, userdata).
1032/// byte_offset/byte_len are UTF-8 bytes into the spoken text; an
1033/// unlocatable word holds the last known offset with byte_len -1 (see
1034/// CBoundaryCb for the full contract and the SAPI UTF-16 exception).
1035///
1036/// # Safety
1037/// `ctx` must be valid.
1038#[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/// Set the mark/bookmark callback: cb(name, char_offset, start_s, end_s, userdata).
1057/// Fires for `<mark>`/`<bookmark>` SSML tags at their measured audio
1058///
1059/// # Safety
1060/// `ctx` must be valid.
1061#[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/// Viseme callback for lip-sync / facial animation.
1076/// cb(viseme_id, audio_offset_sec, userdata)
1077///
1078/// # Safety
1079/// `ctx` must be valid.
1080#[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/// Set the callback fired when speech starts.
1099///
1100/// # Safety
1101/// `ctx` must be valid.
1102#[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/// Set the callback fired when speech completes successfully.
1121///
1122/// # Safety
1123/// `ctx` must be valid.
1124#[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/// Set the callback fired when speech fails.
1139///
1140/// The error message is a null-terminated C string valid for the duration
1141/// of the callback only.
1142///
1143/// # Safety
1144/// `ctx` must be valid.
1145#[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/// Return the number of registered engines.
1164#[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/// Get the list of available engine descriptors.
1172///
1173/// On success, writes a heap-allocated array to `*out_engines` and its length
1174/// to `*out_count`. Caller must free with [`tts_free_engines`].
1175///
1176/// Returns 0 on success, -1 on failure.
1177///
1178/// # Safety
1179///
1180/// `out_engines` and `out_count` must be non-null.
1181#[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/// Free an engine info array previously returned by [`tts_get_engines`].
1237///
1238/// # Safety
1239///
1240/// `engines` must be a pointer from `tts_get_engines` with the matching `count`.
1241#[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/// Return the last error message as a C string, or null if none.
1271///
1272/// If ctx is provided, returns the per-context error. If ctx is null,
1273/// returns the global error (for tts_create failures).
1274///
1275/// The returned pointer is valid until the next call to any TTS function.
1276///
1277/// # Safety
1278///
1279/// `ctx` may be null (returns global error), or a valid context pointer.
1280#[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 context provided and valid, return per-context error.
1284        //
1285        // The CString is stored in the ctx (Mutex<CString>) so the returned
1286        // pointer's backing allocation lives until the next error replaces it.
1287        // The caller must copy if they need the string beyond the next
1288        // synth/speak call on this ctx.
1289        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        // Fallback to global error (for tts_create failures or null
1299        // context); poison-tolerant like every other lock here.
1300        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/// Pause in-progress speech.
1311///
1312/// # Safety
1313/// `ctx` must be valid.
1314#[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/// Resume paused speech.
1326///
1327/// # Safety
1328/// `ctx` must be valid.
1329#[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/// Synthesize text to audio bytes without playback.
1341/// Writes a heap-allocated buffer to `*out_bytes` and its length to `*out_len`.
1342/// Caller must free with [`tts_free_bytes`].
1343/// Returns 0 on success, -1 on failure.
1344///
1345/// # Safety
1346/// `ctx` must be valid. `out_bytes` and `out_len` must be non-null.
1347#[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/// Free a byte buffer returned by [`tts_synth_to_bytes`].
1417///
1418/// # Safety
1419/// `bytes` must be from `tts_synth_to_bytes` with the matching `len`.
1420#[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        // The FFI-reachable poison-recovery path: poison LAST_ERROR
1442        // deliberately, then verify set_error (a write) and
1443        // tts_get_last_error (an FFI read) both still work instead of
1444        // panicking across the boundary.
1445        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        // Reset for other tests: clear the value AND the poison flag
1461        // (into_inner recovery alone would leave the flag set).
1462        *LAST_ERROR
1463            .lock()
1464            .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
1465        LAST_ERROR.clear_poison();
1466    }
1467}