from __future__ import annotations import asyncio import base64 import json import os import weakref from dataclasses import dataclass, replace from typing import Any, Literal, TypedDict, overload import aiohttp from typing_extensions import Required from livekit import rtc from .. import stt, utils, vad from .._exceptions import ( APIConnectionError, APIStatusError, APITimeoutError, create_api_error_from_http, ) from ..language import LanguageCode from ..log import logger from ..types import ( DEFAULT_API_CONNECT_OPTIONS, NOT_GIVEN, APIConnectOptions, NotGivenOr, TimedString, ) from ..utils import is_given from ._utils import create_access_token, get_default_inference_url, get_inference_headers DeepgramModels = Literal[ "deepgram/nova-3", "deepgram/nova-3-medical", "deepgram/nova-2", "deepgram/nova-2-medical", "deepgram/nova-2-conversationalai", "deepgram/nova-2-phonecall", ] DeepgramFluxModels = Literal[ "deepgram/flux-general", "deepgram/flux-general-en", "deepgram/flux-general-multi", ] CartesiaModels = Literal[ "cartesia/ink-whisper", "cartesia/ink-2", ] AssemblyAIModels = Literal[ "assemblyai/universal-streaming", "assemblyai/universal-streaming-multilingual", "assemblyai/u3-rt-pro", "assemblyai/universal-3-5-pro", ] ElevenlabsModels = Literal["elevenlabs/scribe_v2_realtime",] XaiModels = Literal["xai/stt-1",] SpeechmaticsModels = Literal[ "speechmatics/enhanced", "speechmatics/standard", ] InworldModels = Literal["inworld/inworld-stt-1",] class CartesiaOptions(TypedDict, total=False): min_volume: float # default: not specified max_silence_duration_secs: float # default: not specified class DeepgramOptions(TypedDict, total=False): filler_words: bool # default: True interim_results: bool # default: True endpointing: int # default: 25 (ms) punctuate: bool # default: True smart_format: bool keywords: list[tuple[str, float]] keyterm: str | list[str] profanity_filter: bool numerals: bool mip_opt_out: bool # default: False vad_events: bool # default: False diarize: bool # when True, enables speaker diarization (default off) dictation: bool detect_language: bool no_delay: bool # default: True utterance_end: bool redact: str | list[str] replace: str | list[str] search: str | list[str] tag: str | list[str] channels: int version: str callback: str callback_method: str extra: str class DeepgramFluxOptions(TypedDict, total=False): eager_eot_threshold: float # range 0.3-0.9, default: 0.5 eot_threshold: float # range 0.5-0.9 eot_timeout_ms: int keyterm: str | list[str] mip_opt_out: bool # default: False tag: str | list[str] detect_language: bool class AssemblyaiOptions(TypedDict, total=False): format_turns: bool # default: False end_of_turn_confidence_threshold: float # default: 0.01 min_end_of_turn_silence_when_confident: int # default: 0 max_turn_silence: int # default: not specified keyterms_prompt: list[str] # default: not specified language_detection: bool inactivity_timeout: float # seconds prompt: str # default: not specified (u3-rt-pro only, mutually exclusive with keyterms_prompt) speaker_labels: bool # when True, enables speaker diarization (default off) agent_context: str # context to bias recognition (u3-rt-pro only, max 1500 chars) voice_focus: Literal["near-field", "far-field"] # isolate primary voice (u3-rt-pro only) voice_focus_threshold: float # background suppression strength (u3-rt-pro only) mode: Literal["min_latency", "balanced", "max_accuracy"] # accuracy/latency preset (u3-rt-pro) class ElevenlabsOptions(TypedDict, total=False): commit_strategy: Literal["manual", "vad"] include_timestamps: bool vad_silence_threshold_secs: float vad_threshold: float min_speech_duration_ms: int min_silence_duration_ms: int language_code: str class SpeechmaticsOptions(TypedDict, total=False): domain: str # e.g. "finance" output_locale: str # BCP-47 locale for output formatting max_delay: float # 0.7-4.0 seconds, default 1.0 max_delay_mode: str # "flexible" | "fixed" diarization: str # "none" | "speaker" | "channel" | "channel_and_speaker_change" | "speaker_change"; non-"none" enables diarization speaker_sensitivity: float # 0.0-1.0 max_speakers: int prefer_current_speaker: bool enable_partials: bool # default True (overridden by gateway) enable_entities: bool punctuation_overrides: dict[str, Any] additional_vocab: list[dict[str, Any]] end_of_utterance_silence_trigger: float # seconds of silence before final audio_filtering_config: dict[str, Any] transcript_filtering_config: dict[str, Any] class XaiOptions(TypedDict, total=False): diarize: bool # when True, enables speaker diarization (default off) endpointing: int # silence duration in ms before utterance-final (0-5000) format: bool # enables Inverse Text Normalization (e.g. "one hundred dollars" -> "$100"); requires language interim_results: bool # default True; set False to opt out of interim transcripts class InworldOptions(TypedDict, total=False): enable_voice_profile: bool # default: True voice_profile_top_n: int # range 1-20, default 10 include_word_timestamps: bool # default: True audio_encoding: Literal["LINEAR16", "AUTO_DETECT"] # default: LINEAR16 inactivity_timeout_seconds: int # >= 0; 0 disables end_of_turn_confidence_threshold: float # range 0.0-1.0, default 0.5 min_end_of_turn_silence_when_confident: int # >= 0 (ms) prompts: list[str] vad_threshold: float # range 0.0-1.0, default 0.5 # Diarization is requested via different extra_kwargs keys across # providers. Keep this list in one place so adding a new provider is a # single-line change and there's no divergence between __init__ and # update_options capability inference. _DIARIZATION_EXTRA_KEYS: tuple[str, ...] = ( "diarize", # Deepgram, xAI "speaker_labels", # AssemblyAI "diarization", # Speechmatics ) def _diarization_enabled(extra_kwargs: dict[str, Any] | None) -> bool: """Return True if any known provider diarization flag is truthy.""" if not extra_kwargs: return False for key in _DIARIZATION_EXTRA_KEYS: value = extra_kwargs.get(key) if not value: continue # Speechmatics' "diarization" accepts the string "none" to mean off. if isinstance(value, str) and value.lower() == "none": continue return True return False def _keyterms_extra_for_model( model: NotGivenOr[str], *, extra_kwargs: dict[str, Any] | None = None, session_keyterms: list[str] | None = None, ) -> dict[str, Any] | None: """Return the provider's keyterm ``extra`` entry: user keyterms (from ``extra_kwargs``) merged with the framework ``session_keyterms``. None if the model has no keyterm prompting, so ``_keyterms_extra_for_model(model) is not None`` is also the capability check. """ if not (is_given(model) and isinstance(model, str)): return None extra_kwargs = extra_kwargs or {} session_keyterms = session_keyterms or [] if model.startswith("speechmatics/"): # keep existing entries as-is (they may carry sounds_like etc.); append new session terms existing = list(extra_kwargs.get("additional_vocab", [])) seen = {v["content"] for v in existing} additions = set(session_keyterms) - seen return {"additional_vocab": existing + [{"content": term} for term in additions]} key: str | None = None if model.startswith("deepgram/"): key = "keyterm" elif model.startswith("assemblyai/"): key = "keyterms_prompt" if key is None: return None # deepgram's keyterm may be a bare string; wrap it so it isn't splat char-by-char existing = extra_kwargs.get(key, []) if isinstance(existing, str): existing = [existing] return {key: list(dict.fromkeys([*existing, *session_keyterms]))} STTLanguages = Literal["multi", "en", "de", "es", "fr", "ja", "pt", "zh", "hi"] class FallbackModel(TypedDict, total=False): """Inference Fallback Adapter: configuration for a fallback STT model that runs server-side in LiveKit Inference, providing automatic fallback between providers. Extra fields are passed through to the provider. Example: >>> FallbackModel(model="deepgram/nova-3", extra_kwargs={"keyterm": ["livekit"]}) """ model: Required[str] """Model name (e.g. "deepgram/nova-3", "assemblyai/universal-streaming", "cartesia/ink-whisper").""" extra_kwargs: dict[str, Any] """Extra configuration for the model.""" FallbackModelType = FallbackModel | str def _parse_model_string(model: str) -> tuple[str, NotGivenOr[LanguageCode]]: language: NotGivenOr[LanguageCode] = NOT_GIVEN if (idx := model.rfind(":")) != -1: language = LanguageCode(model[idx + 1 :]) model = model[:idx] return model, language def _resolve_vad_for_model( model: NotGivenOr[STTModels | str], vad_instance: vad.VAD | None, ) -> vad.VAD | None: is_speechmatics = ( is_given(model) and isinstance(model, str) and model.startswith("speechmatics/") ) if vad_instance is not None and not is_speechmatics: logger.warning( "`vad` will be ignored: model %r handles endpointing server-side.", model, ) return None if is_speechmatics and vad_instance is None: from .vad import VAD vad_instance = VAD() return vad_instance def _normalize_fallback( fallback: list[FallbackModelType] | FallbackModelType, ) -> list[FallbackModel]: def _make_fallback(model: FallbackModelType) -> FallbackModel: if isinstance(model, str): name, _ = _parse_model_string(model) return FallbackModel(model=name) return model if isinstance(fallback, list): return [_make_fallback(m) for m in fallback] return [_make_fallback(fallback)] STTModels = ( DeepgramModels | DeepgramFluxModels | CartesiaModels | AssemblyAIModels | ElevenlabsModels | XaiModels | SpeechmaticsModels | InworldModels | Literal["auto"] # automatically select a provider based on the language ) STTEncoding = Literal["pcm_s16le"] DEFAULT_ENCODING: STTEncoding = "pcm_s16le" DEFAULT_SAMPLE_RATE: int = 16000 @dataclass class STTOptions: model: NotGivenOr[STTModels | str] language: NotGivenOr[LanguageCode] encoding: STTEncoding sample_rate: int base_url: str api_key: str api_secret: str extra_kwargs: dict[str, Any] fallback: NotGivenOr[list[FallbackModel]] conn_options: NotGivenOr[APIConnectOptions] class STT(stt.STT): @overload def __init__( self, model: CartesiaModels, *, language: NotGivenOr[str] = NOT_GIVEN, base_url: NotGivenOr[str] = NOT_GIVEN, encoding: NotGivenOr[STTEncoding] = NOT_GIVEN, sample_rate: NotGivenOr[int] = NOT_GIVEN, api_key: NotGivenOr[str] = NOT_GIVEN, api_secret: NotGivenOr[str] = NOT_GIVEN, http_session: aiohttp.ClientSession | None = None, extra_kwargs: NotGivenOr[CartesiaOptions] = NOT_GIVEN, fallback: NotGivenOr[list[FallbackModelType] | FallbackModelType] = NOT_GIVEN, conn_options: NotGivenOr[APIConnectOptions] = NOT_GIVEN, ) -> None: ... @overload def __init__( self, model: DeepgramModels, *, language: NotGivenOr[str] = NOT_GIVEN, base_url: NotGivenOr[str] = NOT_GIVEN, encoding: NotGivenOr[STTEncoding] = NOT_GIVEN, sample_rate: NotGivenOr[int] = NOT_GIVEN, api_key: NotGivenOr[str] = NOT_GIVEN, api_secret: NotGivenOr[str] = NOT_GIVEN, http_session: aiohttp.ClientSession | None = None, extra_kwargs: NotGivenOr[DeepgramOptions] = NOT_GIVEN, fallback: NotGivenOr[list[FallbackModelType] | FallbackModelType] = NOT_GIVEN, conn_options: NotGivenOr[APIConnectOptions] = NOT_GIVEN, ) -> None: ... @overload def __init__( self, model: DeepgramFluxModels, *, language: NotGivenOr[str] = NOT_GIVEN, base_url: NotGivenOr[str] = NOT_GIVEN, encoding: NotGivenOr[STTEncoding] = NOT_GIVEN, sample_rate: NotGivenOr[int] = NOT_GIVEN, api_key: NotGivenOr[str] = NOT_GIVEN, api_secret: NotGivenOr[str] = NOT_GIVEN, http_session: aiohttp.ClientSession | None = None, extra_kwargs: NotGivenOr[DeepgramFluxOptions] = NOT_GIVEN, fallback: NotGivenOr[list[FallbackModelType] | FallbackModelType] = NOT_GIVEN, conn_options: NotGivenOr[APIConnectOptions] = NOT_GIVEN, ) -> None: ... @overload def __init__( self, model: AssemblyAIModels, *, language: NotGivenOr[str] = NOT_GIVEN, base_url: NotGivenOr[str] = NOT_GIVEN, encoding: NotGivenOr[STTEncoding] = NOT_GIVEN, sample_rate: NotGivenOr[int] = NOT_GIVEN, api_key: NotGivenOr[str] = NOT_GIVEN, api_secret: NotGivenOr[str] = NOT_GIVEN, http_session: aiohttp.ClientSession | None = None, extra_kwargs: NotGivenOr[AssemblyaiOptions] = NOT_GIVEN, fallback: NotGivenOr[list[FallbackModelType] | FallbackModelType] = NOT_GIVEN, conn_options: NotGivenOr[APIConnectOptions] = NOT_GIVEN, ) -> None: ... @overload def __init__( self, model: ElevenlabsModels, *, language: NotGivenOr[str] = NOT_GIVEN, base_url: NotGivenOr[str] = NOT_GIVEN, encoding: NotGivenOr[STTEncoding] = NOT_GIVEN, sample_rate: NotGivenOr[int] = NOT_GIVEN, api_key: NotGivenOr[str] = NOT_GIVEN, api_secret: NotGivenOr[str] = NOT_GIVEN, http_session: aiohttp.ClientSession | None = None, extra_kwargs: NotGivenOr[ElevenlabsOptions] = NOT_GIVEN, fallback: NotGivenOr[list[FallbackModelType] | FallbackModelType] = NOT_GIVEN, conn_options: NotGivenOr[APIConnectOptions] = NOT_GIVEN, ) -> None: ... @overload def __init__( self, model: XaiModels, *, language: NotGivenOr[str] = NOT_GIVEN, base_url: NotGivenOr[str] = NOT_GIVEN, encoding: NotGivenOr[STTEncoding] = NOT_GIVEN, sample_rate: NotGivenOr[int] = NOT_GIVEN, api_key: NotGivenOr[str] = NOT_GIVEN, api_secret: NotGivenOr[str] = NOT_GIVEN, http_session: aiohttp.ClientSession | None = None, extra_kwargs: NotGivenOr[XaiOptions] = NOT_GIVEN, fallback: NotGivenOr[list[FallbackModelType] | FallbackModelType] = NOT_GIVEN, conn_options: NotGivenOr[APIConnectOptions] = NOT_GIVEN, ) -> None: ... @overload def __init__( self, model: SpeechmaticsModels, *, language: NotGivenOr[str] = NOT_GIVEN, base_url: NotGivenOr[str] = NOT_GIVEN, encoding: NotGivenOr[STTEncoding] = NOT_GIVEN, sample_rate: NotGivenOr[int] = NOT_GIVEN, api_key: NotGivenOr[str] = NOT_GIVEN, api_secret: NotGivenOr[str] = NOT_GIVEN, http_session: aiohttp.ClientSession | None = None, extra_kwargs: NotGivenOr[SpeechmaticsOptions] = NOT_GIVEN, fallback: NotGivenOr[list[FallbackModelType] | FallbackModelType] = NOT_GIVEN, conn_options: NotGivenOr[APIConnectOptions] = NOT_GIVEN, vad: NotGivenOr[vad.VAD | None] = NOT_GIVEN, ) -> None: ... @overload def __init__( self, model: InworldModels, *, language: NotGivenOr[str] = NOT_GIVEN, base_url: NotGivenOr[str] = NOT_GIVEN, encoding: NotGivenOr[STTEncoding] = NOT_GIVEN, sample_rate: NotGivenOr[int] = NOT_GIVEN, api_key: NotGivenOr[str] = NOT_GIVEN, api_secret: NotGivenOr[str] = NOT_GIVEN, http_session: aiohttp.ClientSession | None = None, extra_kwargs: NotGivenOr[InworldOptions] = NOT_GIVEN, fallback: NotGivenOr[list[FallbackModelType] | FallbackModelType] = NOT_GIVEN, conn_options: NotGivenOr[APIConnectOptions] = NOT_GIVEN, ) -> None: ... @overload def __init__( self, model: str, *, language: NotGivenOr[str] = NOT_GIVEN, base_url: NotGivenOr[str] = NOT_GIVEN, encoding: NotGivenOr[STTEncoding] = NOT_GIVEN, sample_rate: NotGivenOr[int] = NOT_GIVEN, api_key: NotGivenOr[str] = NOT_GIVEN, api_secret: NotGivenOr[str] = NOT_GIVEN, http_session: aiohttp.ClientSession | None = None, extra_kwargs: NotGivenOr[dict[str, Any]] = NOT_GIVEN, fallback: NotGivenOr[list[FallbackModelType] | FallbackModelType] = NOT_GIVEN, conn_options: NotGivenOr[APIConnectOptions] = NOT_GIVEN, ) -> None: ... def __init__( self, model: NotGivenOr[STTModels | str] = NOT_GIVEN, *, language: NotGivenOr[str] = NOT_GIVEN, base_url: NotGivenOr[str] = NOT_GIVEN, encoding: NotGivenOr[STTEncoding] = NOT_GIVEN, sample_rate: NotGivenOr[int] = NOT_GIVEN, api_key: NotGivenOr[str] = NOT_GIVEN, api_secret: NotGivenOr[str] = NOT_GIVEN, http_session: aiohttp.ClientSession | None = None, extra_kwargs: NotGivenOr[ dict[str, Any] | CartesiaOptions | DeepgramOptions | DeepgramFluxOptions | AssemblyaiOptions | ElevenlabsOptions | XaiOptions | SpeechmaticsOptions | InworldOptions ] = NOT_GIVEN, fallback: NotGivenOr[list[FallbackModelType] | FallbackModelType] = NOT_GIVEN, conn_options: NotGivenOr[APIConnectOptions] = NOT_GIVEN, vad: NotGivenOr[vad.VAD | None] = NOT_GIVEN, ) -> None: """Livekit Cloud Inference STT Args: model (STTModels | str, optional): STT model to use, in "provider/model[:language]" format. language (str, optional): Language of the STT model. encoding (STTEncoding, optional): Encoding of the STT model. sample_rate (int, optional): Sample rate of the STT model. base_url (str, optional): LIVEKIT_URL, if not provided, read from environment variable. api_key (str, optional): LIVEKIT_API_KEY, if not provided, read from environment variable. api_secret (str, optional): LIVEKIT_API_SECRET, if not provided, read from environment variable. http_session (aiohttp.ClientSession, optional): HTTP session to use. extra_kwargs (dict, optional): Extra kwargs to pass to the STT model. fallback (FallbackModelType, optional): Fallback models - either a list of model names, a list of FallbackModel instances. conn_options (APIConnectOptions, optional): Connection options for request attempts. vad (VAD, optional): External Voice Activity Detector. When provided, each audio frame is forwarded to the VAD and `session.finalize` is sent to the inference gateway on end of speech. Only applicable to Speechmatics models. """ # Infer diarization capability from provider-specific extra_kwargs # keys (see _DIARIZATION_EXTRA_KEYS). xAI uses "diarize" (same as # Deepgram); AssemblyAI uses "speaker_labels". diarization_enabled = _diarization_enabled( dict(extra_kwargs) if is_given(extra_kwargs) else None ) # Parse language from model string if provided: "provider/model:language" if is_given(model) and isinstance(model, str): parsed_model, parsed_language = _parse_model_string(model) model = parsed_model if is_given(parsed_language) and not is_given(language): language = parsed_language vad = _resolve_vad_for_model(model, vad if is_given(vad) else None) super().__init__( capabilities=stt.STTCapabilities( streaming=True, interim_results=True, diarization=diarization_enabled, aligned_transcript="word", offline_recognize=False, keyterms=_keyterms_extra_for_model(model) is not None, ), ) lk_base_url = base_url if is_given(base_url) else get_default_inference_url() lk_api_key = ( api_key if is_given(api_key) else os.getenv("LIVEKIT_INFERENCE_API_KEY", os.getenv("LIVEKIT_API_KEY", "")) ) if not lk_api_key: raise ValueError( "api_key is required, either as argument or set LIVEKIT_API_KEY environmental variable" ) lk_api_secret = ( api_secret if is_given(api_secret) else os.getenv("LIVEKIT_INFERENCE_API_SECRET", os.getenv("LIVEKIT_API_SECRET", "")) ) if not lk_api_secret: raise ValueError( "api_secret is required, either as argument or set LIVEKIT_API_SECRET environmental variable" ) fallback_models: NotGivenOr[list[FallbackModel]] = NOT_GIVEN if is_given(fallback): fallback_models = _normalize_fallback(fallback) self._opts = STTOptions( model=model, language=LanguageCode(language) if isinstance(language, str) else language, encoding=encoding if is_given(encoding) else DEFAULT_ENCODING, sample_rate=sample_rate if is_given(sample_rate) else DEFAULT_SAMPLE_RATE, base_url=lk_base_url, api_key=lk_api_key, api_secret=lk_api_secret, extra_kwargs=dict(extra_kwargs) if is_given(extra_kwargs) else {}, fallback=fallback_models, conn_options=conn_options if is_given(conn_options) else DEFAULT_API_CONNECT_OPTIONS, ) self._session = http_session self._vad = vad self._session_keyterms: list[str] = [] # framework-managed; merged into extra_kwargs self._streams = weakref.WeakSet[SpeechStream]() @classmethod def from_model_string(cls, model: str) -> STT: """Create a STT instance from a model string Args: model (str): STT model to use, in "provider/model[:language]" format Returns: STT: STT instance """ model_name, language = _parse_model_string(model) return cls(model=model_name, language=language) @property def model(self) -> str: return self._opts.model if is_given(self._opts.model) else "unknown" @property def provider(self) -> str: return "livekit" def _ensure_session(self) -> aiohttp.ClientSession: if not self._session: self._session = utils.http_context.http_session() return self._session async def _recognize_impl( self, buffer: utils.AudioBuffer, *, language: NotGivenOr[str] = NOT_GIVEN, conn_options: APIConnectOptions, ) -> stt.SpeechEvent: raise NotImplementedError( "LiveKit Inference STT does not support batch recognition, use stream() instead" ) def stream( self, *, language: NotGivenOr[STTLanguages | str] = NOT_GIVEN, conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS, ) -> SpeechStream: """Create a streaming transcription session.""" options = self._sanitize_options(language=language) stream = SpeechStream( stt=self, opts=options, conn_options=conn_options, vad_instance=self._vad, ) self._streams.add(stream) return stream def update_options( self, *, model: NotGivenOr[STTModels | str] = NOT_GIVEN, language: NotGivenOr[STTLanguages | str] = NOT_GIVEN, extra: NotGivenOr[dict[str, Any]] = NOT_GIVEN, ) -> None: """Update STT configuration options.""" if is_given(model): # Mirror __init__: strip ":language" suffix and apply if not overridden. if isinstance(model, str): parsed_model, parsed_language = _parse_model_string(model) model = parsed_model if is_given(parsed_language) and not is_given(language): language = parsed_language self._opts.model = model self._vad = _resolve_vad_for_model(model, self._vad) self._capabilities = replace( self._capabilities, keyterms=_keyterms_extra_for_model(self._opts.model) is not None, ) if is_given(language): self._opts.language = LanguageCode(language) if is_given(extra): self._opts.extra_kwargs.update(extra) self._capabilities = replace( self._capabilities, diarization=_diarization_enabled(self._opts.extra_kwargs), ) # re-merge the active session keyterms so a user extra update doesn't drop them keyterm_extra = _keyterms_extra_for_model( self._opts.model, extra_kwargs=self._opts.extra_kwargs, session_keyterms=self._session_keyterms, ) if keyterm_extra is not None: extra = {**extra, **keyterm_extra} for stream in self._streams: stream.update_options(model=model, language=language, extra=extra) def _update_session_keyterms(self, keyterms: list[str]) -> None: if keyterms == self._session_keyterms: return keyterm_extra = _keyterms_extra_for_model( self._opts.model, extra_kwargs=self._opts.extra_kwargs, session_keyterms=keyterms ) if keyterm_extra is None: super()._update_session_keyterms(keyterms) # warn-and-skip for unsupported models return self._session_keyterms = list(keyterms) # inference applies extra live via session.update; defer to END_OF_SPEECH since the # gateway may reconnect upstream when the keyterms change for stream in self._streams: if stream._speaking: stream._pending_extra = keyterm_extra else: stream.update_options(extra=keyterm_extra) def _sanitize_options( self, *, language: NotGivenOr[STTLanguages | str] = NOT_GIVEN ) -> STTOptions: """Create a sanitized copy of options with language override if provided.""" options = replace(self._opts) options.extra_kwargs = dict(options.extra_kwargs) if is_given(language): options.language = LanguageCode(language) return options class SpeechStream(stt.SpeechStream): def __init__( self, *, stt: STT, opts: STTOptions, conn_options: APIConnectOptions, vad_instance: vad.VAD | None = None, ) -> None: super().__init__(stt=stt, conn_options=conn_options, sample_rate=opts.sample_rate) self._stt: STT = stt self._opts = opts self._request_id = str(utils.shortuuid("stt_request_")) self._speaking = False # keyterm extra set while the user is speaking; applied at END_OF_SPEECH (latest wins). # inference applies live, but the gateway may reconnect upstream, so defer to a calm moment. self._pending_extra: dict[str, Any] | None = None self._speech_duration: float = 0 self._ws: aiohttp.ClientWebSocketResponse | None = None self._vad: vad.VAD | None = vad_instance def update_options( self, *, model: NotGivenOr[STTModels | str] = NOT_GIVEN, language: NotGivenOr[STTLanguages | str] = NOT_GIVEN, extra: NotGivenOr[dict[str, Any]] = NOT_GIVEN, ) -> None: """Update streaming transcription options. When the WebSocket is live, a mid-stream session.update is sent so providers that support it (e.g. AssemblyAI, Deepgram Flux) can apply changes without reconnecting. Unsupported providers ignore the message. """ if is_given(model): self._opts.model = model if is_given(language): self._opts.language = LanguageCode(language) if is_given(extra): self._opts.extra_kwargs.update(extra) self._pending_extra = None has_update = is_given(model) or is_given(language) or is_given(extra) if has_update and self._ws is not None and not self._ws.closed: settings: dict[str, Any] = {} if is_given(model): settings["model"] = model if is_given(language): settings["language"] = str(LanguageCode(language)) if is_given(extra): settings["extra"] = extra update_msg = { "type": "session.update", "settings": settings, } asyncio.ensure_future(self._send_session_update(update_msg)) def _on_end_of_speech(self) -> None: if self._pending_extra is not None: self.update_options(extra=self._pending_extra) self._pending_extra = None async def _send_session_update(self, msg: dict[str, Any]) -> None: try: if self._ws is not None and not self._ws.closed: await self._ws.send_str(json.dumps(msg)) except Exception: logger.debug("failed to send session.update, ws may be closing") async def _run(self) -> None: """Main loop for streaming transcription.""" closing_ws = False http_session = self._stt._ensure_session() vad_stream: vad.VADStream | None = self._vad.stream() if self._vad is not None else None @utils.log_exceptions(logger=logger) async def send_task(ws: aiohttp.ClientWebSocketResponse) -> None: nonlocal closing_ws audio_bstream = utils.audio.AudioByteStream( sample_rate=self._opts.sample_rate, num_channels=1, samples_per_channel=self._opts.sample_rate // 20, # 50ms ) async for ev in self._input_ch: frames: list[rtc.AudioFrame] = [] if isinstance(ev, rtc.AudioFrame): if vad_stream is not None: vad_stream.push_frame(ev) frames.extend(audio_bstream.push(ev.data)) elif isinstance(ev, self._FlushSentinel): frames.extend(audio_bstream.flush()) for frame in frames: self._speech_duration += frame.duration audio_bytes = frame.data.tobytes() base64_audio = base64.b64encode(audio_bytes).decode("utf-8") audio_msg = { "type": "input_audio", "audio": base64_audio, } await ws.send_str(json.dumps(audio_msg)) if vad_stream is not None: vad_stream.end_input() closing_ws = True finalize_msg = { "type": "session.finalize", } await ws.send_str(json.dumps(finalize_msg)) @utils.log_exceptions(logger=logger) async def vad_task(ws: aiohttp.ClientWebSocketResponse, stream: vad.VADStream) -> None: async for ev in stream: if ev.type != vad.VADEventType.END_OF_SPEECH: continue if ws.closed: return try: await ws.send_str(json.dumps({"type": "session.finalize"})) except Exception: logger.debug("failed to send session.finalize from VAD, ws may be closing") return @utils.log_exceptions(logger=logger) async def recv_task(ws: aiohttp.ClientWebSocketResponse) -> None: nonlocal closing_ws while True: msg = await ws.receive() if msg.type in ( aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSING, ): if closing_ws or http_session.closed: return raise APIStatusError( message="LiveKit Inference STT connection closed unexpectedly" ) if msg.type != aiohttp.WSMsgType.TEXT: logger.warning("unexpected LiveKit Inference STT message type %s", msg.type) continue data = json.loads(msg.data) msg_type = data.get("type") if msg_type == "session.created": pass elif msg_type == "interim_transcript": self._process_transcript(data, is_final=False) elif msg_type == "preflight_transcript": self._process_preflight_transcript(data) elif msg_type == "final_transcript": self._process_transcript(data, is_final=True) elif msg_type == "session.finalized": pass elif msg_type == "session.closed": pass elif msg_type == "error": raise APIStatusError( f"LiveKit Inference STT returned error: {data.get('message')}", status_code=data.get("code", -1), body=data, ) ws: aiohttp.ClientWebSocketResponse | None = None try: ws = await self._connect_ws(http_session) self._ws = ws tasks = [ asyncio.create_task(send_task(ws)), asyncio.create_task(recv_task(ws)), ] if vad_stream is not None: tasks.append(asyncio.create_task(vad_task(ws, vad_stream))) try: await asyncio.gather(*tasks) finally: await utils.aio.gracefully_cancel(*tasks) finally: self._ws = None if ws is not None: await ws.close() if vad_stream is not None: await vad_stream.aclose() async def _connect_ws( self, http_session: aiohttp.ClientSession ) -> aiohttp.ClientWebSocketResponse: """Connect to the LiveKit Inference STT WebSocket.""" params: dict[str, Any] = { "settings": { "sample_rate": str(self._opts.sample_rate), "encoding": self._opts.encoding, # merge the framework session keyterms into the user's extra_kwargs keyterm key "extra": { **self._opts.extra_kwargs, **( _keyterms_extra_for_model( self._opts.model, extra_kwargs=self._opts.extra_kwargs, session_keyterms=self._stt._session_keyterms, ) or {} ), }, }, } if self._opts.model and self._opts.model != "auto": params["model"] = self._opts.model if self._opts.language: params["settings"]["language"] = self._opts.language if self._opts.fallback: models = [ {"model": m.get("model"), "extra": m.get("extra_kwargs")} for m in self._opts.fallback ] params["fallback"] = {"models": models} if self._opts.conn_options: params["connection"] = { "timeout": self._opts.conn_options.timeout, "retries": self._opts.conn_options.max_retry, } base_url = self._opts.base_url if base_url.startswith(("http://", "https://")): base_url = base_url.replace("http", "ws", 1) headers = { **get_inference_headers(), "Authorization": f"Bearer {create_access_token(self._opts.api_key, self._opts.api_secret)}", } try: ws = await asyncio.wait_for( http_session.ws_connect( f"{base_url}/stt?model={self._opts.model}", headers=headers ), self._conn_options.timeout, ) params["type"] = "session.create" await ws.send_str(json.dumps(params)) except aiohttp.ClientResponseError as e: raise create_api_error_from_http(e.message, status=e.status) from e except asyncio.TimeoutError as e: raise APITimeoutError("LiveKit Inference STT connection timed out.") from e except aiohttp.ClientConnectorError as e: raise APIConnectionError("failed to connect to LiveKit Inference STT") from e return ws def _build_speech_data(self, data: dict) -> stt.SpeechData: language = LanguageCode(data.get("language", self._opts.language or "en")) words = data.get("words", []) or [] # The gateway carries provider-specific data on the `extra` field # of the transcript message. We surface it on SpeechData.metadata extra = data.get("extra") metadata = extra if isinstance(extra, dict) and extra else None return stt.SpeechData( language=language, start_time=self.start_time_offset + data.get("start", 0), end_time=self.start_time_offset + data.get("start", 0) + data.get("duration", 0), confidence=data.get("confidence", 1.0), text=data.get("transcript", ""), speaker_id=data.get("speaker_id"), words=[ TimedString( text=word.get("word", ""), start_time=word.get("start", 0) + self.start_time_offset, end_time=word.get("end", 0) + self.start_time_offset, start_time_offset=self.start_time_offset, confidence=word.get("confidence", 0.0), speaker_id=word.get("speaker_id"), ) for word in words ], metadata=metadata, ) def _process_preflight_transcript(self, data: dict) -> None: text = data.get("transcript", "") if not text or not self._speaking: return speech_data = self._build_speech_data(data) request_id = data.get("request_id", self._request_id) event = stt.SpeechEvent( type=stt.SpeechEventType.PREFLIGHT_TRANSCRIPT, request_id=request_id, alternatives=[speech_data], ) self._event_ch.send_nowait(event) def _process_transcript(self, data: dict, is_final: bool) -> None: request_id = data.get("request_id", self._request_id) text = data.get("transcript", "") if not text and not is_final: return # We'll have a more accurate way of detecting when speech started when we have VAD if not self._speaking: self._speaking = True start_event = stt.SpeechEvent(type=stt.SpeechEventType.START_OF_SPEECH) self._event_ch.send_nowait(start_event) speech_data = self._build_speech_data(data) if is_final: if self._speech_duration > 0: self._event_ch.send_nowait( stt.SpeechEvent( type=stt.SpeechEventType.RECOGNITION_USAGE, request_id=request_id, recognition_usage=stt.RecognitionUsage( audio_duration=self._speech_duration, ), ) ) self._speech_duration = 0 event = stt.SpeechEvent( type=stt.SpeechEventType.FINAL_TRANSCRIPT, request_id=request_id, alternatives=[speech_data], ) self._event_ch.send_nowait(event) if self._speaking: self._speaking = False end_event = stt.SpeechEvent(type=stt.SpeechEventType.END_OF_SPEECH) self._event_ch.send_nowait(end_event) self._on_end_of_speech() else: event = stt.SpeechEvent( type=stt.SpeechEventType.INTERIM_TRANSCRIPT, request_id=request_id, alternatives=[speech_data], ) self._event_ch.send_nowait(event)