项目文件夹

文件
2026-07-13 13:39:38 +08:00

1077 行
40 KiB
Python

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)