1use serde::{Deserialize, Serialize};
2use std::borrow::Cow;
3#[cfg(feature = "ts-bindings")]
4use ts_rs::TS;
5
6pub const MAX_TEXT_LENGTH: usize = 10_000;
8pub const MAX_VOICE_ID_LENGTH: usize = 256;
10pub 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 #[default]
23 Flush,
24 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 pub text: String,
38 #[serde(skip_serializing_if = "Option::is_none")]
40 pub language: Option<String>,
41 #[serde(skip_serializing_if = "Option::is_none")]
43 pub voice_id: Option<String>,
44 #[serde(skip_serializing_if = "Option::is_none")]
46 pub rate: Option<f32>,
47 #[serde(skip_serializing_if = "Option::is_none")]
49 pub pitch: Option<f32>,
50 #[serde(skip_serializing_if = "Option::is_none")]
52 pub volume: Option<f32>,
53 #[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 pub voice_id: String,
68 #[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 pub text: String,
78 #[serde(default)]
80 pub language: Option<String>,
81 #[serde(default)]
83 pub voice_id: Option<String>,
84 #[serde(default = "default_rate")]
86 pub rate: f32,
87 #[serde(default = "default_pitch")]
89 pub pitch: f32,
90 #[serde(default = "default_volume")]
92 pub volume: f32,
93 #[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 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 let sanitized_language = self
148 .language
149 .as_ref()
150 .map(|lang| Self::validate_language(lang))
151 .transpose()?;
152
153 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
180fn 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 pub success: bool,
207 #[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 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 pub id: String,
243 pub name: String,
245 pub language: String,
247}
248
249#[derive(Debug, Deserialize, Serialize)]
250#[serde(rename_all = "camelCase")]
251pub struct GetVoicesRequest {
252 #[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 pub initialized: bool,
274 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 #[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 pub voice_id: String,
297 #[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(&self.voice_id)?;
316
317 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#[derive(Debug, Clone, Default, Deserialize, Serialize)]
341#[serde(rename_all = "camelCase")]
342pub struct TtsEventPayload {
343 pub event_type: String,
345 #[serde(skip_serializing_if = "Option::is_none")]
347 pub id: Option<String>,
348 #[serde(skip_serializing_if = "Option::is_none")]
350 pub error: Option<String>,
351 #[serde(skip_serializing_if = "Option::is_none")]
353 pub interrupted: Option<bool>,
354 #[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); }
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); }
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); }
556
557 #[test]
558 fn test_preview_voice_validation() {
559 let valid = PreviewVoiceRequest {
561 voice_id: "valid-voice_123".to_string(),
562 text: None,
563 };
564 assert!(valid.validate().is_ok());
565
566 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}