feat: adiciona Silero VAD local com configuração por ambiente
This commit is contained in:
247
src/voice_assistant/vad/silero.py
Normal file
247
src/voice_assistant/vad/silero.py
Normal 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
|
||||
Reference in New Issue
Block a user