"""Utterance detection: adaptive energy VAD with hangover. Deliberately simple and dependency-free; swap for silero/webrtcvad later if the Phase-0 PoC shows it is needed. Operates on 20 ms SLIN frames. """ from __future__ import annotations from dataclasses import dataclass, field from gogo.voice.audio import FRAME_MS, rms @dataclass class VadConfig: # energy threshold = max(min_threshold, noise_floor * ratio) min_threshold: int = 500 noise_ratio: float = 3.0 start_ms: int = 120 # this much voiced audio starts an utterance end_ms: int = 700 # this much silence ends it max_utterance_ms: int = 15000 # hard cap pre_roll_ms: int = 200 # audio kept from before the trigger @dataclass class UtteranceCollector: """Feed 20 ms frames; returns the full utterance PCM when one completes.""" config: VadConfig = field(default_factory=VadConfig) _noise_floor: float = 200.0 _voiced_ms: int = 0 _silence_ms: int = 0 _in_utterance: bool = False _buffer: bytearray = field(default_factory=bytearray) _pre_roll: bytearray = field(default_factory=bytearray) def is_speech(self, frame: bytes) -> bool: energy = rms(frame) threshold = max(self.config.min_threshold, self._noise_floor * self.config.noise_ratio) speech = energy > threshold if not speech: # slowly track the noise floor on non-speech frames self._noise_floor = 0.95 * self._noise_floor + 0.05 * energy return speech def feed(self, frame: bytes) -> bytes | None: """Returns utterance PCM when a complete utterance is detected, else None.""" speech = self.is_speech(frame) if not self._in_utterance: self._pre_roll.extend(frame) max_pre = self.config.pre_roll_ms * len(frame) // FRAME_MS if len(self._pre_roll) > max_pre: del self._pre_roll[: len(self._pre_roll) - max_pre] if speech: self._voiced_ms += FRAME_MS if self._voiced_ms >= self.config.start_ms: self._in_utterance = True self._buffer = bytearray(self._pre_roll) self._silence_ms = 0 else: self._voiced_ms = 0 return None self._buffer.extend(frame) if speech: self._silence_ms = 0 else: self._silence_ms += FRAME_MS utterance_ms = len(self._buffer) * FRAME_MS // len(frame) if self._silence_ms >= self.config.end_ms or utterance_ms >= self.config.max_utterance_ms: utterance = bytes(self._buffer) self.reset() return utterance return None def reset(self) -> None: self._in_utterance = False self._voiced_ms = 0 self._silence_ms = 0 self._buffer = bytearray() self._pre_roll = bytearray()