teachingAssistant / src /domain /models /voice_settings.py
Michael Hu
add chatterbox
0f99c8d
"""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
)