Skip to main content

rust_tts_wrapper/
types.rs

1//! Shared types used across the crate.
2
3use std::collections::HashMap;
4use std::fmt;
5use std::os::raw::c_char;
6
7/// Voice gender, matching Swift's `UnifiedVoice.Gender`.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum Gender {
10    Male,
11    Female,
12    Unknown,
13}
14
15impl fmt::Display for Gender {
16    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
17        match self {
18            Self::Male => write!(f, "Male"),
19            Self::Female => write!(f, "Female"),
20            Self::Unknown => write!(f, "Unknown"),
21        }
22    }
23}
24
25/// Normalize a raw gender string to a typed [`Gender`].
26#[must_use]
27pub fn normalize_gender(value: &str) -> Gender {
28    match value.to_lowercase().as_str() {
29        "female" => Gender::Female,
30        "male" => Gender::Male,
31        _ => Gender::Unknown,
32    }
33}
34
35/// A language code entry with BCP-47, ISO 639-3, and display name.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct LanguageCode {
38    /// BCP-47 language tag (e.g. `"en-US"`).
39    pub bcp47: String,
40    /// ISO 639-3 language code (e.g. `"eng"`).
41    pub iso639_3: String,
42    /// Human-readable language name (e.g. `"English (United States)"`).
43    pub display: String,
44}
45
46/// A single voice offered by an engine, unified across all providers.
47/// Mirrors Swift's `UnifiedVoice`.
48#[derive(Debug, Clone)]
49pub struct Voice {
50    /// Unique voice identifier within the engine.
51    pub id: String,
52    /// Human-readable voice name.
53    pub name: String,
54    /// Gender of the voice.
55    pub gender: Gender,
56    /// The engine/provider that provides this voice (e.g. `"azure"`, `"google"`).
57    pub provider: String,
58    /// Language codes supported by this voice.
59    pub language_codes: Vec<LanguageCode>,
60}
61
62impl Voice {
63    /// Convenience: return the primary (first) BCP-47 language code, or empty string.
64    #[must_use]
65    pub fn primary_language(&self) -> &str {
66        self.language_codes.first().map_or("", |l| l.bcp47.as_str())
67    }
68}
69
70/// Map a BCP-47 locale code to a human-readable display name.
71///
72/// Uses ICU4X (Unicode-maintained, covers ~8000 locales) when the
73/// `display_names` feature is enabled, producing consistent output like
74/// `"Afrikaans (South Africa)"` from `"af-ZA"` regardless of which engine
75/// provided the voice. Falls back to the raw code when the feature is off.
76#[cfg(feature = "display_names")]
77#[must_use]
78pub fn locale_display_name(bcp47: &str) -> String {
79    use icu_displaynames::{DisplayNamesOptions, LocaleDisplayNamesFormatter};
80
81    // Parse the BCP-47 code into a Locale (e.g. "af-ZA" → Locale).
82    let Ok(locale) = bcp47.parse::<icu_locid::Locale>() else {
83        return bcp47.to_string();
84    };
85
86    // Create a formatter that produces English display names, then format.
87    let Ok(formatter) = LocaleDisplayNamesFormatter::try_new(
88        &icu_locid::locale!("en").into(),
89        DisplayNamesOptions::default(),
90    ) else {
91        return bcp47.to_string();
92    };
93
94    let name = formatter.of(&locale);
95    if name.is_empty() {
96        bcp47.to_string()
97    } else {
98        name.into_owned()
99    }
100}
101
102#[cfg(not(feature = "display_names"))]
103#[must_use]
104pub fn locale_display_name(bcp47: &str) -> String {
105    bcp47.to_string()
106}
107
108/// Audio output format, matching Swift's `AudioFormat`.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub enum AudioFormat {
111    Mp3,
112    Wav,
113    Ogg,
114    Opus,
115    Aac,
116    Flac,
117    Pcm,
118}
119
120impl fmt::Display for AudioFormat {
121    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122        match self {
123            Self::Mp3 => write!(f, "mp3"),
124            Self::Wav => write!(f, "wav"),
125            Self::Ogg => write!(f, "ogg"),
126            Self::Opus => write!(f, "opus"),
127            Self::Aac => write!(f, "aac"),
128            Self::Flac => write!(f, "flac"),
129            Self::Pcm => write!(f, "pcm"),
130        }
131    }
132}
133
134/// Named speech rate presets, matching Swift's `SpeechRate`.
135#[derive(Debug, Clone, Copy, PartialEq)]
136pub enum SpeechRate {
137    XSlow,
138    Slow,
139    Medium,
140    Fast,
141    XFast,
142}
143
144impl SpeechRate {
145    /// Convert to a float multiplier (1.0 = normal).
146    #[must_use]
147    pub fn rate_value(self) -> f32 {
148        match self {
149            Self::XSlow => 0.5,
150            Self::Slow => 0.75,
151            Self::Medium => 1.0,
152            Self::Fast => 1.25,
153            Self::XFast => 1.5,
154        }
155    }
156}
157
158/// Named speech pitch presets, matching Swift's `SpeechPitch`.
159#[derive(Debug, Clone, Copy, PartialEq)]
160pub enum SpeechPitch {
161    XLow,
162    Low,
163    Medium,
164    High,
165    XHigh,
166}
167
168impl SpeechPitch {
169    /// Convert to a float multiplier (1.0 = normal).
170    #[must_use]
171    pub fn pitch_value(self) -> f32 {
172        match self {
173            Self::XLow => 0.5,
174            Self::Low => 0.75,
175            Self::Medium => 1.0,
176            Self::High => 1.25,
177            Self::XHigh => 1.5,
178        }
179    }
180}
181
182/// Options for speak/synth calls, matching Swift's `SpeakOptions`.
183#[derive(Debug, Clone, Default)]
184pub struct SpeakOptions {
185    /// Speech rate as a float multiplier (1.0 = normal). Overrides `speech_rate`.
186    pub rate: Option<f32>,
187    /// Speech rate as a named preset.
188    pub speech_rate: Option<SpeechRate>,
189    /// Speech pitch as a float multiplier (1.0 = normal). Overrides `speech_pitch`.
190    pub pitch: Option<f32>,
191    /// Speech pitch as a named preset.
192    pub speech_pitch: Option<SpeechPitch>,
193    /// Volume (0.0–1.0).
194    pub volume: Option<f32>,
195    /// Voice identifier.
196    pub voice: Option<String>,
197    /// Desired audio output format.
198    pub format: Option<AudioFormat>,
199    /// Whether to preprocess SpeechMarkdown to SSML.
200    pub use_speech_markdown: bool,
201    /// Whether to request real word boundary events from the API.
202    pub use_word_boundary: bool,
203    /// If true, pass SSML directly to the engine without wrapping.
204    pub raw_ssml: bool,
205    /// Engine-specific extra options.
206    pub extra: HashMap<String, String>,
207}
208
209impl SpeakOptions {
210    /// Resolve the effective rate value.
211    #[must_use]
212    pub fn effective_rate(&self) -> f32 {
213        self.rate
214            .or_else(|| self.speech_rate.map(SpeechRate::rate_value))
215            .unwrap_or(1.0)
216    }
217
218    /// Resolve the effective pitch value.
219    #[must_use]
220    pub fn effective_pitch(&self) -> f32 {
221        self.pitch
222            .or_else(|| self.speech_pitch.map(SpeechPitch::pitch_value))
223            .unwrap_or(1.0)
224    }
225
226    /// Resolve the effective volume value.
227    #[must_use]
228    pub fn effective_volume(&self) -> f32 {
229        self.volume.unwrap_or(1.0)
230    }
231}
232
233/// A word boundary event with timing information.
234/// Mirrors Swift's `WordBoundary`.
235#[derive(Debug, Clone, PartialEq)]
236pub struct WordBoundary {
237    /// The spoken word text.
238    pub text: String,
239    /// Offset from start of audio in milliseconds.
240    pub offset: u64,
241    /// Duration of the word in milliseconds.
242    pub duration: u64,
243    /// True when the timings are proportional estimates (unpatched
244    /// voices, sherpa-onnx's 150-wpm model); false when measured (cloud
245    /// provider timings that track the audio).
246    pub estimated: bool,
247}
248
249/// Describes a registered engine for introspection.
250#[derive(Debug, Clone)]
251pub struct EngineDescriptor {
252    /// Unique engine identifier.
253    pub id: String,
254    /// Human-readable engine name.
255    pub name: String,
256    /// Whether this engine requires API credentials.
257    pub needs_credentials: bool,
258    /// JSON array of credential key names, e.g. `r#"["apiKey"]"#`.
259    pub credential_keys_json: String,
260}
261
262/// Metadata for a Sherpa-ONNX model from the registry.
263#[derive(Debug, Clone)]
264pub struct SherpaModelInfo {
265    /// Model identifier (e.g. `"kokoro-en-v0_19"`).
266    pub id: String,
267    /// Model type (e.g. `"kokoro"`, `"vits"`).
268    pub model_type: String,
269    /// Engine that drives this family (always `"sherpa-onnx"` since the
270    /// floravox engine moved to its own project).
271    pub engines: String,
272    /// Human-readable model name.
273    pub name: String,
274    /// Languages supported by this model.
275    pub language: Vec<SherpaLanguage>,
276    /// Sample rate in Hz.
277    pub sample_rate: u32,
278    /// Number of speakers (for multi-speaker models).
279    pub num_speakers: u32,
280    /// Quality/variant tier from the registry (e.g. `"x_low"`, `"low"`,
281    /// `"medium"`, `"high"`; some entries carry variant markers like
282    /// `"int8"` or `"fp16"` instead). Empty when the entry omits it.
283    pub quality: String,
284    /// Download URL for the model archive.
285    pub url: String,
286    /// Whether the archive is compressed.
287    pub compression: bool,
288    /// Approximate download size in megabytes.
289    pub filesize_mb: f64,
290    /// SPDX licence identifier for the model (e.g. `"MIT"`, `"Apache-2.0"`).
291    /// Empty string when the registry entry omits it.
292    pub license: String,
293    /// Human-readable URL for the full licence text. Empty when omitted.
294    pub license_url: String,
295}
296
297/// A language entry within a Sherpa-ONNX model.
298#[derive(Debug, Clone)]
299pub struct SherpaLanguage {
300    /// ISO 639 language code.
301    pub lang_code: String,
302    /// Full language name.
303    pub language_name: String,
304    /// Country code.
305    pub country: String,
306}
307
308/// C-compatible voice descriptor returned by [`tts_get_voices`](crate::tts_get_voices).
309#[repr(C)]
310pub struct tts_voice {
311    /// Voice identifier (owned C string).
312    pub id: *mut c_char,
313    /// Voice name (owned C string).
314    pub name: *mut c_char,
315    /// Language tag (owned C string).
316    pub language: *mut c_char,
317    /// Gender (owned C string).
318    pub gender: *mut c_char,
319    /// Engine identifier (owned C string).
320    pub engine: *mut c_char,
321}
322
323/// C-compatible engine descriptor returned by [`tts_get_engines`](crate::tts_get_engines).
324#[repr(C)]
325pub struct tts_engine_info {
326    /// Engine identifier (owned C string).
327    pub id: *mut c_char,
328    /// Engine name (owned C string).
329    pub name: *mut c_char,
330    /// Whether credentials are required.
331    pub needs_credentials: bool,
332    /// JSON array of credential key names (owned C string).
333    pub credential_keys_json: *mut c_char,
334}
335
336/// Error type for TTS operations.
337#[derive(Debug)]
338pub struct TtsError(pub String);
339
340impl fmt::Display for TtsError {
341    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
342        write!(f, "{}", self.0)
343    }
344}
345
346impl std::error::Error for TtsError {}
347
348impl From<anyhow::Error> for TtsError {
349    fn from(e: anyhow::Error) -> Self {
350        TtsError(e.to_string())
351    }
352}
353
354/// Result alias using [`TtsError`].
355pub type TtsResult<T> = Result<T, TtsError>;
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360
361    #[test]
362    #[cfg(feature = "display_names")]
363    fn test_locale_display_name_known() {
364        let name = locale_display_name("en-US");
365        assert!(
366            name.to_lowercase().contains("english"),
367            "expected 'English' in display name for en-US, got {name}"
368        );
369    }
370
371    #[test]
372    #[cfg(feature = "display_names")]
373    fn test_locale_display_name_non_latin() {
374        let name = locale_display_name("ar-EG");
375        assert!(
376            name.contains("Arabic") || name.contains("ar-EG"),
377            "expected Arabic in display name, got {name}"
378        );
379    }
380
381    #[test]
382    #[cfg(feature = "display_names")]
383    fn test_locale_display_name_unknown_falls_back() {
384        let name = locale_display_name("xx-YY");
385        assert!(!name.is_empty());
386    }
387
388    #[test]
389    #[cfg(not(feature = "display_names"))]
390    fn test_locale_display_name_fallback_is_raw_code() {
391        assert_eq!(locale_display_name("en-US"), "en-US");
392    }
393}