191 lines
7.0 KiB
Python
191 lines
7.0 KiB
Python
"""Barge-in: notice that the user wants to interrupt *while the pet is
|
|
talking* so playback can be cut short mid-sentence.
|
|
|
|
Two detectors, picked by BARGE_IN_MODE:
|
|
|
|
- **wake** (default) — the interruption has to be the wake word. Every mic
|
|
frame goes through the same openWakeWord model the idle listener uses, so
|
|
a sneeze, a door, or the TV can't cut Bolt off mid-sentence; only saying
|
|
"thunderbolt" does.
|
|
- **energy** — the original behaviour: N consecutive frames above
|
|
BARGE_IN_RMS_THRESHOLD. Faster to trigger and needs no model inference,
|
|
but it fires on any sustained noise. Deliberately dumber than the
|
|
utterance VAD in mic.py, since the mic hears the pet's own voice coming
|
|
back out of the speakers, so the threshold defaults to 4x the VAD one.
|
|
|
|
Both take the same injectable stream shape as mic.record_utterance and expose
|
|
the same reset()/check() pair, so tests feed them fake frames instead of real
|
|
audio hardware and controller.py doesn't care which one it holds.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Callable, Optional, Union
|
|
|
|
import numpy as np
|
|
|
|
from .. import config
|
|
from .mic import AudioStream, rms
|
|
from .wake_word import WakeModel, _default_model, hard_reset
|
|
|
|
|
|
def _read_frame(stream: AudioStream, frame_len: int) -> Optional[np.ndarray]:
|
|
"""One mono frame, or None if the mic hiccuped or gave us nothing. Never
|
|
raises: a bad frame mid-playback should mean "no barge-in this frame",
|
|
not a dead reply."""
|
|
try:
|
|
chunk, _ = stream.read(frame_len)
|
|
except Exception:
|
|
return None
|
|
frame = np.asarray(chunk)
|
|
if frame.ndim > 1:
|
|
frame = frame[:, 0]
|
|
return frame if frame.size else None
|
|
|
|
|
|
class BargeInDetector:
|
|
"""Energy mode. Poll-driven: call check() repeatedly while audio plays.
|
|
Each call consumes exactly one mic frame (80ms at the default frame
|
|
length), which is also what paces the playback loop's polling."""
|
|
|
|
def __init__(
|
|
self,
|
|
stream: AudioStream,
|
|
threshold: int = None,
|
|
required_frames: int = None,
|
|
frame_len: int = config.FRAME_LEN,
|
|
):
|
|
self._stream = stream
|
|
self._threshold = config.BARGE_IN_RMS_THRESHOLD if threshold is None else threshold
|
|
self._required = max(1, config.BARGE_IN_FRAMES if required_frames is None else required_frames)
|
|
self._frame_len = frame_len
|
|
self._loud_frames = 0
|
|
|
|
@property
|
|
def loud_frames(self) -> int:
|
|
return self._loud_frames
|
|
|
|
def reset(self) -> None:
|
|
self._loud_frames = 0
|
|
|
|
def check(self) -> bool:
|
|
"""True once the user has been loud for long enough to count as an
|
|
interruption."""
|
|
frame = _read_frame(self._stream, self._frame_len)
|
|
if frame is None:
|
|
return False
|
|
if rms(frame) >= self._threshold:
|
|
self._loud_frames += 1
|
|
else:
|
|
self._loud_frames = 0 # a single thump/cough shouldn't count
|
|
return self._loud_frames >= self._required
|
|
|
|
|
|
class WakeWordBargeIn:
|
|
"""Wake-word mode: only "thunderbolt" interrupts.
|
|
|
|
Same per-frame predict() loop as listen_for_wake_word, just driven by the
|
|
playback poll instead of its own read loop. The model instance is shared
|
|
with the idle listener by default — the two never run at the same time
|
|
(the pipeline is either speaking or listening), and reusing it avoids
|
|
loading a second copy of the ONNX graph.
|
|
|
|
Two wrinkles the energy detector doesn't have:
|
|
|
|
- The mic hears the pet's own voice, so the model is scoring Bolt's
|
|
speech too. That's harmless unless Bolt says its own wake word, which
|
|
is why the threshold can be raised independently
|
|
(BARGE_IN_WAKE_THRESHOLD) without desensitizing the idle listener.
|
|
- reset() has to be a *hard* reset. openwakeword keeps ~10s of audio
|
|
history in its preprocessor, so the "thunderbolt" that started this
|
|
turn is still in the model's window when playback begins — feed it one
|
|
new frame and it fires on the old phrase, cutting the reply off a word
|
|
in. Clearing that window is what makes wake-mode barge-in work at all.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
stream: AudioStream,
|
|
model: Optional[WakeModel] = None,
|
|
threshold: Union[float, Callable[[], float], None] = None,
|
|
frame_len: int = config.FRAME_LEN,
|
|
on_score: Optional[Callable[[float, float], None]] = None,
|
|
):
|
|
self._stream = stream
|
|
self._model = model if model is not None else _default_model
|
|
if threshold is None:
|
|
threshold = config.BARGE_IN_WAKE_THRESHOLD or config.WAKE_WORD_THRESHOLD
|
|
self._resolve_threshold = threshold if callable(threshold) else (lambda: threshold)
|
|
self._frame_len = frame_len
|
|
self._on_score = on_score
|
|
self._frames = 0
|
|
self._peak = 0.0
|
|
self._last = 0.0
|
|
self._last_threshold = 0.0
|
|
|
|
# Scoring history for the current reply. Without this an interruption is
|
|
# indistinguishable from a crash in the logs — you can't tell a genuine
|
|
# "thunderbolt" from the model firing on Bolt's own voice, or on the first
|
|
# frame (a stale window) versus halfway through (something it heard).
|
|
@property
|
|
def frames_checked(self) -> int:
|
|
return self._frames
|
|
|
|
@property
|
|
def seconds_checked(self) -> float:
|
|
return self._frames * self._frame_len / config.SAMPLE_RATE
|
|
|
|
@property
|
|
def peak_score(self) -> float:
|
|
return self._peak
|
|
|
|
@property
|
|
def last_score(self) -> float:
|
|
return self._last
|
|
|
|
@property
|
|
def last_threshold(self) -> float:
|
|
return self._last_threshold
|
|
|
|
def reset(self) -> None:
|
|
hard_reset(self._model) # never raises
|
|
self._frames = 0
|
|
self._peak = 0.0
|
|
self._last = 0.0
|
|
|
|
def check(self) -> bool:
|
|
frame = _read_frame(self._stream, self._frame_len)
|
|
if frame is None:
|
|
return False
|
|
try:
|
|
scores = self._model.predict(frame)
|
|
except Exception:
|
|
return False # same contract as a mic hiccup: no barge-in, no crash
|
|
threshold = self._resolve_threshold()
|
|
best = max(scores.values()) if scores else 0.0
|
|
self._frames += 1
|
|
self._last = best
|
|
self._peak = max(self._peak, best)
|
|
self._last_threshold = threshold
|
|
if self._on_score is not None:
|
|
self._on_score(best, threshold)
|
|
if scores and best >= threshold:
|
|
self.reset()
|
|
return True
|
|
return False
|
|
|
|
|
|
def make_detector(
|
|
stream: AudioStream,
|
|
mode: str = None,
|
|
wake_threshold: Union[float, Callable[[], float], None] = None,
|
|
on_score: Optional[Callable[[float, float], None]] = None,
|
|
):
|
|
"""Build whichever detector BARGE_IN_MODE asks for. An unrecognized mode
|
|
falls back to energy rather than raising — a typo in .env shouldn't stop
|
|
the pet from starting."""
|
|
mode = (config.BARGE_IN_MODE if mode is None else mode).strip().lower()
|
|
if mode in ("wake", "wakeword", "wake_word"):
|
|
return WakeWordBargeIn(stream, threshold=wake_threshold, on_score=on_score)
|
|
return BargeInDetector(stream)
|