"""VoiceSettings value object for TTS voice configuration with validation.""" from dataclasses import dataclass from typing import Optional import re @dataclass(frozen=True) class VoiceSettings: """Value object representing voice settings for text-to-speech synthesis.""" voice_id: str speed: float language: str pitch: Optional[float] = None volume: Optional[float] = None audio_prompt_path: Optional[str] = None # For voice cloning (e.g., Chatterbox) def __post_init__(self): """Validate voice settings after initialization.""" self._validate() def _validate(self): """Validate voice settings properties.""" if not isinstance(self.voice_id, str): raise TypeError("Voice ID must be a string") if not self.voice_id.strip(): raise ValueError("Voice ID cannot be empty") # Voice ID should be alphanumeric with possible underscores/hyphens if not re.match(r'^[a-zA-Z0-9_-]+$', self.voice_id): raise ValueError(f"Invalid voice ID format: {self.voice_id}. Must contain only letters, numbers, underscores, and hyphens") if not isinstance(self.speed, (int, float)): raise TypeError("Speed must be a number") if not 0.1 <= self.speed <= 3.0: raise ValueError(f"Speed must be between 0.1 and 3.0, got {self.speed}") if not isinstance(self.language, str): raise TypeError("Language must be a string") if not self.language.strip(): raise ValueError("Language cannot be empty") # Validate language code format (ISO 639-1 or ISO 639-3) if not re.match(r'^[a-z]{2,3}(-[A-Z]{2})?$', self.language): raise ValueError(f"Invalid language code format: {self.language}. Expected format: 'en', 'en-US', etc.") if self.pitch is not None: if not isinstance(self.pitch, (int, float)): raise TypeError("Pitch must be a number") if not -2.0 <= self.pitch <= 2.0: raise ValueError(f"Pitch must be between -2.0 and 2.0, got {self.pitch}") if self.volume is not None: if not isinstance(self.volume, (int, float)): raise TypeError("Volume must be a number") if not 0.0 <= self.volume <= 2.0: raise ValueError(f"Volume must be between 0.0 and 2.0, got {self.volume}") if self.audio_prompt_path is not None: if not isinstance(self.audio_prompt_path, str): raise TypeError("Audio prompt path must be a string") if not self.audio_prompt_path.strip(): raise ValueError("Audio prompt path cannot be empty") @property def is_default_speed(self) -> bool: """Check if speed is at default value (1.0).""" return abs(self.speed - 1.0) < 0.01 @property def is_default_pitch(self) -> bool: """Check if pitch is at default value (0.0 or None).""" return self.pitch is None or abs(self.pitch) < 0.01 @property def is_default_volume(self) -> bool: """Check if volume is at default value (1.0 or None).""" return self.volume is None or abs(self.volume - 1.0) < 0.01 def with_speed(self, speed: float) -> 'VoiceSettings': """Create a new VoiceSettings with different speed.""" return VoiceSettings( voice_id=self.voice_id, speed=speed, language=self.language, pitch=self.pitch, volume=self.volume, audio_prompt_path=self.audio_prompt_path ) def with_pitch(self, pitch: Optional[float]) -> 'VoiceSettings': """Create a new VoiceSettings with different pitch.""" return VoiceSettings( voice_id=self.voice_id, speed=self.speed, language=self.language, pitch=pitch, volume=self.volume, audio_prompt_path=self.audio_prompt_path ) def with_audio_prompt(self, audio_prompt_path: Optional[str]) -> 'VoiceSettings': """Create a new VoiceSettings with different audio prompt path.""" return VoiceSettings( voice_id=self.voice_id, speed=self.speed, language=self.language, pitch=self.pitch, volume=self.volume, audio_prompt_path=audio_prompt_path )