nifty-coder/stemsplit-backend
0
1"""2Web Speech API provider implementation.3 4This module implements a proxy provider that integrates with the browser's5Web Speech API for client-side speech recognition. Since the Web Speech API6runs in the browser, this provider acts as a proxy that coordinates with7frontend JavaScript code.8"""9 10import asyncio11import json12import logging13from typing import Dict, List, Optional, AsyncIterator, Any14from datetime import datetime, timezone15 16from ..interfaces import STTProvider17from ..models import (18 ProviderConfig, 19 TranscriptionResult, 20 QuotaStatus, 21 QuotaType,22 WordTimestamp,23 ProviderType24)25from ..exceptions import (26 ProviderError,27 ProviderUnavailableError,28 UnsupportedFormatError,29 UnsupportedLanguageError,30 QuotaExceededError,31 TranscriptionError32)33 34logger = logging.getLogger(__name__)35 36 37class WebSpeechProvider(STTProvider):38 """39 Web Speech API provider implementation.40 41 This provider acts as a proxy to the browser's Web Speech API. Since the42 Web Speech API runs client-side, this provider coordinates with frontend43 JavaScript code through WebSocket messages and handles browser compatibility.44 45 Key features:46 - Browser compatibility detection47 - Real-time streaming support48 - Fallback handling for unsupported browsers49 - Client-side processing (no server costs)50 - Language detection and support51 """52 53 # Supported audio formats (limited by browser capabilities)54 SUPPORTED_FORMATS = ["webm", "wav", "mp3", "ogg"]55 56 # Supported languages (common Web Speech API languages)57 SUPPORTED_LANGUAGES = [58 "en-US", "en-GB", "en-AU", "en-CA", "en-IN", "en-NZ", "en-ZA",59 "es-ES", "es-MX", "es-AR", "es-CO", "es-CL", "es-PE", "es-VE",60 "fr-FR", "fr-CA", "fr-BE", "fr-CH",61 "de-DE", "de-AT", "de-CH",62 "it-IT", "it-CH",63 "pt-BR", "pt-PT",64 "ru-RU",65 "ja-JP",66 "ko-KR",67 "zh-CN", "zh-TW", "zh-HK",68 "ar-SA", "ar-EG",69 "hi-IN",70 "th-TH",71 "tr-TR",72 "pl-PL",73 "nl-NL", "nl-BE",74 "sv-SE",75 "da-DK",76 "no-NO",77 "fi-FI"78 ]79 80 def __init__(self, config: ProviderConfig):81 """82 Initialize the Web Speech API provider.83 84 Args:85 config: Provider configuration86 """87 super().__init__(config)88 89 # Validate configuration90 if config.provider_type != ProviderType.WEB_SPEECH_API:91 raise ValueError(f"Invalid provider type: {config.provider_type}")92 93 # Web Speech API is free, so we track usage for monitoring only94 self._usage_stats = {95 "requests_today": 0,96 "audio_minutes_today": 0.0,97 "last_reset": datetime.now(timezone.utc).date()98 }99 100 # Browser compatibility status101 self._browser_compatible = None102 self._last_compatibility_check = None103 104 # Session management for streaming105 self._active_sessions = {}106 107 logger.info(f"Initialized WebSpeechProvider: {self.name}")108 109 async def transcribe_audio(110 self, 111 audio_data: bytes, 112 format: str,113 language: str = "en-US",114 **kwargs115 ) -> TranscriptionResult:116 """117 Transcribe audio data using Web Speech API.118 119 Since Web Speech API runs in the browser, this method coordinates120 with frontend JavaScript to perform the transcription.121 122 Args:123 audio_data: Raw audio bytes124 format: Audio format125 language: Language code for transcription126 **kwargs: Additional options (session_id, timeout, etc.)127 128 Returns:129 TranscriptionResult with transcribed text130 131 Raises:132 ProviderUnavailableError: If browser doesn't support Web Speech API133 UnsupportedFormatError: If audio format is not supported134 UnsupportedLanguageError: If language is not supported135 TranscriptionError: If transcription fails136 """137 start_time = datetime.now(timezone.utc)138 139 # Check browser compatibility140 await self._check_browser_compatibility()141 142 # Validate format and language143 if not self.supports_format(format):144 raise UnsupportedFormatError(self.name, format, self.SUPPORTED_FORMATS)145 146 if not self.supports_language(language):147 raise UnsupportedLanguageError(self.name, language, self.SUPPORTED_LANGUAGES)148 149 # Check quota (for monitoring purposes)150 audio_duration = kwargs.get('audio_duration', 0.0)151 await self._check_usage_quota(audio_duration)152 153 try:154 # Create transcription request155 session_id = kwargs.get('session_id', f"ws_{int(datetime.now().timestamp())}")156 timeout = kwargs.get('timeout', 30.0)157 158 # Prepare transcription parameters159 transcription_params = {160 "language": language,161 "continuous": kwargs.get('continuous', False),162 "interim_results": kwargs.get('interim_results', True),163 "max_alternatives": kwargs.get('max_alternatives', 1),164 "session_id": session_id165 }166 167 # Since this is a proxy provider, we simulate the transcription process168 # In a real implementation, this would coordinate with frontend JavaScript169 result = await self._perform_transcription(170 audio_data, 171 format, 172 transcription_params,173 timeout174 )175 176 # Update usage statistics177 processing_time = (datetime.now(timezone.utc) - start_time).total_seconds()178 await self._update_usage_stats(audio_duration, processing_time)179 180 return result181 182 except Exception as e:183 logger.error(f"Web Speech API transcription failed: {e}")184 if isinstance(e, (ProviderError, TranscriptionError)):185 raise186 raise TranscriptionError(f"Transcription failed: {str(e)}")187 188 async def transcribe_streaming(189 self,190 audio_stream: AsyncIterator[bytes],191 format: str,192 language: str = "en-US",193 **kwargs194 ) -> AsyncIterator[TranscriptionResult]:195 """196 Transcribe streaming audio data using Web Speech API.197 198 Args:199 audio_stream: Async iterator of audio chunks200 format: Audio format201 language: Language code for transcription202 **kwargs: Additional options203 204 Yields:205 TranscriptionResult for each processed chunk206 """207 # Check browser compatibility208 await self._check_browser_compatibility()209 210 # Validate format and language211 if not self.supports_format(format):212 raise UnsupportedFormatError(self.name, format, self.SUPPORTED_FORMATS)213 214 if not self.supports_language(language):215 raise UnsupportedLanguageError(self.name, language, self.SUPPORTED_LANGUAGES)216 217 session_id = kwargs.get('session_id', f"ws_stream_{int(datetime.now().timestamp())}")218 219 try:220 # Initialize streaming session221 self._active_sessions[session_id] = {222 "start_time": datetime.now(timezone.utc),223 "language": language,224 "format": format,225 "chunk_count": 0,226 "total_duration": 0.0227 }228 229 logger.info(f"Started streaming session: {session_id}")230 231 # Process audio chunks232 async for audio_chunk in audio_stream:233 try:234 # Estimate chunk duration (rough approximation)235 chunk_duration = len(audio_chunk) / (16000 * 2) # Assume 16kHz, 16-bit236 237 # Check quota for this chunk238 await self._check_usage_quota(chunk_duration)239 240 # Process the chunk241 result = await self._process_streaming_chunk(242 audio_chunk,243 session_id,244 language,245 format,246 **kwargs247 )248 249 if result:250 # Update session stats251 session = self._active_sessions[session_id]252 session["chunk_count"] += 1253 session["total_duration"] += chunk_duration254 255 yield result256 257 except Exception as e:258 logger.error(f"Error processing streaming chunk: {e}")259 # Continue processing other chunks260 continue261 262 finally:263 # Clean up session264 if session_id in self._active_sessions:265 session = self._active_sessions.pop(session_id)266 total_duration = session["total_duration"]267 processing_time = (datetime.now(timezone.utc) - session["start_time"]).total_seconds()268 269 await self._update_usage_stats(total_duration, processing_time)270 logger.info(f"Completed streaming session: {session_id}, duration: {total_duration:.2f}s")271 272 async def check_health(self) -> bool:273 """274 Check if the Web Speech API is available and healthy.275 276 Returns:277 True if provider is healthy, False otherwise278 """279 try:280 await self._check_browser_compatibility()281 return self._browser_compatible282 except Exception as e:283 logger.error(f"Health check failed: {e}")284 return False285 286 async def get_quota_status(self) -> Dict[str, QuotaStatus]:287 """288 Get current quota usage status.289 290 Web Speech API is free, so this returns monitoring information only.291 292 Returns:293 Dictionary mapping quota types to their status294 """295 await self._reset_daily_stats_if_needed()296 297 # Web Speech API doesn't have hard limits, but we track usage for monitoring298 return {299 "requests_per_day": QuotaStatus(300 provider=self.name,301 quota_type=QuotaType.REQUESTS_PER_DAY,302 current_usage=self._usage_stats["requests_today"],303 limit=float('inf'), # No hard limit304 remaining=float('inf'),305 reset_time=datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0),306 percentage_used=0.0307 ),308 "audio_minutes_per_day": QuotaStatus(309 provider=self.name,310 quota_type=QuotaType.AUDIO_MINUTES_PER_DAY,311 current_usage=self._usage_stats["audio_minutes_today"],312 limit=float('inf'), # No hard limit313 remaining=float('inf'),314 reset_time=datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0),315 percentage_used=0.0316 )317 }318 319 def supports_format(self, format: str) -> bool:320 """321 Check if the provider supports a given audio format.322 323 Args:324 format: Audio format to check325 326 Returns:327 True if format is supported, False otherwise328 """329 return format.lower() in [f.lower() for f in self.SUPPORTED_FORMATS]330 331 def supports_language(self, language: str) -> bool:332 """333 Check if the provider supports a given language.334 335 Args:336 language: Language code to check337 338 Returns:339 True if language is supported, False otherwise340 """341 return language in self.SUPPORTED_LANGUAGES342 343 async def estimate_cost(self, audio_duration: float) -> float:344 """345 Estimate the cost for transcribing audio of given duration.346 347 Web Speech API is free, so this always returns 0.0.348 349 Args:350 audio_duration: Duration in seconds351 352 Returns:353 Estimated cost (always 0.0 for Web Speech API)354 """355 return 0.0356 357 # Private methods358 359 async def _check_browser_compatibility(self) -> None:360 """361 Check if the browser supports Web Speech API.362 363 Raises:364 ProviderUnavailableError: If browser doesn't support Web Speech API365 """366 # In a real implementation, this would check browser capabilities367 # For now, we simulate compatibility check368 369 current_time = datetime.now(timezone.utc)370 371 # Cache compatibility check for 5 minutes372 if (self._last_compatibility_check and 373 (current_time - self._last_compatibility_check).total_seconds() < 300):374 if not self._browser_compatible:375 raise ProviderUnavailableError(376 self.name, 377 "Browser does not support Web Speech API"378 )379 return380 381 # Simulate compatibility check382 # In reality, this would be done on the frontend and communicated via WebSocket383 self._browser_compatible = True # Assume compatible for now384 self._last_compatibility_check = current_time385 386 if not self._browser_compatible:387 raise ProviderUnavailableError(388 self.name, 389 "Browser does not support Web Speech API"390 )391 392 async def _check_usage_quota(self, audio_duration: float) -> None:393 """394 Check usage quota (for monitoring purposes).395 396 Args:397 audio_duration: Duration of audio to be processed398 """399 await self._reset_daily_stats_if_needed()400 401 # Web Speech API is free, but we can implement soft limits for monitoring402 max_daily_requests = self.config.free_tier_limits.get("max_daily_requests", float('inf'))403 max_daily_minutes = self.config.free_tier_limits.get("max_daily_minutes", float('inf'))404 405 if self._usage_stats["requests_today"] >= max_daily_requests:406 raise QuotaExceededError(407 self.name,408 "requests_per_day",409 self._usage_stats["requests_today"],410 max_daily_requests411 )412 413 if self._usage_stats["audio_minutes_today"] + (audio_duration / 60.0) > max_daily_minutes:414 raise QuotaExceededError(415 self.name,416 "audio_minutes_per_day",417 self._usage_stats["audio_minutes_today"] + (audio_duration / 60.0),418 max_daily_minutes419 )420 421 async def _perform_transcription(422 self,423 audio_data: bytes,424 format: str,425 params: Dict[str, Any],426 timeout: float427 ) -> TranscriptionResult:428 """429 Perform the actual transcription.430 431 In a real implementation, this would coordinate with frontend JavaScript432 to use the Web Speech API. For now, we simulate the process.433 434 Args:435 audio_data: Raw audio bytes436 format: Audio format437 params: Transcription parameters438 timeout: Request timeout439 440 Returns:441 TranscriptionResult442 """443 start_time = datetime.now(timezone.utc)444 445 # Simulate transcription delay446 await asyncio.sleep(0.1)447 448 # Estimate audio duration449 audio_duration = len(audio_data) / (16000 * 2) # Assume 16kHz, 16-bit450 451 # Simulate transcription result452 # In reality, this would come from the browser's Web Speech API453 transcribed_text = "This is a simulated transcription from Web Speech API"454 confidence = 0.85455 456 processing_time = (datetime.now(timezone.utc) - start_time).total_seconds()457 458 return TranscriptionResult(459 text=transcribed_text,460 confidence=confidence,461 provider=self.name,462 processing_time=processing_time,463 audio_duration=audio_duration,464 language=params["language"],465 alternatives=[],466 word_timestamps=None,467 is_final=True,468 session_id=params["session_id"]469 )470 471 async def _process_streaming_chunk(472 self,473 audio_chunk: bytes,474 session_id: str,475 language: str,476 format: str,477 **kwargs478 ) -> Optional[TranscriptionResult]:479 """480 Process a streaming audio chunk.481 482 Args:483 audio_chunk: Audio data chunk484 session_id: Streaming session ID485 language: Language code486 format: Audio format487 **kwargs: Additional options488 489 Returns:490 TranscriptionResult if chunk produced results, None otherwise491 """492 start_time = datetime.now(timezone.utc)493 494 # Simulate processing delay495 await asyncio.sleep(0.05)496 497 # Estimate chunk duration498 chunk_duration = len(audio_chunk) / (16000 * 2)499 500 # Simulate interim results (not every chunk produces results)501 if len(audio_chunk) < 1000: # Skip very small chunks502 return None503 504 # Simulate transcription505 interim_results = kwargs.get('interim_results', True)506 is_final = len(audio_chunk) > 8000 # Larger chunks are more likely to be final507 508 if interim_results or is_final:509 text = f"Streaming chunk {session_id[-4:]}"510 confidence = 0.7 if not is_final else 0.85511 512 processing_time = (datetime.now(timezone.utc) - start_time).total_seconds()513 514 return TranscriptionResult(515 text=text,516 confidence=confidence,517 provider=self.name,518 processing_time=processing_time,519 audio_duration=chunk_duration,520 language=language,521 alternatives=[],522 word_timestamps=None,523 is_final=is_final,524 session_id=session_id525 )526 527 return None528 529 async def _update_usage_stats(self, audio_duration: float, processing_time: float) -> None:530 """531 Update usage statistics.532 533 Args:534 audio_duration: Duration of processed audio535 processing_time: Time taken to process536 """537 await self._reset_daily_stats_if_needed()538 539 self._usage_stats["requests_today"] += 1540 self._usage_stats["audio_minutes_today"] += audio_duration / 60.0541 542 logger.debug(543 f"Updated usage stats: {self._usage_stats['requests_today']} requests, "544 f"{self._usage_stats['audio_minutes_today']:.2f} minutes today"545 )546 547 async def _reset_daily_stats_if_needed(self) -> None:548 """Reset daily statistics if a new day has started."""549 current_date = datetime.now(timezone.utc).date()550 551 if current_date > self._usage_stats["last_reset"]:552 self._usage_stats["requests_today"] = 0553 self._usage_stats["audio_minutes_today"] = 0.0554 self._usage_stats["last_reset"] = current_date555 logger.info("Reset daily usage statistics")556 557 558def create_web_speech_provider(559 name: str = "web_speech_api",560 priority: int = 1,561 **config_overrides562) -> WebSpeechProvider:563 """564 Factory function to create a WebSpeechProvider with default configuration.565 566 Args:567 name: Provider name568 priority: Provider priority (lower = higher priority)569 **config_overrides: Configuration overrides570 571 Returns:572 Configured WebSpeechProvider instance573 """574 default_config = {575 "name": name,576 "provider_type": ProviderType.WEB_SPEECH_API,577 "enabled": True,578 "priority": priority,579 "free_tier_limits": {580 "max_daily_requests": 1000, # Soft limit for monitoring581 "max_daily_minutes": 60.0 # Soft limit for monitoring582 },583 "rate_limits": {584 "requests_per_minute": 60,585 "requests_per_hour": 1000586 },587 "supported_formats": WebSpeechProvider.SUPPORTED_FORMATS,588 "supported_languages": WebSpeechProvider.SUPPORTED_LANGUAGES,589 "cost_per_minute": 0.0, # Free590 "api_credentials": {}, # No credentials needed591 "endpoint_url": None, # Browser-based592 "timeout_seconds": 30,593 "max_retries": 2594 }595 596 # Apply overrides597 default_config.update(config_overrides)598 599 # Create configuration object600 config = ProviderConfig(**default_config)601 602 return WebSpeechProvider(config)