Michael Hu commited on
Commit
e3cb97b
·
1 Parent(s): 55e29e2

Implement infrastructure base classes

Browse files
src/infrastructure/__init__.py CHANGED
@@ -1,3 +1 @@
1
- """Infrastructure layer package."""
2
-
3
- # Infrastructure implementations will be added in subsequent tasks
 
1
+ """Infrastructure layer for external service implementations."""
 
 
src/infrastructure/base/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Base classes for infrastructure providers."""
src/infrastructure/base/file_utils.py ADDED
@@ -0,0 +1,409 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """File generation and management utilities for infrastructure providers."""
2
+
3
+ import logging
4
+ import os
5
+ import tempfile
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Optional, Union
9
+ import hashlib
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class FileManager:
15
+ """Utility class for managing temporary files and directories."""
16
+
17
+ def __init__(self, base_dir: Optional[Union[str, Path]] = None):
18
+ """
19
+ Initialize the file manager.
20
+
21
+ Args:
22
+ base_dir: Base directory for file operations (defaults to system temp)
23
+ """
24
+ if base_dir:
25
+ self.base_dir = Path(base_dir)
26
+ else:
27
+ self.base_dir = Path(tempfile.gettempdir()) / "tts_app"
28
+
29
+ self.base_dir.mkdir(exist_ok=True)
30
+ logger.debug(f"FileManager initialized with base directory: {self.base_dir}")
31
+
32
+ def create_temp_file(self, suffix: str = ".tmp", prefix: str = "temp", content: bytes = None) -> Path:
33
+ """
34
+ Create a temporary file.
35
+
36
+ Args:
37
+ suffix: File suffix/extension
38
+ prefix: File prefix
39
+ content: Optional content to write to the file
40
+
41
+ Returns:
42
+ Path: Path to the created temporary file
43
+ """
44
+ timestamp = int(time.time() * 1000)
45
+ filename = f"{prefix}_{timestamp}{suffix}"
46
+ file_path = self.base_dir / filename
47
+
48
+ if content:
49
+ with open(file_path, 'wb') as f:
50
+ f.write(content)
51
+ else:
52
+ file_path.touch()
53
+
54
+ logger.debug(f"Created temporary file: {file_path}")
55
+ return file_path
56
+
57
+ def create_unique_filename(self, base_name: str, extension: str = "", content_hash: bool = False, content: bytes = None) -> str:
58
+ """
59
+ Create a unique filename.
60
+
61
+ Args:
62
+ base_name: Base name for the file
63
+ extension: File extension (with or without dot)
64
+ content_hash: Whether to include content hash in filename
65
+ content: Content to hash (required if content_hash=True)
66
+
67
+ Returns:
68
+ str: Unique filename
69
+ """
70
+ timestamp = int(time.time() * 1000)
71
+
72
+ if not extension.startswith('.') and extension:
73
+ extension = '.' + extension
74
+
75
+ filename = f"{base_name}_{timestamp}"
76
+
77
+ if content_hash and content:
78
+ hash_obj = hashlib.md5(content)
79
+ content_hash_str = hash_obj.hexdigest()[:8]
80
+ filename += f"_{content_hash_str}"
81
+
82
+ filename += extension
83
+ return filename
84
+
85
+ def save_audio_file(self, audio_data: bytes, format: str = "wav", prefix: str = "audio") -> Path:
86
+ """
87
+ Save audio data to a file.
88
+
89
+ Args:
90
+ audio_data: Raw audio data
91
+ format: Audio format (wav, mp3, etc.)
92
+ prefix: Filename prefix
93
+
94
+ Returns:
95
+ Path: Path to the saved audio file
96
+ """
97
+ if not format.startswith('.'):
98
+ format = '.' + format
99
+
100
+ filename = self.create_unique_filename(prefix, format, content_hash=True, content=audio_data)
101
+ file_path = self.base_dir / filename
102
+
103
+ with open(file_path, 'wb') as f:
104
+ f.write(audio_data)
105
+
106
+ logger.debug(f"Saved audio file: {file_path} ({len(audio_data)} bytes)")
107
+ return file_path
108
+
109
+ def save_text_file(self, text_content: str, encoding: str = "utf-8", prefix: str = "text") -> Path:
110
+ """
111
+ Save text content to a file.
112
+
113
+ Args:
114
+ text_content: Text content to save
115
+ encoding: Text encoding
116
+ prefix: Filename prefix
117
+
118
+ Returns:
119
+ Path: Path to the saved text file
120
+ """
121
+ filename = self.create_unique_filename(prefix, ".txt")
122
+ file_path = self.base_dir / filename
123
+
124
+ with open(file_path, 'w', encoding=encoding) as f:
125
+ f.write(text_content)
126
+
127
+ logger.debug(f"Saved text file: {file_path} ({len(text_content)} characters)")
128
+ return file_path
129
+
130
+ def cleanup_file(self, file_path: Union[str, Path]) -> bool:
131
+ """
132
+ Clean up a single file.
133
+
134
+ Args:
135
+ file_path: Path to the file to clean up
136
+
137
+ Returns:
138
+ bool: True if file was successfully deleted, False otherwise
139
+ """
140
+ try:
141
+ path = Path(file_path)
142
+ if path.exists() and path.is_file():
143
+ path.unlink()
144
+ logger.debug(f"Cleaned up file: {path}")
145
+ return True
146
+ return False
147
+ except Exception as e:
148
+ logger.warning(f"Failed to cleanup file {file_path}: {str(e)}")
149
+ return False
150
+
151
+ def cleanup_old_files(self, max_age_hours: int = 24, pattern: str = "*") -> int:
152
+ """
153
+ Clean up old files in the base directory.
154
+
155
+ Args:
156
+ max_age_hours: Maximum age of files to keep in hours
157
+ pattern: File pattern to match (glob pattern)
158
+
159
+ Returns:
160
+ int: Number of files cleaned up
161
+ """
162
+ try:
163
+ current_time = time.time()
164
+ max_age_seconds = max_age_hours * 3600
165
+ cleaned_count = 0
166
+
167
+ for file_path in self.base_dir.glob(pattern):
168
+ if file_path.is_file():
169
+ file_age = current_time - file_path.stat().st_mtime
170
+ if file_age > max_age_seconds:
171
+ if self.cleanup_file(file_path):
172
+ cleaned_count += 1
173
+
174
+ if cleaned_count > 0:
175
+ logger.info(f"Cleaned up {cleaned_count} old files")
176
+
177
+ return cleaned_count
178
+
179
+ except Exception as e:
180
+ logger.error(f"Failed to cleanup old files: {str(e)}")
181
+ return 0
182
+
183
+ def get_file_info(self, file_path: Union[str, Path]) -> dict:
184
+ """
185
+ Get information about a file.
186
+
187
+ Args:
188
+ file_path: Path to the file
189
+
190
+ Returns:
191
+ dict: File information
192
+ """
193
+ try:
194
+ path = Path(file_path)
195
+ if not path.exists():
196
+ return {'exists': False}
197
+
198
+ stat = path.stat()
199
+ return {
200
+ 'exists': True,
201
+ 'size_bytes': stat.st_size,
202
+ 'created_time': stat.st_ctime,
203
+ 'modified_time': stat.st_mtime,
204
+ 'is_file': path.is_file(),
205
+ 'is_directory': path.is_dir(),
206
+ 'extension': path.suffix,
207
+ 'name': path.name,
208
+ 'parent': str(path.parent)
209
+ }
210
+ except Exception as e:
211
+ logger.error(f"Failed to get file info for {file_path}: {str(e)}")
212
+ return {'exists': False, 'error': str(e)}
213
+
214
+ def ensure_directory(self, dir_path: Union[str, Path]) -> Path:
215
+ """
216
+ Ensure a directory exists, creating it if necessary.
217
+
218
+ Args:
219
+ dir_path: Path to the directory
220
+
221
+ Returns:
222
+ Path: Path to the directory
223
+ """
224
+ path = Path(dir_path)
225
+ path.mkdir(parents=True, exist_ok=True)
226
+ logger.debug(f"Ensured directory exists: {path}")
227
+ return path
228
+
229
+ def get_disk_usage(self) -> dict:
230
+ """
231
+ Get disk usage information for the base directory.
232
+
233
+ Returns:
234
+ dict: Disk usage information
235
+ """
236
+ try:
237
+ total_size = 0
238
+ file_count = 0
239
+
240
+ for file_path in self.base_dir.rglob('*'):
241
+ if file_path.is_file():
242
+ total_size += file_path.stat().st_size
243
+ file_count += 1
244
+
245
+ return {
246
+ 'base_directory': str(self.base_dir),
247
+ 'total_size_bytes': total_size,
248
+ 'total_size_mb': total_size / (1024 * 1024),
249
+ 'file_count': file_count
250
+ }
251
+ except Exception as e:
252
+ logger.error(f"Failed to get disk usage: {str(e)}")
253
+ return {'error': str(e)}
254
+
255
+
256
+ class AudioFileGenerator:
257
+ """Utility class for generating audio files from raw audio data."""
258
+
259
+ @staticmethod
260
+ def save_wav_file(audio_data: bytes, sample_rate: int, file_path: Union[str, Path], channels: int = 1, sample_width: int = 2) -> Path:
261
+ """
262
+ Save raw audio data as a WAV file.
263
+
264
+ Args:
265
+ audio_data: Raw audio data
266
+ sample_rate: Sample rate in Hz
267
+ file_path: Output file path
268
+ channels: Number of audio channels
269
+ sample_width: Sample width in bytes
270
+
271
+ Returns:
272
+ Path: Path to the saved WAV file
273
+ """
274
+ try:
275
+ import wave
276
+
277
+ path = Path(file_path)
278
+
279
+ with wave.open(str(path), 'wb') as wav_file:
280
+ wav_file.setnchannels(channels)
281
+ wav_file.setsampwidth(sample_width)
282
+ wav_file.setframerate(sample_rate)
283
+ wav_file.writeframes(audio_data)
284
+
285
+ logger.debug(f"Saved WAV file: {path} (sample_rate={sample_rate}, channels={channels})")
286
+ return path
287
+
288
+ except Exception as e:
289
+ logger.error(f"Failed to save WAV file: {str(e)}")
290
+ raise
291
+
292
+ @staticmethod
293
+ def convert_numpy_to_wav(audio_array, sample_rate: int, file_path: Union[str, Path]) -> Path:
294
+ """
295
+ Convert numpy array to WAV file.
296
+
297
+ Args:
298
+ audio_array: Numpy array containing audio data
299
+ sample_rate: Sample rate in Hz
300
+ file_path: Output file path
301
+
302
+ Returns:
303
+ Path: Path to the saved WAV file
304
+ """
305
+ try:
306
+ import numpy as np
307
+ import soundfile as sf
308
+
309
+ path = Path(file_path)
310
+
311
+ # Ensure audio is in the correct format
312
+ if audio_array.dtype != np.float32:
313
+ audio_array = audio_array.astype(np.float32)
314
+
315
+ # Normalize if needed
316
+ if np.max(np.abs(audio_array)) > 1.0:
317
+ audio_array = audio_array / np.max(np.abs(audio_array))
318
+
319
+ sf.write(str(path), audio_array, sample_rate)
320
+
321
+ logger.debug(f"Converted numpy array to WAV: {path}")
322
+ return path
323
+
324
+ except ImportError:
325
+ logger.error("soundfile library not available for numpy conversion")
326
+ raise
327
+ except Exception as e:
328
+ logger.error(f"Failed to convert numpy array to WAV: {str(e)}")
329
+ raise
330
+
331
+
332
+ class ErrorHandler:
333
+ """Utility class for handling and logging errors in infrastructure providers."""
334
+
335
+ def __init__(self, provider_name: str):
336
+ """
337
+ Initialize the error handler.
338
+
339
+ Args:
340
+ provider_name: Name of the provider for error context
341
+ """
342
+ self.provider_name = provider_name
343
+ self.logger = logging.getLogger(f"{__name__}.{provider_name}")
344
+
345
+ def handle_error(self, error: Exception, context: str = "", reraise_as: type = None) -> None:
346
+ """
347
+ Handle an error with proper logging and optional re-raising.
348
+
349
+ Args:
350
+ error: The original error
351
+ context: Additional context about when the error occurred
352
+ reraise_as: Exception type to re-raise as (if None, re-raises original)
353
+ """
354
+ error_msg = f"{self.provider_name} error"
355
+ if context:
356
+ error_msg += f" during {context}"
357
+ error_msg += f": {str(error)}"
358
+
359
+ self.logger.error(error_msg, exc_info=True)
360
+
361
+ if reraise_as:
362
+ raise reraise_as(error_msg) from error
363
+ else:
364
+ raise
365
+
366
+ def log_warning(self, message: str, context: str = "") -> None:
367
+ """
368
+ Log a warning message.
369
+
370
+ Args:
371
+ message: Warning message
372
+ context: Additional context
373
+ """
374
+ warning_msg = f"{self.provider_name}"
375
+ if context:
376
+ warning_msg += f" ({context})"
377
+ warning_msg += f": {message}"
378
+
379
+ self.logger.warning(warning_msg)
380
+
381
+ def log_info(self, message: str, context: str = "") -> None:
382
+ """
383
+ Log an info message.
384
+
385
+ Args:
386
+ message: Info message
387
+ context: Additional context
388
+ """
389
+ info_msg = f"{self.provider_name}"
390
+ if context:
391
+ info_msg += f" ({context})"
392
+ info_msg += f": {message}"
393
+
394
+ self.logger.info(info_msg)
395
+
396
+ def log_debug(self, message: str, context: str = "") -> None:
397
+ """
398
+ Log a debug message.
399
+
400
+ Args:
401
+ message: Debug message
402
+ context: Additional context
403
+ """
404
+ debug_msg = f"{self.provider_name}"
405
+ if context:
406
+ debug_msg += f" ({context})"
407
+ debug_msg += f": {message}"
408
+
409
+ self.logger.debug(debug_msg)
src/infrastructure/base/stt_provider_base.py ADDED
@@ -0,0 +1,306 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Base class for STT provider implementations."""
2
+
3
+ import logging
4
+ import os
5
+ import tempfile
6
+ from abc import ABC, abstractmethod
7
+ from pathlib import Path
8
+ from typing import Optional, TYPE_CHECKING
9
+
10
+ if TYPE_CHECKING:
11
+ from ...domain.models.audio_content import AudioContent
12
+ from ...domain.models.text_content import TextContent
13
+
14
+ from ...domain.interfaces.speech_recognition import ISpeechRecognitionService
15
+ from ...domain.exceptions import SpeechRecognitionException
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class STTProviderBase(ISpeechRecognitionService, ABC):
21
+ """Abstract base class for STT provider implementations."""
22
+
23
+ def __init__(self, provider_name: str, supported_languages: list[str] = None):
24
+ """
25
+ Initialize the STT provider.
26
+
27
+ Args:
28
+ provider_name: Name of the STT provider
29
+ supported_languages: List of supported language codes
30
+ """
31
+ self.provider_name = provider_name
32
+ self.supported_languages = supported_languages or []
33
+ self._temp_dir = self._ensure_temp_directory()
34
+
35
+ def transcribe(self, audio: 'AudioContent', model: str) -> 'TextContent':
36
+ """
37
+ Transcribe audio content to text.
38
+
39
+ Args:
40
+ audio: The audio content to transcribe
41
+ model: The STT model to use for transcription
42
+
43
+ Returns:
44
+ TextContent: The transcribed text
45
+
46
+ Raises:
47
+ SpeechRecognitionException: If transcription fails
48
+ """
49
+ try:
50
+ logger.info(f"Starting transcription with {self.provider_name} provider using model {model}")
51
+ self._validate_audio(audio)
52
+
53
+ # Preprocess audio if needed
54
+ processed_audio_path = self._preprocess_audio(audio)
55
+
56
+ try:
57
+ # Perform transcription using provider-specific implementation
58
+ transcribed_text = self._perform_transcription(processed_audio_path, model)
59
+
60
+ # Create TextContent from transcription result
61
+ from ...domain.models.text_content import TextContent
62
+
63
+ # Detect language if not specified (default to English)
64
+ detected_language = self._detect_language(transcribed_text) or 'en'
65
+
66
+ text_content = TextContent(
67
+ text=transcribed_text,
68
+ language=detected_language,
69
+ encoding='utf-8'
70
+ )
71
+
72
+ logger.info(f"Transcription completed successfully with {self.provider_name}")
73
+ return text_content
74
+
75
+ finally:
76
+ # Clean up temporary audio file
77
+ self._cleanup_temp_file(processed_audio_path)
78
+
79
+ except Exception as e:
80
+ logger.error(f"Transcription failed with {self.provider_name}: {str(e)}")
81
+ raise SpeechRecognitionException(f"STT transcription failed: {str(e)}") from e
82
+
83
+ @abstractmethod
84
+ def _perform_transcription(self, audio_path: Path, model: str) -> str:
85
+ """
86
+ Perform the actual transcription using provider-specific implementation.
87
+
88
+ Args:
89
+ audio_path: Path to the preprocessed audio file
90
+ model: The STT model to use
91
+
92
+ Returns:
93
+ str: The transcribed text
94
+ """
95
+ pass
96
+
97
+ @abstractmethod
98
+ def is_available(self) -> bool:
99
+ """
100
+ Check if the STT provider is available and ready to use.
101
+
102
+ Returns:
103
+ bool: True if provider is available, False otherwise
104
+ """
105
+ pass
106
+
107
+ @abstractmethod
108
+ def get_available_models(self) -> list[str]:
109
+ """
110
+ Get list of available models for this provider.
111
+
112
+ Returns:
113
+ list[str]: List of model identifiers
114
+ """
115
+ pass
116
+
117
+ def _preprocess_audio(self, audio: 'AudioContent') -> Path:
118
+ """
119
+ Preprocess audio content for transcription.
120
+
121
+ Args:
122
+ audio: The audio content to preprocess
123
+
124
+ Returns:
125
+ Path: Path to the preprocessed audio file
126
+ """
127
+ try:
128
+ # Create temporary file for audio processing
129
+ temp_file = self._temp_dir / f"audio_{id(audio)}.wav"
130
+
131
+ # Write audio data to temporary file
132
+ with open(temp_file, 'wb') as f:
133
+ f.write(audio.data)
134
+
135
+ # Convert to required format if needed
136
+ processed_file = self._convert_audio_format(temp_file, audio)
137
+
138
+ logger.debug(f"Audio preprocessed and saved to: {processed_file}")
139
+ return processed_file
140
+
141
+ except Exception as e:
142
+ logger.error(f"Audio preprocessing failed: {str(e)}")
143
+ raise SpeechRecognitionException(f"Audio preprocessing failed: {str(e)}") from e
144
+
145
+ def _convert_audio_format(self, audio_path: Path, audio: 'AudioContent') -> Path:
146
+ """
147
+ Convert audio to the required format for transcription.
148
+
149
+ Args:
150
+ audio_path: Path to the original audio file
151
+ audio: The audio content metadata
152
+
153
+ Returns:
154
+ Path: Path to the converted audio file
155
+ """
156
+ try:
157
+ # Import audio processing library
158
+ from pydub import AudioSegment
159
+
160
+ # Load audio file
161
+ if audio.format.lower() == 'mp3':
162
+ audio_segment = AudioSegment.from_mp3(audio_path)
163
+ elif audio.format.lower() == 'wav':
164
+ audio_segment = AudioSegment.from_wav(audio_path)
165
+ elif audio.format.lower() == 'flac':
166
+ audio_segment = AudioSegment.from_file(audio_path, format='flac')
167
+ elif audio.format.lower() == 'ogg':
168
+ audio_segment = AudioSegment.from_ogg(audio_path)
169
+ else:
170
+ # Try to load as generic audio file
171
+ audio_segment = AudioSegment.from_file(audio_path)
172
+
173
+ # Convert to standard format for STT (16kHz, mono, WAV)
174
+ standardized_audio = audio_segment.set_frame_rate(16000).set_channels(1)
175
+
176
+ # Create output path
177
+ output_path = audio_path.with_suffix('.wav')
178
+ if output_path == audio_path:
179
+ output_path = audio_path.with_name(f"converted_{audio_path.name}")
180
+
181
+ # Export converted audio
182
+ standardized_audio.export(output_path, format="wav")
183
+
184
+ logger.debug(f"Audio converted from {audio.format} to WAV: {output_path}")
185
+ return output_path
186
+
187
+ except ImportError:
188
+ logger.warning("pydub not available, using original audio file")
189
+ return audio_path
190
+ except Exception as e:
191
+ logger.warning(f"Audio conversion failed, using original file: {str(e)}")
192
+ return audio_path
193
+
194
+ def _validate_audio(self, audio: 'AudioContent') -> None:
195
+ """
196
+ Validate the audio content for transcription.
197
+
198
+ Args:
199
+ audio: The audio content to validate
200
+
201
+ Raises:
202
+ SpeechRecognitionException: If audio is invalid
203
+ """
204
+ if not audio.data:
205
+ raise SpeechRecognitionException("Audio data cannot be empty")
206
+
207
+ if audio.duration > 3600: # 1 hour limit
208
+ raise SpeechRecognitionException("Audio duration exceeds maximum limit of 1 hour")
209
+
210
+ if audio.duration < 0.1: # Minimum 100ms
211
+ raise SpeechRecognitionException("Audio duration too short (minimum 100ms)")
212
+
213
+ if not audio.is_valid_format:
214
+ raise SpeechRecognitionException(f"Unsupported audio format: {audio.format}")
215
+
216
+ def _detect_language(self, text: str) -> Optional[str]:
217
+ """
218
+ Detect the language of transcribed text.
219
+
220
+ Args:
221
+ text: The transcribed text
222
+
223
+ Returns:
224
+ Optional[str]: Detected language code or None if detection fails
225
+ """
226
+ try:
227
+ # Simple heuristic-based language detection
228
+ # This is a basic implementation - in production, you might use langdetect or similar
229
+
230
+ # Check for common English words
231
+ english_indicators = ['the', 'and', 'is', 'in', 'to', 'of', 'a', 'that', 'it', 'with']
232
+ text_lower = text.lower()
233
+ english_count = sum(1 for word in english_indicators if word in text_lower)
234
+
235
+ if english_count >= 2:
236
+ return 'en'
237
+
238
+ # Default to English if uncertain
239
+ return 'en'
240
+
241
+ except Exception as e:
242
+ logger.warning(f"Language detection failed: {str(e)}")
243
+ return None
244
+
245
+ def _ensure_temp_directory(self) -> Path:
246
+ """
247
+ Ensure temporary directory exists and return its path.
248
+
249
+ Returns:
250
+ Path: Path to the temporary directory
251
+ """
252
+ temp_dir = Path(tempfile.gettempdir()) / "stt_temp"
253
+ temp_dir.mkdir(exist_ok=True)
254
+ return temp_dir
255
+
256
+ def _cleanup_temp_file(self, file_path: Path) -> None:
257
+ """
258
+ Clean up a temporary file.
259
+
260
+ Args:
261
+ file_path: Path to the file to clean up
262
+ """
263
+ try:
264
+ if file_path.exists():
265
+ file_path.unlink()
266
+ logger.debug(f"Cleaned up temp file: {file_path}")
267
+ except Exception as e:
268
+ logger.warning(f"Failed to cleanup temp file {file_path}: {str(e)}")
269
+
270
+ def _cleanup_old_temp_files(self, max_age_hours: int = 24) -> None:
271
+ """
272
+ Clean up old temporary files.
273
+
274
+ Args:
275
+ max_age_hours: Maximum age of files to keep in hours
276
+ """
277
+ try:
278
+ import time
279
+ current_time = time.time()
280
+ max_age_seconds = max_age_hours * 3600
281
+
282
+ for file_path in self._temp_dir.glob("*"):
283
+ if file_path.is_file():
284
+ file_age = current_time - file_path.stat().st_mtime
285
+ if file_age > max_age_seconds:
286
+ file_path.unlink()
287
+ logger.debug(f"Cleaned up old temp file: {file_path}")
288
+
289
+ except Exception as e:
290
+ logger.warning(f"Failed to cleanup old temp files: {str(e)}")
291
+
292
+ def _handle_provider_error(self, error: Exception, context: str = "") -> None:
293
+ """
294
+ Handle provider-specific errors and convert to domain exceptions.
295
+
296
+ Args:
297
+ error: The original error
298
+ context: Additional context about when the error occurred
299
+ """
300
+ error_msg = f"{self.provider_name} error"
301
+ if context:
302
+ error_msg += f" during {context}"
303
+ error_msg += f": {str(error)}"
304
+
305
+ logger.error(error_msg, exc_info=True)
306
+ raise SpeechRecognitionException(error_msg) from error
src/infrastructure/base/translation_provider_base.py ADDED
@@ -0,0 +1,356 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Base class for translation provider implementations."""
2
+
3
+ import logging
4
+ import re
5
+ from abc import ABC, abstractmethod
6
+ from typing import List, TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from ...domain.models.translation_request import TranslationRequest
10
+ from ...domain.models.text_content import TextContent
11
+
12
+ from ...domain.interfaces.translation import ITranslationService
13
+ from ...domain.exceptions import TranslationFailedException
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class TranslationProviderBase(ITranslationService, ABC):
19
+ """Abstract base class for translation provider implementations."""
20
+
21
+ def __init__(self, provider_name: str, supported_languages: dict[str, list[str]] = None):
22
+ """
23
+ Initialize the translation provider.
24
+
25
+ Args:
26
+ provider_name: Name of the translation provider
27
+ supported_languages: Dict mapping source languages to supported target languages
28
+ """
29
+ self.provider_name = provider_name
30
+ self.supported_languages = supported_languages or {}
31
+ self.max_chunk_length = 1000 # Default chunk size for text processing
32
+
33
+ def translate(self, request: 'TranslationRequest') -> 'TextContent':
34
+ """
35
+ Translate text from source language to target language.
36
+
37
+ Args:
38
+ request: The translation request
39
+
40
+ Returns:
41
+ TextContent: The translated text
42
+
43
+ Raises:
44
+ TranslationFailedException: If translation fails
45
+ """
46
+ try:
47
+ logger.info(f"Starting translation with {self.provider_name} provider")
48
+ logger.info(f"Translating from {request.source_text.language} to {request.target_language}")
49
+
50
+ self._validate_request(request)
51
+
52
+ # Split text into chunks for processing
53
+ text_chunks = self._chunk_text(request.source_text.text)
54
+ logger.info(f"Split text into {len(text_chunks)} chunks for processing")
55
+
56
+ # Translate each chunk
57
+ translated_chunks = []
58
+ for i, chunk in enumerate(text_chunks):
59
+ logger.debug(f"Translating chunk {i+1}/{len(text_chunks)}")
60
+ translated_chunk = self._translate_chunk(
61
+ chunk,
62
+ request.source_text.language,
63
+ request.target_language
64
+ )
65
+ translated_chunks.append(translated_chunk)
66
+
67
+ # Reassemble translated text
68
+ translated_text = self._reassemble_chunks(translated_chunks)
69
+
70
+ # Create TextContent from translation result
71
+ from ...domain.models.text_content import TextContent
72
+
73
+ result = TextContent(
74
+ text=translated_text,
75
+ language=request.target_language,
76
+ encoding='utf-8'
77
+ )
78
+
79
+ logger.info(f"Translation completed successfully with {self.provider_name}")
80
+ logger.info(f"Original length: {len(request.source_text.text)}, Translated length: {len(translated_text)}")
81
+
82
+ return result
83
+
84
+ except Exception as e:
85
+ logger.error(f"Translation failed with {self.provider_name}: {str(e)}")
86
+ raise TranslationFailedException(f"Translation failed: {str(e)}") from e
87
+
88
+ @abstractmethod
89
+ def _translate_chunk(self, text: str, source_language: str, target_language: str) -> str:
90
+ """
91
+ Translate a single chunk of text using provider-specific implementation.
92
+
93
+ Args:
94
+ text: The text chunk to translate
95
+ source_language: Source language code
96
+ target_language: Target language code
97
+
98
+ Returns:
99
+ str: The translated text chunk
100
+ """
101
+ pass
102
+
103
+ @abstractmethod
104
+ def is_available(self) -> bool:
105
+ """
106
+ Check if the translation provider is available and ready to use.
107
+
108
+ Returns:
109
+ bool: True if provider is available, False otherwise
110
+ """
111
+ pass
112
+
113
+ @abstractmethod
114
+ def get_supported_languages(self) -> dict[str, list[str]]:
115
+ """
116
+ Get supported language pairs for this provider.
117
+
118
+ Returns:
119
+ dict: Mapping of source languages to supported target languages
120
+ """
121
+ pass
122
+
123
+ def _chunk_text(self, text: str) -> List[str]:
124
+ """
125
+ Split text into chunks for translation processing.
126
+
127
+ Args:
128
+ text: The text to chunk
129
+
130
+ Returns:
131
+ List[str]: List of text chunks
132
+ """
133
+ if len(text) <= self.max_chunk_length:
134
+ return [text]
135
+
136
+ chunks = []
137
+ current_chunk = ""
138
+
139
+ # Split by sentences first to maintain context
140
+ sentences = self._split_into_sentences(text)
141
+
142
+ for sentence in sentences:
143
+ # If adding this sentence would exceed chunk limit
144
+ if len(current_chunk) + len(sentence) > self.max_chunk_length:
145
+ if current_chunk:
146
+ chunks.append(current_chunk.strip())
147
+ current_chunk = ""
148
+
149
+ # If single sentence is too long, split by words
150
+ if len(sentence) > self.max_chunk_length:
151
+ word_chunks = self._split_long_sentence(sentence)
152
+ chunks.extend(word_chunks[:-1]) # Add all but last chunk
153
+ current_chunk = word_chunks[-1] # Start new chunk with last piece
154
+ else:
155
+ current_chunk = sentence
156
+ else:
157
+ current_chunk += " " + sentence if current_chunk else sentence
158
+
159
+ # Add remaining chunk
160
+ if current_chunk.strip():
161
+ chunks.append(current_chunk.strip())
162
+
163
+ logger.debug(f"Text chunked into {len(chunks)} pieces")
164
+ return chunks
165
+
166
+ def _split_into_sentences(self, text: str) -> List[str]:
167
+ """
168
+ Split text into sentences using basic punctuation rules.
169
+
170
+ Args:
171
+ text: The text to split
172
+
173
+ Returns:
174
+ List[str]: List of sentences
175
+ """
176
+ # Simple sentence splitting using regex
177
+ # This handles basic cases - more sophisticated NLP libraries could be used
178
+ sentence_endings = r'[.!?]+\s+'
179
+ sentences = re.split(sentence_endings, text)
180
+
181
+ # Filter out empty sentences and strip whitespace
182
+ sentences = [s.strip() for s in sentences if s.strip()]
183
+
184
+ return sentences
185
+
186
+ def _split_long_sentence(self, sentence: str) -> List[str]:
187
+ """
188
+ Split a long sentence into smaller chunks by words.
189
+
190
+ Args:
191
+ sentence: The sentence to split
192
+
193
+ Returns:
194
+ List[str]: List of word chunks
195
+ """
196
+ words = sentence.split()
197
+ chunks = []
198
+ current_chunk = ""
199
+
200
+ for word in words:
201
+ if len(current_chunk) + len(word) + 1 > self.max_chunk_length:
202
+ if current_chunk:
203
+ chunks.append(current_chunk.strip())
204
+ current_chunk = word
205
+ else:
206
+ # Single word is too long, just add it
207
+ chunks.append(word)
208
+ else:
209
+ current_chunk += " " + word if current_chunk else word
210
+
211
+ if current_chunk.strip():
212
+ chunks.append(current_chunk.strip())
213
+
214
+ return chunks
215
+
216
+ def _reassemble_chunks(self, chunks: List[str]) -> str:
217
+ """
218
+ Reassemble translated chunks into a single text.
219
+
220
+ Args:
221
+ chunks: List of translated text chunks
222
+
223
+ Returns:
224
+ str: Reassembled text
225
+ """
226
+ # Simple reassembly with space separation
227
+ # More sophisticated approaches could preserve original formatting
228
+ return " ".join(chunk.strip() for chunk in chunks if chunk.strip())
229
+
230
+ def _validate_request(self, request: 'TranslationRequest') -> None:
231
+ """
232
+ Validate the translation request.
233
+
234
+ Args:
235
+ request: The translation request to validate
236
+
237
+ Raises:
238
+ TranslationFailedException: If request is invalid
239
+ """
240
+ if not request.source_text.text.strip():
241
+ raise TranslationFailedException("Source text cannot be empty")
242
+
243
+ if request.source_text.language == request.target_language:
244
+ raise TranslationFailedException("Source and target languages cannot be the same")
245
+
246
+ # Check if language pair is supported
247
+ if self.supported_languages:
248
+ source_lang = request.source_text.language
249
+ target_lang = request.target_language
250
+
251
+ if source_lang not in self.supported_languages:
252
+ raise TranslationFailedException(
253
+ f"Source language {source_lang} not supported by {self.provider_name}. "
254
+ f"Supported source languages: {list(self.supported_languages.keys())}"
255
+ )
256
+
257
+ if target_lang not in self.supported_languages[source_lang]:
258
+ raise TranslationFailedException(
259
+ f"Translation from {source_lang} to {target_lang} not supported by {self.provider_name}. "
260
+ f"Supported target languages for {source_lang}: {self.supported_languages[source_lang]}"
261
+ )
262
+
263
+ def _preprocess_text(self, text: str) -> str:
264
+ """
265
+ Preprocess text before translation.
266
+
267
+ Args:
268
+ text: The text to preprocess
269
+
270
+ Returns:
271
+ str: Preprocessed text
272
+ """
273
+ # Basic text preprocessing
274
+ # Remove excessive whitespace
275
+ text = re.sub(r'\s+', ' ', text)
276
+
277
+ # Strip leading/trailing whitespace
278
+ text = text.strip()
279
+
280
+ return text
281
+
282
+ def _postprocess_text(self, text: str) -> str:
283
+ """
284
+ Postprocess text after translation.
285
+
286
+ Args:
287
+ text: The text to postprocess
288
+
289
+ Returns:
290
+ str: Postprocessed text
291
+ """
292
+ # Basic text postprocessing
293
+ # Remove excessive whitespace
294
+ text = re.sub(r'\s+', ' ', text)
295
+
296
+ # Strip leading/trailing whitespace
297
+ text = text.strip()
298
+
299
+ # Fix common spacing issues around punctuation
300
+ text = re.sub(r'\s+([.!?,:;])', r'\1', text)
301
+ text = re.sub(r'([.!?])\s*([A-Z])', r'\1 \2', text)
302
+
303
+ return text
304
+
305
+ def _handle_provider_error(self, error: Exception, context: str = "") -> None:
306
+ """
307
+ Handle provider-specific errors and convert to domain exceptions.
308
+
309
+ Args:
310
+ error: The original error
311
+ context: Additional context about when the error occurred
312
+ """
313
+ error_msg = f"{self.provider_name} error"
314
+ if context:
315
+ error_msg += f" during {context}"
316
+ error_msg += f": {str(error)}"
317
+
318
+ logger.error(error_msg, exc_info=True)
319
+ raise TranslationFailedException(error_msg) from error
320
+
321
+ def set_chunk_size(self, chunk_size: int) -> None:
322
+ """
323
+ Set the maximum chunk size for text processing.
324
+
325
+ Args:
326
+ chunk_size: Maximum characters per chunk
327
+ """
328
+ if chunk_size <= 0:
329
+ raise ValueError("Chunk size must be positive")
330
+
331
+ self.max_chunk_length = chunk_size
332
+ logger.info(f"Chunk size set to {chunk_size} characters")
333
+
334
+ def get_translation_stats(self, request: 'TranslationRequest') -> dict:
335
+ """
336
+ Get statistics about a translation request.
337
+
338
+ Args:
339
+ request: The translation request
340
+
341
+ Returns:
342
+ dict: Translation statistics
343
+ """
344
+ text = request.source_text.text
345
+ chunks = self._chunk_text(text)
346
+
347
+ return {
348
+ 'provider': self.provider_name,
349
+ 'source_language': request.source_text.language,
350
+ 'target_language': request.target_language,
351
+ 'text_length': len(text),
352
+ 'word_count': len(text.split()),
353
+ 'chunk_count': len(chunks),
354
+ 'max_chunk_length': max(len(chunk) for chunk in chunks) if chunks else 0,
355
+ 'avg_chunk_length': sum(len(chunk) for chunk in chunks) / len(chunks) if chunks else 0
356
+ }
src/infrastructure/base/tts_provider_base.py ADDED
@@ -0,0 +1,269 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Base class for TTS provider implementations."""
2
+
3
+ import logging
4
+ import os
5
+ import time
6
+ import tempfile
7
+ from abc import ABC, abstractmethod
8
+ from typing import Iterator, Optional, TYPE_CHECKING
9
+ from pathlib import Path
10
+
11
+ if TYPE_CHECKING:
12
+ from ...domain.models.speech_synthesis_request import SpeechSynthesisRequest
13
+ from ...domain.models.audio_content import AudioContent
14
+ from ...domain.models.audio_chunk import AudioChunk
15
+
16
+ from ...domain.interfaces.speech_synthesis import ISpeechSynthesisService
17
+ from ...domain.exceptions import SpeechSynthesisException
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class TTSProviderBase(ISpeechSynthesisService, ABC):
23
+ """Abstract base class for TTS provider implementations."""
24
+
25
+ def __init__(self, provider_name: str, supported_languages: list[str] = None):
26
+ """
27
+ Initialize the TTS provider.
28
+
29
+ Args:
30
+ provider_name: Name of the TTS provider
31
+ supported_languages: List of supported language codes
32
+ """
33
+ self.provider_name = provider_name
34
+ self.supported_languages = supported_languages or []
35
+ self._output_dir = self._ensure_output_directory()
36
+
37
+ def synthesize(self, request: 'SpeechSynthesisRequest') -> 'AudioContent':
38
+ """
39
+ Synthesize speech from text.
40
+
41
+ Args:
42
+ request: The speech synthesis request
43
+
44
+ Returns:
45
+ AudioContent: The synthesized audio
46
+
47
+ Raises:
48
+ SpeechSynthesisException: If synthesis fails
49
+ """
50
+ try:
51
+ logger.info(f"Starting synthesis with {self.provider_name} provider")
52
+ self._validate_request(request)
53
+
54
+ # Generate audio using provider-specific implementation
55
+ audio_data, sample_rate = self._generate_audio(request)
56
+
57
+ # Create AudioContent from the generated data
58
+ from ...domain.models.audio_content import AudioContent
59
+
60
+ audio_content = AudioContent(
61
+ data=audio_data,
62
+ format='wav', # Most providers output WAV
63
+ sample_rate=sample_rate,
64
+ duration=self._calculate_duration(audio_data, sample_rate),
65
+ filename=f"{self.provider_name}_{int(time.time())}.wav"
66
+ )
67
+
68
+ logger.info(f"Synthesis completed successfully with {self.provider_name}")
69
+ return audio_content
70
+
71
+ except Exception as e:
72
+ logger.error(f"Synthesis failed with {self.provider_name}: {str(e)}")
73
+ raise SpeechSynthesisException(f"TTS synthesis failed: {str(e)}") from e
74
+
75
+ def synthesize_stream(self, request: 'SpeechSynthesisRequest') -> Iterator['AudioChunk']:
76
+ """
77
+ Synthesize speech from text as a stream.
78
+
79
+ Args:
80
+ request: The speech synthesis request
81
+
82
+ Returns:
83
+ Iterator[AudioChunk]: Stream of audio chunks
84
+
85
+ Raises:
86
+ SpeechSynthesisException: If synthesis fails
87
+ """
88
+ try:
89
+ logger.info(f"Starting streaming synthesis with {self.provider_name} provider")
90
+ self._validate_request(request)
91
+
92
+ # Generate audio stream using provider-specific implementation
93
+ chunk_index = 0
94
+ for audio_data, sample_rate, is_final in self._generate_audio_stream(request):
95
+ from ...domain.models.audio_chunk import AudioChunk
96
+
97
+ chunk = AudioChunk(
98
+ data=audio_data,
99
+ format='wav',
100
+ sample_rate=sample_rate,
101
+ chunk_index=chunk_index,
102
+ is_final=is_final,
103
+ timestamp=time.time()
104
+ )
105
+
106
+ yield chunk
107
+ chunk_index += 1
108
+
109
+ logger.info(f"Streaming synthesis completed with {self.provider_name}")
110
+
111
+ except Exception as e:
112
+ logger.error(f"Streaming synthesis failed with {self.provider_name}: {str(e)}")
113
+ raise SpeechSynthesisException(f"TTS streaming synthesis failed: {str(e)}") from e
114
+
115
+ @abstractmethod
116
+ def _generate_audio(self, request: 'SpeechSynthesisRequest') -> tuple[bytes, int]:
117
+ """
118
+ Generate audio data from synthesis request.
119
+
120
+ Args:
121
+ request: The speech synthesis request
122
+
123
+ Returns:
124
+ tuple: (audio_data_bytes, sample_rate)
125
+ """
126
+ pass
127
+
128
+ @abstractmethod
129
+ def _generate_audio_stream(self, request: 'SpeechSynthesisRequest') -> Iterator[tuple[bytes, int, bool]]:
130
+ """
131
+ Generate audio data stream from synthesis request.
132
+
133
+ Args:
134
+ request: The speech synthesis request
135
+
136
+ Returns:
137
+ Iterator: (audio_data_bytes, sample_rate, is_final) tuples
138
+ """
139
+ pass
140
+
141
+ @abstractmethod
142
+ def is_available(self) -> bool:
143
+ """
144
+ Check if the TTS provider is available and ready to use.
145
+
146
+ Returns:
147
+ bool: True if provider is available, False otherwise
148
+ """
149
+ pass
150
+
151
+ @abstractmethod
152
+ def get_available_voices(self) -> list[str]:
153
+ """
154
+ Get list of available voices for this provider.
155
+
156
+ Returns:
157
+ list[str]: List of voice identifiers
158
+ """
159
+ pass
160
+
161
+ def _validate_request(self, request: 'SpeechSynthesisRequest') -> None:
162
+ """
163
+ Validate the synthesis request.
164
+
165
+ Args:
166
+ request: The synthesis request to validate
167
+
168
+ Raises:
169
+ SpeechSynthesisException: If request is invalid
170
+ """
171
+ if not request.text_content.text.strip():
172
+ raise SpeechSynthesisException("Text content cannot be empty")
173
+
174
+ if self.supported_languages and request.text_content.language not in self.supported_languages:
175
+ raise SpeechSynthesisException(
176
+ f"Language {request.text_content.language} not supported by {self.provider_name}. "
177
+ f"Supported languages: {self.supported_languages}"
178
+ )
179
+
180
+ available_voices = self.get_available_voices()
181
+ if available_voices and request.voice_settings.voice_id not in available_voices:
182
+ raise SpeechSynthesisException(
183
+ f"Voice {request.voice_settings.voice_id} not available for {self.provider_name}. "
184
+ f"Available voices: {available_voices}"
185
+ )
186
+
187
+ def _ensure_output_directory(self) -> Path:
188
+ """
189
+ Ensure output directory exists and return its path.
190
+
191
+ Returns:
192
+ Path: Path to the output directory
193
+ """
194
+ output_dir = Path(tempfile.gettempdir()) / "tts_output"
195
+ output_dir.mkdir(exist_ok=True)
196
+ return output_dir
197
+
198
+ def _generate_output_path(self, prefix: str = None, extension: str = "wav") -> Path:
199
+ """
200
+ Generate a unique output path for audio files.
201
+
202
+ Args:
203
+ prefix: Optional prefix for the filename
204
+ extension: File extension (default: wav)
205
+
206
+ Returns:
207
+ Path: Unique file path
208
+ """
209
+ prefix = prefix or self.provider_name
210
+ timestamp = int(time.time() * 1000)
211
+ filename = f"{prefix}_{timestamp}.{extension}"
212
+ return self._output_dir / filename
213
+
214
+ def _calculate_duration(self, audio_data: bytes, sample_rate: int, channels: int = 1, sample_width: int = 2) -> float:
215
+ """
216
+ Calculate audio duration from raw audio data.
217
+
218
+ Args:
219
+ audio_data: Raw audio data in bytes
220
+ sample_rate: Sample rate in Hz
221
+ channels: Number of audio channels (default: 1)
222
+ sample_width: Sample width in bytes (default: 2 for 16-bit)
223
+
224
+ Returns:
225
+ float: Duration in seconds
226
+ """
227
+ if not audio_data or sample_rate <= 0:
228
+ return 0.0
229
+
230
+ bytes_per_sample = channels * sample_width
231
+ total_samples = len(audio_data) // bytes_per_sample
232
+ return total_samples / sample_rate
233
+
234
+ def _cleanup_temp_files(self, max_age_hours: int = 24) -> None:
235
+ """
236
+ Clean up old temporary files.
237
+
238
+ Args:
239
+ max_age_hours: Maximum age of files to keep in hours
240
+ """
241
+ try:
242
+ current_time = time.time()
243
+ max_age_seconds = max_age_hours * 3600
244
+
245
+ for file_path in self._output_dir.glob("*"):
246
+ if file_path.is_file():
247
+ file_age = current_time - file_path.stat().st_mtime
248
+ if file_age > max_age_seconds:
249
+ file_path.unlink()
250
+ logger.debug(f"Cleaned up old temp file: {file_path}")
251
+
252
+ except Exception as e:
253
+ logger.warning(f"Failed to cleanup temp files: {str(e)}")
254
+
255
+ def _handle_provider_error(self, error: Exception, context: str = "") -> None:
256
+ """
257
+ Handle provider-specific errors and convert to domain exceptions.
258
+
259
+ Args:
260
+ error: The original error
261
+ context: Additional context about when the error occurred
262
+ """
263
+ error_msg = f"{self.provider_name} error"
264
+ if context:
265
+ error_msg += f" during {context}"
266
+ error_msg += f": {str(error)}"
267
+
268
+ logger.error(error_msg, exc_info=True)
269
+ raise SpeechSynthesisException(error_msg) from error