| """A deterministic controller around an audio endpoint classifier. |
| |
| The neural model is deliberately not allowed to own the whole product policy. |
| It is queried only at VAD pause checkpoints. The controller enforces minimum |
| and maximum silence bounds, optional debounce, and a gradual long-pause |
| threshold relaxation. This keeps latency/false-interruption trade-offs explicit |
| and testable. |
| """ |
|
|
| from __future__ import annotations |
|
|
| from dataclasses import dataclass |
|
|
| from .types import Prediction, TurnDecision, TurnState |
|
|
|
|
| @dataclass(frozen=True, slots=True) |
| class ControllerConfig: |
| endpoint_threshold: float = 0.60 |
| long_pause_threshold: float = 0.42 |
| min_silence_ms: float = 200.0 |
| relax_after_ms: float = 800.0 |
| max_silence_ms: float = 1800.0 |
| required_confirmations: int = 1 |
|
|
| def __post_init__(self) -> None: |
| for name, value in ( |
| ("endpoint_threshold", self.endpoint_threshold), |
| ("long_pause_threshold", self.long_pause_threshold), |
| ): |
| if not 0.0 <= value <= 1.0: |
| raise ValueError(f"{name} must be in [0, 1]") |
| if self.long_pause_threshold > self.endpoint_threshold: |
| raise ValueError("long_pause_threshold cannot exceed endpoint_threshold") |
| if self.min_silence_ms < 0.0: |
| raise ValueError("min_silence_ms cannot be negative") |
| if not self.min_silence_ms <= self.relax_after_ms <= self.max_silence_ms: |
| raise ValueError( |
| "silence bounds must satisfy min_silence_ms <= relax_after_ms <= max_silence_ms" |
| ) |
| if self.required_confirmations < 1: |
| raise ValueError("required_confirmations must be at least one") |
|
|
|
|
| class TurnController: |
| """Stateful SPEAKING/HOLD/END controller. |
| |
| Call :meth:`observe_speech` whenever VAD sees speech. Call |
| :meth:`evaluate_pause` at silence checkpoints. A new speech event after an |
| END decision starts a fresh turn automatically. |
| """ |
|
|
| def __init__(self, config: ControllerConfig | None = None) -> None: |
| self.config = config or ControllerConfig() |
| self._state = TurnState.SPEAKING |
| self._confirmations = 0 |
| self._last_timestamp_ms: float | None = None |
|
|
| @property |
| def state(self) -> TurnState: |
| return self._state |
|
|
| def reset(self) -> None: |
| self._state = TurnState.SPEAKING |
| self._confirmations = 0 |
| self._last_timestamp_ms = None |
|
|
| def observe_speech(self, timestamp_ms: float | None = None) -> TurnDecision: |
| self._validate_timestamp(timestamp_ms) |
| self._state = TurnState.SPEAKING |
| self._confirmations = 0 |
| return TurnDecision( |
| state=self._state, |
| endpoint_probability=None, |
| threshold=None, |
| silence_ms=0.0, |
| reason="speech_observed", |
| timestamp_ms=timestamp_ms, |
| ) |
|
|
| def threshold_for_silence(self, silence_ms: float) -> float: |
| """Return the decision threshold at the current silence duration.""" |
|
|
| if silence_ms <= self.config.relax_after_ms: |
| return self.config.endpoint_threshold |
| span = self.config.max_silence_ms - self.config.relax_after_ms |
| if span <= 0.0: |
| return self.config.long_pause_threshold |
| progress = min(1.0, (silence_ms - self.config.relax_after_ms) / span) |
| delta = self.config.endpoint_threshold - self.config.long_pause_threshold |
| return self.config.endpoint_threshold - progress * delta |
|
|
| def evaluate_pause( |
| self, |
| prediction: Prediction | float, |
| silence_ms: float, |
| timestamp_ms: float | None = None, |
| ) -> TurnDecision: |
| """Combine a model score and silence duration into a product decision.""" |
|
|
| self._validate_timestamp(timestamp_ms) |
| if silence_ms < 0.0: |
| raise ValueError("silence_ms cannot be negative") |
| if isinstance(prediction, Prediction): |
| probability = prediction.endpoint_probability |
| inference_ms = prediction.inference_ms |
| metadata = { |
| "model_name": prediction.model_name, |
| "auxiliary": prediction.auxiliary, |
| } |
| else: |
| probability = float(prediction) |
| if not 0.0 <= probability <= 1.0: |
| raise ValueError("endpoint probability must be in [0, 1]") |
| inference_ms = 0.0 |
| metadata = {} |
|
|
| threshold = self.threshold_for_silence(silence_ms) |
| emit_response = False |
| if self._state is TurnState.END: |
| reason = "endpoint_latched" |
| elif silence_ms < self.config.min_silence_ms: |
| self._state = TurnState.HOLD |
| self._confirmations = 0 |
| reason = "minimum_silence_not_reached" |
| elif silence_ms >= self.config.max_silence_ms: |
| self._state = TurnState.END |
| self._confirmations = self.config.required_confirmations |
| reason = "maximum_timeout" |
| emit_response = True |
| elif probability >= threshold: |
| self._confirmations += 1 |
| if self._confirmations >= self.config.required_confirmations: |
| self._state = TurnState.END |
| reason = "model_endpoint" |
| emit_response = True |
| else: |
| self._state = TurnState.HOLD |
| reason = "awaiting_confirmation" |
| else: |
| self._state = TurnState.HOLD |
| self._confirmations = 0 |
| reason = "model_hold" |
|
|
| return TurnDecision( |
| state=self._state, |
| endpoint_probability=probability, |
| threshold=threshold, |
| silence_ms=silence_ms, |
| reason=reason, |
| timestamp_ms=timestamp_ms, |
| inference_ms=inference_ms, |
| confirmations=self._confirmations, |
| emit_response=emit_response, |
| metadata=metadata, |
| ) |
|
|
| def _validate_timestamp(self, timestamp_ms: float | None) -> None: |
| if timestamp_ms is None: |
| return |
| if timestamp_ms < 0.0: |
| raise ValueError("timestamp_ms cannot be negative") |
| if self._last_timestamp_ms is not None and timestamp_ms < self._last_timestamp_ms: |
| raise ValueError("timestamps must be monotonic") |
| self._last_timestamp_ms = timestamp_ms |
|
|