aloof-wall-83154
03/17/2026, 6:36 AMtall-airline-16756
03/17/2026, 6:37 AMaloof-wall-83154
03/17/2026, 6:37 AMtall-airline-16756
03/17/2026, 6:40 AMimport asyncio
import numpy as np
from tenvad import VAD # adapt to actual ten-vad import path
import resampy # pip install resampy
from typing import AsyncIterable
# Configuration
SRC_SAMPLE_RATE = 48000 # incoming LiveKit frame rate (adjust if different)
TARGET_SR = 16000 # ten-vad expected rate
WINDOW_MS = 200 # analyze every 200ms
SAMPLES_PER_WINDOW = int(TARGET_SR * WINDOW_MS / 1000)
# Simple helper to convert interleaved bytes to mono int16 numpy
def bytes_to_mono_int16(frame_bytes: bytes, src_sample_rate: int, channels: int = 1) -> np.ndarray:
# assuming 16-bit PCM little-endian
audio = np.frombuffer(frame_bytes, dtype=np.int16)
if channels > 1:
audio = audio.reshape(-1, channels)
audio = audio.mean(axis=1).astype(np.int16) # downmix to mono
return audio
class TenVADWrapper:
def __init__(self, model_name: str = "default"):
# Initialize the TEN VAD model - adjust constructor to library API
self.vad = VAD(model_name=model_name)
self.buffer = np.array([], dtype=np.int16)
def process_pcm_frame(self, pcm_int16: np.ndarray, src_sr: int):
# Resample to target if needed
if src_sr != TARGET_SR:
pcm_float = pcm_int16.astype(np.float32) / 32768.0
resampled = resampy.resample(pcm_float, src_sr, TARGET_SR)
pcm_int16 = (resampled * 32767).astype(np.int16)
# Append to buffer
self.buffer = np.concatenate([self.buffer, pcm_int16])
results = []
# Consume windows
while len(self.buffer) >= SAMPLES_PER_WINDOW:
window = self.buffer[:SAMPLES_PER_WINDOW]
self.buffer = self.buffer[SAMPLES_PER_WINDOW:]
# TEN VAD API: adapt this call to the real one
# assume self.vad.is_speech(np_array, sample_rate) -> bool
is_speech = self.vad.is_speech(window.astype(np.float32) / 32768.0, sampling_rate=TARGET_SR)
results.append(is_speech)
return results
# Example consumer that integrates with a LiveKit audio stream
async def vad_consumer(audio_frame_stream: AsyncIterable[bytes], src_sr: int, channels: int = 1):
ten_vad = TenVADWrapper()
in_speech = False
async for frame_bytes in audio_frame_stream:
pcm = bytes_to_mono_int16(frame_bytes, src_sr, channels=channels)
vad_results = ten_vad.process_pcm_frame(pcm, src_sr)
for speech in vad_results:
if speech and not in_speech:
in_speech = True
# Voice started
print("VAD: voice started")
# Trigger start STT, mark turn, etc.
elif not speech and in_speech:
in_speech = False
# Voice ended
print("VAD: voice ended")
# Trigger stop STT, finalize utterance, etc.
# Example usage:
# - replace `my_livekit_audio_stream()` with how you obtain raw PCM frames from LiveKit SDK
# asyncio.create_task(vad_consumer(my_livekit_audio_stream(), SRC_SAMPLE_RATE, channels=2))
Practical tips and choices
• Window size: 200 ms is common to get stable speech decisions; you can use smaller windows (20–30 ms) but you’ll need a smoother (hangover) to avoid chopping.
• Hangover logic: implement a short post-speech grace period (e.g., keep considered “in speech” for 200–400ms after last positive frame) to avoid flicker.
• Batch STT sending: start STT on voice start and send buffered voiced audio; stop on voice end and finalize transcription. This reduces costly STT calls.
• Performance: run VAD inference on CPU in an asyncio Task; if many participants, consider batching or a worker pool to avoid blocking.
• Thread-safety: if the VAD library is not async-safe, call it inside ThreadPoolExecutor via asyncio.get_running_loop().run_in_executor.
Adapting to LiveKit agent specifics
• If you receive frames as livekit.rtc.AudioFrame or an AudioStream wrapper, extract the raw bytes similarly to the helper above. See the LiveKit AudioStream helper for how frames are yielded (example reference: audio_stream API).
• If you want to insert this as a VAD node or pipeline node inside LiveKit Agents, create a custom node that wraps STT nodes and filters frames using the ten-vad decisions (LiveKit supports custom pipeline nodes).
Edge cases and validation
• Confirm ten-vad API: replace VAD(...).is_speech(...) with actual ten-vad function names and parameter types (ten-vad README shows exact usage).
• Validate the expected sample rate and dtype for ten-vad; adjust conversion accordingly.
• Test with recorded audio samples first, then live streams.
• Add logging and metrics (start/stop counts, false positive rate) to tune thresholds.
If you want, I can:
• Produce a concrete, runnable example tailored to the actual ten-vad API (I’ll inspect the repo usage signature if you want me to pull the exact function names).
• Convert the sketch into a LiveKit Agent node implementation that filters audio before STT, including hangover and batching logic.
Which of those would you like next?
Sources: Silero VAD plugin | LiveKit Documentation | Pipeline nodes and hooks | LiveKit Documentation | livekit-rtc/livekit/rtc/audio_stream.py | Speech-to-text (STT) models overview | LiveKit Documentation, with interactive elementsaloof-wall-83154
03/17/2026, 6:45 AMtall-airline-16756
03/17/2026, 6:45 AMVAD(...).is_speech()), paste it; otherwise I’ll infer from the repo.
1. Integration style (pick one)
◦ A: Replace built-in VAD/turn_detection and register TEN VAD as the agent’s vad plugin (preferred if you want centralized turn detection).
◦ B: Pre-filter audio before calling your existing STT adapter (minimal change).
◦ C: As a separate async task that listens to tracks and emits start/end events to your AgentSession (non-invasive).
1. Behavior on VAD events
◦ What should happen on voice-start and voice-end? (e.g., start/stop STT, send RPC to client, buffer audio for a single STT call)
◦ Desired hangover (post-speech tail) in ms? Common values: 200–400 ms.
1. Runtime constraints
◦ Python version and whether you can install native deps (wheels)? This affects resampling choices.
◦ Approximate concurrent live audio streams (1, 10, 100) — helps sizing worker pool.
If you want a default working example right away, I can provide one that assumes:
• LiveKit Python agent receives 48 kHz stereo AudioFrame objects,
• We resample to 16 kHz mono,
• Use ten-vad via a blocking API wrapped in ThreadPoolExecutor,
• Integration style B (pre-filter before STT),
• Hangover = 300 ms,
• Works for single or a few concurrent streams without special scaling.
Tell me whether you want the default now or answer the 6 questions above and I’ll tailor the file to your environment.
Sources: Turns overview | LiveKit Documentation | Speech-to-text (STT) models overview | LiveKit Documentation, with interactive elements