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;
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/// Voice cloning: bank a voice once (Personal Voice export / LJSpeech
45/// corpus), enroll it with cloning-capable engines, speak it everywhere.
46/// Rust-only for now; enable the `cloning` feature.
47#[cfg(feature = "cloning")]
48pub mod cloning;
49#[cfg(any(feature = "cloud", feature = "cloud-core"))]
50mod cloud_engine;
51
52/// The pure cloud provider shapes shared by the native engine and the
53/// wasm/js crate: sigv4 signing, request builders, response parsers,
54/// provider configs. Enabled by `cloud-core` (implied by `cloud`).
55#[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
72/// Pure helpers for the qwen3-local engine — always compiled (and
73/// unit-tested in CI) even when the engine feature is off.
74pub mod qwen3_local_support;
75
76/// Local Qwen3-TTS via qwen3-tts.cpp (GGML). Requires the user-built C++
77/// library — see `scripts/build-qwen3-local.sh` and the feature docs in
78/// Cargo.toml. Zero-shot voice cloning: pass a reference WAV path (or an
79/// `emb:` handle from the cloning cloner) as the voice string.
80#[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/// Pocket-TTS with real word timings (own streaming loop over the tapped
89/// Kyutai ONNX graphs; attention-based alignment — no estimator).
90#[cfg(feature = "pocket-timing")]
91pub mod pocket;
92
93// Re-exports for user-friendly API
94pub 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
104/// Shared engine handle. Using `Arc<dyn TtsEngine>` instead of
105/// `Mutex<Box<dyn TtsEngine>>` means synthesis no longer blocks
106/// `tts_stop` / `tts_set_*` / `tts_destroy`. It also keeps the
107/// engine alive if `tts_destroy` is called while synthesis is still
108/// running — the Arc is cloned before speak() and dropped afterwards.
109type BoxedEngine = Arc<dyn TtsEngine>;
110
111/// Opaque context holding an engine instance and its per-instance settings.
112pub type CAudioCb = Option<extern "C" fn(*const u8, usize, *mut std::ffi::c_void)>;
113/// Word-boundary callback:
114/// cb(word, byte_offset, byte_len, start_s, end_s, estimated, userdata).
115/// byte_offset is a UTF-8 byte index into the spoken text, byte_len a
116/// byte length — pair them to slice the text safely. An unlocatable
117/// word holds the last known offset (0 if nothing matched) with
118/// byte_len -1. `estimated` is 1 when the timings are proportional
119/// estimates (unpatched voice, wpm model), 0 when measured (cloud
120/// provider timings). Exceptions: the Windows
121/// SAPI native-boundary path reports UTF-16 code-unit offsets/lengths
122/// (the units ISpVoice events provide), and the Azure/Edge WebSocket
123/// path passes Azure's own text.Offset/text.Length through unchanged
124/// when Azure supplies them (units per Azure's documentation; the
125/// wrapper's fallback arithmetic when they are absent is UTF-8 bytes).
126pub type CBoundaryCb =
127    Option<extern "C" fn(*const c_char, i32, i32, f32, f32, i32, *mut std::ffi::c_void)>;
128/// Mark/bookmark callback: cb(name, char_offset, start_s, end_s, userdata).
129/// char_offset is -1 when unknown; start/end are the measured (or
130/// estimated) audio position the mark fires at.
131pub 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    // The TtsEngine trait already requires Send + Sync, and every engine
141    // impl uses internal Mutexes (or atomic state) for thread safety. The
142    // outer Mutex that used to wrap the engine was held for the entire
143    // synthesis call, blocking concurrent tts_stop / tts_set_*. Removing
144    // it lets those calls proceed while synthesis runs.
145    engine: BoxedEngine,
146    voice_id: Mutex<Option<String>>,
147    rate: Mutex<f32>,
148    pitch: Mutex<f32>,
149    volume: Mutex<f32>,
150    // Cached CString of the last error message. We must return a *const c_char
151    // from tts_get_last_error whose backing allocation outlives the function
152    // call; the previous version constructed a fresh CString inside the
153    // getter and returned its as_ptr() while the CString was dropped at end
154    // of scope — use-after-free, surfaced as empty reads on Windows.
155    // Storing the CString here keeps the allocation alive until the next
156    // error replaces it.
157    last_error: Mutex<CString>,
158    // Callback + userdata are bundled into a single Mutex each so a reader
159    // can never observe a new callback paired with stale userdata (or vice
160    // versa). The `Send` wrapper below lets us ship the raw pointer across
161    // threads safely because access is mediated by the Mutex.
162    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/// Bundled audio callback + userdata so updates are atomic.
172#[derive(Clone, Copy)]
173struct AudioCallback {
174    cb: CAudioCb,
175    userdata: *mut std::ffi::c_void,
176}
177
178/// Bundled start/end callback (no payload, just a lifecycle signal).
179#[derive(Clone, Copy)]
180struct VoidCallback {
181    cb: CVoidCb,
182    userdata: *mut std::ffi::c_void,
183}
184
185/// Bundled error callback (error message + userdata).
186#[derive(Clone, Copy)]
187struct ErrorCallback {
188    cb: CErrorCb,
189    userdata: *mut std::ffi::c_void,
190}
191
192/// Extended boundary callback with source-text offsets.
193#[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/// Bundled viseme callback.
206#[derive(Clone, Copy)]
207struct VisemeCallback {
208    cb: CVisemeCb,
209    userdata: *mut std::ffi::c_void,
210}
211
212// SAFETY: the raw userdata pointers are only dereferenced via the C callback
213// signatures, and access is serialised by the surrounding Mutex on each
214// `tts_ctx` field. The host is responsible for the userdata lifetime, which
215// is the standard FFI callback contract.
216unsafe 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
227// All mutex locks in this file recover from poisoning
228// (`PoisonError::into_inner`): a poisoned lock must not turn every
229// subsequent FFI call into a panic (UB across the C boundary). The
230// realistic poison vector is an allocation failing mid-update (engines
231// and callbacks run after the guards are snapshot and dropped); the
232// guarded fields are all wholesale-replaced values, so recovery cannot
233// expose a half-written state.
234static 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
243/// Helper macro to wrap FFI functions with panic catching
244macro_rules! ffi_catch {
245    ($expr:expr) => {
246        std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| $expr)).unwrap_or(-1)
247    };
248}
249
250/// Create a CString from any string, replacing interior null bytes with a
251/// replacement character so it never panics. Used for all FFI string
252/// conversions where the source data comes from engine APIs (voice names,
253/// error messages, etc.) and should never contain nulls, but we must not
254/// panic across the FFI boundary if it does.
255fn 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    // Replace interior nulls and retry.
261    let cleaned: String = s.chars().filter(|&c| c != '\0').collect();
262    CString::new(cleaned).unwrap_or_default()
263}
264
265/// Create a new TTS engine instance.
266///
267/// Returns an opaque context pointer on success, or null on failure.
268/// Call [`tts_get_last_error`] to retrieve the error message on failure.
269///
270/// # Safety
271///
272/// `engine_id` must be a valid null-terminated C string.
273/// `credentials_json` may be null or a valid null-terminated JSON string.
274#[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/// Destroy a TTS context and free all associated resources.
351///
352/// Attempts to stop any in-progress speech before dropping the engine so the
353/// underlying resources (speech-dispatcher connection, COM objects, etc.) get
354/// a chance to clean up
355///
356/// # Safety
357///
358/// `ctx` must be a pointer previously returned by [`tts_create`],
359/// or null (no-op).
360#[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        // Safety: ctx is non-null and was produced by Box::into_raw above.
367        let boxed = unsafe { Box::from_raw(ctx) };
368        // Best-effort stop before drop. The engine's Drop impl is responsible
369        // for any additional cleanup (closing sockets, releasing COM refs).
370        let _ = boxed.engine.stop();
371        drop(boxed);
372    }));
373}
374
375/// Speak `text` asynchronously using the engine in `ctx`.
376///
377/// Returns 0 on success, -1 on failure.
378///
379/// # Safety
380///
381/// `ctx` must be a valid pointer from [`tts_create`].
382/// `text` must be a valid null-terminated C string.
383#[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/// Speak pre-built SSML using the engine in `ctx`.
389///
390/// The SSML bypasses rate/pitch/volume wrapping; callers are
391/// responsible for embedding all prosody in the SSML. Engines that
392/// parse SSML receive it directly; engines that do not (ElevenLabs)
393/// get it translated via SpeechMarkdown into their model-matched
394/// prompt dialect.
395///
396/// Returns 0 on success, -1 on failure.
397///
398/// # Safety
399///
400/// `ctx` must be a valid pointer from [`tts_create`].
401/// `ssml` must be a valid null-terminated C string.
402#[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/// Speak `text` synchronously (blocks until complete).
599///
600/// Returns 0 on success, -1 on failure.
601///
602/// # Safety
603///
604/// `ctx` must be a valid pointer from [`tts_create`].
605/// `text` must be a valid null-terminated C string.
606#[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        // Snapshot lifecycle callbacks atomically.
698        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        // Fire on_start before synthesis.
718        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/// Stop any in-progress speech.
782///
783/// # Safety
784///
785/// `ctx` must be a valid pointer from [`tts_create`].
786#[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/// Retrieve the list of available voices for the engine.
798///
799/// On success, writes a heap-allocated array to `*out_voices` and its length
800/// to `*out_count`. Caller must free with [`tts_free_voices`].
801///
802/// Returns 0 on success, -1 on failure.
803///
804/// # Safety
805///
806/// `ctx` must be valid. `out_voices` and `out_count` must be non-null.
807#[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/// Free a voice array previously returned by [`tts_get_voices`].
889///
890/// # Safety
891///
892/// `voices` must be a pointer from `tts_get_voices` with the matching `count`.
893#[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/// Set the voice for subsequent speak calls.
929///
930/// # Safety
931///
932/// `ctx` must be valid. `voice_id` must be a valid null-terminated C string.
933#[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/// Set the speech rate (1.0 = normal).
951///
952/// # Safety
953///
954/// `ctx` must be valid.
955#[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/// Set the speech pitch (1.0 = normal).
969///
970/// # Safety
971///
972/// `ctx` must be valid.
973#[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/// Set the speech volume (1.0 = normal).
987///
988/// # Safety
989///
990/// `ctx` must be valid.
991#[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/// Set the callback for streaming audio chunks.
1004///
1005/// # Safety
1006/// `ctx` must be valid.
1007#[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        // Single critical section: a reader can never observe a new cb
1019        // paired with stale userdata.
1020        *ctx_ref
1021            .on_audio
1022            .lock()
1023            .unwrap_or_else(std::sync::PoisonError::into_inner) = AudioCallback { cb, userdata };
1024    }));
1025}
1026
1027/// Set the word-boundary callback:
1028/// cb(word, byte_offset, byte_len, start_s, end_s, estimated, userdata).
1029/// byte_offset/byte_len are UTF-8 bytes into the spoken text; an
1030/// unlocatable word holds the last known offset with byte_len -1 (see
1031/// CBoundaryCb for the full contract and the SAPI UTF-16 exception).
1032///
1033/// # Safety
1034/// `ctx` must be valid.
1035#[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/// Set the mark/bookmark callback: cb(name, char_offset, start_s, end_s, userdata).
1054/// Fires for `<mark>`/`<bookmark>` SSML tags at their measured audio
1055///
1056/// # Safety
1057/// `ctx` must be valid.
1058#[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/// Viseme callback for lip-sync / facial animation.
1073/// cb(viseme_id, audio_offset_sec, userdata)
1074///
1075/// # Safety
1076/// `ctx` must be valid.
1077#[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/// Set the callback fired when speech starts.
1096///
1097/// # Safety
1098/// `ctx` must be valid.
1099#[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/// Set the callback fired when speech completes successfully.
1118///
1119/// # Safety
1120/// `ctx` must be valid.
1121#[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/// Set the callback fired when speech fails.
1136///
1137/// The error message is a null-terminated C string valid for the duration
1138/// of the callback only.
1139///
1140/// # Safety
1141/// `ctx` must be valid.
1142#[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/// Return the number of registered engines.
1161#[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/// Get the list of available engine descriptors.
1169///
1170/// On success, writes a heap-allocated array to `*out_engines` and its length
1171/// to `*out_count`. Caller must free with [`tts_free_engines`].
1172///
1173/// Returns 0 on success, -1 on failure.
1174///
1175/// # Safety
1176///
1177/// `out_engines` and `out_count` must be non-null.
1178#[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/// Free an engine info array previously returned by [`tts_get_engines`].
1234///
1235/// # Safety
1236///
1237/// `engines` must be a pointer from `tts_get_engines` with the matching `count`.
1238#[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/// Return the last error message as a C string, or null if none.
1268///
1269/// If ctx is provided, returns the per-context error. If ctx is null,
1270/// returns the global error (for tts_create failures).
1271///
1272/// The returned pointer is valid until the next call to any TTS function.
1273///
1274/// # Safety
1275///
1276/// `ctx` may be null (returns global error), or a valid context pointer.
1277#[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 context provided and valid, return per-context error.
1281        //
1282        // The CString is stored in the ctx (Mutex<CString>) so the returned
1283        // pointer's backing allocation lives until the next error replaces it.
1284        // The caller must copy if they need the string beyond the next
1285        // synth/speak call on this ctx.
1286        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        // Fallback to global error (for tts_create failures or null
1296        // context); poison-tolerant like every other lock here.
1297        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/// Pause in-progress speech.
1308///
1309/// # Safety
1310/// `ctx` must be valid.
1311#[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/// Resume paused speech.
1323///
1324/// # Safety
1325/// `ctx` must be valid.
1326#[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/// Synthesize text to audio bytes without playback.
1338/// Writes a heap-allocated buffer to `*out_bytes` and its length to `*out_len`.
1339/// Caller must free with [`tts_free_bytes`].
1340/// Returns 0 on success, -1 on failure.
1341///
1342/// # Safety
1343/// `ctx` must be valid. `out_bytes` and `out_len` must be non-null.
1344#[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/// Free a byte buffer returned by [`tts_synth_to_bytes`].
1414///
1415/// # Safety
1416/// `bytes` must be from `tts_synth_to_bytes` with the matching `len`.
1417#[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        // The FFI-reachable poison-recovery path: poison LAST_ERROR
1439        // deliberately, then verify set_error (a write) and
1440        // tts_get_last_error (an FFI read) both still work instead of
1441        // panicking across the boundary.
1442        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        // Reset for other tests: clear the value AND the poison flag
1458        // (into_inner recovery alone would leave the flag set).
1459        *LAST_ERROR
1460            .lock()
1461            .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
1462        LAST_ERROR.clear_poison();
1463    }
1464}