Files
vllm-omni/vllm_omni/engine/duplex/vad.py
T
chickeytonandClaude Opus 5 99ff4f3070 [Core][Frontend] Unified Full-duplex Framework (#7413)
Signed-off-by: chickeyton <ngton2014@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 15:35:36 +08:00

489 lines
20 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Silero server VAD for the engine-resident duplex session.
The detector keeps the split upstream's serving-side ``server_vad`` module
used, because the split is what makes one model serve many sessions: a
**backend** scores one 512-sample frame and is otherwise stateless, and the
per-stream state -- the partial frame, the model state, the endpoint counters --
belongs to :class:`SileroStreamingVAD`, one per session.
Two backends. :class:`SileroVADBackend` runs the pinned Silero v6.2 ONNX graph on
CPU and is shared process-wide, which is why its state is passed in and out
rather than held on the session object. :class:`TorchSileroBackend` loads the
same model through the ``silero-vad`` package for environments that have no
local ONNX artifact; torch keeps its state inside the module, so that one is per
session.
Endpointing follows Silero v6.2's streaming hysteresis: activation uses the
configured threshold, while a turn can only *end* on frames below
``max(threshold - 0.15, 0.01)``. Once a silence candidate exists, louder frames
keep the elapsed-silence clock running but cannot themselves close the turn.
"""
from __future__ import annotations
import binascii
import hashlib
import threading
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import Protocol
import numpy as np
import pybase64 as base64
from vllm.logger import init_logger
logger = init_logger(__name__)
SILERO_VAD_REPO_ID = "istupakov/silero-vad-onnx"
SILERO_VAD_REVISION = "8b14476858ef240c50b3884bb38cc67290c1cc70"
SILERO_VAD_FILENAME = "silero_vad.onnx"
SILERO_VAD_SHA256 = "1a153a22f4509e292a94e67d6f9b85e8deb25b4988682b7e174c65279d8788e3"
SILERO_VAD_MIN_THRESHOLD = 0.15
_SAMPLE_RATE_HZ = 16_000
_FRAME_SAMPLES = 512
class ServerVADUnavailableError(RuntimeError):
pass
@dataclass(frozen=True, slots=True)
class SileroVADConfig:
threshold: float = 0.5
prefix_padding_ms: int = 300
silence_duration_ms: int = 500
min_speech_duration_ms: int = 96
@dataclass(frozen=True, slots=True)
class StreamingVADResult:
is_speech: bool
speech_active: bool
speech_started: bool = False
speech_stopped: bool = False
speech_probability: float = 0.0
speech_start_ms: int | None = None
speech_end_ms: int | None = None
# --------------------------------------------------------------------------- #
# Backends #
# --------------------------------------------------------------------------- #
class SpeechDetectorBackend(Protocol):
"""Scores one frame. Per-stream state is created here but owned by the caller."""
frame_samples: int
def new_state(self) -> object: ...
def infer(self, frame: np.ndarray, state: object) -> tuple[float, object]: ...
@dataclass(frozen=True, slots=True)
class _SileroVADState:
model_state: np.ndarray
context: np.ndarray
class SileroVADBackend:
"""Shared ONNX Runtime Silero v6.2 detector running on CPU."""
sample_rate_hz = _SAMPLE_RATE_HZ
frame_samples = _FRAME_SAMPLES
context_samples = 64
model_state_shape = (2, 1, 128)
def __init__(self, model_path: str | Path) -> None:
try:
import onnxruntime as ort
except ImportError as exc: # pragma: no cover - platform packaging supplies ORT.
raise ServerVADUnavailableError("server_vad requires ONNX Runtime") from exc
session_options = ort.SessionOptions()
session_options.inter_op_num_threads = 1
session_options.intra_op_num_threads = 1
self.model_path = Path(model_path)
self._session = ort.InferenceSession(
str(self.model_path),
providers=["CPUExecutionProvider"],
sess_options=session_options,
)
input_names = {item.name for item in self._session.get_inputs()}
if not {"input", "sr", "state"} <= input_names:
raise ServerVADUnavailableError(f"Unsupported Silero ONNX input contract: {sorted(input_names)}")
self._warm_up()
def _warm_up(self) -> None:
self.infer(np.zeros(self.frame_samples, dtype=np.float32), self.new_state())
def new_state(self) -> _SileroVADState:
return _SileroVADState(
model_state=np.zeros(self.model_state_shape, dtype=np.float32),
context=np.zeros((1, self.context_samples), dtype=np.float32),
)
def infer(self, frame: np.ndarray, state: object) -> tuple[float, object]:
if not isinstance(state, _SileroVADState):
raise TypeError("Silero detector state must be created by SileroVADBackend.new_state()")
model_state = np.ascontiguousarray(state.model_state, dtype=np.float32)
context = np.ascontiguousarray(state.context, dtype=np.float32)
if model_state.shape != self.model_state_shape:
raise ValueError(f"Silero model state must have shape {self.model_state_shape}, got {model_state.shape}")
expected_context_shape = (1, self.context_samples)
if context.shape != expected_context_shape:
raise ValueError(f"Silero context must have shape {expected_context_shape}, got {context.shape}")
audio = np.ascontiguousarray(frame, dtype=np.float32).reshape(1, -1)
if audio.shape[1] != self.frame_samples:
raise ValueError(
f"Silero detector frame must contain exactly {self.frame_samples} samples, got {audio.shape[1]}"
)
model_input = np.concatenate((context, audio), axis=1)
# ONNX Runtime permits concurrent Run calls on one CPU session. Stream
# state is explicit, so independent sessions can safely share this one.
output = self._session.run(
None,
{
"input": model_input,
"state": model_state,
"sr": np.asarray(self.sample_rate_hz, dtype=np.int64),
},
)
if len(output) < 2:
raise ServerVADUnavailableError("Silero ONNX model did not return probability and model state")
probability = float(np.asarray(output[0]).reshape(-1)[0])
next_state = _SileroVADState(
model_state=np.ascontiguousarray(output[1], dtype=np.float32),
context=np.ascontiguousarray(model_input[:, -self.context_samples :], dtype=np.float32),
)
return probability, next_state
class TorchSileroBackend:
"""The same model through the ``silero-vad`` package, for hosts with no ONNX artifact.
Torch keeps the recurrent state inside the module, so unlike the ONNX
backend this one cannot be shared: each session gets its own instance and
``new_state`` only resets it.
"""
sample_rate_hz = _SAMPLE_RATE_HZ
frame_samples = _FRAME_SAMPLES
def __init__(self) -> None:
try:
import torch
from silero_vad import load_silero_vad
except ImportError as exc:
raise ServerVADUnavailableError(
"server_vad needs either a local Silero ONNX artifact (see "
"duplex_session.server_vad_model_path) or the optional 'silero-vad' "
"package; install vllm-omni[server-vad]"
) from exc
self._torch = torch
self._model = load_silero_vad(onnx=False)
def new_state(self) -> object:
reset_states = getattr(self._model, "reset_states", None)
if callable(reset_states):
reset_states()
return None
def infer(self, frame: np.ndarray, state: object) -> tuple[float, object]:
tensor = self._torch.from_numpy(np.ascontiguousarray(frame, dtype=np.float32))
return float(self._model(tensor, self.sample_rate_hz).item()), state
class SileroVADBackendProvider:
"""Resolve, verify and load one detector backend per engine process.
ONNX is preferred and shared; the torch package is the fallback and is built
per call because its state is internal.
"""
def __init__(self, *, model_path: str | None = None) -> None:
self.model_path = model_path
self._backend: SileroVADBackend | None = None
self._lock = threading.Lock()
def get(self) -> SpeechDetectorBackend:
"""The shared ONNX backend, or a per-session torch one when it cannot be built.
An explicitly configured ``server_vad_model_path`` never falls back: the
operator named that artifact, so a missing file or a missing ONNX Runtime
is their error, not something to paper over with a different model.
"""
if self._backend is not None:
return self._backend
with self._lock:
if self._backend is not None:
return self._backend
path = self._resolve_local_artifact()
if path is None:
# No pinned artifact: the torch package keeps state internally,
# so this instance belongs to one session only.
return TorchSileroBackend()
self._verify_checksum(path)
try:
self._backend = SileroVADBackend(path)
except ServerVADUnavailableError:
if self.model_path:
raise
logger.warning(
"Silero ONNX artifact %s found but ONNX Runtime is unavailable; "
"falling back to the torch 'silero-vad' package (one model per session)",
path,
)
return TorchSileroBackend()
return self._backend
def _resolve_local_artifact(self) -> Path | None:
if self.model_path:
path = Path(self.model_path).expanduser()
if path.is_file():
return path
# Explicitly configured: a missing file is an error, not a fallback.
raise ServerVADUnavailableError(f"Configured Silero VAD model does not exist: {path}")
try:
from vllm.transformers_utils.repo_utils import try_get_local_file
cached = try_get_local_file(
model=SILERO_VAD_REPO_ID,
file_name=SILERO_VAD_FILENAME,
revision=SILERO_VAD_REVISION,
)
except Exception:
return None
return cached if isinstance(cached, Path) and cached.is_file() else None
def _verify_checksum(self, path: Path) -> None:
digest = hashlib.sha256(path.read_bytes()).hexdigest()
if digest != SILERO_VAD_SHA256:
raise ServerVADUnavailableError(
f"Silero VAD checksum mismatch for {path}: expected {SILERO_VAD_SHA256}, got {digest}"
)
# --------------------------------------------------------------------------- #
# Per-session streaming detector #
# --------------------------------------------------------------------------- #
class SileroStreamingVAD:
_SAMPLE_RATE_HZ = _SAMPLE_RATE_HZ
_WINDOW_SAMPLES = _FRAME_SAMPLES
def __init__(
self,
config: SileroVADConfig,
*,
backend: SpeechDetectorBackend | None = None,
backend_provider: SileroVADBackendProvider | None = None,
frame_scorer: Callable[[np.ndarray], float] | None = None,
) -> None:
self.config = config
self._explicit_backend = backend
self._provider = backend_provider
self._frame_scorer = frame_scorer
self._backend: SpeechDetectorBackend | None = backend
self._detector_state: object = None
self._clear_stream_state()
def _clear_stream_state(self) -> None:
self._pending = np.empty(0, dtype=np.float32)
self._speech_active = False
self._candidate_samples = 0
self._candidate_start_sample: int | None = None
self._silence_samples = 0
self._processed_samples = 0
self._stream_start_ms = 0
self._resample_rate_hz: int | None = None
self._resample_remainder = 0
@property
def scratch_bytes(self) -> int:
"""Bytes this detector holds between chunks (the partial frame only)."""
return int(self._pending.nbytes)
@property
def speech_active(self) -> bool:
return self._speech_active
def reset(self) -> None:
"""Drop stream state while preserving the session-relative audio clock.
The clock survives so ``speech_start_ms`` after a barge-in still refers
to the session's timeline; ``_stream_start_ms`` then stops the prefix
padding reaching back into audio that was discarded.
"""
stream_start_ms = round(self._processed_samples * 1000 / self._SAMPLE_RATE_HZ)
processed = self._processed_samples
self._clear_stream_state()
self._processed_samples = processed
self._stream_start_ms = stream_start_ms
if self._backend is not None:
self._detector_state = self._backend.new_state()
# ------------------------------------------------------------------ #
# Input #
# ------------------------------------------------------------------ #
def process_base64(
self,
audio: object,
*,
fmt: object,
sample_rate_hz: object,
) -> StreamingVADResult:
if not isinstance(audio, str):
raise ValueError("Silero server VAD requires base64 audio")
try:
raw = base64.b64decode(audio, validate=True)
except (binascii.Error, ValueError) as exc:
raise ValueError("Silero server VAD received invalid base64 audio") from exc
rate = int(sample_rate_hz) if isinstance(sample_rate_hz, int | float) else self._SAMPLE_RATE_HZ
if rate <= 0:
raise ValueError("Silero server VAD requires a positive sample rate")
if fmt == "pcm_f32le":
if len(raw) % 4:
raise ValueError("Silero server VAD received an incomplete pcm_f32le frame")
samples = np.frombuffer(raw, dtype="<f4").astype(np.float32, copy=False)
elif fmt == "pcm16":
if len(raw) % 2:
raise ValueError("Silero server VAD received an incomplete pcm16 sample")
samples = np.frombuffer(raw, dtype="<i2").astype(np.float32) * np.float32(1.0 / 32768.0)
else:
raise ValueError("Silero server VAD requires pcm_f32le or pcm16 audio")
return self.process(self._to_16k(samples, rate))
def _to_16k(self, samples: np.ndarray, rate: int) -> np.ndarray:
"""Resample to 16 kHz, carrying the fractional sample-count remainder.
A fallback path: the session runner already normalises input to 16 kHz
through ``convert_input_audio_with_rate`` before the detector sees it,
so this runs only for callers that feed the VAD directly.
Carrying the remainder keeps the *number* of output samples exact, so
frame boundaries cannot drift over a long stream. The interpolation
itself is per chunk and has no filter state, so sample values near a
chunk edge differ slightly from resampling the same audio in one go --
enough for the frame grid to stay put, not enough to call it a
streaming resampler.
"""
if rate == self._SAMPLE_RATE_HZ:
self._resample_rate_hz = None
self._resample_remainder = 0
return samples
if self._resample_rate_hz is not None and rate != self._resample_rate_hz:
raise ValueError("server_vad input sample rate cannot change within a continuous audio stream")
self._resample_rate_hz = rate
numerator = samples.size * self._SAMPLE_RATE_HZ + self._resample_remainder
target_size, self._resample_remainder = divmod(numerator, rate)
if target_size == 0:
return np.empty(0, dtype=np.float32)
if samples.size == 1:
return np.full(target_size, samples[0], dtype=np.float32)
source_x = np.linspace(0.0, 1.0, num=samples.size, endpoint=True)
target_x = np.linspace(0.0, 1.0, num=target_size, endpoint=True)
return np.interp(target_x, source_x, samples).astype(np.float32)
# ------------------------------------------------------------------ #
# Endpointing #
# ------------------------------------------------------------------ #
def process(self, samples: np.ndarray) -> StreamingVADResult:
samples = np.asarray(samples, dtype=np.float32).reshape(-1)
if samples.size:
self._pending = np.concatenate((self._pending, samples))
started = False
stopped = False
start_ms: int | None = None
end_ms: int | None = None
max_probability = 0.0
contained_speech = self._speech_active
min_speech_samples = max(1, round(self.config.min_speech_duration_ms * self._SAMPLE_RATE_HZ / 1000))
min_silence_samples = max(1, round(self.config.silence_duration_ms * self._SAMPLE_RATE_HZ / 1000))
negative_threshold = max(self.config.threshold - SILERO_VAD_MIN_THRESHOLD, 0.01)
pending = self._pending
offset = 0
while pending.size - offset >= self._WINDOW_SAMPLES:
frame = np.ascontiguousarray(pending[offset : offset + self._WINDOW_SAMPLES], dtype=np.float32)
offset += self._WINDOW_SAMPLES
frame_start = self._processed_samples
self._processed_samples += self._WINDOW_SAMPLES
probability = min(1.0, max(0.0, self._score_frame(frame)))
max_probability = max(max_probability, probability)
if self._speech_active:
contained_speech = True
below_negative_threshold = probability < negative_threshold
if not below_negative_threshold and self._silence_samples == 0:
continue
# A silence candidate is running: louder frames keep its clock
# moving but cannot themselves close the turn.
self._silence_samples += self._WINDOW_SAMPLES
if not below_negative_threshold or self._silence_samples < min_silence_samples:
continue
self._speech_active = False
self._silence_samples = 0
stopped = True
# OpenAI defines audio_end_ms as the end of the audio sent to
# the model, trailing silence included.
end_ms = max(0, round(self._processed_samples * 1000 / self._SAMPLE_RATE_HZ))
continue
if probability >= self.config.threshold:
if self._candidate_samples == 0:
self._candidate_start_sample = frame_start
self._candidate_samples += self._WINDOW_SAMPLES
if self._candidate_samples >= min_speech_samples:
candidate_start = self._candidate_start_sample or 0
detected_start_ms = round(candidate_start * 1000 / self._SAMPLE_RATE_HZ)
self._speech_active = True
self._candidate_samples = self._silence_samples = 0
self._candidate_start_sample = None
contained_speech = True
started = True
# Prefix audio from before the last reset is gone.
start_ms = max(
self._stream_start_ms,
max(0, detected_start_ms - self.config.prefix_padding_ms),
)
else:
self._candidate_samples = 0
self._candidate_start_sample = None
# Copy the residual once, after the frames are scored, so a large input
# chunk is neither pinned by a view nor re-copied for every frame.
self._pending = pending[offset:].copy()
return StreamingVADResult(
contained_speech, self._speech_active, started, stopped, max_probability, start_ms, end_ms
)
def _score_frame(self, frame: np.ndarray) -> float:
if self._frame_scorer is not None:
return float(self._frame_scorer(frame))
backend = self._require_backend()
probability, self._detector_state = backend.infer(frame, self._detector_state)
return float(probability)
def _require_backend(self) -> SpeechDetectorBackend:
if self._backend is None:
provider = self._provider or SileroVADBackendProvider()
self._backend = provider.get()
self._detector_state = self._backend.new_state()
return self._backend