feat: adiciona Silero VAD local com configuração por ambiente

This commit is contained in:
alex alves
2026-11-03 17:20:51 -03:00
parent 2d1acdac31
commit d27c26c166
11 changed files with 1154 additions and 11 deletions

View File

@@ -6,7 +6,7 @@ Purpose:
application and by the dynamic provider loading system.
Responsibilities:
- Define the application configuration for STT, Agent, and TTS.
- Define the application configuration for STT, Agent, TTS, and VAD.
- Define the catalog structure for available providers.
- Represent provider-specific options without coupling the core
configuration model to a specific vendor or implementation.
@@ -31,7 +31,7 @@ Architecture:
Provider Loader
|
v
STTAdapter / AgentAdapter / TTSAdapter
STTAdapter / AgentAdapter / TTSAdapter / VADAdapter
"""
from typing import Any
@@ -70,4 +70,5 @@ class AppConfig(BaseModel):
stt: ComponentConfig
tts: ComponentConfig
agent: ComponentConfig
agent: ComponentConfig
vad: ComponentConfig | None = None

View File

@@ -10,7 +10,7 @@ Responsibilities:
- Resolve provider implementations from ProviderCatalog metadata.
- Import provider classes dynamically.
- Validate that the loaded implementation matches the expected adapter.
- Instantiate STT, Agent, and TTS providers using provider-specific options.
- Instantiate STT, Agent, TTS, and VAD providers using provider-specific options.
Non-responsibilities:
- Loading YAML files.
@@ -26,7 +26,7 @@ Architecture:
Provider Loader
|
v
STTAdapter / AgentAdapter / TTSAdapter
STTAdapter / AgentAdapter / TTSAdapter / VADAdapter
|
v
concrete provider
@@ -43,12 +43,14 @@ from voice_assistant.config.models import (
)
from voice_assistant.stt.adapter import STTAdapter
from voice_assistant.tts.adapter import TTSAdapter
from voice_assistant.vad.adapter import VADAdapter
PROVIDER_CONTRACTS: dict[str, type] = {
"stt": STTAdapter,
"agent": AgentAdapter,
"tts": TTSAdapter,
"vad": VADAdapter,
}
@@ -184,3 +186,19 @@ def create_tts_adapter(
config=config.tts,
)
def create_vad_adapter(
config: AppConfig,
catalog: ProviderCatalog,
) -> VADAdapter:
"""Create the VAD selected by the active environment configuration."""
if config.vad is None:
raise ValueError("VAD provider is not configured for this environment.")
return _create_provider(
catalog=catalog,
component="vad",
config=config.vad,
)

View File

@@ -0,0 +1,247 @@
"""Adapt the official Silero iterator to continuous PCM and async lifecycle."""
import asyncio
import logging
import math
import struct
from collections.abc import Callable
from typing import Protocol
from voice_assistant.vad.adapter import VADAdapter
from voice_assistant.vad.models import SpeechEnded, SpeechStarted, VADEvent
logger = logging.getLogger(__name__)
_WINDOW_SAMPLES = 512
_WINDOW_BYTES = 2 * _WINDOW_SAMPLES
class _Iterator(Protocol):
def __call__(
self, samples: list[float], *, return_seconds: bool,
) -> dict[str, int] | None: ...
def reset_states(self) -> None: ...
def _load_iterator(
threshold: float,
min_silence_duration_ms: int,
speech_pad_ms: int,
) -> _Iterator:
# The installed package contains the model. No torch.hub or URL loading.
from silero_vad import VADIterator, load_silero_vad
return VADIterator(
load_silero_vad(onnx=True),
threshold=threshold,
sampling_rate=16000,
min_silence_duration_ms=min_silence_duration_ms,
speech_pad_ms=speech_pad_ms,
)
class SileroVADAdapter(VADAdapter):
"""One independent CPU detector per input stream.
Model loading is lazy and occurs in the same worker as inference.
Cancellation preserves worker ownership and requires reset before
more audio can be accepted. Reset/close wait for outstanding work.
"""
def __init__(
self,
threshold: float = 0.5,
min_silence_duration_ms: int = 300,
speech_pad_ms: int = 30,
worker_timeout_seconds: float = 10.0,
) -> None:
if (
isinstance(threshold, bool)
or not isinstance(threshold, (int, float))
or not math.isfinite(threshold)
or not 0 < threshold < 1
):
raise ValueError("threshold must be finite and between 0 and 1.")
if type(min_silence_duration_ms) is not int or min_silence_duration_ms <= 0:
raise ValueError("min_silence_duration_ms must be a positive integer.")
if type(speech_pad_ms) is not int or speech_pad_ms < 0:
raise ValueError("speech_pad_ms must be a nonnegative integer.")
if (
isinstance(worker_timeout_seconds, bool)
or not isinstance(worker_timeout_seconds, (int, float))
or not math.isfinite(worker_timeout_seconds)
or worker_timeout_seconds <= 0
):
raise ValueError("worker_timeout_seconds must be finite and positive.")
self._worker_timeout_seconds = worker_timeout_seconds
self._threshold = threshold
self._min_silence_duration_ms = min_silence_duration_ms
self._speech_pad_ms = speech_pad_ms
self._iterator: _Iterator | None = None
self._lock = asyncio.Lock()
self._worker: asyncio.Task | None = None
self._needs_reset = False
self._closed = False
self._buffer = bytearray()
self._received_samples = 0
self._processed_samples = 0
self._last_boundary = 0
self._speaking = False
self._finished = False
async def process(self, audio: bytes) -> tuple[VADEvent, ...]:
self.input_format.validate_chunk(audio)
return await self._run(lambda: self._process(audio))
async def flush(self) -> tuple[VADEvent, ...]:
return await self._run(self._flush)
async def reset(self) -> None:
await self._run(self._reset, recovery=True)
async def aclose(self) -> None:
await self._run(self._close, recovery=True)
async def _run[T](
self, operation: Callable[[], T], *, recovery: bool = False,
) -> T:
async with self._lock:
await self._join_worker()
if self._needs_reset and not recovery:
raise RuntimeError("VAD requires reset after failure or cancellation.")
task = asyncio.create_task(asyncio.to_thread(operation))
self._worker = task
# Observe failures even if cancellation leaves no awaiting caller.
task.add_done_callback(self._observe_worker)
try:
result = await self._wait_worker(task)
except (asyncio.CancelledError, Exception):
self._needs_reset = True
raise
else:
if recovery:
self._needs_reset = False
return result
finally:
if task.done() and self._worker is task:
self._worker = None
@staticmethod
def _observe_worker(task: asyncio.Task) -> None:
if not task.cancelled():
task.exception()
async def _wait_worker[T](self, task: asyncio.Task[T]) -> T:
# asyncio.wait does not cancel the worker on timeout or caller cancellation.
done, _ = await asyncio.wait({task}, timeout=self._worker_timeout_seconds)
if not done:
raise TimeoutError("Silero worker exceeded its time limit; ownership retained.")
return task.result()
async def _join_worker(self) -> None:
task = self._worker
if task is None:
return
try:
await self._wait_worker(task)
except Exception:
if not task.done():
raise
logger.exception("Previous Silero worker failed; recovery is required.")
finally:
if task.done() and self._worker is task:
self._worker = None
def _require_open(self) -> None:
if self._closed:
raise RuntimeError("VAD is closed.")
def _process(self, audio: bytes) -> tuple[VADEvent, ...]:
self._require_open()
if self._finished:
raise RuntimeError("VAD input is finished; reset before new audio.")
self._received_samples += len(audio) // 2
self._buffer.extend(audio)
complete_size = len(self._buffer) - len(self._buffer) % _WINDOW_BYTES
complete = bytes(self._buffer[:complete_size])
del self._buffer[:complete_size]
events: list[VADEvent] = []
for offset in range(0, complete_size, _WINDOW_BYTES):
event = self._infer(complete[offset:offset + _WINDOW_BYTES])
if event is not None:
events.append(event)
return tuple(events)
def _infer(self, window: bytes, *, final_window: bool = False) -> VADEvent | None:
if self._iterator is None:
self._iterator = _load_iterator(
self._threshold, self._min_silence_duration_ms, self._speech_pad_ms,
)
samples = [sample / 32768.0 for sample in struct.unpack("<512h", window)]
boundary = self._iterator(samples, return_seconds=False)
self._processed_samples += _WINDOW_SAMPLES
if boundary is None:
return None
limit = min(self._processed_samples, self._received_samples)
if "start" in boundary:
if self._speaking:
raise RuntimeError("Silero emitted a duplicate speech start.")
position = min(max(boundary["start"], self._last_boundary, 0), limit)
if position >= self._received_samples:
# Synthetic tail padding must not create a new speech interval.
return None
self._speaking = True
self._last_boundary = position
return SpeechStarted(position)
if "end" in boundary:
if not self._speaking:
raise RuntimeError("Silero emitted a speech end without a start.")
position = min(max(boundary["end"], self._last_boundary, 0), limit)
self._speaking = False
self._last_boundary = position
return SpeechEnded(
position, "end_of_input" if final_window else "silence",
)
raise RuntimeError("Silero emitted an unsupported boundary.")
def _flush(self) -> tuple[VADEvent, ...]:
self._require_open()
if self._finished:
return ()
events: list[VADEvent] = []
if self._buffer:
window = bytes(self._buffer).ljust(_WINDOW_BYTES, b"\x00")
self._buffer.clear()
event = self._infer(window, final_window=True)
if event is not None:
events.append(event)
if self._speaking:
events.append(SpeechEnded(self._received_samples, "end_of_input"))
self._last_boundary = self._received_samples
self._speaking = False
self._finished = True
return tuple(events)
def _clear_input(self) -> None:
self._buffer.clear()
self._received_samples = 0
self._processed_samples = 0
self._last_boundary = 0
self._speaking = False
self._finished = False
def _reset(self) -> None:
self._require_open()
if self._iterator is not None:
self._iterator.reset_states()
self._clear_input()
def _close(self) -> None:
if self._closed:
return
self._iterator = None
self._clear_input()
self._closed = True