Module livekit.plugins.reson8
Reson8 plugin for LiveKit Agents.
Support for speech-to-text with Reson8, including server-side turn detection.
See https://docs.reson8.dev/integrations/livekit/ for more information.
Classes
class AudioOptions (sample_rate: int = 16000,
encoding: Encoding = 'pcm_s16le',
num_channels: int = 1)-
Expand source code
@dataclass(frozen=True) class AudioOptions: """ How to describe the audio sent to Reson8. These label the stream rather than convert it, so they have to match the frames actually sent. Streaming input is resampled to ``sample_rate``, but nothing remixes channels or transcodes samples: a pushed frame whose channel count disagrees with ``num_channels`` is rejected instead of being relabelled. See https://docs.reson8.dev/speech-to-text/features/audio-formats/. Args: sample_rate: Rate in Hz. Streaming input is resampled to this. encoding: Encoding of the audio sent to Reson8. ``rtc.AudioFrame`` carries signed 16-bit PCM, which this plugin forwards unchanged, so ``"pcm_s16le"`` is the only value there is. num_channels: Channel count of the frames you push, 1 to 10. """ sample_rate: int = 16000 encoding: Encoding = "pcm_s16le" num_channels: int = 1 def __post_init__(self) -> None: if self.encoding not in ENCODINGS: raise ValueError( f"unsupported encoding: {self.encoding}. " f"Reson8 accepts: {', '.join(sorted(ENCODINGS))}." ) check_channels(self.num_channels) if self.sample_rate <= 0: raise ValueError(f"sample_rate must be positive, got {self.sample_rate}") def query_params(self) -> dict[str, str]: return { "encoding": self.encoding, "sample_rate": str(self.sample_rate), "channels": str(self.num_channels), }How to describe the audio sent to Reson8.
These label the stream rather than convert it, so they have to match the frames actually sent. Streaming input is resampled to
sample_rate, but nothing remixes channels or transcodes samples: a pushed frame whose channel count disagrees withnum_channelsis rejected instead of being relabelled. See https://docs.reson8.dev/speech-to-text/features/audio-formats/.Args
sample_rate- Rate in Hz. Streaming input is resampled to this.
encoding- Encoding of the audio sent to Reson8.
rtc.AudioFramecarries signed 16-bit PCM, which this plugin forwards unchanged, so"pcm_s16le"is the only value there is. num_channels- Channel count of the frames you push, 1 to 10.
Instance variables
var encoding : Literal['pcm_s16le']var num_channels : intvar sample_rate : int
Methods
def query_params(self) ‑> dict[str, str]-
Expand source code
def query_params(self) -> dict[str, str]: return { "encoding": self.encoding, "sample_rate": str(self.sample_rate), "channels": str(self.num_channels), }
class BiasingOptions (custom_model_id: str | None = None,
phrases: Sequence[str] | None = None,
strength: float | None = None,
patterns: Sequence[str] | None = None)-
Expand source code
@dataclass(frozen=True) class BiasingOptions: """ How to bias recognition toward terminology you expect. See https://docs.reson8.dev/speech-to-text/features/custom-models/ and https://docs.reson8.dev/speech-to-text/features/patterns/. Args: custom_model_id: A custom model to bias toward, for a vocabulary too large for ``phrases`` or one reused across requests. Build the model in Reson8 and pass its id here. phrases: Terms to bias toward, at most 250. strength: Additive boost on top of the model's trained calibration, non-negative and unbounded. The server default suits most requests; raise it only when expected terminology is not being recovered. patterns: Regex-style shapes for short alphanumeric tokens to recover, such as ``"AMZ[0-9]{6}"`` for an order code, ``"[0-9]{4,6}"`` for a variable-length one, or ``"[A-Z]{2}[0-9]{2} [A-Z]{3}"`` for a licence plate. Set these only when the token is likely to be spoken. """ custom_model_id: str | None = None phrases: Sequence[str] | None = None strength: float | None = None patterns: Sequence[str] | None = None def __post_init__(self) -> None: check_comma_joined("phrases", self.phrases, limit=MAX_PHRASES) check_comma_joined("patterns", self.patterns, allow_braced_commas=True) if self.strength is not None and self.strength < 0: raise ValueError(f"strength must be non-negative, got {self.strength}") if self.phrases is not None: object.__setattr__(self, "phrases", tuple(self.phrases)) if self.patterns is not None: object.__setattr__(self, "patterns", tuple(self.patterns)) def query_params(self) -> dict[str, str]: params: dict[str, str] = {} if self.custom_model_id: params["custom_model_id"] = self.custom_model_id if self.phrases: params["phrases"] = ",".join(self.phrases) if self.patterns: params["patterns"] = ",".join(self.patterns) if self.strength is not None: params["bias_strength"] = str(self.strength) return paramsHow to bias recognition toward terminology you expect.
See https://docs.reson8.dev/speech-to-text/features/custom-models/ and https://docs.reson8.dev/speech-to-text/features/patterns/.
Args
custom_model_id- A custom model to bias toward, for a vocabulary too
large for
phrasesor one reused across requests. Build the model in Reson8 and pass its id here. phrases- Terms to bias toward, at most 250.
strength- Additive boost on top of the model's trained calibration, non-negative and unbounded. The server default suits most requests; raise it only when expected terminology is not being recovered.
patterns- Regex-style shapes for short alphanumeric tokens to recover,
such as
"AMZ[0-9]{6}"for an order code,"[0-9]{4,6}"for a variable-length one, or"[A-Z]{2}[0-9]{2} [A-Z]{3}"for a licence plate. Set these only when the token is likely to be spoken.
Instance variables
var custom_model_id : str | Nonevar patterns : collections.abc.Sequence[str] | Nonevar phrases : collections.abc.Sequence[str] | Nonevar strength : float | None
Methods
def query_params(self) ‑> dict[str, str]-
Expand source code
def query_params(self) -> dict[str, str]: params: dict[str, str] = {} if self.custom_model_id: params["custom_model_id"] = self.custom_model_id if self.phrases: params["phrases"] = ",".join(self.phrases) if self.patterns: params["patterns"] = ",".join(self.patterns) if self.strength is not None: params["bias_strength"] = str(self.strength) return params
class STT (*,
api_key: str | None = None,
base_url: str | None = None,
language: str | Sequence[str] | None = None,
turn: TurnOptions | None = None,
audio: AudioOptions | None = None,
transcript: TranscriptOptions | None = None,
biasing: BiasingOptions | None = None,
http_session: aiohttp.ClientSession | None = None)-
Expand source code
class STT(stt.STT): """ Reson8 speech-to-text. A single model that adapts to how LiveKit uses it: * **Streaming** (:meth:`stream`) connects to the turn-aware ``/v1/speech-to-text/turns`` endpoint. Reson8 detects conversational turn boundaries server-side and emits a turn-end *candidate* once it believes a turn is complete. That candidate surfaces as a preflight transcript the agent can act on speculatively, and is then either confirmed as a final transcript or replaced by a later candidate. Ideal for low-latency voice agents. * **Batch** (:meth:`recognize`) sends pre-recorded audio to ``/v1/speech-to-text/prerecorded`` and returns the full transcript. Leave ``language`` as ``None`` (the default) to auto-detect the spoken language, or pass one or more :data:`SupportedLanguage` codes to pin recognition. :class:`TurnOptions` is the main lever on end-of-turn latency, and a stream can commit a turn on demand without touching it:: # the server's 0.92 default is tuned for conversational speech and is # slow to commit a one-word answer stt = reson8.STT(language="es", turn=reson8.TurnOptions(final_probability=0.7)) # or leave it alone and commit when you already know they are done stream = stt.stream() stream.flush() Reson8 detects turns server-side, so hand turn-taking to it rather than letting LiveKit run its own detector:: session = AgentSession( stt=reson8.STT(), llm=openai.LLM(), tts=openai.TTS(), turn_handling={ "turn_detection": "stt", "preemptive_generation": {"enabled": True}, }, ) See https://docs.reson8.dev/speech-to-text/turns/ """ def __init__( self, *, api_key: str | None = None, base_url: str | None = None, language: str | Sequence[str] | None = None, turn: TurnOptions | None = None, audio: AudioOptions | None = None, transcript: TranscriptOptions | None = None, biasing: BiasingOptions | None = None, http_session: aiohttp.ClientSession | None = None, ) -> None: """ Args: api_key: Reson8 API key. Falls back to the ``RESON8_API_KEY`` env var. base_url: Reson8 API base URL. Falls back to ``RESON8_BASE_URL``, then ``https://api.reson8.dev``. language: One or more :data:`SupportedLanguage` codes to pin recognition to. Pass a single code (``"nl"``), a comma-string (``"nl,de"``), or a list (``["nl", "de"]``). Leave as ``None`` to auto-detect. Raises ``ValueError`` for unsupported codes. turn: When Reson8 ends a turn. See :class:`TurnOptions`. audio: How the audio sent to Reson8 is described. See :class:`AudioOptions`. transcript: Which extra detail to report. See :class:`TranscriptOptions`. biasing: How to bias recognition. See :class:`BiasingOptions`. http_session: Optional session to use for requests. Defaults to the shared session managed by the agent framework. """ transcript = transcript or TranscriptOptions() super().__init__( capabilities=stt.STTCapabilities( streaming=True, interim_results=False, offline_recognize=True, aligned_transcript="word" if transcript.words else False, ), ) api_key = api_key or os.environ.get("RESON8_API_KEY") if not api_key: raise ValueError( "Reson8 API key is required, either as argument or RESON8_API_KEY env var" ) self._api_key = api_key self._base_url = resolve_base_url(base_url) self._opts = STTOptions( language=normalize_languages(language), turn=turn or TurnOptions(), audio=audio or AudioOptions(), transcript=transcript, biasing=biasing or BiasingOptions(), ) self._session = http_session self._streams = weakref.WeakSet[SpeechStream]() def _ensure_session(self) -> aiohttp.ClientSession: if not self._session: self._session = utils.http_context.http_session() return self._session @property def model(self) -> str: return self._opts.biasing.custom_model_id or "default" @property def provider(self) -> str: return "Reson8" def update_options( self, *, language: NotGivenOr[str | Sequence[str] | None] = NOT_GIVEN, turn: NotGivenOr[TurnOptions] = NOT_GIVEN, transcript: NotGivenOr[TranscriptOptions] = NOT_GIVEN, biasing: NotGivenOr[BiasingOptions] = NOT_GIVEN, ) -> None: """ Change settings at runtime. Reson8 takes its configuration from the query string, so a live stream applies the new settings by reconnecting immediately. Reson8 holds turn state server-side, so a redial mid-utterance abandons the audio already sent with the session it belongs to: that utterance is transcribed from wherever the new connection picks up. Change options between turns, or accept losing the one in progress. Waiting for a safe moment is not something the client can decide -- nothing in the protocol ties a ``turn_end`` to a position in the audio that was sent, so there is no way to know what the server still owes. This reaches every live stream, so it also replaces a ``language`` that was passed to :meth:`stream` for one of them. :class:`AudioOptions` is deliberately absent: the input resampler is built when a stream opens, so changing the rate mid-stream would describe the audio to Reson8 as something it is not. Turning word timings on or off also moves ``capabilities.aligned_transcript``. A running ``AgentSession`` reads that when the STT is attached or swapped, so it keeps the value it saw until then. """ self._opts = self._opts.merged( language=language, turn=turn, transcript=transcript, biasing=biasing ) self._capabilities.aligned_transcript = "word" if self._opts.transcript.words else False for stream in self._streams: stream.update_options( language=language, turn=turn, transcript=transcript, biasing=biasing ) async def aclose(self) -> None: """ Close every stream created by :meth:`stream`. The HTTP session is left open: it is either supplied by the caller or owned by the shared HTTP context, so it is not ours to close. """ streams = list(self._streams) self._streams.clear() await asyncio.gather(*(stream.aclose() for stream in streams), return_exceptions=True) def stream( self, *, language: NotGivenOr[str | Sequence[str]] = NOT_GIVEN, conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS, ) -> SpeechStream: opts = self._opts.merged(language=language) if is_given(language) else self._opts stream = SpeechStream( stt=self, opts=opts, api_key=self._api_key, base_url=self._base_url, conn_options=conn_options, http_session=self._session, ) self._streams.add(stream) return stream async def _recognize_impl( self, buffer: utils.AudioBuffer, *, language: NotGivenOr[str | Sequence[str]] = NOT_GIVEN, conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS, ) -> stt.SpeechEvent: lang = normalize_languages(language) if is_given(language) else self._opts.language frames = rtc.combine_audio_frames(buffer) opts = replace( self._opts, language=lang, audio=replace( self._opts.audio, encoding="pcm_s16le", sample_rate=frames.sample_rate, num_channels=frames.num_channels, ), ) url = build_url(self._base_url, PRERECORDED_PATH, opts.query_params(streaming=False)) try: async with self._ensure_session().post( url, data=frames.data.tobytes(), headers={ **auth_headers(self._api_key), **integration_headers(), "Content-Type": "application/octet-stream", }, timeout=aiohttp.ClientTimeout(total=30, sock_connect=conn_options.timeout), ) as resp: text = await resp.text() if resp.status != 200: code, detail = problem_parts(text) raise status_error(resp.status, code=code, detail=detail) except asyncio.TimeoutError: raise APITimeoutError("Reson8 did not respond in time") from None except aiohttp.ClientError as e: raise APIConnectionError(f"Failed to reach Reson8 ({type(e).__name__})") from None try: body = json.loads(text) except ValueError: body = None if not isinstance(body, dict): raise APIConnectionError("Reson8 returned a malformed response body") from None try: alternative = build_speech_data(body, language=lang) except (AttributeError, OverflowError, TypeError, ValueError): raise APIConnectionError("Reson8 returned a malformed response body") from None return stt.SpeechEvent( type=stt.SpeechEventType.FINAL_TRANSCRIPT, request_id=str(uuid.uuid4()), alternatives=[alternative], )Reson8 speech-to-text.
A single model that adapts to how LiveKit uses it:
- Streaming (:meth:
stream) connects to the turn-aware/v1/speech-to-text/turnsendpoint. Reson8 detects conversational turn boundaries server-side and emits a turn-end candidate once it believes a turn is complete. That candidate surfaces as a preflight transcript the agent can act on speculatively, and is then either confirmed as a final transcript or replaced by a later candidate. Ideal for low-latency voice agents. - Batch (:meth:
recognize) sends pre-recorded audio to/v1/speech-to-text/prerecordedand returns the full transcript.
Leave
languageasNone(the default) to auto-detect the spoken language, or pass one or more :data:SupportedLanguagecodes to pin recognition.:class:
TurnOptionsis the main lever on end-of-turn latency, and a stream can commit a turn on demand without touching it::# the server's 0.92 default is tuned for conversational speech and is # slow to commit a one-word answer stt = reson8.STT(language="es", turn=reson8.TurnOptions(final_probability=0.7)) # or leave it alone and commit when you already know they are done stream = stt.stream() stream.flush()Reson8 detects turns server-side, so hand turn-taking to it rather than letting LiveKit run its own detector::
session = AgentSession( stt=reson8.STT(), llm=openai.LLM(), tts=openai.TTS(), turn_handling={ "turn_detection": "stt", "preemptive_generation": {"enabled": True}, }, )See https://docs.reson8.dev/speech-to-text/turns/
Args
api_key- Reson8 API key. Falls back to the
RESON8_API_KEYenv var. base_url- Reson8 API base URL. Falls back to
RESON8_BASE_URL, thenhttps://api.reson8.dev. language- One or more :data:
SupportedLanguagecodes to pin recognition to. Pass a single code ("nl"), a comma-string ("nl,de"), or a list (["nl", "de"]). Leave asNoneto auto-detect. RaisesValueErrorfor unsupported codes. turn- When Reson8 ends a turn. See :class:
TurnOptions. audio- How the audio sent to Reson8 is described. See
:class:
AudioOptions. transcript- Which extra detail to report. See
:class:
TranscriptOptions. biasing- How to bias recognition. See :class:
BiasingOptions. http_session- Optional session to use for requests. Defaults to the shared session managed by the agent framework.
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._opts.biasing.custom_model_id or "default"Get 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 "Reson8"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: """ Close every stream created by :meth:`stream`. The HTTP session is left open: it is either supplied by the caller or owned by the shared HTTP context, so it is not ours to close. """ streams = list(self._streams) self._streams.clear() await asyncio.gather(*(stream.aclose() for stream in streams), return_exceptions=True)Close every stream created by :meth:
stream.The HTTP session is left open: it is either supplied by the caller or owned by the shared HTTP context, so it is not ours to close.
def stream(self,
*,
language: NotGivenOr[str | Sequence[str]] = NOT_GIVEN,
conn_options: APIConnectOptions = APIConnectOptions(max_retry=3, retry_interval=2.0, timeout=10.0, liveness_timeout=6.0)) ‑> livekit.plugins.reson8.stt.SpeechStream-
Expand source code
def stream( self, *, language: NotGivenOr[str | Sequence[str]] = NOT_GIVEN, conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS, ) -> SpeechStream: opts = self._opts.merged(language=language) if is_given(language) else self._opts stream = SpeechStream( stt=self, opts=opts, api_key=self._api_key, base_url=self._base_url, conn_options=conn_options, http_session=self._session, ) self._streams.add(stream) return stream def update_options(self,
*,
language: NotGivenOr[str | Sequence[str] | None] = NOT_GIVEN,
turn: NotGivenOr[TurnOptions] = NOT_GIVEN,
transcript: NotGivenOr[TranscriptOptions] = NOT_GIVEN,
biasing: NotGivenOr[BiasingOptions] = NOT_GIVEN) ‑> None-
Expand source code
def update_options( self, *, language: NotGivenOr[str | Sequence[str] | None] = NOT_GIVEN, turn: NotGivenOr[TurnOptions] = NOT_GIVEN, transcript: NotGivenOr[TranscriptOptions] = NOT_GIVEN, biasing: NotGivenOr[BiasingOptions] = NOT_GIVEN, ) -> None: """ Change settings at runtime. Reson8 takes its configuration from the query string, so a live stream applies the new settings by reconnecting immediately. Reson8 holds turn state server-side, so a redial mid-utterance abandons the audio already sent with the session it belongs to: that utterance is transcribed from wherever the new connection picks up. Change options between turns, or accept losing the one in progress. Waiting for a safe moment is not something the client can decide -- nothing in the protocol ties a ``turn_end`` to a position in the audio that was sent, so there is no way to know what the server still owes. This reaches every live stream, so it also replaces a ``language`` that was passed to :meth:`stream` for one of them. :class:`AudioOptions` is deliberately absent: the input resampler is built when a stream opens, so changing the rate mid-stream would describe the audio to Reson8 as something it is not. Turning word timings on or off also moves ``capabilities.aligned_transcript``. A running ``AgentSession`` reads that when the STT is attached or swapped, so it keeps the value it saw until then. """ self._opts = self._opts.merged( language=language, turn=turn, transcript=transcript, biasing=biasing ) self._capabilities.aligned_transcript = "word" if self._opts.transcript.words else False for stream in self._streams: stream.update_options( language=language, turn=turn, transcript=transcript, biasing=biasing )Change settings at runtime.
Reson8 takes its configuration from the query string, so a live stream applies the new settings by reconnecting immediately.
Reson8 holds turn state server-side, so a redial mid-utterance abandons the audio already sent with the session it belongs to: that utterance is transcribed from wherever the new connection picks up. Change options between turns, or accept losing the one in progress. Waiting for a safe moment is not something the client can decide – nothing in the protocol ties a
turn_endto a position in the audio that was sent, so there is no way to know what the server still owes.This reaches every live stream, so it also replaces a
languagethat was passed to :meth:streamfor one of them.:class:
AudioOptionsis deliberately absent: the input resampler is built when a stream opens, so changing the rate mid-stream would describe the audio to Reson8 as something it is not.Turning word timings on or off also moves
capabilities.aligned_transcript. A runningAgentSessionreads that when the STT is attached or swapped, so it keeps the value it saw until then.
Inherited members
- Streaming (:meth:
class SpeechStream (*,
stt: STT,
opts: STTOptions,
api_key: str,
base_url: str,
conn_options: APIConnectOptions,
http_session: aiohttp.ClientSession | None = None)-
Expand source code
class SpeechStream(stt.RecognizeStream): """ Turn-aware streaming session against the ``/turns`` endpoint. A reconnect -- whether the framework retrying this stream or an option change redialling -- cannot resume the turn it interrupted. Frames are consumed from the input channel as they are sent, so audio already handed to a dead socket is gone, and Reson8 holds the turn state that would have interpreted it. The replacement connection therefore starts clean: any speech already announced is closed out with END_OF_SPEECH so the caller is not left waiting, the pending candidate is dropped rather than promoted by an unrelated ``turn_end``, and the utterance in progress is transcribed from wherever the new connection picks it up. Buffering audio for replay would be the alternative, at the cost of holding every utterance in memory for a failure that is rare. """ def __init__( self, *, stt: STT, opts: STTOptions, api_key: str, base_url: str, conn_options: APIConnectOptions, http_session: aiohttp.ClientSession | None = None, ) -> None: super().__init__(stt=stt, conn_options=conn_options, sample_rate=opts.audio.sample_rate) self._opts = opts self._api_key = api_key self._base_url = base_url self._session = http_session self._request_id = str(uuid.uuid4()) self._reconnect_event = asyncio.Event() # durable: Reson8 owes nothing for the audio it was sent. Read when # classifying a close, and never touched by the waiter below self._turn_settled = asyncio.Event() self._turn_settled.set() # notification: a turn_end arrived. The waiter clears this to notice the # next one, which is why the two cannot be the same event self._turn_ended = asyncio.Event() self._audio_sent = False self._speaking = False # the most recent turn-end candidate, promoted to a final transcript # once the server confirms the turn ended self._candidate: SpeechData | None = None self._speech_duration = 0.0 # outlives a reconnect on purpose: a fresh one would strand the bytes # already taken off _input_ch but not yet big enough to send self._audio_bstream = utils.audio.AudioByteStream( sample_rate=opts.audio.sample_rate, num_channels=opts.audio.num_channels, samples_per_channel=opts.audio.sample_rate * _SEND_CHUNK_MS // 1000, ) def update_options( self, *, language: NotGivenOr[str | Sequence[str] | None] = NOT_GIVEN, turn: NotGivenOr[TurnOptions] = NOT_GIVEN, transcript: NotGivenOr[TranscriptOptions] = NOT_GIVEN, biasing: NotGivenOr[BiasingOptions] = NOT_GIVEN, ) -> None: previous = self._opts self._opts = self._opts.merged( language=language, turn=turn, transcript=transcript, biasing=biasing ) if self._opts == previous: return self._reconnect_event.set() def push_frame(self, frame: rtc.AudioFrame) -> None: if frame.num_channels != self._opts.audio.num_channels: raise ValueError( f"expected {self._opts.audio.num_channels}-channel frames, got " f"{frame.num_channels}; set AudioOptions(num_channels=" f"{frame.num_channels}) or push audio in the configured shape" ) super().push_frame(frame) async def _await_final_turn(self) -> None: """ Wait for Reson8 to answer the flush that ``end_input`` queued. ``end_input`` queues a flush sentinel and then closes the input channel, so the last thing the send task does is ask Reson8 to finalise the audio it holds. Hanging up on the socket at that point would throw away the turn_end that answers it, and the caller would see the stream finish with no transcript at all. ``aclose`` cancels the run task instead of closing the input channel cleanly, so it never reaches this and stays immediate. No ``turn_end`` says how much of the audio sent it covers, and Reson8 confirms one turn while later audio is already on its way -- so a confirmation owed from before the flush can satisfy the wait on its own. Rather than hang up on that, the wait settles and then holds briefly for anything further; each new turn restarts the hold. """ if not self._audio_sent: return deadline = time.time() + _FINAL_TURN_TIMEOUT try: while True: await asyncio.wait_for(self._turn_ended.wait(), max(deadline - time.time(), 0.0)) self._turn_ended.clear() await asyncio.sleep(min(_FINAL_TURN_GRACE, max(deadline - time.time(), 0.0))) if not self._turn_ended.is_set(): return except asyncio.TimeoutError: logger.debug("Reson8 sent no further turn before the input closed") def _ensure_session(self) -> aiohttp.ClientSession: if not self._session: self._session = utils.http_context.http_session() return self._session async def _connect_ws(self) -> aiohttp.ClientWebSocketResponse: url = build_url(self._base_url, TURNS_PATH, self._opts.query_params(streaming=True)) connect = self._ensure_session().ws_connect( url, headers={**auth_headers(self._api_key), **integration_headers()}, heartbeat=KEEPALIVE_INTERVAL, ) try: return await asyncio.wait_for( cast("Awaitable[aiohttp.ClientWebSocketResponse]", connect), self._conn_options.timeout, ) except aiohttp.WSServerHandshakeError as e: reason = e.headers.get(ERROR_MESSAGE_HEADER) if e.headers else None raise status_error(e.status, detail=reason) from None except asyncio.TimeoutError: raise APITimeoutError("Timed out connecting to Reson8") from None except aiohttp.ClientError as e: raise APIConnectionError(f"Failed to connect to Reson8 ({type(e).__name__})") from None async def _run(self) -> None: closing_ws = False input_ended = False @utils.log_exceptions(logger=logger) async def send_task(ws: aiohttp.ClientWebSocketResponse) -> None: nonlocal closing_ws, input_ended try: async for data in self._input_ch: flushing = isinstance(data, self._FlushSentinel) if isinstance(data, rtc.AudioFrame): frames = self._audio_bstream.write(data.data.tobytes()) else: frames = self._audio_bstream.flush() for frame in frames: self._speech_duration += frame.duration self._audio_sent = True self._turn_settled.clear() self._turn_ended.clear() await ws.send_bytes(frame.data.tobytes()) if flushing: await ws.send_str(json.dumps({"type": "flush_request"})) except (aiohttp.ClientError, ConnectionError) as e: if closing_ws or self._ensure_session().closed: return raise APIConnectionError( f"Failed to send audio to Reson8 ({type(e).__name__})" ) from None input_ended = True await self._await_final_turn() closing_ws = True await ws.close() @utils.log_exceptions(logger=logger) async def recv_task(ws: aiohttp.ClientWebSocketResponse) -> None: while True: msg = await ws.receive() if msg.type in ( aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSING, ): if closing_ws or self._ensure_session().closed: return if input_ended: if not self._speaking: return raise APIConnectionError( "Reson8 closed with a turn still open", retryable=False, ) raise APIConnectionError( f"Reson8 connection closed unexpectedly (code={ws.close_code})" ) if msg.type is aiohttp.WSMsgType.ERROR: raise APIConnectionError( f"Reson8 connection failed ({type(msg.data).__name__})" ) from None if msg.type is not aiohttp.WSMsgType.TEXT: continue try: parsed = json.loads(msg.data) except (ValueError, TypeError): parsed = None if not isinstance(parsed, dict): logger.warning( "Ignoring unparseable Reson8 message", extra={"lk.pii.message": msg.data}, ) continue self._process_message(parsed) connection_started: float | None = None carried = 0.0 try: while True: ws: aiohttp.ClientWebSocketResponse | None = None now = time.time() if connection_started is not None: elapsed = now - connection_started self.start_time_offset += elapsed carried += elapsed self.start_time = now connection_started = now self._end_speaking() self._candidate = None self._turn_settled.set() self._turn_ended.clear() self._audio_sent = False try: ws = await self._connect_ws() tasks = [ asyncio.create_task(send_task(ws)), asyncio.create_task(recv_task(ws)), ] tasks_group = asyncio.gather(*tasks) wait_reconnect = asyncio.create_task(self._reconnect_event.wait()) try: done, _ = await asyncio.wait( (tasks_group, wait_reconnect), return_when=asyncio.FIRST_COMPLETED, ) for task in done: if task is not wait_reconnect: task.result() if wait_reconnect not in done: break self._reconnect_event.clear() logger.debug("Reconnecting to Reson8 to apply updated options") finally: await utils.aio.gracefully_cancel(*tasks, wait_reconnect) tasks_group.cancel() tasks_group.exception() finally: if ws is not None: await ws.close() finally: self.start_time_offset -= carried def _process_message(self, msg: dict[str, Any]) -> None: msg_type = msg.get("type") logger.debug( "received turn event", extra={"lk.pii.type": msg_type, "lk.pii.text": msg.get("text")}, ) if msg_type == "turn_start": self._candidate = None self._start_speaking() elif msg_type == "turn_end_candidate": self._start_speaking() try: current = build_speech_data( msg, language=self._opts.language, start_time_offset=self.start_time_offset, ) except (AttributeError, OverflowError, TypeError, ValueError) as e: logger.warning( "Ignoring malformed Reson8 turn payload", extra={"error": type(e).__name__, "lk.pii.message": msg}, ) return previous = self._candidate self._candidate = current repeated = previous is not None and previous.text == self._candidate.text if self._candidate.text and not repeated: self._event_ch.send_nowait( stt.SpeechEvent( type=stt.SpeechEventType.PREFLIGHT_TRANSCRIPT, request_id=self._request_id, alternatives=[self._candidate], ) ) elif msg_type == "turn_end": candidate = self._candidate self._candidate = None if candidate is not None: self._event_ch.send_nowait( stt.SpeechEvent( type=stt.SpeechEventType.FINAL_TRANSCRIPT, request_id=self._request_id, alternatives=[candidate], ) ) self._end_speaking() if self._speech_duration > 0: self._event_ch.send_nowait( stt.SpeechEvent( type=stt.SpeechEventType.RECOGNITION_USAGE, request_id=self._request_id, recognition_usage=stt.RecognitionUsage( audio_duration=self._speech_duration ), ) ) self._speech_duration = 0.0 self._turn_settled.set() self._turn_ended.set() else: logger.debug("ignoring unhandled Reson8 message", extra={"lk.pii.type": msg_type}) def _start_speaking(self) -> None: if self._speaking: return self._speaking = True self._event_ch.send_nowait(stt.SpeechEvent(type=stt.SpeechEventType.START_OF_SPEECH)) def _end_speaking(self) -> None: if not self._speaking: return self._speaking = False self._event_ch.send_nowait(stt.SpeechEvent(type=stt.SpeechEventType.END_OF_SPEECH))Turn-aware streaming session against the
/turnsendpoint.A reconnect – whether the framework retrying this stream or an option change redialling – cannot resume the turn it interrupted. Frames are consumed from the input channel as they are sent, so audio already handed to a dead socket is gone, and Reson8 holds the turn state that would have interpreted it. The replacement connection therefore starts clean: any speech already announced is closed out with END_OF_SPEECH so the caller is not left waiting, the pending candidate is dropped rather than promoted by an unrelated
turn_end, and the utterance in progress is transcribed from wherever the new connection picks it up. Buffering audio for replay would be the alternative, at the cost of holding every utterance in memory for a failure that is rare.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
Methods
def push_frame(self, frame: rtc.AudioFrame) ‑> None-
Expand source code
def push_frame(self, frame: rtc.AudioFrame) -> None: if frame.num_channels != self._opts.audio.num_channels: raise ValueError( f"expected {self._opts.audio.num_channels}-channel frames, got " f"{frame.num_channels}; set AudioOptions(num_channels=" f"{frame.num_channels}) or push audio in the configured shape" ) super().push_frame(frame)Push audio to be recognized
def update_options(self,
*,
language: NotGivenOr[str | Sequence[str] | None] = NOT_GIVEN,
turn: NotGivenOr[TurnOptions] = NOT_GIVEN,
transcript: NotGivenOr[TranscriptOptions] = NOT_GIVEN,
biasing: NotGivenOr[BiasingOptions] = NOT_GIVEN) ‑> None-
Expand source code
def update_options( self, *, language: NotGivenOr[str | Sequence[str] | None] = NOT_GIVEN, turn: NotGivenOr[TurnOptions] = NOT_GIVEN, transcript: NotGivenOr[TranscriptOptions] = NOT_GIVEN, biasing: NotGivenOr[BiasingOptions] = NOT_GIVEN, ) -> None: previous = self._opts self._opts = self._opts.merged( language=language, turn=turn, transcript=transcript, biasing=biasing ) if self._opts == previous: return self._reconnect_event.set()
class TranscriptOptions (words: bool = False,
language: bool = True,
confidence: bool = False,
filler_mode: FillerMode | None = None)-
Expand source code
@dataclass(frozen=True) class TranscriptOptions: """ How the transcript is produced, and what detail accompanies it. Args: words: Word-level results, each with its own timing. language: The detected language code, on by default so ``SpeechData.language`` is populated when the language is auto-detected rather than pinned. confidence: Per-word confidence. Batch recognition only. filler_mode: What to do with filler words: ``"clean"`` removes them, ``"natural"`` lets the model decide, ``"verbatim"`` preserves them. ``None`` leaves the server's default. """ words: bool = False language: bool = True confidence: bool = False filler_mode: FillerMode | None = None def __post_init__(self) -> None: if self.filler_mode is not None and self.filler_mode not in FILLER_MODES: raise ValueError( f"unsupported filler_mode: {self.filler_mode}. " f"Reson8 accepts: {', '.join(sorted(FILLER_MODES))}." ) def query_params(self, *, streaming: bool) -> dict[str, str]: params: dict[str, str] = {"include_timestamps": "true"} if self.words: params["include_words"] = "true" if self.language: params["include_language"] = "true" if self.confidence and not streaming: params["include_confidence"] = "true" if self.filler_mode is not None: params["filler_mode"] = self.filler_mode return paramsHow the transcript is produced, and what detail accompanies it.
Args
words- Word-level results, each with its own timing.
language- The detected language code, on by default so
SpeechData.languageis populated when the language is auto-detected rather than pinned. confidence- Per-word confidence. Batch recognition only.
filler_mode- What to do with filler words:
"clean"removes them,"natural"lets the model decide,"verbatim"preserves them.Noneleaves the server's default.
Instance variables
var confidence : boolvar filler_mode : Literal['clean', 'natural', 'verbatim'] | Nonevar language : boolvar words : bool
Methods
def query_params(self, *, streaming: bool) ‑> dict[str, str]-
Expand source code
def query_params(self, *, streaming: bool) -> dict[str, str]: params: dict[str, str] = {"include_timestamps": "true"} if self.words: params["include_words"] = "true" if self.language: params["include_language"] = "true" if self.confidence and not streaming: params["include_confidence"] = "true" if self.filler_mode is not None: params["filler_mode"] = self.filler_mode return params
class TurnOptions (eager_probability: float | None = None,
final_probability: float | None = None)-
Expand source code
@dataclass(frozen=True) class TurnOptions: """ When Reson8 ends a turn, the main lever on end-of-turn latency. ``None`` leaves the server's default. See https://docs.reson8.dev/speech-to-text/turns/. Args: eager_probability: Confidence at which the preflight transcript is emitted, so the agent can start generating speculatively. final_probability: Confidence at which the turn commits. The server default is tuned for conversational speech and is slow to commit a one-word answer; lower it to commit sooner, at the risk of cutting off longer utterances. """ # /turns does not report its effective config, so the defaults are mirrored # here to check one threshold when only the other is set. Keep in sync with # https://docs.reson8.dev/api/speech-to-text/turns/ SERVER_DEFAULT_EAGER: ClassVar[float] = 0.5 SERVER_DEFAULT_FINAL: ClassVar[float] = 0.92 eager_probability: float | None = None final_probability: float | None = None def __post_init__(self) -> None: check_probability("eager_probability", self.eager_probability) check_probability("final_probability", self.final_probability) eager = self.eager_probability final = self.final_probability eager = self.SERVER_DEFAULT_EAGER if eager is None else eager final = self.SERVER_DEFAULT_FINAL if final is None else final if eager > final: raise ValueError( f"eager_probability ({eager}) must be below final_probability ({final}); " f"raise final_probability or lower eager_probability" ) if eager == final: logger.warning( "eager_probability and final_probability are both %s, so the turn commits " "on the same event as the preflight transcript and preemptive generation " "gets no lead time", final, ) def query_params(self) -> dict[str, str]: params: dict[str, str] = {} if self.eager_probability is not None: params["eager_turn_probability"] = str(self.eager_probability) if self.final_probability is not None: params["final_turn_probability"] = str(self.final_probability) return paramsWhen Reson8 ends a turn, the main lever on end-of-turn latency.
Noneleaves the server's default. See https://docs.reson8.dev/speech-to-text/turns/.Args
eager_probability- Confidence at which the preflight transcript is emitted, so the agent can start generating speculatively.
final_probability- Confidence at which the turn commits. The server default is tuned for conversational speech and is slow to commit a one-word answer; lower it to commit sooner, at the risk of cutting off longer utterances.
Instance variables
var SERVER_DEFAULT_EAGER : ClassVar[float]var SERVER_DEFAULT_FINAL : ClassVar[float]var eager_probability : float | Nonevar final_probability : float | None
Methods
def query_params(self) ‑> dict[str, str]-
Expand source code
def query_params(self) -> dict[str, str]: params: dict[str, str] = {} if self.eager_probability is not None: params["eager_turn_probability"] = str(self.eager_probability) if self.final_probability is not None: params["final_turn_probability"] = str(self.final_probability) return params