Files
2026-07-13 13:39:38 +08:00

1061 lines
41 KiB
Python

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("<Q", created_at) # 8 bytes
await ws.send_bytes(header + audio_data.tobytes())
self._cache[created_at] = InterruptionCacheEntry(
created_at=created_at,
speech_input=audio_data,
)
closing_ws = True
msg = InterruptionWSSessionCloseMessage(
type=InterruptionWSMessageType.SESSION_CLOSE,
)
await ws.send_str(msg.model_dump_json())
async def recv_task(ws: aiohttp.ClientWebSocketResponse) -> 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])