from __future__ import annotations import base64 import io import json import os import queue import threading import time import urllib.parse import uuid import wave from collections.abc import Callable from dataclasses import dataclass, field import httpx from services.ai_voice_runtime_service.audiosocket import pcm16le_to_wav_bytes, resample_pcm16le from services.shared.audioop_compat import audioop from services.shared.security import issue_app_token def _api_base() -> str: return (os.getenv("AI_API_BASE", "https://api.openai.com/v1").strip() or "https://api.openai.com/v1").rstrip("/") def _api_key() -> str: return os.getenv("AI_API_KEY", "").strip() def _timeout_seconds() -> float: raw = os.getenv("AI_TIMEOUT_SECONDS", "20").strip() try: value = float(raw) except ValueError: value = 20.0 return max(value, 3.0) def _openai_asr_model() -> str: return os.getenv("AI_VOICE_ASR_MODEL", "gpt-4o-mini-transcribe").strip() or "gpt-4o-mini-transcribe" def _elevenlabs_asr_api_base() -> str: return ( os.getenv("AI_VOICE_ASR_ELEVENLABS_API_BASE", "https://api.elevenlabs.io").strip() or "https://api.elevenlabs.io" ).rstrip("/") def _elevenlabs_asr_api_key() -> str: return ( os.getenv("AI_VOICE_ASR_ELEVENLABS_API_KEY", "").strip() or os.getenv("AI_VOICE_TTS_ELEVENLABS_API_KEY", "").strip() ) def _elevenlabs_asr_model_id() -> str: return os.getenv("AI_VOICE_ASR_ELEVENLABS_MODEL_ID", "scribe_v1").strip() or "scribe_v1" def _elevenlabs_asr_default_language() -> str: return os.getenv("AI_VOICE_ASR_ELEVENLABS_LANGUAGE", "ru").strip() or "ru" def _normalize_elevenlabs_asr_language(language: str | None) -> str: raw = str(language or "").strip().lower().replace("_", "-") if not raw: raw = _elevenlabs_asr_default_language().strip().lower().replace("_", "-") mapping = { "ru": "rus", "ru-ru": "rus", "kk": "kaz", "kz": "kaz", "kk-kz": "kaz", "kz-kz": "kaz", "en": "eng", "en-us": "eng", "en-gb": "eng", } return mapping.get(raw, raw or "rus") def _elevenlabs_asr_sample_rate_hz() -> int: raw = os.getenv("AI_VOICE_ASR_ELEVENLABS_SAMPLE_RATE_HZ", "16000").strip() try: value = int(raw) except ValueError: value = 16000 if value < 8000 or value > 48000: return 16000 return value def _elevenlabs_realtime_model_id() -> str: return ( os.getenv("AI_VOICE_ASR_ELEVENLABS_REALTIME_MODEL_ID", "scribe_v2_realtime").strip() or "scribe_v2_realtime" ) def _elevenlabs_realtime_audio_format() -> str: return ( os.getenv("AI_VOICE_ASR_ELEVENLABS_REALTIME_AUDIO_FORMAT", "pcm_16000").strip().lower() or "pcm_16000" ) def _elevenlabs_realtime_sample_rate_hz(audio_format: str | None = None) -> int: explicit = os.getenv("AI_VOICE_ASR_ELEVENLABS_REALTIME_SAMPLE_RATE_HZ", "").strip() if explicit: try: value = int(explicit) except ValueError: value = 16000 return value if 8000 <= value <= 48000 else 16000 normalized = str(audio_format or _elevenlabs_realtime_audio_format()).strip().lower() if normalized.startswith("pcm_"): try: value = int(normalized.rsplit("_", 1)[-1]) except ValueError: value = 16000 return value if 8000 <= value <= 48000 else 16000 return 16000 def _elevenlabs_realtime_commit_strategy() -> str: raw = os.getenv("AI_VOICE_ASR_ELEVENLABS_REALTIME_COMMIT_STRATEGY", "manual").strip().lower() return raw if raw in {"manual", "vad"} else "manual" def _elevenlabs_realtime_finalize_timeout_seconds() -> float: raw_ms = os.getenv("AI_VOICE_V2_STREAMING_FINAL_HARD_TIMEOUT_MS", "").strip() if raw_ms: try: return max(float(raw_ms) / 1000.0, 0.25) except ValueError: pass raw = os.getenv("AI_VOICE_ASR_ELEVENLABS_REALTIME_FINALIZE_TIMEOUT_SECONDS", "").strip() if not raw: return 0.7 try: value = float(raw) except ValueError: value = 0.7 return max(value, 0.25) def _normalize_elevenlabs_realtime_language(language: str | None) -> str: raw = str(language or "").strip().lower().replace("_", "-") if not raw: raw = _elevenlabs_asr_default_language().strip().lower().replace("_", "-") mapping = { "rus": "ru", "ru-ru": "ru", "kaz": "kk", "kz": "kk", "kk-kz": "kk", "kz-kz": "kk", "eng": "en", "en-us": "en", "en-gb": "en", } return mapping.get(raw, raw or "ru") def _resolve_elevenlabs_asr_language(language_code: str | None, language_hint: str | None) -> str | None: normalized = str(language_code or "").strip().lower() if normalized in {"rus", "ru", "ru-ru"}: return "ru" if normalized in {"kaz", "kk", "kz", "kk-kz", "kz-kz"}: return "kz" resolved = str(language_code or language_hint or "").strip() return resolved or language_hint def _yandex_asr_api_base() -> str: explicit = os.getenv("AI_VOICE_ASR_YANDEX_API_BASE", "").strip() if explicit: normalized = explicit.rstrip("/") if "stt.api.ml.yandexcloud.kz" in normalized.lower(): return "https://stt.api.cloud.yandex.net" return normalized return "https://stt.api.cloud.yandex.net" def _yandex_asr_operations_base() -> str: explicit = os.getenv("AI_VOICE_ASR_YANDEX_OPERATIONS_BASE", "").strip() if explicit: return explicit.rstrip("/") return "https://operation.api.cloud.yandex.net" def _yandex_asr_api_key() -> str: return ( os.getenv("AI_VOICE_ASR_YANDEX_API_KEY", "").strip() or os.getenv("AI_VOICE_TTS_YANDEX_API_KEY", "").strip() ) def _yandex_asr_iam_token() -> str: return ( os.getenv("AI_VOICE_ASR_YANDEX_IAM_TOKEN", "").strip() or os.getenv("AI_VOICE_TTS_YANDEX_IAM_TOKEN", "").strip() ) def _yandex_asr_folder_id() -> str: return ( os.getenv("AI_VOICE_ASR_YANDEX_FOLDER_ID", "").strip() or os.getenv("AI_VOICE_TTS_YANDEX_FOLDER_ID", "").strip() ) def _yandex_asr_timeout_seconds() -> float: raw = os.getenv("AI_VOICE_ASR_YANDEX_TIMEOUT_SECONDS", "").strip() if not raw: return _timeout_seconds() try: value = float(raw) except ValueError: value = _timeout_seconds() return max(value, 3.0) def _yandex_asr_sample_rate_hz() -> int: raw = os.getenv("AI_VOICE_ASR_YANDEX_SAMPLE_RATE_HZ", "8000").strip() try: value = int(raw) except ValueError: value = 8000 if value < 8000 or value > 48000: return 8000 return value def _yandex_asr_topic() -> str: return os.getenv("AI_VOICE_ASR_YANDEX_TOPIC", "general").strip() or "general" def _yandex_asr_api_version(api_base: str) -> str: raw = os.getenv("AI_VOICE_ASR_YANDEX_API_VERSION", "auto").strip().lower() or "auto" if raw in {"v1", "v1_sync", "sync"}: return "v1" if raw in {"v3", "v3_async", "async"}: return "v3" normalized_base = str(api_base or "").lower() if "stt.api.cloud.yandex.net" in normalized_base or "stt.api.ml.yandexcloud.kz" in normalized_base: return "v3" return "v1" def _yandex_asr_poll_interval_seconds() -> float: raw = os.getenv("AI_VOICE_ASR_YANDEX_POLL_INTERVAL_SECONDS", "0.25").strip() try: value = float(raw) except ValueError: value = 0.25 return max(min(value, 2.0), 0.05) def _yandex_asr_grpc_target(api_base: str) -> str: explicit = os.getenv("AI_VOICE_ASR_YANDEX_GRPC_TARGET", "").strip() if explicit: if "stt.api.ml.yandexcloud.kz" in explicit.lower(): return "stt.api.cloud.yandex.net:443" return explicit return "stt.api.cloud.yandex.net:443" def _yandex_asr_stream_queue_max_chunks() -> int: raw = os.getenv("AI_VOICE_ASR_YANDEX_STREAM_QUEUE_MAX_CHUNKS", "300").strip() try: value = int(raw) except ValueError: value = 300 return max(min(value, 2000), 10) def _yandex_asr_eou_max_pause_ms() -> int: raw = os.getenv("AI_VOICE_ASR_YANDEX_EOU_MAX_PAUSE_MS", "450").strip() try: value = int(raw) except ValueError: value = 450 return max(min(value, 3000), 100) def _yandex_asr_default_language() -> str: return os.getenv("AI_VOICE_ASR_YANDEX_LANGUAGE", "ru-RU").strip() or "ru-RU" def _normalize_yandex_asr_language(language: str | None) -> str: raw = str(language or "").strip() if not raw: return _yandex_asr_default_language() lowered = raw.replace("_", "-").lower() if lowered in {"ru", "ru-ru"}: return "ru-RU" if lowered in {"kk", "kz", "kk-kz", "kz-kz"}: return "kk-KZ" return raw def _streaming_asr_api_base() -> str: return ( os.getenv("AI_VOICE_V2_STREAMING_ASR_BASE_URL", "http://127.0.0.1:8021").strip() or "http://127.0.0.1:8021" ).rstrip("/") def _streaming_asr_timeout_seconds() -> float: raw = os.getenv("AI_VOICE_V2_STREAMING_ASR_TIMEOUT_SECONDS", "4").strip() try: value = float(raw) except ValueError: value = 4.0 return max(value, 0.25) def _service_headers() -> dict[str, str]: token = issue_app_token( subject="svc:ai-voice-runtime", username="ai-voice-runtime", role="admin", auth_source="service", provider="ai-voice-runtime", ttl_seconds=300, ) return {"Authorization": f"Bearer {token}"} @dataclass(slots=True) class ASRTranscription: text: str language: str | None = None confidence: float | None = None @dataclass(slots=True) class StreamingASRPartial: text: str language: str | None = None confidence: float | None = None is_final: bool = False is_stable: bool = False class StreamingASRUnavailable(RuntimeError): pass class ASRProvider: name = "stub" def transcribe(self, audio_bytes: bytes, *, language_hint: str | None = None) -> ASRTranscription: del audio_bytes return ASRTranscription(text="", language=language_hint, confidence=None) def transcribe_partial(self, audio_bytes: bytes, *, language_hint: str | None = None) -> ASRTranscription: return self.transcribe(audio_bytes, language_hint=language_hint) class StreamingASRProvider: name = "streaming-stub" supports_streaming = False def open_stream(self, session_id: str, *, language_hint: str | None = None) -> str: del session_id, language_hint raise StreamingASRUnavailable("Streaming ASR backend is not configured") def push_pcm(self, stream_id: str, pcm_8k_chunk: bytes) -> None: del stream_id, pcm_8k_chunk raise StreamingASRUnavailable("Streaming ASR backend is not configured") def poll_partial(self, stream_id: str) -> StreamingASRPartial | None: del stream_id return None def finalize(self, stream_id: str) -> ASRTranscription: del stream_id raise StreamingASRUnavailable("Streaming ASR backend is not configured") def close_stream(self, stream_id: str) -> None: del stream_id class OpenAIASRProvider(ASRProvider): name = "openai" def __init__(self) -> None: self._api_base = _api_base() self._api_key = _api_key() self._timeout_seconds = _timeout_seconds() self._model = _openai_asr_model() def transcribe(self, audio_bytes: bytes, *, language_hint: str | None = None) -> ASRTranscription: if not audio_bytes: return ASRTranscription(text="", language=language_hint, confidence=None) if not self._api_key: raise RuntimeError("AI_API_KEY is required for OpenAI ASR") data: dict[str, str] = {"model": self._model} language = str(language_hint or "").strip() if language: data["language"] = language with httpx.Client(timeout=self._timeout_seconds) as client: response = client.post( f"{self._api_base}/audio/transcriptions", headers={"Authorization": f"Bearer {self._api_key}"}, data=data, files={"file": ("turn.wav", audio_bytes, "audio/wav")}, ) response.raise_for_status() payload = response.json() return ASRTranscription( text=str(payload.get("text") or "").strip(), language=str(payload.get("language") or language_hint or "").strip() or language_hint, confidence=None, ) def transcribe_partial(self, audio_bytes: bytes, *, language_hint: str | None = None) -> ASRTranscription: return self.transcribe(audio_bytes, language_hint=language_hint) class ElevenLabsASRProvider(ASRProvider): name = "elevenlabs" def __init__( self, *, api_base: str | None = None, api_key: str | None = None, timeout_seconds: float | None = None, model_id: str | None = None, sample_rate_hz: int | None = None, ) -> None: self._api_base = str(api_base or _elevenlabs_asr_api_base()).strip().rstrip("/") self._api_key = str(api_key if api_key is not None else _elevenlabs_asr_api_key()).strip() self._timeout_seconds = max(float(timeout_seconds if timeout_seconds is not None else _timeout_seconds()), 3.0) self._model_id = str(model_id or _elevenlabs_asr_model_id()).strip() or _elevenlabs_asr_model_id() self._sample_rate_hz = int(sample_rate_hz if sample_rate_hz is not None else _elevenlabs_asr_sample_rate_hz()) @staticmethod def _pcm_from_audio_bytes(audio_bytes: bytes, *, default_sample_rate_hz: int) -> tuple[bytes, int]: if not audio_bytes: return b"", default_sample_rate_hz try: with wave.open(io.BytesIO(audio_bytes), "rb") as wav_file: sample_width = wav_file.getsampwidth() channels = wav_file.getnchannels() sample_rate = wav_file.getframerate() pcm_bytes = wav_file.readframes(wav_file.getnframes()) if sample_width != 2: return audio_bytes, default_sample_rate_hz if channels == 2: pcm_bytes = audioop.tomono(pcm_bytes, sample_width, 0.5, 0.5) # type: ignore[name-defined] elif channels != 1: return audio_bytes, default_sample_rate_hz return pcm_bytes, int(sample_rate or default_sample_rate_hz) except (wave.Error, EOFError): return audio_bytes, default_sample_rate_hz def transcribe(self, audio_bytes: bytes, *, language_hint: str | None = None) -> ASRTranscription: if not audio_bytes: return ASRTranscription(text="", language=language_hint, confidence=None) if not self._api_key: raise RuntimeError( "AI_VOICE_ASR_ELEVENLABS_API_KEY or AI_VOICE_TTS_ELEVENLABS_API_KEY is required for ElevenLabs ASR" ) pcm_bytes, input_sample_rate_hz = self._pcm_from_audio_bytes( audio_bytes, default_sample_rate_hz=self._sample_rate_hz, ) if not pcm_bytes: return ASRTranscription(text="", language=language_hint, confidence=None) normalized_sample_rate_hz = self._sample_rate_hz if input_sample_rate_hz != normalized_sample_rate_hz: pcm_bytes = resample_pcm16le( pcm_bytes, input_rate_hz=input_sample_rate_hz, output_rate_hz=normalized_sample_rate_hz, ) wav_bytes = pcm16le_to_wav_bytes(pcm_bytes, sample_rate_hz=normalized_sample_rate_hz) language_code = _normalize_elevenlabs_asr_language(language_hint) with httpx.Client(timeout=self._timeout_seconds) as client: response = client.post( f"{self._api_base}/v1/speech-to-text", headers={"xi-api-key": self._api_key}, data={ "model_id": self._model_id, "language_code": language_code, }, files={"file": ("turn.wav", wav_bytes, "audio/wav")}, ) response.raise_for_status() payload = response.json() transcript_text = str(payload.get("text") or payload.get("transcript") or "").strip() resolved_language = _resolve_elevenlabs_asr_language(payload.get("language_code"), language_hint) return ASRTranscription( text=transcript_text, language=resolved_language, confidence=None, ) def transcribe_partial(self, audio_bytes: bytes, *, language_hint: str | None = None) -> ASRTranscription: return self.transcribe(audio_bytes, language_hint=language_hint) @dataclass class _ElevenLabsRealtimeStreamState: stream_id: str session_id: str language: str websocket: object updates_queue: queue.Queue[StreamingASRPartial] thread: threading.Thread | None = None latest_partial: StreamingASRPartial | None = None stable_partial: StreamingASRPartial | None = None last_partial_text: str = "" partial_streak: int = 0 final_transcription: ASRTranscription | None = None error: BaseException | None = None close_requested: bool = False final_event: threading.Event = field(default_factory=threading.Event) lock: threading.Lock = field(default_factory=threading.Lock) send_lock: threading.Lock = field(default_factory=threading.Lock) class ElevenLabsRealtimeStreamingASRProvider(StreamingASRProvider): name = "elevenlabs-realtime" supports_streaming = True def __init__( self, *, api_base: str | None = None, api_key: str | None = None, timeout_seconds: float | None = None, finalize_timeout_seconds: float | None = None, model_id: str | None = None, audio_format: str | None = None, commit_strategy: str | None = None, websocket_factory: Callable[..., object] | None = None, ) -> None: self._api_base = str(api_base or _elevenlabs_asr_api_base()).strip().rstrip("/") self._api_key = str(api_key if api_key is not None else _elevenlabs_asr_api_key()).strip() self._timeout_seconds = max( float(timeout_seconds if timeout_seconds is not None else _streaming_asr_timeout_seconds()), 0.25, ) self._finalize_timeout_seconds = max( float( finalize_timeout_seconds if finalize_timeout_seconds is not None else _elevenlabs_realtime_finalize_timeout_seconds() ), 0.25, ) self._model_id = str(model_id or _elevenlabs_realtime_model_id()).strip() or "scribe_v2_realtime" self._audio_format = str(audio_format or _elevenlabs_realtime_audio_format()).strip().lower() or "pcm_16000" self._sample_rate_hz = _elevenlabs_realtime_sample_rate_hz(self._audio_format) self._commit_strategy = str(commit_strategy or _elevenlabs_realtime_commit_strategy()).strip().lower() if self._commit_strategy not in {"manual", "vad"}: self._commit_strategy = "manual" self._websocket_factory = websocket_factory self._streams: dict[str, _ElevenLabsRealtimeStreamState] = {} self._lock = threading.Lock() def _websocket_url(self, *, language: str) -> str: base = self._api_base if base.startswith("https://"): websocket_base = "wss://" + base[len("https://") :] elif base.startswith("http://"): websocket_base = "ws://" + base[len("http://") :] else: websocket_base = base params = { "model_id": self._model_id, "audio_format": self._audio_format, "language_code": language, "commit_strategy": self._commit_strategy, "include_timestamps": "false", "include_language_detection": "false", } return f"{websocket_base}/v1/speech-to-text/realtime?{urllib.parse.urlencode(params)}" def _create_websocket(self, url: str) -> object: if not self._api_key: raise StreamingASRUnavailable( "AI_VOICE_ASR_ELEVENLABS_API_KEY or AI_VOICE_TTS_ELEVENLABS_API_KEY is required for ElevenLabs realtime ASR" ) factory = self._websocket_factory if factory is None: try: import websocket # type: ignore[import-not-found] except ImportError as exc: raise StreamingASRUnavailable("websocket-client is required for ElevenLabs realtime ASR") from exc factory = websocket.create_connection try: return factory( url, header=[f"xi-api-key: {self._api_key}"], timeout=self._timeout_seconds, ) except BaseException as exc: raise StreamingASRUnavailable(str(exc)[:500] or "ElevenLabs realtime ASR is unavailable") from exc @staticmethod def _message_type(payload: object) -> str: if not isinstance(payload, dict): return "" return str(payload.get("message_type") or payload.get("type") or "").strip() @staticmethod def _payload_text(payload: object) -> str: if not isinstance(payload, dict): return "" return str(payload.get("text") or payload.get("transcript") or "").strip() @staticmethod def _payload_language(payload: object, fallback: str) -> str: if not isinstance(payload, dict): return fallback return str(payload.get("language_code") or payload.get("language") or fallback).strip() or fallback @staticmethod def _payload_error_message(payload: object) -> str: if not isinstance(payload, dict): return "ElevenLabs realtime ASR error" for key in ("message", "error", "reason", "detail"): value = payload.get(key) if value: return str(value)[:500] return json.dumps(payload, ensure_ascii=True)[:500] def _send_json(self, state: _ElevenLabsRealtimeStreamState, payload: dict[str, object]) -> None: with state.lock: if state.error is not None: raise StreamingASRUnavailable(str(state.error)[:500]) if state.close_requested: raise StreamingASRUnavailable("ElevenLabs realtime ASR stream is closed") try: with state.send_lock: state.websocket.send(json.dumps(payload, separators=(",", ":"))) # type: ignore[attr-defined] except BaseException as exc: with state.lock: state.error = exc state.final_event.set() raise StreamingASRUnavailable(str(exc)[:500] or "ElevenLabs realtime ASR send failed") from exc def _drain_updates(self, state: _ElevenLabsRealtimeStreamState) -> None: while True: try: update = state.updates_queue.get_nowait() except queue.Empty: return with state.lock: normalized_text = " ".join(str(update.text or "").strip().lower().split()) if normalized_text and normalized_text == state.last_partial_text: state.partial_streak += 1 else: state.last_partial_text = normalized_text state.partial_streak = 1 if normalized_text else 0 if update.is_final or update.is_stable or state.partial_streak >= 2: state.stable_partial = StreamingASRPartial( text=update.text, language=update.language or state.language, confidence=update.confidence, is_final=update.is_final, is_stable=True, ) update = state.stable_partial state.latest_partial = update if update.is_final: state.final_transcription = ASRTranscription( text=update.text, language=update.language or state.language, confidence=update.confidence, ) state.final_event.set() @staticmethod def _transcription_from_partial( partial: StreamingASRPartial, *, fallback_language: str, ) -> ASRTranscription: return ASRTranscription( text=partial.text, language=partial.language or fallback_language, confidence=partial.confidence, ) def _best_partial_transcription(self, state: _ElevenLabsRealtimeStreamState) -> ASRTranscription | None: with state.lock: partial = state.stable_partial or state.latest_partial if partial is None: return None return self._transcription_from_partial(partial, fallback_language=state.language) def _run_reader(self, state: _ElevenLabsRealtimeStreamState) -> None: try: while True: with state.lock: if state.close_requested: return raw_message = state.websocket.recv() # type: ignore[attr-defined] if raw_message is None: continue if isinstance(raw_message, bytes): raw_message = raw_message.decode("utf-8", errors="replace") try: payload = json.loads(str(raw_message)) except json.JSONDecodeError: continue message_type = self._message_type(payload) if message_type == "partial_transcript": text = self._payload_text(payload) if text: state.updates_queue.put( StreamingASRPartial( text=text, language=self._payload_language(payload, state.language), confidence=None, is_final=False, is_stable=False, ) ) continue if message_type in {"committed_transcript", "committed_transcript_with_timestamps"}: text = self._payload_text(payload) language = self._payload_language(payload, state.language) if text: state.updates_queue.put( StreamingASRPartial( text=text, language=language, confidence=None, is_final=True, is_stable=True, ) ) else: with state.lock: state.final_event.set() continue if message_type.startswith("scribe") and "error" in message_type.lower(): raise StreamingASRUnavailable(self._payload_error_message(payload)) except BaseException as exc: with state.lock: if not state.close_requested: state.error = exc state.final_event.set() def _state(self, stream_id: str) -> _ElevenLabsRealtimeStreamState: with self._lock: state = self._streams.get(stream_id) if state is None: raise StreamingASRUnavailable("ElevenLabs realtime ASR stream is not active") return state def open_stream(self, session_id: str, *, language_hint: str | None = None) -> str: stream_id = f"elrt_{uuid.uuid4().hex}" language = _normalize_elevenlabs_realtime_language(language_hint) websocket = self._create_websocket(self._websocket_url(language=language)) state = _ElevenLabsRealtimeStreamState( stream_id=stream_id, session_id=str(session_id or "").strip(), language=language, websocket=websocket, updates_queue=queue.Queue(), ) thread = threading.Thread( target=self._run_reader, args=(state,), name=f"elevenlabs-realtime-asr-{stream_id[:16]}", daemon=True, ) state.thread = thread with self._lock: self._streams[stream_id] = state thread.start() return stream_id def push_pcm(self, stream_id: str, pcm_8k_chunk: bytes) -> None: if not pcm_8k_chunk: return state = self._state(stream_id) pcm_bytes = pcm_8k_chunk if self._sample_rate_hz != 8000: pcm_bytes = resample_pcm16le( pcm_8k_chunk, input_rate_hz=8000, output_rate_hz=self._sample_rate_hz, ) self._send_json( state, { "message_type": "input_audio_chunk", "audio_base_64": base64.b64encode(pcm_bytes).decode("ascii"), "commit": False, "sample_rate": self._sample_rate_hz, }, ) def poll_partial(self, stream_id: str) -> StreamingASRPartial | None: state = self._state(stream_id) self._drain_updates(state) with state.lock: if state.error is not None and state.latest_partial is None: raise StreamingASRUnavailable(str(state.error)[:500]) return state.stable_partial or state.latest_partial def finalize(self, stream_id: str) -> ASRTranscription: state = self._state(stream_id) silence = b"\x00\x00" * int(self._sample_rate_hz * 0.1) try: self._send_json( state, { "message_type": "input_audio_chunk", "audio_base_64": base64.b64encode(silence).decode("ascii"), "commit": True, "sample_rate": self._sample_rate_hz, }, ) except StreamingASRUnavailable: partial_transcription = self._best_partial_transcription(state) if partial_transcription is not None and partial_transcription.text.strip(): return partial_transcription raise deadline = time.monotonic() + self._finalize_timeout_seconds while True: self._drain_updates(state) with state.lock: if state.final_transcription is not None: return state.final_transcription latest_partial = state.stable_partial or state.latest_partial error = state.error event_is_set = state.final_event.is_set() if event_is_set or time.monotonic() >= deadline: if latest_partial is not None: return self._transcription_from_partial(latest_partial, fallback_language=state.language) if error is not None: raise StreamingASRUnavailable(str(error)[:500]) if time.monotonic() >= deadline: raise StreamingASRUnavailable("ElevenLabs realtime ASR finalize timed out") return ASRTranscription(text="", language=state.language, confidence=None) state.final_event.wait(timeout=0.05) def close_stream(self, stream_id: str) -> None: try: state = self._state(stream_id) except StreamingASRUnavailable: return with state.lock: state.close_requested = True state.final_event.set() close = getattr(state.websocket, "close", None) if callable(close): try: close() except BaseException: pass thread = state.thread if thread is not None and thread.is_alive(): thread.join(timeout=0.5) with self._lock: self._streams.pop(stream_id, None) class YandexSpeechKitASRProvider(ASRProvider): name = "yandex" def __init__( self, *, api_base: str | None = None, api_key: str | None = None, iam_token: str | None = None, folder_id: str | None = None, timeout_seconds: float | None = None, sample_rate_hz: int | None = None, topic: str | None = None, ) -> None: self._api_base = str(api_base or _yandex_asr_api_base()).strip().rstrip("/") self._api_key = str(api_key if api_key is not None else _yandex_asr_api_key()).strip() self._iam_token = str(iam_token if iam_token is not None else _yandex_asr_iam_token()).strip() self._folder_id = str(folder_id if folder_id is not None else _yandex_asr_folder_id()).strip() self._operations_base = _yandex_asr_operations_base() self._timeout_seconds = max( float(timeout_seconds if timeout_seconds is not None else _yandex_asr_timeout_seconds()), 3.0, ) self._sample_rate_hz = int(sample_rate_hz if sample_rate_hz is not None else _yandex_asr_sample_rate_hz()) self._topic = str(topic if topic is not None else _yandex_asr_topic()).strip() self._api_version = _yandex_asr_api_version(self._api_base) self._poll_interval_seconds = _yandex_asr_poll_interval_seconds() @staticmethod def _lpcm_from_audio_bytes(audio_bytes: bytes, *, default_sample_rate_hz: int) -> tuple[bytes, int]: if not audio_bytes: return b"", default_sample_rate_hz try: with wave.open(io.BytesIO(audio_bytes), "rb") as wav_file: sample_width = wav_file.getsampwidth() channels = wav_file.getnchannels() sample_rate = wav_file.getframerate() if sample_width != 2 or channels != 1: return audio_bytes, default_sample_rate_hz return wav_file.readframes(wav_file.getnframes()), int(sample_rate or default_sample_rate_hz) except (wave.Error, EOFError): return audio_bytes, default_sample_rate_hz def _headers(self, *, content_type: str = "application/octet-stream") -> dict[str, str]: if not self._api_key and not self._iam_token: raise RuntimeError( "AI_VOICE_ASR_YANDEX_API_KEY or AI_VOICE_ASR_YANDEX_IAM_TOKEN is required for Yandex ASR" ) headers = {"Content-Type": content_type} if self._api_key: headers["Authorization"] = f"Api-Key {self._api_key}" else: headers["Authorization"] = f"Bearer {self._iam_token}" if self._folder_id and self._api_version == "v3": headers["x-folder-id"] = self._folder_id return headers def transcribe(self, audio_bytes: bytes, *, language_hint: str | None = None) -> ASRTranscription: pcm_bytes, sample_rate_hz = self._lpcm_from_audio_bytes( audio_bytes, default_sample_rate_hz=self._sample_rate_hz, ) if not pcm_bytes: return ASRTranscription(text="", language=language_hint, confidence=None) language = _normalize_yandex_asr_language(language_hint) if self._api_version == "v3": return self._transcribe_v3_async(pcm_bytes, sample_rate_hz=sample_rate_hz, language=language) return self._transcribe_v1_sync(pcm_bytes, sample_rate_hz=sample_rate_hz, language=language) def _transcribe_v1_sync(self, pcm_bytes: bytes, *, sample_rate_hz: int, language: str) -> ASRTranscription: params: dict[str, str] = { "lang": language, "format": "lpcm", "sampleRateHertz": str(sample_rate_hz), } if self._topic: params["topic"] = self._topic if self._folder_id: params["folderId"] = self._folder_id with httpx.Client(timeout=self._timeout_seconds) as client: response = client.post( f"{self._api_base}/speech/v1/stt:recognize", headers=self._headers(), params=params, content=pcm_bytes, ) response.raise_for_status() payload = response.json() return ASRTranscription( text=str(payload.get("result") or "").strip(), language=language, confidence=None, ) def _transcribe_v3_async(self, pcm_bytes: bytes, *, sample_rate_hz: int, language: str) -> ASRTranscription: payload = { "content": base64.b64encode(pcm_bytes).decode("ascii"), "recognitionModel": { "model": self._topic or "general", "audioFormat": { "rawAudio": { "audioEncoding": "LINEAR16_PCM", "sampleRateHertz": str(sample_rate_hz), "audioChannelCount": "1", } }, "textNormalization": { "textNormalization": "TEXT_NORMALIZATION_ENABLED", "profanityFilter": False, "literatureText": False, "phoneFormattingMode": "PHONE_FORMATTING_MODE_DISABLED", }, "languageRestriction": { "restrictionType": "WHITELIST", "languageCode": [language], }, "audioProcessingType": "FULL_DATA", }, } headers = self._headers(content_type="application/json") deadline = time.monotonic() + self._timeout_seconds with httpx.Client(timeout=self._timeout_seconds) as client: response = client.post( f"{self._api_base}/stt/v3/recognizeFileAsync", headers=headers, json=payload, ) response.raise_for_status() operation_payload = response.json() operation_id = str(operation_payload.get("id") or "").strip() if not operation_id: raise RuntimeError("Yandex ASR v3 response is missing operation id") while not bool(operation_payload.get("done")): if time.monotonic() >= deadline: raise TimeoutError("Yandex ASR v3 recognition timed out") time.sleep(self._poll_interval_seconds) operation_response = client.get( f"{self._operations_base}/operations/{operation_id}", headers=headers, ) operation_response.raise_for_status() operation_payload = operation_response.json() error_payload = operation_payload.get("error") if isinstance(error_payload, dict) and error_payload: raise RuntimeError(str(error_payload.get("message") or error_payload)[:500]) result_response = client.get( f"{self._api_base}/stt/v3/getRecognition", headers=headers, params={"operationId": operation_id}, ) result_response.raise_for_status() result_payload = result_response.json() return ASRTranscription( text=self._extract_v3_text(result_payload), language=language, confidence=None, ) @classmethod def _extract_v3_text(cls, payload: object) -> str: if isinstance(payload, list): texts = [cls._extract_v3_text(item) for item in payload] return " ".join(text for text in texts if text).strip() if not isinstance(payload, dict): return "" final_refinement = payload.get("finalRefinement") if isinstance(final_refinement, dict): normalized = final_refinement.get("normalizedText") text = cls._extract_alternatives_text(normalized) if text: return text for key in ("final", "partial"): text = cls._extract_alternatives_text(payload.get(key)) if text: return text response = payload.get("response") if isinstance(response, (dict, list)): return cls._extract_v3_text(response) return "" @staticmethod def _extract_alternatives_text(payload: object) -> str: if not isinstance(payload, dict): return "" alternatives = payload.get("alternatives") if not isinstance(alternatives, list): return "" texts: list[str] = [] for alternative in alternatives: if not isinstance(alternative, dict): continue text = str(alternative.get("text") or "").strip() if text: texts.append(text) return " ".join(texts).strip() def transcribe_partial(self, audio_bytes: bytes, *, language_hint: str | None = None) -> ASRTranscription: return self.transcribe(audio_bytes, language_hint=language_hint) class LocalSidecarStreamingASRProvider(StreamingASRProvider): name = "local-sidecar" supports_streaming = True def __init__( self, *, api_base: str | None = None, timeout_seconds: float | None = None, ) -> None: self._api_base = str(api_base or _streaming_asr_api_base()).strip().rstrip("/") self._timeout_seconds = max(float(timeout_seconds or _streaming_asr_timeout_seconds()), 0.25) def _request( self, method: str, path: str, *, payload: dict[str, object] | None = None, ) -> dict[str, object]: if not self._api_base: raise StreamingASRUnavailable("Streaming ASR API base is not configured") try: with httpx.Client(timeout=self._timeout_seconds) as client: response = client.request( method, f"{self._api_base}{path}", json=payload, headers=_service_headers(), ) response.raise_for_status() except httpx.HTTPError as exc: raise StreamingASRUnavailable(str(exc)[:500] or "Streaming ASR sidecar is unavailable") from exc body = response.json() if response.content else {} return body if isinstance(body, dict) else {} def open_stream(self, session_id: str, *, language_hint: str | None = None) -> str: payload = { "session_id": str(session_id or "").strip(), "language_hint": str(language_hint or "").strip() or None, "sample_rate_hz": 8000, "encoding": "pcm_s16le", } body = self._request("POST", "/internal/asr/streams", payload=payload) stream_id = str(body.get("stream_id") or "").strip() if not stream_id: raise StreamingASRUnavailable("Streaming ASR sidecar did not return stream_id") return stream_id def push_pcm(self, stream_id: str, pcm_8k_chunk: bytes) -> None: if not pcm_8k_chunk: return self._request( "POST", f"/internal/asr/streams/{stream_id}/chunks", payload={ "pcm_b64": base64.b64encode(pcm_8k_chunk).decode("ascii"), "sample_rate_hz": 8000, "encoding": "pcm_s16le", }, ) def poll_partial(self, stream_id: str) -> StreamingASRPartial | None: body = self._request("GET", f"/internal/asr/streams/{stream_id}/partial") text = str(body.get("text") or "").strip() if not text: return None language = str(body.get("language") or "").strip() or None confidence_raw = body.get("confidence") confidence = float(confidence_raw) if isinstance(confidence_raw, (int, float)) else None return StreamingASRPartial( text=text, language=language, confidence=confidence, is_final=bool(body.get("is_final")), is_stable=bool(body.get("is_stable")), ) def finalize(self, stream_id: str) -> ASRTranscription: body = self._request("POST", f"/internal/asr/streams/{stream_id}/finalize") return ASRTranscription( text=str(body.get("text") or "").strip(), language=str(body.get("language") or "").strip() or None, confidence=float(body["confidence"]) if isinstance(body.get("confidence"), (int, float)) else None, ) def close_stream(self, stream_id: str) -> None: try: self._request("DELETE", f"/internal/asr/streams/{stream_id}") except StreamingASRUnavailable: return @dataclass class _YandexGrpcStreamState: stream_id: str session_id: str language: str input_queue: queue.Queue[bytes | None] updates_queue: queue.Queue[StreamingASRPartial] thread: threading.Thread | None = None latest_partial: StreamingASRPartial | None = None final_transcription: ASRTranscription | None = None error: BaseException | None = None finish_requested: bool = False lock: threading.Lock = field(default_factory=threading.Lock) class YandexSpeechKitGrpcStreamingASRProvider(StreamingASRProvider): name = "yandex-speechkit-grpc" supports_streaming = True def __init__( self, *, api_base: str | None = None, api_key: str | None = None, iam_token: str | None = None, folder_id: str | None = None, timeout_seconds: float | None = None, sample_rate_hz: int | None = None, topic: str | None = None, grpc_target: str | None = None, queue_max_chunks: int | None = None, eou_max_pause_ms: int | None = None, grpc_module: object | None = None, stt_pb2_module: object | None = None, stt_service_pb2_grpc_module: object | None = None, ) -> None: self._api_base = str(api_base or _yandex_asr_api_base()).strip().rstrip("/") self._api_key = str(api_key if api_key is not None else _yandex_asr_api_key()).strip() self._iam_token = str(iam_token if iam_token is not None else _yandex_asr_iam_token()).strip() self._folder_id = str(folder_id if folder_id is not None else _yandex_asr_folder_id()).strip() self._timeout_seconds = max( float(timeout_seconds if timeout_seconds is not None else _yandex_asr_timeout_seconds()), 3.0, ) self._sample_rate_hz = int(sample_rate_hz if sample_rate_hz is not None else _yandex_asr_sample_rate_hz()) self._topic = str(topic if topic is not None else _yandex_asr_topic()).strip() or "general" self._grpc_target = str(grpc_target or _yandex_asr_grpc_target(self._api_base)).strip() self._queue_max_chunks = int(queue_max_chunks or _yandex_asr_stream_queue_max_chunks()) self._eou_max_pause_ms = int(eou_max_pause_ms or _yandex_asr_eou_max_pause_ms()) self._grpc_module = grpc_module self._stt_pb2_module = stt_pb2_module self._stt_service_pb2_grpc_module = stt_service_pb2_grpc_module self._streams: dict[str, _YandexGrpcStreamState] = {} self._lock = threading.Lock() def _load_grpc_modules(self) -> tuple[object, object, object]: if ( self._grpc_module is not None and self._stt_pb2_module is not None and self._stt_service_pb2_grpc_module is not None ): return self._grpc_module, self._stt_pb2_module, self._stt_service_pb2_grpc_module try: import grpc # type: ignore[import-not-found] from yandex.cloud.ai.stt.v3 import stt_pb2 # type: ignore[import-not-found] from yandex.cloud.ai.stt.v3 import stt_service_pb2_grpc # type: ignore[import-not-found] except ImportError as exc: raise StreamingASRUnavailable("Yandex SpeechKit gRPC dependencies are not installed") from exc return grpc, stt_pb2, stt_service_pb2_grpc def _metadata(self) -> tuple[tuple[str, str], ...]: if not self._api_key and not self._iam_token: raise StreamingASRUnavailable( "AI_VOICE_ASR_YANDEX_API_KEY or AI_VOICE_ASR_YANDEX_IAM_TOKEN is required for Yandex streaming ASR" ) metadata: list[tuple[str, str]] = [] if self._api_key: metadata.append(("authorization", f"Api-Key {self._api_key}")) else: metadata.append(("authorization", f"Bearer {self._iam_token}")) if self._folder_id: metadata.append(("x-folder-id", self._folder_id)) return tuple(metadata) @staticmethod def _enum_value(owner: object, name: str, default: int) -> int: return int(getattr(owner, name, default)) def _build_session_options(self, stt_pb2: object, *, language: str) -> object: raw_audio = stt_pb2.RawAudio( # type: ignore[attr-defined] audio_encoding=self._enum_value(stt_pb2.RawAudio, "LINEAR16_PCM", 1), # type: ignore[attr-defined] sample_rate_hertz=self._sample_rate_hz, audio_channel_count=1, ) recognition_model = stt_pb2.RecognitionModelOptions( # type: ignore[attr-defined] model=self._topic, audio_format=stt_pb2.AudioFormatOptions(raw_audio=raw_audio), # type: ignore[attr-defined] text_normalization=stt_pb2.TextNormalizationOptions( # type: ignore[attr-defined] text_normalization=self._enum_value( stt_pb2.TextNormalizationOptions, # type: ignore[attr-defined] "TEXT_NORMALIZATION_ENABLED", 1, ), profanity_filter=False, literature_text=False, phone_formatting_mode=self._enum_value( stt_pb2.TextNormalizationOptions, # type: ignore[attr-defined] "PHONE_FORMATTING_MODE_DISABLED", 1, ), ), language_restriction=stt_pb2.LanguageRestrictionOptions( # type: ignore[attr-defined] restriction_type=self._enum_value(stt_pb2.LanguageRestrictionOptions, "WHITELIST", 1), # type: ignore[attr-defined] language_code=[language], ), audio_processing_type=self._enum_value(stt_pb2.RecognitionModelOptions, "REAL_TIME", 1), # type: ignore[attr-defined] ) eou_classifier = stt_pb2.EouClassifierOptions( # type: ignore[attr-defined] default_classifier=stt_pb2.DefaultEouClassifier( # type: ignore[attr-defined] type=self._enum_value(stt_pb2.DefaultEouClassifier, "HIGH", 2), # type: ignore[attr-defined] max_pause_between_words_hint_ms=self._eou_max_pause_ms, ) ) return stt_pb2.StreamingOptions( # type: ignore[attr-defined] recognition_model=recognition_model, eou_classifier=eou_classifier, ) def _request_iterator(self, state: _YandexGrpcStreamState, stt_pb2: object): yield stt_pb2.StreamingRequest( # type: ignore[attr-defined] session_options=self._build_session_options(stt_pb2, language=state.language) ) while True: item = state.input_queue.get() try: if item is None: yield stt_pb2.StreamingRequest(eou=stt_pb2.Eou()) # type: ignore[attr-defined] return if item: yield stt_pb2.StreamingRequest(chunk=stt_pb2.AudioChunk(data=item)) # type: ignore[attr-defined] finally: state.input_queue.task_done() @staticmethod def _has_field(message: object, field_name: str) -> bool: try: return bool(message.HasField(field_name)) # type: ignore[attr-defined] except (AttributeError, ValueError): return getattr(message, field_name, None) is not None @classmethod def _partial_from_response(cls, response: object) -> StreamingASRPartial | None: for field_name, is_final in ( ("final_refinement", True), ("final", True), ("partial", False), ): if not cls._has_field(response, field_name): continue payload = getattr(response, field_name, None) if field_name == "final_refinement" and payload is not None: payload = getattr(payload, "normalized_text", None) text, confidence, language = cls._extract_alternatives(payload) if text: return StreamingASRPartial( text=text, language=language, confidence=confidence, is_final=is_final, is_stable=is_final, ) return None @staticmethod def _extract_alternatives(payload: object) -> tuple[str, float | None, str | None]: alternatives = getattr(payload, "alternatives", None) if not alternatives: return "", None, None texts: list[str] = [] confidence: float | None = None language: str | None = None for alternative in alternatives: text = str(getattr(alternative, "text", "") or "").strip() if text: texts.append(text) if confidence is None: raw_confidence = getattr(alternative, "confidence", None) if isinstance(raw_confidence, (int, float)): confidence = float(raw_confidence) if language is None: languages = getattr(alternative, "languages", None) if languages: first_language = languages[0] language_code = str(getattr(first_language, "language_code", "") or "").strip() if language_code: language = language_code return " ".join(texts).strip(), confidence, language def _drain_updates(self, state: _YandexGrpcStreamState) -> None: while True: try: update = state.updates_queue.get_nowait() except queue.Empty: return with state.lock: state.latest_partial = update if update.is_final: state.final_transcription = ASRTranscription( text=update.text, language=update.language or state.language, confidence=update.confidence, ) def _run_stream(self, state: _YandexGrpcStreamState) -> None: channel = None try: grpc, stt_pb2, stt_service_pb2_grpc = self._load_grpc_modules() channel = grpc.secure_channel( # type: ignore[attr-defined] self._grpc_target, grpc.ssl_channel_credentials(), # type: ignore[attr-defined] ) stub = stt_service_pb2_grpc.RecognizerStub(channel) # type: ignore[attr-defined] responses = stub.RecognizeStreaming( self._request_iterator(state, stt_pb2), metadata=self._metadata(), timeout=self._timeout_seconds, ) for response in responses: partial = self._partial_from_response(response) if partial is not None: state.updates_queue.put(partial) except BaseException as exc: with state.lock: state.error = exc finally: if channel is not None: close = getattr(channel, "close", None) if callable(close): close() def _state(self, stream_id: str) -> _YandexGrpcStreamState: with self._lock: state = self._streams.get(stream_id) if state is None: raise StreamingASRUnavailable("Yandex SpeechKit gRPC stream is not active") return state def _signal_finish(self, state: _YandexGrpcStreamState) -> None: with state.lock: if state.finish_requested: return state.finish_requested = True try: state.input_queue.put_nowait(None) except queue.Full: try: state.input_queue.get_nowait() state.input_queue.task_done() except queue.Empty: pass state.input_queue.put_nowait(None) def open_stream(self, session_id: str, *, language_hint: str | None = None) -> str: self._metadata() self._load_grpc_modules() stream_id = f"yasr_grpc_{uuid.uuid4().hex}" language = _normalize_yandex_asr_language(language_hint) state = _YandexGrpcStreamState( stream_id=stream_id, session_id=str(session_id or "").strip(), language=language, input_queue=queue.Queue(maxsize=self._queue_max_chunks), updates_queue=queue.Queue(), ) thread = threading.Thread( target=self._run_stream, args=(state,), name=f"yandex-speechkit-asr-{stream_id[:16]}", daemon=True, ) state.thread = thread with self._lock: self._streams[stream_id] = state thread.start() return stream_id def push_pcm(self, stream_id: str, pcm_8k_chunk: bytes) -> None: if not pcm_8k_chunk: return state = self._state(stream_id) with state.lock: if state.error is not None: raise StreamingASRUnavailable(str(state.error)[:500]) if state.finish_requested: raise StreamingASRUnavailable("Yandex SpeechKit gRPC stream is already finalizing") try: state.input_queue.put(pcm_8k_chunk, timeout=0.1) except queue.Full as exc: raise StreamingASRUnavailable("Yandex SpeechKit gRPC input queue is full") from exc def poll_partial(self, stream_id: str) -> StreamingASRPartial | None: state = self._state(stream_id) self._drain_updates(state) with state.lock: if state.error is not None and state.latest_partial is None: raise StreamingASRUnavailable(str(state.error)[:500]) return state.latest_partial def finalize(self, stream_id: str) -> ASRTranscription: state = self._state(stream_id) self._signal_finish(state) deadline = time.monotonic() + self._timeout_seconds while True: self._drain_updates(state) thread = state.thread if thread is None or not thread.is_alive(): break if time.monotonic() >= deadline: raise StreamingASRUnavailable("Yandex SpeechKit gRPC stream finalize timed out") thread.join(timeout=0.05) self._drain_updates(state) with state.lock: if state.final_transcription is not None: return state.final_transcription if state.latest_partial is not None: return ASRTranscription( text=state.latest_partial.text, language=state.latest_partial.language or state.language, confidence=state.latest_partial.confidence, ) if state.error is not None: raise StreamingASRUnavailable(str(state.error)[:500]) return ASRTranscription(text="", language=state.language, confidence=None) def close_stream(self, stream_id: str) -> None: try: state = self._state(stream_id) except StreamingASRUnavailable: return self._signal_finish(state) thread = state.thread if thread is not None and thread.is_alive(): thread.join(timeout=0.5) with self._lock: self._streams.pop(stream_id, None) class YandexSpeechKitBufferedStreamingASRProvider(StreamingASRProvider): name = "yandex-speechkit-buffered" supports_streaming = True def __init__(self, *, asr_provider: YandexSpeechKitASRProvider | None = None) -> None: self._asr_provider = asr_provider or YandexSpeechKitASRProvider() self._streams: dict[str, dict[str, object]] = {} self._lock = threading.Lock() def open_stream(self, session_id: str, *, language_hint: str | None = None) -> str: stream_id = f"yasr_{uuid.uuid4().hex}" with self._lock: self._streams[stream_id] = { "session_id": str(session_id or "").strip(), "language_hint": str(language_hint or "").strip() or None, "pcm": bytearray(), } return stream_id def push_pcm(self, stream_id: str, pcm_8k_chunk: bytes) -> None: if not pcm_8k_chunk: return with self._lock: stream = self._streams.get(stream_id) if stream is None: raise StreamingASRUnavailable("Yandex ASR stream is not active") pcm = stream.get("pcm") if isinstance(pcm, bytearray): pcm.extend(pcm_8k_chunk) def poll_partial(self, stream_id: str) -> StreamingASRPartial | None: del stream_id return None def finalize(self, stream_id: str) -> ASRTranscription: with self._lock: stream = self._streams.get(stream_id) if stream is None: raise StreamingASRUnavailable("Yandex ASR stream is not active") pcm = bytes(stream.get("pcm") or b"") language_hint = str(stream.get("language_hint") or "").strip() or None return self._asr_provider.transcribe(pcm, language_hint=language_hint) def close_stream(self, stream_id: str) -> None: with self._lock: self._streams.pop(stream_id, None) def build_asr_provider(name: str) -> ASRProvider: normalized = str(name or "stub").strip().lower() if normalized == "elevenlabs": return ElevenLabsASRProvider() if normalized == "openai": return OpenAIASRProvider() if normalized in {"yandex", "yandex_speechkit", "speechkit"}: return YandexSpeechKitASRProvider() return ASRProvider() def build_streaming_asr_provider(name: str) -> StreamingASRProvider: normalized = str(name or "disabled").strip().lower() if normalized in { "elevenlabs", "elevenlabs_realtime", "elevenlabs-realtime", "scribe_realtime", "scribe-v2-realtime", "scribe_v2_realtime", }: return ElevenLabsRealtimeStreamingASRProvider() if normalized in {"local_sidecar", "local-sidecar", "sidecar"}: return LocalSidecarStreamingASRProvider() if normalized in {"yandex", "yandex_speechkit", "yandex-speechkit", "speechkit", "yandex_grpc"}: return YandexSpeechKitGrpcStreamingASRProvider() if normalized in {"yandex_buffered", "yandex_speechkit_buffered", "yandex-speechkit-buffered"}: return YandexSpeechKitBufferedStreamingASRProvider() return StreamingASRProvider()