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

203 lines
6.5 KiB
Python

from __future__ import annotations
import asyncio
import time
from abc import ABC, abstractmethod
from collections.abc import AsyncIterable, AsyncIterator
from dataclasses import dataclass, field
from enum import Enum, unique
from typing import Literal
from livekit import rtc
from livekit.agents.metrics.base import Metadata
from .metrics import VADMetrics
from .utils import aio
@unique
class VADEventType(str, Enum):
START_OF_SPEECH = "start_of_speech"
INFERENCE_DONE = "inference_done"
END_OF_SPEECH = "end_of_speech"
@dataclass
class VADEvent:
"""
Represents an event detected by the Voice Activity Detector (VAD).
"""
type: VADEventType
"""Type of the VAD event (e.g., start of speech, end of speech, inference done)."""
samples_index: int
"""Index of the audio sample where the event occurred, relative to the inference sample rate."""
timestamp: float
"""Timestamp (in seconds) when the event was fired."""
speech_duration: float
"""Duration of the speech segment in seconds."""
silence_duration: float
"""Duration of the silence segment in seconds."""
frames: list[rtc.AudioFrame] = field(default_factory=list)
"""
List of audio frames associated with the speech.
- For `start_of_speech` events, this contains the audio chunks that triggered the detection.
- For `inference_done` events, this contains the audio chunks that were processed.
- For `end_of_speech` events, this contains the complete user speech.
"""
probability: float = 0.0
"""Probability that speech is present (only for `INFERENCE_DONE` events)."""
inference_duration: float = 0.0
"""Time taken to perform the inference, in seconds (only for `INFERENCE_DONE` events)."""
speaking: bool = False
"""Indicates whether speech was detected in the frames."""
raw_accumulated_silence: float = 0.0
"""Threshold used to detect silence."""
raw_accumulated_speech: float = 0.0
"""Threshold used to detect speech."""
@dataclass
class VADCapabilities:
update_interval: float
class VAD(ABC, rtc.EventEmitter[Literal["metrics_collected"]]):
def __init__(self, *, capabilities: VADCapabilities) -> None:
super().__init__()
self._capabilities = capabilities
self._label = f"{type(self).__module__}.{type(self).__name__}"
@property
def model(self) -> str:
return "unknown"
@property
def provider(self) -> str:
return "unknown"
@property
def capabilities(self) -> VADCapabilities:
return self._capabilities
@abstractmethod
def stream(self) -> VADStream: ...
class VADStream(ABC):
class _FlushSentinel:
pass
def __init__(self, vad: VAD) -> None:
self._vad = vad
self._last_activity_time = time.perf_counter()
self._input_ch = aio.Chan[rtc.AudioFrame | VADStream._FlushSentinel]()
self._event_ch = aio.Chan[VADEvent]()
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="TTS._metrics_task"
)
self._task = asyncio.create_task(self._main_task())
self._task.add_done_callback(lambda _: self._event_ch.close())
@abstractmethod
async def _main_task(self) -> None: ...
async def _metrics_monitor_task(self, event_aiter: AsyncIterable[VADEvent]) -> None:
"""Task used to collect metrics"""
inference_duration_total = 0.0
inference_count = 0
async for ev in event_aiter:
if ev.type == VADEventType.INFERENCE_DONE:
inference_duration_total += ev.inference_duration
inference_count += 1
if inference_count >= 1 / self._vad.capabilities.update_interval:
vad_metrics = VADMetrics(
timestamp=time.time(),
idle_time=time.perf_counter() - self._last_activity_time,
inference_duration_total=inference_duration_total,
inference_count=inference_count,
label=self._vad._label,
metadata=Metadata(
model_name=self._vad.model, model_provider=self._vad.provider
),
)
self._vad.emit("metrics_collected", vad_metrics)
inference_duration_total = 0.0
inference_count = 0
elif ev.type in [VADEventType.START_OF_SPEECH, VADEventType.END_OF_SPEECH]:
self._last_activity_time = time.perf_counter()
def push_frame(self, frame: rtc.AudioFrame) -> 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.
Implementations MUST treat this as a hard segment boundary: drop any accumulated
speech/silence state so the next pushed frame starts a fresh segment. Used by the
pipeline to recover from out-of-band end-of-turn signals (e.g. STT EOS) without
tearing down and recreating the stream.
"""
self._check_input_not_ended()
self._check_not_closed()
self._input_ch.send_nowait(self._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()
await self._metrics_task
await self._tee_aiter.aclose()
async def __anext__(self) -> VADEvent:
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[VADEvent]:
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")