Source code for scribed.vad

"""Voice-activity detection and utterance segmentation for live transcription.

VAD owns utterance boundaries (never fixed time-slicing, which cuts words and
wrecks accuracy): detect speech -> buffer the utterance -> on trailing silence,
finalize. :func:`segment_utterances` turns an async stream of audio blocks into an
async stream of ``(utterance_samples, start_ms)`` pairs that a batch STT engine can
transcribe one at a time — the mechanism behind scribed's synthesized streaming
fallback (see :mod:`scribed.streaming`).

Two detectors behind a tiny :class:`VAD` protocol:

* :class:`EnergyVAD` — dependency-free RMS-threshold VAD. The default; great for
  clean/loud audio and fully deterministic (so tests need no models).
* :class:`SileroVAD` — neural VAD (accurate on noisy audio); optional, lazy.

This module needs numpy; it is imported lazily by the streaming layer so plain
``import scribed`` never pulls numpy in.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import AsyncIterator, Optional, Protocol, Tuple, runtime_checkable

import numpy as np

from scribed.audio import rms_energy

#: Default trailing-silence (ms) that marks a turn end.
DEFAULT_SILENCE_MS: int = 700
#: Minimum speech (ms) for an utterance to be worth transcribing.
DEFAULT_MIN_SPEECH_MS: int = 200
#: Force-flush an utterance that runs this long without a silence (ms).
DEFAULT_MAX_UTTERANCE_MS: int = 30_000


[docs] @runtime_checkable class VAD(Protocol): """Decides whether a block of audio contains speech."""
[docs] def is_speech(self, block: np.ndarray, sample_rate: int) -> bool: """True if ``block`` (mono float32) contains speech.""" ...
[docs] @dataclass class EnergyVAD: """RMS-energy threshold VAD — the dependency-free default. A block counts as speech when its RMS energy exceeds ``threshold`` (in the same units as the float audio, roughly ``[-1, 1]``). Simple and deterministic; for noisy real-world audio prefer :class:`SileroVAD`. """ threshold: float = 0.01
[docs] def is_speech(self, block: np.ndarray, sample_rate: int) -> bool: """True if the block's RMS energy is above ``threshold``.""" return rms_energy(np.asarray(block, dtype="float32")) >= self.threshold
[docs] @dataclass class SileroVAD: """Neural VAD via silero-vad (optional, lazy). Accurate on noisy audio. Requires ``pip install silero-vad`` (or torch.hub). Raises an informative error if unavailable; :class:`EnergyVAD` needs no dependencies. """ speech_prob_threshold: float = 0.5 def __post_init__(self) -> None: self._model = None def _ensure_model(self): # pragma: no cover - needs torch/silero if self._model is None: try: from silero_vad import load_silero_vad except ImportError as e: raise ImportError( "SileroVAD needs silero-vad. Install with: pip install silero-vad\n" "or use EnergyVAD (the dependency-free default)." ) from e self._model = load_silero_vad() return self._model
[docs] def is_speech( self, block: np.ndarray, sample_rate: int ) -> bool: # pragma: no cover """True if silero's speech probability for the block exceeds the threshold.""" import torch model = self._ensure_model() tensor = torch.from_numpy(np.ascontiguousarray(block, dtype="float32")) prob = float(model(tensor, sample_rate).item()) return prob >= self.speech_prob_threshold
[docs] async def segment_utterances( frames: AsyncIterator[np.ndarray], *, sample_rate: int, vad: Optional[VAD] = None, silence_ms: int = DEFAULT_SILENCE_MS, min_speech_ms: int = DEFAULT_MIN_SPEECH_MS, max_utterance_ms: int = DEFAULT_MAX_UTTERANCE_MS, ) -> AsyncIterator[Tuple[np.ndarray, int]]: """Group an async stream of audio blocks into utterances. Yields ``(utterance_samples, start_ms)`` once per finalized utterance: speech accumulates into a buffer; after ``silence_ms`` of trailing silence (or ``max_utterance_ms`` of continuous speech) the buffer is emitted with its start offset (integer ms from the beginning of the stream). Utterances with less than ``min_speech_ms`` of actual speech are dropped as noise. ``sample_rate`` is the rate of the incoming blocks (used only for the ms arithmetic). """ vad = vad or EnergyVAD() buf: list[np.ndarray] = [] in_speech = False trailing_silence_ms = 0.0 speech_ms = 0.0 consumed = 0 # absolute samples consumed so far start_sample = 0 def _emit() -> Optional[Tuple[np.ndarray, int]]: if buf and speech_ms >= min_speech_ms: return np.concatenate(buf), int(round(start_sample / sample_rate * 1000)) return None async for block in frames: block = np.asarray(block, dtype="float32") block_ms = len(block) / sample_rate * 1000 is_speech = vad.is_speech(block, sample_rate) if is_speech: if not in_speech: in_speech, buf, trailing_silence_ms, speech_ms = True, [], 0.0, 0.0 start_sample = consumed buf.append(block) speech_ms += block_ms trailing_silence_ms = 0.0 elif in_speech: buf.append(block) # keep a little trailing silence for the decoder trailing_silence_ms += block_ms if trailing_silence_ms >= silence_ms: emitted = _emit() if emitted is not None: yield emitted in_speech, buf = False, [] consumed += len(block) if ( in_speech and (consumed - start_sample) / sample_rate * 1000 >= max_utterance_ms ): emitted = _emit() if emitted is not None: yield emitted in_speech, buf = False, [] if in_speech: # flush the final utterance at end-of-stream emitted = _emit() if emitted is not None: yield emitted