Wake-word barge-in, Gitea auto-updater, hard_reset fix
This commit is contained in:
@@ -64,6 +64,65 @@ class NearMissLog:
|
||||
self._peak = 0.0
|
||||
|
||||
|
||||
# Where the cached blank state is stashed — on the preprocessor itself
|
||||
# rather than in a dict keyed by id(), which CPython reuses after garbage
|
||||
# collection and would hand one model another's buffers.
|
||||
_BLANK_STATE_ATTR = "_bolt_blank_state"
|
||||
|
||||
|
||||
def _blank_state(preprocessor) -> Optional[tuple]:
|
||||
"""(feature_buffer, melspectrogram_buffer) as they are on a freshly
|
||||
constructed model — i.e. "having heard nothing". Computing it costs an
|
||||
ONNX pass over 10s of silence, so it's cached on the preprocessor and
|
||||
copied from thereafter. Returns None if openwakeword's internals don't
|
||||
look the way we expect, in which case callers leave the state alone
|
||||
rather than corrupting it."""
|
||||
cached = getattr(preprocessor, _BLANK_STATE_ATTR, None)
|
||||
if cached is not None:
|
||||
return cached
|
||||
try:
|
||||
# Same call the AudioFeatures constructor uses to prime the buffer.
|
||||
state = (preprocessor._get_embeddings(np.zeros(160000).astype(np.int16)), np.ones((76, 32)))
|
||||
setattr(preprocessor, _BLANK_STATE_ATTR, state)
|
||||
except Exception:
|
||||
return None
|
||||
return state
|
||||
|
||||
|
||||
def hard_reset(model) -> None:
|
||||
"""Make the model forget the audio it has already heard — not just its
|
||||
predictions.
|
||||
|
||||
openwakeword's ``Model.reset()`` clears the *prediction* buffer only.
|
||||
The rolling audio window the classifier actually scores lives in
|
||||
``model.preprocessor`` (raw_data_buffer / melspectrogram_buffer /
|
||||
feature_buffer, ~10s of history) and has no reset method of its own. So
|
||||
after a detection the wake word is still sitting in that window, and the
|
||||
next frame fed to the model re-fires on it — which is exactly what made
|
||||
the pet interrupt itself a word into every reply: the "thunderbolt" that
|
||||
started the turn was still in the buffer when barge-in resumed feeding it.
|
||||
|
||||
Never raises. A model whose internals don't match (a fake in tests, a
|
||||
future openwakeword release) just gets the plain reset()."""
|
||||
try:
|
||||
model.reset()
|
||||
except Exception:
|
||||
pass
|
||||
preprocessor = getattr(model, "preprocessor", None)
|
||||
if preprocessor is None:
|
||||
return
|
||||
blank = _blank_state(preprocessor)
|
||||
try:
|
||||
if blank is not None:
|
||||
features, melspectrogram = blank
|
||||
preprocessor.feature_buffer = features.copy()
|
||||
preprocessor.melspectrogram_buffer = melspectrogram.copy()
|
||||
preprocessor.raw_data_buffer.clear()
|
||||
preprocessor.accumulated_samples = 0
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _construct_model(model_cls, model_path: str):
|
||||
"""openwakeword's Model() constructor keyword has drifted across
|
||||
releases (wakeword_models -> wakeword_model_paths) and some builds
|
||||
@@ -112,6 +171,17 @@ class _OpenWakeWordModel:
|
||||
self._model = _construct_model(Model, config.WAKE_MODEL_PATH)
|
||||
return self._model
|
||||
|
||||
@property
|
||||
def preprocessor(self):
|
||||
"""Proxy the wrapped model's audio-feature buffers.
|
||||
|
||||
Without this, hard_reset() sees a wrapper with no `preprocessor` and
|
||||
silently degrades to openwakeword's shallow reset() — which leaves the
|
||||
previous detection sitting in the audio window, i.e. exactly the bug
|
||||
hard_reset exists to fix. Returns None before the model is loaded, so
|
||||
a reset that happens first is a no-op rather than a load."""
|
||||
return getattr(self._model, "preprocessor", None)
|
||||
|
||||
def predict(self, frame: np.ndarray) -> dict:
|
||||
return self._ensure_model().predict(frame)
|
||||
|
||||
@@ -136,8 +206,9 @@ def listen_for_wake_word(
|
||||
|
||||
Feeds every frame to *model* (the thunderbolt openWakeWord model by
|
||||
default) and treats any class score >= *threshold* as a detection,
|
||||
resetting the model's internal state afterward so the next call starts
|
||||
clean — same pattern as desk_client/bolt_desk.py's main loop.
|
||||
clearing the model's internal state (audio window included, see
|
||||
hard_reset) afterward so the next call starts clean — same pattern as
|
||||
desk_client/bolt_desk.py's main loop.
|
||||
|
||||
*threshold* may be a number or a zero-argument callable. The callable
|
||||
form exists because this function blocks for minutes at a time: the
|
||||
@@ -180,6 +251,9 @@ def listen_for_wake_word(
|
||||
on_score(best, current_threshold)
|
||||
|
||||
if scores and best >= current_threshold:
|
||||
model.reset()
|
||||
# hard_reset, not reset: the phrase has to leave the model's audio
|
||||
# window too, or the very next frame we feed it re-fires on the
|
||||
# same "thunderbolt" (see hard_reset's docstring).
|
||||
hard_reset(model)
|
||||
return True
|
||||
return False
|
||||
|
||||
Reference in New Issue
Block a user