from __future__ import annotations import asyncio import json import math import os import struct import time import weakref from abc import ABC, abstractmethod from collections.abc import AsyncIterable, AsyncIterator from dataclasses import dataclass, field from enum import Enum from time import perf_counter_ns from typing import Annotated, Any, Literal, TypeAlias import aiohttp import numpy as np import numpy.typing as npt from opentelemetry import trace from pydantic import ( BaseModel, ConfigDict, Field, SerializerFunctionWrapHandler, TypeAdapter, model_serializer, ) from livekit import rtc from .. import utils from .._exceptions import APIConnectionError, APIError, APIStatusError from ..log import logger from ..metrics.base import InterruptionMetrics, Metadata from ..telemetry import trace_types from ..types import DEFAULT_API_CONNECT_OPTIONS, NOT_GIVEN, APIConnectOptions, NotGivenOr from ..utils import ( AudioArrayBuffer, BoundedDict, aio, http_context, is_given, shortuuid, ) from ._utils import ( create_access_token, get_default_inference_url, get_inference_headers, ) SAMPLE_RATE = 16000 MIN_INTERRUPTION_DURATION = 0.025 * 2 # 25ms per frame, 2 consecutive frames MAX_AUDIO_DURATION = 3 # 3 seconds DETECTION_INTERVAL = 0.1 # 0.1 second AUDIO_PREFIX_DURATION = 1.0 # 1.0 second REMOTE_INFERENCE_TIMEOUT = 0.7 # 700ms _FRAMES_PER_SECOND = 40 class InterruptionDetectionError(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True) type: Literal["interruption_detection_error"] = "interruption_detection_error" timestamp: float = Field(default_factory=time.time) label: str error: Exception = Field(..., exclude=True) recoverable: bool @dataclass(slots=True, kw_only=True) class InterruptionOptions: sample_rate: int """The sample rate of the audio frames, defaults to 16000Hz""" threshold: NotGivenOr[float] """The threshold for the interruption detection. NOT_GIVEN to use server defaults.""" min_frames: int """The minimum number of frames to detect a interruption, defaults to 50ms/2 frames""" max_audio_duration: float """The maximum audio duration for the interruption detection, including the audio prefix, defaults to 3 seconds""" audio_prefix_duration: float """The audio prefix duration for the interruption detection, defaults to 1.0 seconds""" detection_interval: float """The interval between detections, defaults to 0.1 seconds""" inference_timeout: float """The timeout for the interruption detection, defaults to 1 second""" base_url: str api_key: str api_secret: str @dataclass(slots=True, kw_only=True) class InterruptionCacheEntry: """Typed cache entry for interruption inference results.""" created_at: int = field(default_factory=time.perf_counter_ns) """The timestamp when the cache entry was created, in nanoseconds. Used only for indexing and latency calculation.""" speech_input: npt.NDArray[np.int16] | None = None total_duration: float | None = None prediction_duration: float | None = None detection_delay: float | None = None probabilities: npt.NDArray[np.float32] | None = None is_interruption: bool | None = None def get_total_duration(self, default: float = 0.0) -> float: """RTT (Round Trip Time) time taken to perform the inference, in seconds.""" return self.total_duration if self.total_duration is not None else default def get_prediction_duration(self, default: float = 0.0) -> float: """Time taken to perform the inference from the model side, in seconds.""" return self.prediction_duration if self.prediction_duration is not None else default def get_detection_delay(self, default: float = 0.0) -> float: """Total time from the onset of the speech to the final prediction, in seconds.""" return self.detection_delay if self.detection_delay is not None else default def get_probability(self, default: float = 0.0) -> float: """The conservative estimated probability of the interruption event.""" return ( _estimate_probability(self.probabilities) if self.probabilities is not None else default ) class OverlappingSpeechEvent(BaseModel): """Represents an overlapping speech event detected during agent speech.""" model_config = ConfigDict(arbitrary_types_allowed=True) type: Literal["overlapping_speech"] = "overlapping_speech" created_at: float = Field(default_factory=time.time) """Timestamp (in seconds) when the event was emitted.""" detected_at: float = Field(default_factory=time.time) """Timestamp (in seconds) when the overlap was detected.""" is_interruption: bool = False """Whether interruption is detected.""" total_duration: float = 0.0 """RTT (Round Trip Time) time taken to perform the inference, in seconds.""" prediction_duration: float = 0.0 """Time taken to perform the inference from the model side, in seconds.""" detection_delay: float = 0.0 """Total time from the onset of the speech to the final prediction, in seconds.""" overlap_started_at: float | None = None """Timestamp (in seconds) when the overlap speech started. Useful for emitting held transcripts.""" speech_input: npt.NDArray[np.int16] | None = None """The audio input that was used for the inference.""" probabilities: npt.NDArray[np.float32] | None = None """The raw probabilities for the interruption detection.""" probability: float = 0.0 """The conservative estimated probability of the interruption event.""" num_requests: int = 0 """Number of requests sent for this event.""" @model_serializer(mode="wrap") def serialize_model(self, handler: SerializerFunctionWrapHandler) -> Any: # remove numpy arrays from the model dump copy = self.model_copy(deep=True) data = copy.speech_input, copy.probabilities copy.speech_input, copy.probabilities = None, None try: serialized = handler(copy) finally: copy.speech_input, copy.probabilities = data return serialized @classmethod def from_cache_entry( cls, *, entry: InterruptionCacheEntry, is_interruption: bool, started_at: float | None = None, ended_at: float | None = None, ) -> OverlappingSpeechEvent: """Initialize the event from a cache entry. Args: entry: The cache entry to initialize the event from. is_interruption: Whether the interruption is detected. started_at: The timestamp when the overlap speech started. ended_at: The timestamp when the overlap speech ended. Returns: The initialized event. """ return cls( type="overlapping_speech", detected_at=ended_at or time.time(), is_interruption=is_interruption, overlap_started_at=started_at, speech_input=entry.speech_input, probabilities=entry.probabilities, total_duration=entry.get_total_duration(), detection_delay=entry.get_detection_delay(), prediction_duration=entry.get_prediction_duration(), probability=entry.get_probability(), ) # Default empty entry used when cache misses occur _EMPTY_CACHE_ENTRY = InterruptionCacheEntry(created_at=0) # region: Sentinel classes class _AgentSpeechStartedSentinel: pass class _AgentSpeechEndedSentinel: pass class _OverlapSpeechStartedSentinel: def __init__( self, speech_duration: float, started_at: float, user_speaking_span: trace.Span | None = None, ) -> None: self._speech_duration = speech_duration self._user_speaking_span = user_speaking_span self._started_at = started_at class _OverlapSpeechEndedSentinel: def __init__(self, ended_at: float) -> None: self._ended_at = ended_at class _FlushSentinel: pass # endregion: Sentinel classes InterruptionDataFrameType: TypeAlias = ( rtc.AudioFrame | _AgentSpeechStartedSentinel | _AgentSpeechEndedSentinel | _OverlapSpeechStartedSentinel | _OverlapSpeechEndedSentinel | _FlushSentinel ) class AdaptiveInterruptionDetector( rtc.EventEmitter[ Literal[ "overlapping_speech", "error", "metrics_collected", ] ], ): def __init__( self, *, threshold: NotGivenOr[float] = NOT_GIVEN, min_interruption_duration: float = MIN_INTERRUPTION_DURATION, max_audio_duration: float = MAX_AUDIO_DURATION, audio_prefix_duration: float = AUDIO_PREFIX_DURATION, detection_interval: float = DETECTION_INTERVAL, inference_timeout: float = REMOTE_INFERENCE_TIMEOUT, base_url: str | None = None, api_key: str | None = None, api_secret: str | None = None, http_session: aiohttp.ClientSession | None = None, ) -> None: """ Initialize a AdaptiveInterruptionDetector instance. Args: threshold (float, optional): The threshold for the interruption detection. When not set, the server-recommended default (returned in session.created) is used. min_interruption_duration (float, optional): The minimum duration, in seconds, of the interruption event, defaults to 50ms. max_audio_duration (float, optional): The maximum audio duration, including the audio prefix, in seconds, for the interruption detection, defaults to 3s. audio_prefix_duration (float, optional): The audio prefix duration, in seconds, for the interruption detection, defaults to 0.5s. detection_interval (float, optional): The interval between detections, in seconds, for the interruption detection, defaults to 0.1s. inference_timeout (float, optional): The timeout for the interruption detection, defaults to 1 second. base_url (str, optional): The base URL for the interruption detection, defaults to the shared LIVEKIT_INFERENCE_URL environment variable. api_key (str, optional): The API key for the interruption detection, defaults to the LIVEKIT_INFERENCE_API_KEY environment variable. api_secret (str, optional): The API secret for the interruption detection, defaults to the LIVEKIT_INFERENCE_API_SECRET environment variable. http_session (aiohttp.ClientSession, optional): The HTTP session to use for the interruption detection. """ super().__init__() if max_audio_duration > 3.0: raise ValueError("max_audio_duration must be less than or equal to 3.0 seconds") lk_base_url = base_url if base_url else get_default_inference_url() lk_api_key = ( api_key if 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 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" ) self._opts = InterruptionOptions( sample_rate=SAMPLE_RATE, threshold=threshold, min_frames=math.ceil(min_interruption_duration * _FRAMES_PER_SECOND), max_audio_duration=max_audio_duration, audio_prefix_duration=audio_prefix_duration, detection_interval=detection_interval, inference_timeout=inference_timeout, base_url=lk_base_url, api_key=lk_api_key, api_secret=lk_api_secret, ) self._label = f"{type(self).__module__}.{type(self).__name__}" self._sample_rate = SAMPLE_RATE self._session = http_session self._streams = weakref.WeakSet[InterruptionWebSocketStream]() logger.info( "adaptive interruption detector initialized", extra={ "base_url": self._opts.base_url, "detection_interval": self._opts.detection_interval, "audio_prefix_duration": self._opts.audio_prefix_duration, "max_audio_duration": self._opts.max_audio_duration, "min_frames": self._opts.min_frames, "threshold": self._opts.threshold if is_given(self._opts.threshold) else None, "inference_timeout": self._opts.inference_timeout, }, ) @property def model(self) -> str: return "adaptive interruption" @property def provider(self) -> str: return "livekit" @property def label(self) -> str: return self._label @property def sample_rate(self) -> int: return self._sample_rate def _emit_error(self, api_error: Exception, recoverable: bool) -> None: self.emit( "error", InterruptionDetectionError( label=self._label, error=api_error, recoverable=recoverable, ), ) def _ensure_session(self) -> aiohttp.ClientSession: if not self._session: self._session = http_context.http_session() return self._session def stream( self, *, conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS ) -> InterruptionWebSocketStream: try: stream = InterruptionWebSocketStream(model=self, conn_options=conn_options) except Exception as e: self._emit_error(e, recoverable=False) raise self._streams.add(stream) return stream def update_options( self, *, threshold: NotGivenOr[float] = NOT_GIVEN, min_interruption_duration: NotGivenOr[float] = NOT_GIVEN, ) -> None: if is_given(threshold): self._opts.threshold = threshold if is_given(min_interruption_duration): self._opts.min_frames = math.ceil(min_interruption_duration * _FRAMES_PER_SECOND) for stream in self._streams: stream.update_options( threshold=threshold, min_interruption_duration=min_interruption_duration ) class InterruptionStreamBase(ABC): def __init__( self, *, model: AdaptiveInterruptionDetector, conn_options: APIConnectOptions ) -> None: self._model = model self._opts = model._opts self._session = model._ensure_session() self._input_ch = aio.Chan[InterruptionDataFrameType]() self._event_ch = aio.Chan[OverlappingSpeechEvent]() self._audio_buffer = AudioArrayBuffer( buffer_size=int(self._opts.max_audio_duration * self._opts.sample_rate), dtype=np.int16, sample_rate=self._opts.sample_rate, ) self._cache = BoundedDict[int, InterruptionCacheEntry](maxsize=10) self._tee_aiter = aio.itertools.tee(self._event_ch, 2) self._event_aiter, monitor_aiter = self._tee_aiter self._metrics_task = asyncio.create_task( self._metrics_monitor_task(monitor_aiter), name="InterruptionStreamBase._metrics_task" ) self._task = asyncio.create_task(self._main_task()) self._task.add_done_callback(lambda _: self._event_ch.close()) self._num_retries = 0 self._conn_options = conn_options self._sample_rate = self._opts.sample_rate self._overlap_started_at: float | None = None self._user_speech_span: trace.Span | None = None self._agent_speech_started: bool = False self._overlap_started: bool = False self._overlap_count: int = 0 self._accumulated_samples: int = 0 self._num_requests = aio.AsyncAtomicCounter(initial=0) self._batch_size: int = int(self._opts.detection_interval * self._opts.sample_rate) self._prefix_size: int = int(self._opts.audio_prefix_duration * self._opts.sample_rate) @abstractmethod async def _run(self) -> None: ... async def _main_task(self) -> None: max_retries = self._conn_options.max_retry while self._num_retries <= max_retries: try: return await self._run() except APIError as e: if max_retries == 0 or not e.retryable: self._emit_error(e, recoverable=False) raise elif self._num_retries == max_retries: self._emit_error(e, recoverable=False) raise APIConnectionError( f"failed to detect interruption after {self._num_retries} attempts", ) from e else: self._emit_error(e, recoverable=True) retry_interval = self._conn_options._interval_for_retry(self._num_retries) logger.warning( "failed to detect interruption, retrying in %ss: %s", retry_interval, e, extra={ "model": self._model._label, "attempt": self._num_retries, }, ) await asyncio.sleep(retry_interval) self._num_retries += 1 except Exception as e: self._emit_error(e, recoverable=False) raise def _emit_error(self, api_error: Exception, recoverable: bool) -> None: self._model._emit_error(api_error, recoverable) def push_frame(self, frame: InterruptionDataFrameType) -> None: """Push some audio frame to be analyzed""" self._check_input_not_ended() self._check_not_closed() self._input_ch.send_nowait(frame) def flush(self) -> None: """Mark the end of the current segment""" self._check_input_not_ended() self._check_not_closed() self._input_ch.send_nowait(_FlushSentinel()) def end_input(self) -> None: """Mark the end of input, no more audio will be pushed""" self.flush() self._input_ch.close() async def aclose(self) -> None: """Close the stream immediately""" self._input_ch.close() await aio.cancel_and_wait(self._task) self._event_ch.close() try: await self._metrics_task finally: await self._tee_aiter.aclose() async def __anext__(self) -> OverlappingSpeechEvent: try: val = await self._event_aiter.__anext__() except StopAsyncIteration: if not self._task.cancelled() and (exc := self._task.exception()): raise exc # noqa: B904 raise StopAsyncIteration from None return val def __aiter__(self) -> AsyncIterator[OverlappingSpeechEvent]: return self def _check_not_closed(self) -> None: if self._event_ch.closed: cls = type(self) raise RuntimeError(f"{cls.__module__}.{cls.__name__} is closed") def _check_input_not_ended(self) -> None: if self._input_ch.closed: cls = type(self) raise RuntimeError(f"{cls.__module__}.{cls.__name__} input ended") @staticmethod def _update_user_speech_span( user_speech_span: trace.Span, entry: InterruptionCacheEntry ) -> None: user_speech_span.set_attribute( trace_types.ATTR_IS_INTERRUPTION, str(entry.is_interruption).lower() ) user_speech_span.set_attribute( trace_types.ATTR_INTERRUPTION_PROBABILITY, entry.get_probability() ) user_speech_span.set_attribute( trace_types.ATTR_INTERRUPTION_TOTAL_DURATION, entry.get_total_duration() ) user_speech_span.set_attribute( trace_types.ATTR_INTERRUPTION_PREDICTION_DURATION, entry.get_prediction_duration() ) user_speech_span.set_attribute( trace_types.ATTR_INTERRUPTION_DETECTION_DELAY, entry.get_detection_delay() ) async def _forward_data(self, output_ch: aio.Chan[npt.NDArray[np.int16]]) -> None: """Preprocess the audio data and forward it to the output channel for inference.""" async def _reset_state() -> None: self._agent_speech_started = False self._overlap_started = False self._overlap_count = 0 self._accumulated_samples = 0 await self._num_requests.set(0) self._audio_buffer.reset() self._cache.clear() self._user_speech_span = None async for input_frame in self._input_ch: match input_frame: case _FlushSentinel(): continue case _AgentSpeechStartedSentinel() | _AgentSpeechEndedSentinel(): await _reset_state() self._agent_speech_started = isinstance( input_frame, _AgentSpeechStartedSentinel ) continue case _OverlapSpeechStartedSentinel() if self._agent_speech_started: self._overlap_started_at = input_frame._started_at self._user_speech_span = input_frame._user_speaking_span self._overlap_started = True self._accumulated_samples = 0 self._overlap_count += 1 # include the audio prefix in the window and # only shift (remove leading silence) when the first overlap speech started # otherwise, keep the existing data if self._overlap_count == 1: shift_size = max( 0, len(self._audio_buffer) - ( int(input_frame._speech_duration * self._sample_rate) + self._prefix_size ), ) self._audio_buffer.shift(shift_size) logger.trace( "overlap speech started, starting interruption inference", extra={ "overlap_count": self._overlap_count, }, ) self._cache.clear() continue case _OverlapSpeechEndedSentinel(): if self._overlap_started and self._overlap_started_at is not None: logger.trace("overlap speech ended, stopping interruption inference") self._user_speech_span = None _, last_entry = self._cache.pop_if( lambda entry: ( entry.total_duration is not None and entry.total_duration > 0 ) ) if last_entry is None: logger.trace("no request made for overlap speech") ev = OverlappingSpeechEvent.from_cache_entry( entry=last_entry or _EMPTY_CACHE_ENTRY, is_interruption=False, started_at=self._overlap_started_at, ended_at=input_frame._ended_at, ) ev.num_requests = await self._num_requests.get_and_reset() self.send(ev) self._overlap_started = False self._accumulated_samples = 0 self._overlap_started_at = None # we don't clear the cache here since responses might be in flight case rtc.AudioFrame() if self._agent_speech_started: samples_written = self._audio_buffer.push_frame(input_frame) self._accumulated_samples += samples_written if self._accumulated_samples >= self._batch_size and self._overlap_started: output_ch.send_nowait(self._audio_buffer.read()) self._accumulated_samples = 0 output_ch.close() def send(self, event: OverlappingSpeechEvent) -> None: self._event_ch.send_nowait(event) self._model.emit(event.type, event) @utils.log_exceptions(logger=logger) async def _metrics_monitor_task( self, event_aiter: AsyncIterable[OverlappingSpeechEvent] ) -> None: async for ev in event_aiter: metrics = InterruptionMetrics( timestamp=time.time(), total_duration=ev.total_duration, prediction_duration=ev.prediction_duration, detection_delay=ev.detection_delay, num_interruptions=1 if ev.is_interruption else 0, num_backchannels=1 if not ev.is_interruption else 0, num_requests=ev.num_requests, metadata=Metadata( model_name=self._model.model, model_provider=self._model.provider ), ) self._model.emit("metrics_collected", metrics) # region: WebSocket Stream # region: WebSocket messages class InterruptionWSMessageType(str, Enum): SESSION_CREATE = "session.create" SESSION_CLOSE = "session.close" SESSION_CREATED = "session.created" SESSION_CLOSED = "session.closed" INTERRUPTION_DETECTED = "bargein_detected" INFERENCE_DONE = "inference_done" ERROR = "error" class InterruptionWSSessionCreatedMessage(BaseModel): type: Literal[InterruptionWSMessageType.SESSION_CREATED] = ( InterruptionWSMessageType.SESSION_CREATED ) default_threshold: float | None = None """The server-recommended interruption threshold.""" class InterruptionWSSessionCreateSettings(BaseModel): sample_rate: int num_channels: int threshold: float | None = None min_frames: int encoding: Literal["s16le"] class InterruptionWSSessionCreateMessage(BaseModel): type: Literal[InterruptionWSMessageType.SESSION_CREATE] = ( InterruptionWSMessageType.SESSION_CREATE ) settings: InterruptionWSSessionCreateSettings class InterruptionWSSessionCloseMessage(BaseModel): type: Literal[InterruptionWSMessageType.SESSION_CLOSE] = InterruptionWSMessageType.SESSION_CLOSE class InterruptionWSSessionClosedMessage(BaseModel): type: Literal[InterruptionWSMessageType.SESSION_CLOSED] = ( InterruptionWSMessageType.SESSION_CLOSED ) class InterruptionWSDetectedMessage(BaseModel): type: Literal[InterruptionWSMessageType.INTERRUPTION_DETECTED] = ( InterruptionWSMessageType.INTERRUPTION_DETECTED ) created_at: int prediction_duration: float = Field(default=0.0) probabilities: list[float] = Field(default_factory=list) class InterruptionWSInferenceDoneMessage(BaseModel): type: Literal[InterruptionWSMessageType.INFERENCE_DONE] = ( InterruptionWSMessageType.INFERENCE_DONE ) created_at: int prediction_duration: float = Field(default=0.0) probabilities: list[float] = Field(default_factory=list) class InterruptionWSErrorMessage(BaseModel): type: Literal[InterruptionWSMessageType.ERROR] = InterruptionWSMessageType.ERROR message: str code: int session_id: str AnyInterruptionWSMessage: TypeAlias = ( InterruptionWSSessionCreateMessage | InterruptionWSSessionCreatedMessage | InterruptionWSSessionCloseMessage | InterruptionWSSessionClosedMessage | InterruptionWSDetectedMessage | InterruptionWSInferenceDoneMessage | InterruptionWSErrorMessage ) InterruptionWSMessage: TypeAdapter[AnyInterruptionWSMessage] = TypeAdapter( Annotated[AnyInterruptionWSMessage, Field(discriminator="type")] ) # endregion class InterruptionWebSocketStream(InterruptionStreamBase): def __init__( self, *, model: AdaptiveInterruptionDetector, conn_options: APIConnectOptions ) -> None: super().__init__(model=model, conn_options=conn_options) self._request_id = str(shortuuid("interruption_request_")) self._reconnect_event = asyncio.Event() def update_options( self, *, threshold: NotGivenOr[float] = NOT_GIVEN, min_interruption_duration: NotGivenOr[float] = NOT_GIVEN, ) -> None: # opts are shared with the detector (self._opts is model._opts), no need to update them here self._reconnect_event.set() def _resolve_effective_threshold(self, default_threshold: float | None) -> float | None: """Return the effective threshold for observability only. Precedence: user override, then server default; None when neither is known. """ if is_given(self._opts.threshold): return self._opts.threshold if default_threshold is not None: return default_threshold return None async def _run(self) -> None: closing_ws = False async def send_task( ws: aiohttp.ClientWebSocketResponse, input_ch: aio.Chan[npt.NDArray[np.int16]] ) -> None: nonlocal closing_ws timeout_ns = int(self._opts.inference_timeout * 1e9) async for audio_data in input_ch: now = perf_counter_ns() for _key, entry in self._cache.items(): if entry.total_duration is not None: continue if now - entry.created_at > timeout_ns: raise APIStatusError( f"interruption inference timed out after " f"{(now - entry.created_at) / 1e9:.1f}s (ws)", status_code=408, retryable=False, ) break # oldest unanswered entry is still within timeout await self._num_requests.increment() created_at = perf_counter_ns() header = struct.pack(" None: nonlocal closing_ws while True: ws_msg = await ws.receive() if ws_msg.type in ( aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSING, ): if closing_ws or self._session.closed: return raise APIStatusError( message=f"LiveKit Adaptive Interruption connection closed unexpectedly: {ws_msg.data}", status_code=ws.close_code or -1, body=f"{ws_msg.data=} {ws_msg.extra=}", ) if ws_msg.type != aiohttp.WSMsgType.TEXT: logger.warning( "unexpected LiveKit Adaptive Interruption message type %s", ws_msg.type ) continue data = json.loads(ws_msg.data) msg: AnyInterruptionWSMessage = InterruptionWSMessage.validate_python(data) match msg: case InterruptionWSSessionCreatedMessage(): if not is_given(self._opts.threshold) and msg.default_threshold is None: raise APIStatusError( message=( "adaptive interruption session created without a threshold: " "no user override and the server did not report a " "default_threshold" ), status_code=500, retryable=False, ) # Observability only — the server makes the actual decision; logger.debug( "adaptive interruption session created", extra={ "default_threshold": msg.default_threshold, "effective_threshold": self._resolve_effective_threshold( msg.default_threshold ), "user_override": is_given(self._opts.threshold), }, ) case InterruptionWSSessionClosedMessage(): pass case InterruptionWSDetectedMessage(): created_at = msg.created_at if ( overlap_started_at := self._overlap_started_at ) is not None and self._overlap_started: entry = self._cache.set_or_update( created_at, lambda c=created_at: InterruptionCacheEntry(created_at=c), # type: ignore[misc] total_duration=(perf_counter_ns() - created_at) / 1e9, probabilities=np.array(msg.probabilities, dtype=np.float32), is_interruption=True, prediction_duration=msg.prediction_duration, detection_delay=time.time() - overlap_started_at, ) if self._user_speech_span: self._update_user_speech_span(self._user_speech_span, entry) self._user_speech_span = None logger.debug( "interruption detected", extra={ "total_duration": entry.get_total_duration(), "prediction_duration": entry.get_prediction_duration(), "detection_delay": entry.get_detection_delay(), "probability": entry.get_probability(), }, ) ev = OverlappingSpeechEvent.from_cache_entry( entry=entry, is_interruption=True, started_at=overlap_started_at, ended_at=time.time(), ) ev.num_requests = await self._num_requests.get_and_reset() self.send(ev) self._overlap_started = False case InterruptionWSInferenceDoneMessage(): created_at = msg.created_at if ( overlap_started_at := self._overlap_started_at ) is not None and self._overlap_started: entry = self._cache.set_or_update( created_at, lambda c=created_at: InterruptionCacheEntry(created_at=c), # type: ignore[misc] total_duration=(perf_counter_ns() - created_at) / 1e9, prediction_duration=msg.prediction_duration, probabilities=np.array(msg.probabilities, dtype=np.float32), is_interruption=False, detection_delay=time.time() - overlap_started_at, ) logger.trace( "interruption inference done", extra={ "total_duration": entry.get_total_duration(), "prediction_duration": entry.get_prediction_duration(), "probability": entry.get_probability(), }, ) case InterruptionWSErrorMessage(): raise APIStatusError( f"LiveKit Adaptive Interruption returned error: {msg.code}", body=msg.message, status_code=msg.code, ) case _: logger.warning( "received unexpected message from LiveKit Adaptive Interruption: %s", data, ) ws: aiohttp.ClientWebSocketResponse | None = None while True: data_ch = aio.Chan[npt.NDArray[np.int16]]() try: closing_ws = False ws = await self._connect_ws() tasks = [ asyncio.create_task(self._forward_data(data_ch)), asyncio.create_task(send_task(ws, data_ch)), asyncio.create_task(recv_task(ws)), ] tasks_group = asyncio.gather(*tasks) wait_reconnect_task = asyncio.create_task(self._reconnect_event.wait()) try: done, _ = await asyncio.wait( (tasks_group, wait_reconnect_task), return_when=asyncio.FIRST_COMPLETED, ) for task in done: if task != wait_reconnect_task: task.result() if wait_reconnect_task not in done: break self._reconnect_event.clear() finally: closing_ws = True if ws is not None and not ws.closed: await ws.close() ws = None await aio.gracefully_cancel(*tasks, wait_reconnect_task) tasks_group.cancel() try: tasks_group.exception() except asyncio.CancelledError: pass finally: closing_ws = True if ws is not None and not ws.closed: await ws.close() async def _connect_ws(self) -> aiohttp.ClientWebSocketResponse: """Connect to the LiveKit Adaptive Interruption WebSocket.""" settings = InterruptionWSSessionCreateSettings( sample_rate=self._opts.sample_rate, num_channels=1, threshold=self._opts.threshold if is_given(self._opts.threshold) else None, min_frames=self._opts.min_frames, encoding="s16le", ) 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( self._session.ws_connect(f"{base_url}/bargein", headers=headers), self._conn_options.timeout, ) except ( aiohttp.ClientConnectorError, asyncio.TimeoutError, aiohttp.ClientResponseError, ) as e: if isinstance(e, aiohttp.ClientResponseError) and e.status == 429: raise APIStatusError( "LiveKit Adaptive Interruption quota exceeded", status_code=e.status, retryable=False, ) from e elif isinstance(e, asyncio.TimeoutError): raise APIConnectionError( "failed to connect to LiveKit Adaptive Interruption: timeout", retryable=False, ) from e raise APIConnectionError("failed to connect to LiveKit Adaptive Interruption") from e try: msg = InterruptionWSSessionCreateMessage( type=InterruptionWSMessageType.SESSION_CREATE, settings=settings, ) await ws.send_str(msg.model_dump_json(exclude_none=True)) except Exception as e: await ws.close() raise APIConnectionError( "failed to send session.create message to LiveKit Adaptive Interruption" ) from e return ws # endregion def _estimate_probability( probabilities: npt.NDArray[np.float32] | None, window_size: float = MIN_INTERRUPTION_DURATION ) -> float: """ Estimate the probability of the interruption event based on the probabilities of the frames. The estimated probability is the maximum of the minimum of every window_size consecutive frames. """ if probabilities is None: return 0.0 n_th = math.ceil(window_size / 0.025) # 25ms per frame if len(probabilities) < n_th: return 0.0 # return the n-th maximum of the probabilities return float(np.partition(probabilities, -n_th)[-n_th])