Files
esp32-server/app/audio/vad.py
Macky b8950b38a4 feat: Phase 1 testable bridge skeleton (18 tests green)
Xiaozhi WebSocket endpoint with handshake/auth, mock STT/TTS/Hermes
backends, Thai chunker, barge-in queue, latency logger, GPU planner,
voice-profile guard (reasoning=none, session_search only).

CON-002: no GPU/audio libs loaded at import. MUST-NOT-001: all model
names/tokens from config.
2026-10-03 11:45:31 +07:00

72 lines
2.2 KiB
Python

"""Voice-activity / end-of-speech detection (REQ-002).
Two implementations behind one interface:
* :class:`EnergyVAD` — frame-level RMS hysteresis. No model, runs anywhere.
Good enough for a PoC and for tests.
* :class:`SileroVAD` — lazy adapter for a real VAD model (webrtcvad/silero)
loaded only if selected.
The interface is binary per frame: ``active`` means "speech in this frame".
End-of-speech is derived by the session: a gap of silence >= ``min_silence_ms``
after speech ends an utterance.
"""
from __future__ import annotations
import struct
from abc import ABC, abstractmethod
class VAD(ABC):
@abstractmethod
def is_speech(self, pcm: bytes) -> bool:
"""Return True if ``pcm`` (one frame) contains speech."""
class EnergyVAD(VAD):
"""RMS hysteresis VAD over 16-bit mono PCM.
``threshold`` is the RMS floor. ``rise_frames`` frames must exceed it to
trigger speech; ``fall_frames`` below it to stop — simple anti-flap.
"""
def __init__(self, threshold: float = 300.0) -> None:
self.threshold = threshold
def is_speech(self, pcm: bytes) -> bool:
samples = max(1, len(pcm) // 2)
# Unpack as many int16 samples as available (pad-safe).
n = (len(pcm) // 2) * 2
if n == 0:
return False
try:
samples = struct.unpack(f"<{n // 2}h", pcm[:n])
except struct.error:
return False
if not samples:
return False
rms = (sum(s * s for s in samples) / len(samples)) ** 0.5
return rms >= self.threshold
class SileroVAD(VAD):
"""Lazy adapter. Import fails on machines without the model installed —
that is expected and fine (CON-002); select EnergyVAD (default) there."""
def __init__(self) -> None:
# Imported at construction, not at module load.
import webrtcvad # type: ignore # noqa: F401
self._vad = webrtcvad.Vad(2)
def is_speech(self, pcm: bytes) -> bool:
return self._vad.is_speech(pcm, 16000)
def make_vad(kind: str = "energy", **kw) -> VAD:
if kind == "energy":
return EnergyVAD(**kw)
if kind == "silero":
return SileroVAD()
raise ValueError(f"unknown vad kind: {kind}")