Files
call-center/services/ai_voice_runtime_service/providers/asr.py
T

1602 lines
62 KiB
Python

from __future__ import annotations
import audioop
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.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()