"""Pure streaming voice-clip state machine.""" from __future__ import annotations from collections import deque from dataclasses import dataclass from datetime import datetime, timedelta import numpy as np SAMPLE_RATE = 16_000 @dataclass(frozen=True) class Start: started_at: datetime samples: np.ndarray @dataclass(frozen=True) class Audio: samples: np.ndarray @dataclass(frozen=True) class End: pass class Segmenter: """Emit stream events while retaining only the configured pre-roll in memory.""" def __init__( self, *, pre_roll_seconds: float, silence_seconds: float, max_segment_seconds: float = 600, sample_rate: int = SAMPLE_RATE, ) -> None: self.sample_rate = sample_rate self.pre_roll_samples = round(pre_roll_seconds * sample_rate) self.silence_samples_limit = round(silence_seconds * sample_rate) self.max_segment_samples = round(max_segment_seconds * sample_rate) self._pre_roll: deque[np.ndarray] = deque() self._pre_roll_size = 0 self._active = False self._segment_samples = 0 self._silence_samples = 0 @property def active(self) -> bool: return self._active def push( self, samples: np.ndarray, is_speech: bool, captured_at: datetime ) -> tuple[Start | Audio | End, ...]: samples = np.asarray(samples, dtype=np.float32) if samples.ndim != 1: raise ValueError("audio must be a mono, one-dimensional array") if not len(samples): return () if not self._active: if not is_speech: self._append_pre_roll(samples) return () pre_roll = self._take_pre_roll() started_at = captured_at - timedelta( seconds=len(pre_roll) / self.sample_rate ) self._active = True self._segment_samples = len(pre_roll) + len(samples) self._silence_samples = 0 self._append_pre_roll(samples) return (Start(started_at, np.concatenate((pre_roll, samples))),) if self._segment_samples + len(samples) > self.max_segment_samples: self._append_pre_roll(samples) self._active = True self._segment_samples = len(samples) self._silence_samples = 0 return (End(), Start(captured_at, samples)) self._segment_samples += len(samples) if is_speech: self._silence_samples = 0 else: self._silence_samples += len(samples) self._append_pre_roll(samples) events: tuple[Start | Audio | End, ...] = (Audio(samples),) if self._silence_samples >= self.silence_samples_limit: self._active = False self._segment_samples = 0 self._silence_samples = 0 events += (End(),) return events def _append_pre_roll(self, samples: np.ndarray) -> None: self._pre_roll.append(samples) self._pre_roll_size += len(samples) while self._pre_roll_size > self.pre_roll_samples: excess = self._pre_roll_size - self.pre_roll_samples oldest = self._pre_roll[0] if len(oldest) <= excess: self._pre_roll.popleft() self._pre_roll_size -= len(oldest) else: self._pre_roll[0] = oldest[excess:] self._pre_roll_size -= excess def _take_pre_roll(self) -> np.ndarray: if not self._pre_roll: return np.empty(0, dtype=np.float32) samples = np.concatenate(tuple(self._pre_roll)) self._pre_roll.clear() self._pre_roll_size = 0 return samples