use serde::{Deserialize, Serialize};
use std::borrow::Cow;
#[cfg(feature = "ts-bindings")]
use ts_rs::TS;
pub const MAX_TEXT_LENGTH: usize = 10_000;
pub const MAX_VOICE_ID_LENGTH: usize = 256;
pub const MAX_LANGUAGE_LENGTH: usize = 35;
#[cfg_attr(feature = "ts-bindings", derive(TS))]
#[cfg_attr(
feature = "ts-bindings",
ts(export, export_to = "../guest-js/bindings/")
)]
#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum QueueMode {
#[default]
Flush,
Add,
}
#[cfg_attr(feature = "ts-bindings", derive(TS))]
#[cfg_attr(
feature = "ts-bindings",
ts(export, export_to = "../guest-js/bindings/")
)]
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SpeakOptions {
pub text: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub voice_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rate: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pitch: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub volume: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub queue_mode: Option<QueueMode>,
}
#[cfg_attr(feature = "ts-bindings", derive(TS))]
#[cfg_attr(
feature = "ts-bindings",
ts(export, export_to = "../guest-js/bindings/")
)]
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PreviewVoiceOptions {
pub voice_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SpeakRequest {
pub text: String,
#[serde(default)]
pub language: Option<String>,
#[serde(default)]
pub voice_id: Option<String>,
#[serde(default = "default_rate")]
pub rate: f32,
#[serde(default = "default_pitch")]
pub pitch: f32,
#[serde(default = "default_volume")]
pub volume: f32,
#[serde(default)]
pub queue_mode: QueueMode,
}
fn default_rate() -> f32 {
1.0
}
fn default_pitch() -> f32 {
1.0
}
fn default_volume() -> f32 {
1.0
}
#[derive(Debug, Clone, thiserror::Error)]
pub enum ValidationError {
#[error("Text cannot be empty")]
EmptyText,
#[error("Text too long: {len} bytes (max: {max})")]
TextTooLong { len: usize, max: usize },
#[error("Voice ID too long: {len} chars (max: {max})")]
VoiceIdTooLong { len: usize, max: usize },
#[error("Invalid voice ID format - only alphanumeric, dots, underscores, and hyphens allowed")]
InvalidVoiceId,
#[error("Language code too long: {len} chars (max: {max})")]
LanguageTooLong { len: usize, max: usize },
}
#[derive(Debug, Clone)]
pub struct ValidatedSpeakRequest {
pub text: String,
pub language: Option<String>,
pub voice_id: Option<String>,
pub rate: f32,
pub pitch: f32,
pub volume: f32,
pub queue_mode: QueueMode,
}
impl SpeakRequest {
pub fn validate(&self) -> Result<ValidatedSpeakRequest, ValidationError> {
if self.text.is_empty() {
return Err(ValidationError::EmptyText);
}
if self.text.len() > MAX_TEXT_LENGTH {
return Err(ValidationError::TextTooLong {
len: self.text.len(),
max: MAX_TEXT_LENGTH,
});
}
let sanitized_language = self
.language
.as_ref()
.map(|lang| Self::validate_language(lang))
.transpose()?;
if let Some(ref voice_id) = self.voice_id {
validate_voice_id(voice_id)?;
}
Ok(ValidatedSpeakRequest {
text: self.text.clone(),
language: sanitized_language,
voice_id: self.voice_id.clone(),
rate: self.rate.clamp(0.1, 4.0),
pitch: self.pitch.clamp(0.5, 2.0),
volume: self.volume.clamp(0.0, 1.0),
queue_mode: self.queue_mode,
})
}
fn validate_language(lang: &str) -> Result<String, ValidationError> {
if lang.len() > MAX_LANGUAGE_LENGTH {
return Err(ValidationError::LanguageTooLong {
len: lang.len(),
max: MAX_LANGUAGE_LENGTH,
});
}
Ok(lang.to_string())
}
}
fn validate_voice_id(voice_id: &str) -> Result<(), ValidationError> {
if voice_id.len() > MAX_VOICE_ID_LENGTH {
return Err(ValidationError::VoiceIdTooLong {
len: voice_id.len(),
max: MAX_VOICE_ID_LENGTH,
});
}
if !voice_id
.chars()
.all(|c| c.is_alphanumeric() || c == '.' || c == '_' || c == '-')
{
return Err(ValidationError::InvalidVoiceId);
}
Ok(())
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SpeakResponse {
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub warning: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StopResponse {
pub success: bool,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SetBackgroundBehaviorRequest {
pub continue_in_background: bool,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SetBackgroundBehaviorResponse {
pub success: bool,
}
#[cfg_attr(feature = "ts-bindings", derive(TS))]
#[cfg_attr(
feature = "ts-bindings",
ts(export, export_to = "../guest-js/bindings/")
)]
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Voice {
pub id: String,
pub name: String,
pub language: String,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GetVoicesRequest {
#[serde(default)]
pub language: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GetVoicesResponse {
pub voices: Vec<Voice>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct IsSpeakingResponse {
pub speaking: bool,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct IsInitializedResponse {
pub initialized: bool,
pub voice_count: u32,
}
#[cfg_attr(feature = "ts-bindings", derive(TS))]
#[cfg_attr(
feature = "ts-bindings",
ts(export, export_to = "../guest-js/bindings/")
)]
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PauseResumeResponse {
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PreviewVoiceRequest {
pub voice_id: String,
#[serde(default)]
pub text: Option<String>,
}
impl PreviewVoiceRequest {
pub const DEFAULT_SAMPLE_TEXT: &'static str =
"Hello! This is a sample of how this voice sounds.";
pub fn sample_text(&self) -> Cow<'_, str> {
match &self.text {
Some(text) => Cow::Borrowed(text.as_str()),
None => Cow::Borrowed(Self::DEFAULT_SAMPLE_TEXT),
}
}
pub fn validate(&self) -> Result<(), ValidationError> {
validate_voice_id(&self.voice_id)?;
if let Some(ref text) = self.text {
if text.is_empty() {
return Err(ValidationError::EmptyText);
}
if text.len() > MAX_TEXT_LENGTH {
return Err(ValidationError::TextTooLong {
len: text.len(),
max: MAX_TEXT_LENGTH,
});
}
}
Ok(())
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TtsEventPayload {
pub event_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub interrupted: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_speak_request_defaults() {
let json = r#"{"text": "Hello world"}"#;
let request: SpeakRequest = serde_json::from_str(json).unwrap();
assert_eq!(request.text, "Hello world");
assert!(request.language.is_none());
assert!(request.voice_id.is_none());
assert_eq!(request.rate, 1.0);
assert_eq!(request.pitch, 1.0);
assert_eq!(request.volume, 1.0);
}
#[test]
fn test_speak_request_full() {
let json = r#"{
"text": "Olá",
"language": "pt-BR",
"voiceId": "com.apple.voice.enhanced.pt-BR",
"rate": 0.8,
"pitch": 1.2,
"volume": 0.9
}"#;
let request: SpeakRequest = serde_json::from_str(json).unwrap();
assert_eq!(request.text, "Olá");
assert_eq!(request.language, Some("pt-BR".to_string()));
assert_eq!(
request.voice_id,
Some("com.apple.voice.enhanced.pt-BR".to_string())
);
assert_eq!(request.rate, 0.8);
assert_eq!(request.pitch, 1.2);
assert_eq!(request.volume, 0.9);
}
#[test]
fn test_voice_serialization() {
let voice = Voice {
id: "test-voice".to_string(),
name: "Test Voice".to_string(),
language: "en-US".to_string(),
};
let json = serde_json::to_string(&voice).unwrap();
assert!(json.contains("\"id\":\"test-voice\""));
assert!(json.contains("\"name\":\"Test Voice\""));
assert!(json.contains("\"language\":\"en-US\""));
}
#[test]
fn test_get_voices_request_optional_language() {
let json1 = r#"{}"#;
let request1: GetVoicesRequest = serde_json::from_str(json1).unwrap();
assert!(request1.language.is_none());
let json2 = r#"{"language": "en"}"#;
let request2: GetVoicesRequest = serde_json::from_str(json2).unwrap();
assert_eq!(request2.language, Some("en".to_string()));
}
#[test]
fn test_validation_empty_text() {
let request = SpeakRequest {
text: "".to_string(),
language: None,
voice_id: None,
rate: 1.0,
pitch: 1.0,
volume: 1.0,
queue_mode: QueueMode::Flush,
};
let result = request.validate();
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), ValidationError::EmptyText));
}
#[test]
fn test_validation_text_too_long() {
let long_text = "x".repeat(MAX_TEXT_LENGTH + 1);
let request = SpeakRequest {
text: long_text,
language: None,
voice_id: None,
rate: 1.0,
pitch: 1.0,
volume: 1.0,
queue_mode: QueueMode::Flush,
};
let result = request.validate();
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
ValidationError::TextTooLong { .. }
));
}
#[test]
fn test_validation_valid_voice_id() {
let request = SpeakRequest {
text: "Hello".to_string(),
language: None,
voice_id: Some("com.apple.voice.enhanced.en-US".to_string()),
rate: 1.0,
pitch: 1.0,
volume: 1.0,
queue_mode: QueueMode::Flush,
};
let result = request.validate();
assert!(result.is_ok());
assert_eq!(
result.unwrap().voice_id,
Some("com.apple.voice.enhanced.en-US".to_string())
);
}
#[test]
fn test_validation_voice_id_too_long() {
let long_voice_id = "x".repeat(MAX_VOICE_ID_LENGTH + 1);
let request = SpeakRequest {
text: "Hello".to_string(),
language: None,
voice_id: Some(long_voice_id),
rate: 1.0,
pitch: 1.0,
volume: 1.0,
queue_mode: QueueMode::Flush,
};
let result = request.validate();
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
ValidationError::VoiceIdTooLong { .. }
));
}
#[test]
fn test_validation_rate_clamping() {
let request = SpeakRequest {
text: "Hello".to_string(),
language: None,
voice_id: None,
rate: 999.0,
pitch: 1.0,
volume: 1.0,
queue_mode: QueueMode::Flush,
};
let result = request.validate();
assert!(result.is_ok());
let validated = result.unwrap();
assert_eq!(validated.rate, 4.0); }
#[test]
fn test_validation_pitch_clamping() {
let request = SpeakRequest {
text: "Hello".to_string(),
language: None,
voice_id: None,
rate: 1.0,
pitch: 0.1,
volume: 1.0,
queue_mode: QueueMode::Flush,
};
let result = request.validate();
assert!(result.is_ok());
let validated = result.unwrap();
assert_eq!(validated.pitch, 0.5); }
#[test]
fn test_validation_volume_clamping() {
let request = SpeakRequest {
text: "Hello".to_string(),
language: None,
voice_id: None,
rate: 1.0,
pitch: 1.0,
volume: 5.0,
queue_mode: QueueMode::Flush,
};
let result = request.validate();
assert!(result.is_ok());
let validated = result.unwrap();
assert_eq!(validated.volume, 1.0); }
#[test]
fn test_preview_voice_validation() {
let valid = PreviewVoiceRequest {
voice_id: "valid-voice_123".to_string(),
text: None,
};
assert!(valid.validate().is_ok());
let invalid = PreviewVoiceRequest {
voice_id: "invalid<script>".to_string(),
text: None,
};
assert!(invalid.validate().is_err());
}
#[test]
fn test_preview_voice_sample_text() {
let without_text = PreviewVoiceRequest {
voice_id: "voice".to_string(),
text: None,
};
assert_eq!(
without_text.sample_text(),
PreviewVoiceRequest::DEFAULT_SAMPLE_TEXT
);
let with_text = PreviewVoiceRequest {
voice_id: "voice".to_string(),
text: Some("Custom sample".to_string()),
};
assert_eq!(with_text.sample_text(), "Custom sample");
}
}