Module livekit.plugins.meta
Meta Muse Voice Transcribe plugin for LiveKit Agents.
Classes
class STT (*,
api_key: str | None = None,
model: str = 'muse-voice-transcribe-1.0',
url: str = 'wss://api.meta.ai/v1/asr/realtime',
keywords: list[str] | None = None,
language_bias: list[str] | None = None,
http_session: aiohttp.ClientSession | None = None)-
Expand source code
class STT(stt.STT[Any]): """Streaming speech recognition with Meta Muse Voice Transcribe.""" def __init__( self, *, api_key: str | None = None, model: str = DEFAULT_MODEL, url: str = DEFAULT_URL, keywords: list[str] | None = None, language_bias: list[str] | None = None, http_session: aiohttp.ClientSession | None = None, ) -> None: """Create a Meta Muse streaming STT provider. Args: api_key: Meta Model API key. Falls back to ``MODEL_API_KEY``, then ``META_API_KEY``. model: Muse Voice Transcribe model identifier. url: Realtime Muse ASR WebSocket endpoint. Must use ``wss://``. keywords: Static recognition keywords sent when each stream starts. language_bias: Static supported language names sent when each stream starts. http_session: Optional aiohttp session. By default, the LiveKit HTTP context session is used. """ if api_key is not None: resolved_key = api_key.strip() else: resolved_key = ( os.getenv("MODEL_API_KEY", "").strip() or os.getenv("META_API_KEY", "").strip() ) if not resolved_key: raise ValueError( "Meta Model API key is required. Pass api_key or set MODEL_API_KEY or META_API_KEY" ) if not model.strip(): raise ValueError("model must be non-empty") parsed_url = urlparse(url) if ( parsed_url.scheme != "wss" or not parsed_url.hostname or parsed_url.username is not None or parsed_url.password is not None or parsed_url.fragment ): raise ValueError("url must be an absolute wss:// URL without credentials or a fragment") super().__init__( capabilities=stt.STTCapabilities( streaming=True, interim_results=True, diarization=False, aligned_transcript=False, offline_recognize=False, keyterms=False, ) ) self._api_key = _normalize_access_token(resolved_key) self._model = model.strip() self._url = url self._keywords = _normalize_hints(keywords, name="keywords") self._language_bias = _normalize_language_bias(language_bias) self._http_session = http_session self._streams: weakref.WeakSet[SpeechStream] = weakref.WeakSet() self._closed = False @property def model(self) -> str: return self._model @property def provider(self) -> str: return "Meta" def _ensure_session(self) -> aiohttp.ClientSession: if self._http_session is None: self._http_session = utils.http_context.http_session() return self._http_session async def _recognize_impl( self, buffer: AudioBuffer, *, language: NotGivenOr[str] = NOT_GIVEN, conn_options: APIConnectOptions, ) -> stt.SpeechEvent: raise APIError( "Meta Muse Voice Transcribe supports streaming recognition only", retryable=False, ) def stream( self, *, language: NotGivenOr[str] = NOT_GIVEN, conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS, ) -> SpeechStream: if self._closed: raise RuntimeError("Meta STT is closed") language_bias = list(self._language_bias) if is_given(language): language_hint = _normalize_language_hint(str(language)) if language_hint not in language_bias: language_bias.append(language_hint) stream = SpeechStream( stt=self, conn_options=conn_options, api_key=self._api_key, model=self._model, url=self._url, keywords=list(self._keywords), language_bias=language_bias, http_session=self._ensure_session(), ) self._streams.add(stream) return stream async def aclose(self) -> None: self._closed = True streams = tuple(self._streams) if streams: await asyncio.gather(*(stream.aclose() for stream in streams), return_exceptions=True)Streaming speech recognition with Meta Muse Voice Transcribe.
Create a Meta Muse streaming STT provider.
Args
api_key- Meta Model API key. Falls back to
MODEL_API_KEY, thenMETA_API_KEY. model- Muse Voice Transcribe model identifier.
url- Realtime Muse ASR WebSocket endpoint. Must use
wss://. keywords- Static recognition keywords sent when each stream starts.
language_bias- Static supported language names sent when each stream starts.
http_session- Optional aiohttp session. By default, the LiveKit HTTP context session is used.
Ancestors
- livekit.agents.stt.stt.STT
- abc.ABC
- EventEmitter
- typing.Generic
Instance variables
prop model : str-
Expand source code
@property def model(self) -> str: return self._modelGet the model name/identifier for this STT instance.
Returns
The model name if available, "unknown" otherwise.
Note
Plugins should override this property to provide their model information.
prop provider : str-
Expand source code
@property def provider(self) -> str: return "Meta"Get the provider name/identifier for this STT instance.
Returns
The provider name if available, "unknown" otherwise.
Note
Plugins should override this property to provide their provider information.
Methods
async def aclose(self) ‑> None-
Expand source code
async def aclose(self) -> None: self._closed = True streams = tuple(self._streams) if streams: await asyncio.gather(*(stream.aclose() for stream in streams), return_exceptions=True)Close the STT, and every stream/requests associated with it
def stream(self,
*,
language: NotGivenOr[str] = NOT_GIVEN,
conn_options: APIConnectOptions = APIConnectOptions(max_retry=3, retry_interval=2.0, timeout=10.0)) ‑> livekit.plugins.meta.stt.SpeechStream-
Expand source code
def stream( self, *, language: NotGivenOr[str] = NOT_GIVEN, conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS, ) -> SpeechStream: if self._closed: raise RuntimeError("Meta STT is closed") language_bias = list(self._language_bias) if is_given(language): language_hint = _normalize_language_hint(str(language)) if language_hint not in language_bias: language_bias.append(language_hint) stream = SpeechStream( stt=self, conn_options=conn_options, api_key=self._api_key, model=self._model, url=self._url, keywords=list(self._keywords), language_bias=language_bias, http_session=self._ensure_session(), ) self._streams.add(stream) return stream
Inherited members
class SpeechStream (*,
stt: STT,
conn_options: APIConnectOptions,
api_key: str,
model: str,
url: str,
keywords: list[str],
language_bias: list[str],
http_session: aiohttp.ClientSession)-
Expand source code
class SpeechStream(stt.RecognizeStream): def __init__( self, *, stt: STT, conn_options: APIConnectOptions, api_key: str, model: str, url: str, keywords: list[str], language_bias: list[str], http_session: aiohttp.ClientSession, ) -> None: super().__init__(stt=stt, conn_options=conn_options, sample_rate=_SAMPLE_RATE) self._api_key = api_key self._model = model self._url = url self._keywords = keywords self._language_bias = language_bias self._session = http_session self._session_id = "" self._turns: OrderedDict[str, _TurnState] = OrderedDict() self._provider_active_turn_id: str | None = None self._completed_turn_ids: set[str] = set() self._completed_turn_order: deque[str] = deque() self._audio_consumed = False self._end_stream_sent = False self._last_audio_processed_ms = 0.0 self._pending_usage_seconds = 0.0 async def _run(self) -> None: self._end_stream_sent = False self._last_audio_processed_ms = 0.0 ws: aiohttp.ClientWebSocketResponse | None = None tasks: list[asyncio.Task[None]] = [] try: ws = await self._connect_ws() sender = asyncio.create_task(self._send_audio(ws), name="meta-stt-send") receiver = asyncio.create_task(self._receive_events(ws), name="meta-stt-receive") tasks = [sender, receiver] await self._drive_tasks(sender, receiver) except asyncio.CancelledError: raise except APIError as exc: if self._audio_consumed and exc.retryable: raise APIConnectionError( "Meta Muse realtime ASR failed after audio was consumed", retryable=False, ) from None raise except Exception as exc: phase = "audio streaming" if self._audio_consumed else "connection" raise APIConnectionError( f"Meta Muse realtime ASR {phase} failed ({type(exc).__name__})", retryable=not self._audio_consumed, ) from None finally: if tasks: await utils.aio.gracefully_cancel(*tasks) self._flush_usage() if ws is not None: await _close_quietly(ws) async def _connect_ws(self) -> aiohttp.ClientWebSocketResponse: started_at = time.perf_counter() try: ws = await asyncio.wait_for( self._session.ws_connect( self._url, max_msg_size=_MAX_MESSAGE_BYTES, heartbeat=_WS_HEARTBEAT, ), timeout=self._conn_options.timeout, ) except asyncio.TimeoutError: raise APITimeoutError("Meta Muse realtime ASR connection timed out") from None except aiohttp.ClientResponseError as exc: raise APIStatusError( "Meta Muse realtime ASR connection was rejected", status_code=exc.status, body=None, ) from None except Exception as exc: raise APIConnectionError( f"Meta Muse realtime ASR connection failed ({type(exc).__name__})" ) from None try: await ws.send_str(json.dumps(self._handshake(), separators=(",", ":"))) raw = await asyncio.wait_for(ws.receive(), timeout=self._conn_options.timeout) message = _parse_ws_message( raw, phase="handshake", close_code=ws.close_code, retryable_on_normal_close=True, ) self._accept_handshake(message) except asyncio.CancelledError: await _close_quietly(ws) raise except asyncio.TimeoutError: await _close_quietly(ws) raise APITimeoutError("Meta Muse realtime ASR handshake timed out") from None except APIError: await _close_quietly(ws) raise except Exception as exc: await _close_quietly(ws) raise APIConnectionError( f"Meta Muse realtime ASR handshake failed ({type(exc).__name__})" ) from None self._report_connection_acquired(time.perf_counter() - started_at, False) return ws def _handshake(self) -> dict[str, object]: handshake: dict[str, object] = { "mode": "ENDPOINTING", "authorization": {"accessToken": self._api_key}, "audioEncoding": "PCM_24KHZ", "model": self._model, "partialMode": "CUMULATIVE", "emitAudioProgress": True, } if self._keywords: handshake["keywords"] = self._keywords if self._language_bias: handshake["languageBias"] = self._language_bias return handshake def _accept_handshake(self, message: dict[str, Any]) -> None: if message.get("type") == "error": raise _server_error(phase="handshake") session_id = message.get("sessionId") if not isinstance(session_id, str) or not session_id: raise APIConnectionError( "Meta Muse realtime ASR sent an invalid handshake response", retryable=False, ) self._session_id = session_id async def _drive_tasks(self, sender: asyncio.Task[None], receiver: asyncio.Task[None]) -> None: done, _ = await asyncio.wait((sender, receiver), return_when=asyncio.FIRST_COMPLETED) if receiver in done: receiver.result() if not self._end_stream_sent: raise APIConnectionError( "Meta Muse realtime ASR closed before input ended", retryable=not self._audio_consumed, ) if not sender.done(): await sender else: sender.result() try: await asyncio.wait_for(receiver, timeout=self._conn_options.timeout) except asyncio.TimeoutError: raise APITimeoutError( "Meta Muse realtime ASR timed out while draining final events", retryable=not self._audio_consumed, ) from None self._validate_clean_close() async def _send_audio(self, ws: aiohttp.ClientWebSocketResponse) -> None: pending = bytearray() loop = asyncio.get_running_loop() pacing_origin: float | None = None sent_duration = 0.0 async def send_packet(packet: bytes) -> None: nonlocal pacing_origin, sent_duration if not packet: return if pacing_origin is None: pacing_origin = loop.time() deadline = pacing_origin + sent_duration delay = deadline - loop.time() if delay > 0: await asyncio.sleep(delay) try: await ws.send_bytes(packet) except Exception as exc: raise APIConnectionError( f"Meta Muse realtime ASR audio send failed ({type(exc).__name__})", retryable=False, ) from None duration = len(packet) / (_SAMPLE_RATE * _CHANNELS * _SAMPLE_WIDTH_BYTES) sent_duration += duration async for item in self._input_ch: if isinstance(item, self._FlushSentinel): if pending: await send_packet(bytes(pending)) pending.clear() continue self._audio_consumed = True if item.num_channels != _CHANNELS: raise APIError("Meta Muse realtime ASR requires mono audio", retryable=False) pending.extend(item.data.tobytes()) while len(pending) >= _CHUNK_BYTES: await send_packet(bytes(pending[:_CHUNK_BYTES])) del pending[:_CHUNK_BYTES] if pending: await send_packet(bytes(pending)) if not self._end_stream_sent: try: await ws.send_str('{"type":"endStream"}') except Exception as exc: raise APIConnectionError( f"Meta Muse realtime ASR end-of-input send failed ({type(exc).__name__})", retryable=False, ) from None self._end_stream_sent = True async def _receive_events(self, ws: aiohttp.ClientWebSocketResponse) -> None: while True: try: raw = await ws.receive() except Exception as exc: raise APIConnectionError( f"Meta Muse realtime ASR receive failed ({type(exc).__name__})", retryable=not self._audio_consumed, ) from None if raw.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSED): close_code = raw.data if isinstance(raw.data, int) else ws.close_code if self._end_stream_sent and close_code == 1000: return raise _close_error( close_code, phase="stream", retryable_on_normal_close=not self._audio_consumed, ) if raw.type == aiohttp.WSMsgType.CLOSING: continue if raw.type == aiohttp.WSMsgType.ERROR: raise APIConnectionError( "Meta Muse realtime ASR WebSocket failed", retryable=not self._audio_consumed, ) from None message = _parse_ws_message(raw, phase="stream") event_type = message.get("type") if event_type == "error": raise _server_error(phase="stream") if event_type == "speechStart": self._speech_start(message) elif event_type == "transcript": self._transcript(message) elif event_type == "speechEnd": self._speech_end(message) elif event_type == "speechComplete": self._speech_complete(message) elif event_type == "audioProgress": self._audio_progress(message) def _audio_progress(self, message: dict[str, Any]) -> None: processed_ms = message.get("audioProcessedMs") if ( isinstance(processed_ms, bool) or not isinstance(processed_ms, (int, float)) or not math.isfinite(processed_ms) or processed_ms < 0 ): raise _protocol_error("audioProgress event has invalid audioProcessedMs") if processed_ms <= self._last_audio_processed_ms: return delta_seconds = (processed_ms - self._last_audio_processed_ms) / 1000 self._last_audio_processed_ms = float(processed_ms) self._pending_usage_seconds += delta_seconds def _speech_start(self, message: dict[str, Any]) -> None: turn_id = self._required_turn_id(message, event="speechStart") if turn_id in self._completed_turn_ids: return turn = self._turns.setdefault(turn_id, _TurnState()) turn.provider_started = True self._provider_active_turn_id = turn_id self._drain_turns() def _transcript(self, message: dict[str, Any]) -> None: text = message.get("transcript") if not isinstance(text, str): raise _protocol_error("transcript event has invalid text") if not text and message.get("turnId") is None and self._provider_active_turn_id is None: return turn_id = self._transcript_turn_id(message) if turn_id in self._completed_turn_ids: return turn = self._turns.setdefault(turn_id, _TurnState()) if turn.final_text is not None or turn.latest_interim == text: return turn.latest_interim = text self._drain_turns() def _speech_end(self, message: dict[str, Any]) -> None: turn_id = self._required_turn_id(message, event="speechEnd") if turn_id in self._completed_turn_ids: return turn = self._turns.setdefault(turn_id, _TurnState()) turn.ended = True if self._provider_active_turn_id == turn_id: self._provider_active_turn_id = None self._capture_turn_usage(turn) self._drain_turns() def _speech_complete(self, message: dict[str, Any]) -> None: turn_id = self._required_turn_id(message, event="speechComplete") if turn_id in self._completed_turn_ids: return text = message.get("transcript") if not isinstance(text, str): raise _protocol_error("speechComplete event has invalid transcript") turn = self._turns.setdefault(turn_id, _TurnState()) if turn.final_text is None: turn.final_text = text self._capture_turn_usage(turn) self._drain_turns() def _capture_turn_usage(self, turn: _TurnState) -> None: if turn.usage_captured or not turn.ended: return turn.usage_seconds = self._pending_usage_seconds turn.usage_captured = True self._pending_usage_seconds = 0.0 def _drain_turns(self) -> None: while self._turns: turn_id = next(iter(self._turns)) turn = self._turns[turn_id] has_content = turn.latest_interim is not None or turn.final_text is not None if not turn.emitted_start and (turn.provider_started or has_content): turn.emitted_start = True self._emit(stt.SpeechEventType.START_OF_SPEECH, turn_id) if ( turn.emitted_start and not turn.final_emitted and turn.latest_interim is not None and turn.latest_interim != turn.emitted_interim ): turn.emitted_interim = turn.latest_interim self._emit( stt.SpeechEventType.INTERIM_TRANSCRIPT, turn_id, turn.latest_interim, ) if turn.emitted_start and not turn.final_emitted and turn.final_text is not None: turn.final_emitted = True self._emit(stt.SpeechEventType.FINAL_TRANSCRIPT, turn_id, turn.final_text) if not (turn.final_emitted and turn.ended): return self._emit(stt.SpeechEventType.END_OF_SPEECH, turn_id) self._emit_usage(turn.usage_seconds) del self._turns[turn_id] self._remember_completed_turn(turn_id) def _remember_completed_turn(self, turn_id: str) -> None: if turn_id in self._completed_turn_ids: return if len(self._completed_turn_order) >= _MAX_COMPLETED_TURNS: oldest = self._completed_turn_order.popleft() self._completed_turn_ids.discard(oldest) self._completed_turn_order.append(turn_id) self._completed_turn_ids.add(turn_id) def _emit(self, event_type: stt.SpeechEventType, turn_id: str, text: str | None = None) -> None: alternatives: list[stt.SpeechData] = [] if text is not None: alternatives.append(stt.SpeechData(language=LanguageCode(""), text=text)) self._event_ch.send_nowait( stt.SpeechEvent(type=event_type, request_id=turn_id, alternatives=alternatives) ) def _emit_usage(self, duration: float) -> None: if duration <= 0: return self._event_ch.send_nowait( stt.SpeechEvent( type=stt.SpeechEventType.RECOGNITION_USAGE, request_id=self._session_id, recognition_usage=stt.RecognitionUsage(audio_duration=duration), ) ) def _flush_usage(self) -> None: duration = self._pending_usage_seconds self._pending_usage_seconds = 0.0 for turn in self._turns.values(): duration += turn.usage_seconds turn.usage_seconds = 0.0 self._emit_usage(duration) def _transcript_turn_id(self, message: dict[str, Any]) -> str: value = message.get("turnId") if value is not None: return _normalize_turn_id(value, event="transcript") if self._provider_active_turn_id is not None: return self._provider_active_turn_id raise _protocol_error("transcript event is missing turnId outside an active turn") def _required_turn_id(self, message: dict[str, Any], *, event: str) -> str: value = message.get("turnId") if value is None: raise _protocol_error(f"{event} event is missing turnId") return _normalize_turn_id(value, event=event) def _validate_clean_close(self) -> None: if self._turns: raise APIConnectionError( "Meta Muse realtime ASR closed with incomplete speech turns", retryable=False, )Helper class that provides a standard way to create an ABC using inheritance.
Args: sample_rate : int or None, optional The desired sample rate for the audio input. If specified, the audio input will be automatically resampled to match the given sample rate before being processed for Speech-to-Text. If not provided (None), the input will retain its original sample rate.
Ancestors
- livekit.agents.stt.stt.RecognizeStream
- abc.ABC