Skip to main content

tauri_plugin_tts/
models.rs

1use serde::{Deserialize, Serialize};
2use std::borrow::Cow;
3#[cfg(feature = "ts-bindings")]
4use ts_rs::TS;
5
6/// Maximum text length in bytes (10KB)
7pub const MAX_TEXT_LENGTH: usize = 10_000;
8/// Maximum voice ID length
9pub const MAX_VOICE_ID_LENGTH: usize = 256;
10/// Maximum language code length
11pub const MAX_LANGUAGE_LENGTH: usize = 35;
12
13#[cfg_attr(feature = "ts-bindings", derive(TS))]
14#[cfg_attr(
15    feature = "ts-bindings",
16    ts(export, export_to = "../guest-js/bindings/")
17)]
18#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
19#[serde(rename_all = "lowercase")]
20pub enum QueueMode {
21    /// Flush any pending speech and start speaking immediately (default)
22    #[default]
23    Flush,
24    /// Add to queue and speak after current speech finishes
25    Add,
26}
27
28#[cfg_attr(feature = "ts-bindings", derive(TS))]
29#[cfg_attr(
30    feature = "ts-bindings",
31    ts(export, export_to = "../guest-js/bindings/")
32)]
33#[derive(Debug, Clone, Deserialize, Serialize)]
34#[serde(rename_all = "camelCase")]
35pub struct SpeakOptions {
36    /// The text to speak (max 10,000 characters)
37    pub text: String,
38    /// The language/locale code (e.g., "en-US", "pt-BR", "ja-JP")
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub language: Option<String>,
41    /// Specific voice ID to use (from getVoices). Takes priority over language
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub voice_id: Option<String>,
44    /// Speech rate (0.1 to 4.0, where 1.0 = normal)
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub rate: Option<f32>,
47    /// Pitch (0.5 to 2.0, where 1.0 = normal)
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub pitch: Option<f32>,
50    /// Volume (0.0 to 1.0, where 1.0 = full volume)
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub volume: Option<f32>,
53    /// Queue mode: "flush" (default) or "add"
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub queue_mode: Option<QueueMode>,
56}
57
58#[cfg_attr(feature = "ts-bindings", derive(TS))]
59#[cfg_attr(
60    feature = "ts-bindings",
61    ts(export, export_to = "../guest-js/bindings/")
62)]
63#[derive(Debug, Clone, Deserialize, Serialize)]
64#[serde(rename_all = "camelCase")]
65pub struct PreviewVoiceOptions {
66    /// Voice ID to preview
67    pub voice_id: String,
68    /// Optional custom sample text (uses default if not provided)
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub text: Option<String>,
71}
72
73#[derive(Debug, Deserialize, Serialize)]
74#[serde(rename_all = "camelCase")]
75pub struct SpeakRequest {
76    /// The text to speak
77    pub text: String,
78    /// The language/locale code (e.g., "en-US", "pt-BR", "ja-JP")
79    #[serde(default)]
80    pub language: Option<String>,
81    /// Voice ID to use (from getVoices)
82    #[serde(default)]
83    pub voice_id: Option<String>,
84    /// Speech rate (0.1 to 4.0, where 1.0 = normal, 2.0 = double, 0.5 = half)
85    #[serde(default = "default_rate")]
86    pub rate: f32,
87    /// Pitch (0.5 = low, 1.0 = normal, 2.0 = high)
88    #[serde(default = "default_pitch")]
89    pub pitch: f32,
90    /// Volume (0.0 = silent, 1.0 = full volume)
91    #[serde(default = "default_volume")]
92    pub volume: f32,
93    /// Queue mode: "flush" (default) or "add"
94    #[serde(default)]
95    pub queue_mode: QueueMode,
96}
97
98fn default_rate() -> f32 {
99    1.0
100}
101fn default_pitch() -> f32 {
102    1.0
103}
104fn default_volume() -> f32 {
105    1.0
106}
107
108#[derive(Debug, Clone, thiserror::Error)]
109pub enum ValidationError {
110    #[error("Text cannot be empty")]
111    EmptyText,
112    #[error("Text too long: {len} bytes (max: {max})")]
113    TextTooLong { len: usize, max: usize },
114    #[error("Voice ID too long: {len} chars (max: {max})")]
115    VoiceIdTooLong { len: usize, max: usize },
116    #[error("Invalid voice ID format - only alphanumeric, dots, underscores, and hyphens allowed")]
117    InvalidVoiceId,
118    #[error("Language code too long: {len} chars (max: {max})")]
119    LanguageTooLong { len: usize, max: usize },
120}
121
122#[derive(Debug, Clone)]
123pub struct ValidatedSpeakRequest {
124    pub text: String,
125    pub language: Option<String>,
126    pub voice_id: Option<String>,
127    pub rate: f32,
128    pub pitch: f32,
129    pub volume: f32,
130    pub queue_mode: QueueMode,
131}
132
133impl SpeakRequest {
134    pub fn validate(&self) -> Result<ValidatedSpeakRequest, ValidationError> {
135        // Text validation
136        if self.text.is_empty() {
137            return Err(ValidationError::EmptyText);
138        }
139        if self.text.len() > MAX_TEXT_LENGTH {
140            return Err(ValidationError::TextTooLong {
141                len: self.text.len(),
142                max: MAX_TEXT_LENGTH,
143            });
144        }
145
146        // Language validation (if provided)
147        let sanitized_language = self
148            .language
149            .as_ref()
150            .map(|lang| Self::validate_language(lang))
151            .transpose()?;
152
153        // Voice ID validation (if provided)
154        if let Some(ref voice_id) = self.voice_id {
155            validate_voice_id(voice_id)?;
156        }
157
158        Ok(ValidatedSpeakRequest {
159            text: self.text.clone(),
160            language: sanitized_language,
161            voice_id: self.voice_id.clone(),
162            rate: self.rate.clamp(0.1, 4.0),
163            pitch: self.pitch.clamp(0.5, 2.0),
164            volume: self.volume.clamp(0.0, 1.0),
165            queue_mode: self.queue_mode,
166        })
167    }
168
169    fn validate_language(lang: &str) -> Result<String, ValidationError> {
170        if lang.len() > MAX_LANGUAGE_LENGTH {
171            return Err(ValidationError::LanguageTooLong {
172                len: lang.len(),
173                max: MAX_LANGUAGE_LENGTH,
174            });
175        }
176        Ok(lang.to_string())
177    }
178}
179
180/// Shared voice ID validation. The id is matched by exact equality against the
181/// engine's own voices in `speak()` / `preview_voice()`, so this only guards
182/// against pathological input (too long, or containing control characters).
183///
184/// A stricter charset filter cannot be used here: native Windows SAPI voice ids
185/// returned by `get_voices()` are registry tokens that contain spaces and
186/// backslashes (e.g. `HKEY_LOCAL_MACHINE\...\Tokens\TTS_MS_EN-US_ZIRA_11.0`), so
187/// restricting to `[alphanumeric . _ -]` rejected every selectable voice on
188/// Windows.
189fn validate_voice_id(voice_id: &str) -> Result<(), ValidationError> {
190    if voice_id.len() > MAX_VOICE_ID_LENGTH {
191        return Err(ValidationError::VoiceIdTooLong {
192            len: voice_id.len(),
193            max: MAX_VOICE_ID_LENGTH,
194        });
195    }
196    if voice_id.chars().any(|c| c.is_control()) {
197        return Err(ValidationError::InvalidVoiceId);
198    }
199    Ok(())
200}
201
202#[derive(Debug, Clone, Default, Deserialize, Serialize)]
203#[serde(rename_all = "camelCase")]
204pub struct SpeakResponse {
205    /// Whether speech was successfully initiated
206    pub success: bool,
207    /// Optional warning message (e.g., voice not found, using fallback)
208    #[serde(skip_serializing_if = "Option::is_none")]
209    pub warning: Option<String>,
210}
211
212#[derive(Debug, Clone, Default, Deserialize, Serialize)]
213#[serde(rename_all = "camelCase")]
214pub struct StopResponse {
215    pub success: bool,
216}
217
218#[derive(Debug, Deserialize, Serialize)]
219#[serde(rename_all = "camelCase")]
220pub struct SetBackgroundBehaviorRequest {
221    /// Whether TTS should continue speaking when the app goes to background / screen locks.
222    /// Defaults to true. When false, speech is paused on background and a `speech:backgroundPause`
223    /// event is emitted (matching the previous behavior). Desktop: ignored (no-op).
224    pub continue_in_background: bool,
225}
226
227#[derive(Debug, Clone, Default, Deserialize, Serialize)]
228#[serde(rename_all = "camelCase")]
229pub struct SetBackgroundBehaviorResponse {
230    pub success: bool,
231}
232
233#[cfg_attr(feature = "ts-bindings", derive(TS))]
234#[cfg_attr(
235    feature = "ts-bindings",
236    ts(export, export_to = "../guest-js/bindings/")
237)]
238#[derive(Debug, Clone, Deserialize, Serialize)]
239#[serde(rename_all = "camelCase")]
240pub struct Voice {
241    /// Unique identifier for the voice
242    pub id: String,
243    /// Display name of the voice
244    pub name: String,
245    /// Language code (e.g., "en-US")
246    pub language: String,
247}
248
249#[derive(Debug, Deserialize, Serialize)]
250#[serde(rename_all = "camelCase")]
251pub struct GetVoicesRequest {
252    /// Optional language filter
253    #[serde(default)]
254    pub language: Option<String>,
255}
256
257#[derive(Debug, Clone, Default, Deserialize, Serialize)]
258#[serde(rename_all = "camelCase")]
259pub struct GetVoicesResponse {
260    pub voices: Vec<Voice>,
261}
262
263#[derive(Debug, Clone, Default, Deserialize, Serialize)]
264#[serde(rename_all = "camelCase")]
265pub struct IsSpeakingResponse {
266    pub speaking: bool,
267}
268
269#[derive(Debug, Clone, Default, Deserialize, Serialize)]
270#[serde(rename_all = "camelCase")]
271pub struct IsInitializedResponse {
272    /// Whether the TTS engine is initialized and ready
273    pub initialized: bool,
274    /// Number of available voices (0 if not initialized)
275    pub voice_count: u32,
276}
277
278#[cfg_attr(feature = "ts-bindings", derive(TS))]
279#[cfg_attr(
280    feature = "ts-bindings",
281    ts(export, export_to = "../guest-js/bindings/")
282)]
283#[derive(Debug, Clone, Default, Deserialize, Serialize)]
284#[serde(rename_all = "camelCase")]
285pub struct PauseResumeResponse {
286    pub success: bool,
287    /// Reason for failure (if success is false)
288    #[serde(skip_serializing_if = "Option::is_none")]
289    pub reason: Option<String>,
290}
291
292#[derive(Debug, Deserialize, Serialize)]
293#[serde(rename_all = "camelCase")]
294pub struct PreviewVoiceRequest {
295    /// Voice ID to preview
296    pub voice_id: String,
297    /// Optional custom sample text (uses default if not provided)
298    #[serde(default)]
299    pub text: Option<String>,
300}
301
302impl PreviewVoiceRequest {
303    pub const DEFAULT_SAMPLE_TEXT: &'static str =
304        "Hello! This is a sample of how this voice sounds.";
305
306    pub fn sample_text(&self) -> Cow<'_, str> {
307        match &self.text {
308            Some(text) => Cow::Borrowed(text.as_str()),
309            None => Cow::Borrowed(Self::DEFAULT_SAMPLE_TEXT),
310        }
311    }
312
313    pub fn validate(&self) -> Result<(), ValidationError> {
314        // Validate voice ID
315        validate_voice_id(&self.voice_id)?;
316
317        // Validate custom text if provided
318        if let Some(ref text) = self.text {
319            if text.is_empty() {
320                return Err(ValidationError::EmptyText);
321            }
322            if text.len() > MAX_TEXT_LENGTH {
323                return Err(ValidationError::TextTooLong {
324                    len: text.len(),
325                    max: MAX_TEXT_LENGTH,
326                });
327            }
328        }
329
330        Ok(())
331    }
332}
333
334/// On desktop, emitted directly via `app.emit("tts://<event_type>", payload)`.
335/// On mobile, native plugins send this through a Tauri `Channel`; the Rust relay
336/// deserializes it and re-emits via `app.emit()` so JS `listen("tts://...")` works
337/// uniformly on every platform.
338///
339/// The shape matches the JS `SpeechEvent` interface.
340#[derive(Debug, Clone, Default, Deserialize, Serialize)]
341#[serde(rename_all = "camelCase")]
342pub struct TtsEventPayload {
343    /// The event name, e.g. "speech:finish". Used to build the emit key "tts://<event_type>".
344    pub event_type: String,
345    /// Unique identifier for the utterance (if available)
346    #[serde(skip_serializing_if = "Option::is_none")]
347    pub id: Option<String>,
348    /// Error message (for error events)
349    #[serde(skip_serializing_if = "Option::is_none")]
350    pub error: Option<String>,
351    /// Whether speech was interrupted
352    #[serde(skip_serializing_if = "Option::is_none")]
353    pub interrupted: Option<bool>,
354    /// Reason for the event (e.g. "audio_focus_lost", "app_paused")
355    #[serde(skip_serializing_if = "Option::is_none")]
356    pub reason: Option<String>,
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362
363    #[test]
364    fn test_speak_request_defaults() {
365        let json = r#"{"text": "Hello world"}"#;
366        let request: SpeakRequest = serde_json::from_str(json).unwrap();
367
368        assert_eq!(request.text, "Hello world");
369        assert!(request.language.is_none());
370        assert!(request.voice_id.is_none());
371        assert_eq!(request.rate, 1.0);
372        assert_eq!(request.pitch, 1.0);
373        assert_eq!(request.volume, 1.0);
374    }
375
376    #[test]
377    fn test_speak_request_full() {
378        let json = r#"{
379            "text": "Olá",
380            "language": "pt-BR",
381            "voiceId": "com.apple.voice.enhanced.pt-BR",
382            "rate": 0.8,
383            "pitch": 1.2,
384            "volume": 0.9
385        }"#;
386
387        let request: SpeakRequest = serde_json::from_str(json).unwrap();
388        assert_eq!(request.text, "Olá");
389        assert_eq!(request.language, Some("pt-BR".to_string()));
390        assert_eq!(
391            request.voice_id,
392            Some("com.apple.voice.enhanced.pt-BR".to_string())
393        );
394        assert_eq!(request.rate, 0.8);
395        assert_eq!(request.pitch, 1.2);
396        assert_eq!(request.volume, 0.9);
397    }
398
399    #[test]
400    fn test_voice_serialization() {
401        let voice = Voice {
402            id: "test-voice".to_string(),
403            name: "Test Voice".to_string(),
404            language: "en-US".to_string(),
405        };
406
407        let json = serde_json::to_string(&voice).unwrap();
408        assert!(json.contains("\"id\":\"test-voice\""));
409        assert!(json.contains("\"name\":\"Test Voice\""));
410        assert!(json.contains("\"language\":\"en-US\""));
411    }
412
413    #[test]
414    fn test_get_voices_request_optional_language() {
415        let json1 = r#"{}"#;
416        let request1: GetVoicesRequest = serde_json::from_str(json1).unwrap();
417        assert!(request1.language.is_none());
418
419        let json2 = r#"{"language": "en"}"#;
420        let request2: GetVoicesRequest = serde_json::from_str(json2).unwrap();
421        assert_eq!(request2.language, Some("en".to_string()));
422    }
423
424    #[test]
425    fn test_validation_empty_text() {
426        let request = SpeakRequest {
427            text: "".to_string(),
428            language: None,
429            voice_id: None,
430            rate: 1.0,
431            pitch: 1.0,
432            volume: 1.0,
433            queue_mode: QueueMode::Flush,
434        };
435
436        let result = request.validate();
437        assert!(result.is_err());
438        assert!(matches!(result.unwrap_err(), ValidationError::EmptyText));
439    }
440
441    #[test]
442    fn test_validation_text_too_long() {
443        let long_text = "x".repeat(MAX_TEXT_LENGTH + 1);
444        let request = SpeakRequest {
445            text: long_text,
446            language: None,
447            voice_id: None,
448            rate: 1.0,
449            pitch: 1.0,
450            volume: 1.0,
451            queue_mode: QueueMode::Flush,
452        };
453
454        let result = request.validate();
455        assert!(result.is_err());
456        assert!(matches!(
457            result.unwrap_err(),
458            ValidationError::TextTooLong { .. }
459        ));
460    }
461
462    #[test]
463    fn test_validation_valid_voice_id() {
464        let request = SpeakRequest {
465            text: "Hello".to_string(),
466            language: None,
467            voice_id: Some("com.apple.voice.enhanced.en-US".to_string()),
468            rate: 1.0,
469            pitch: 1.0,
470            volume: 1.0,
471            queue_mode: QueueMode::Flush,
472        };
473
474        let result = request.validate();
475        assert!(result.is_ok());
476        assert_eq!(
477            result.unwrap().voice_id,
478            Some("com.apple.voice.enhanced.en-US".to_string())
479        );
480    }
481
482    #[test]
483    fn test_validation_voice_id_too_long() {
484        let long_voice_id = "x".repeat(MAX_VOICE_ID_LENGTH + 1);
485        let request = SpeakRequest {
486            text: "Hello".to_string(),
487            language: None,
488            voice_id: Some(long_voice_id),
489            rate: 1.0,
490            pitch: 1.0,
491            volume: 1.0,
492            queue_mode: QueueMode::Flush,
493        };
494
495        let result = request.validate();
496        assert!(result.is_err());
497        assert!(matches!(
498            result.unwrap_err(),
499            ValidationError::VoiceIdTooLong { .. }
500        ));
501    }
502
503    #[test]
504    fn test_validation_rate_clamping() {
505        let request = SpeakRequest {
506            text: "Hello".to_string(),
507            language: None,
508            voice_id: None,
509            rate: 999.0,
510            pitch: 1.0,
511            volume: 1.0,
512            queue_mode: QueueMode::Flush,
513        };
514
515        let result = request.validate();
516        assert!(result.is_ok());
517        let validated = result.unwrap();
518        assert_eq!(validated.rate, 4.0); // Clamped to max
519    }
520
521    #[test]
522    fn test_validation_pitch_clamping() {
523        let request = SpeakRequest {
524            text: "Hello".to_string(),
525            language: None,
526            voice_id: None,
527            rate: 1.0,
528            pitch: 0.1,
529            volume: 1.0,
530            queue_mode: QueueMode::Flush,
531        };
532
533        let result = request.validate();
534        assert!(result.is_ok());
535        let validated = result.unwrap();
536        assert_eq!(validated.pitch, 0.5); // Clamped to min
537    }
538
539    #[test]
540    fn test_validation_volume_clamping() {
541        let request = SpeakRequest {
542            text: "Hello".to_string(),
543            language: None,
544            voice_id: None,
545            rate: 1.0,
546            pitch: 1.0,
547            volume: 5.0,
548            queue_mode: QueueMode::Flush,
549        };
550
551        let result = request.validate();
552        assert!(result.is_ok());
553        let validated = result.unwrap();
554        assert_eq!(validated.volume, 1.0); // Clamped to max
555    }
556
557    #[test]
558    fn test_preview_voice_validation() {
559        // Valid preview
560        let valid = PreviewVoiceRequest {
561            voice_id: "valid-voice_123".to_string(),
562            text: None,
563        };
564        assert!(valid.validate().is_ok());
565
566        // Invalid voice_id
567        let invalid = PreviewVoiceRequest {
568            voice_id: "invalid<script>".to_string(),
569            text: None,
570        };
571        assert!(invalid.validate().is_err());
572    }
573
574    #[test]
575    fn test_preview_voice_sample_text() {
576        let without_text = PreviewVoiceRequest {
577            voice_id: "voice".to_string(),
578            text: None,
579        };
580        assert_eq!(
581            without_text.sample_text(),
582            PreviewVoiceRequest::DEFAULT_SAMPLE_TEXT
583        );
584
585        let with_text = PreviewVoiceRequest {
586            voice_id: "voice".to_string(),
587            text: Some("Custom sample".to_string()),
588        };
589        assert_eq!(with_text.sample_text(), "Custom sample");
590    }
591}