Update audio_engine.py

Behavioral & logging changes

Improved the wake word processing loop to make the following clearly visible in DEBUG logs:

The current set of active wake word IDs (MWW/OWW).

When the Stop wake word:

is activated by the model,

is accepted (i.e. meets refractory timing + is currently allowed), and

is ignored because it is no longer in active_wake_words.

Ensured the special stop model:

Is only treated as “active” when state.stop_word.is_active is True.

Is flipped back to inactive immediately after satellite.stop() is called, so:

The first “Stop” that passes all checks cancels playback and alarm tones.

Subsequent detections in the same moment appear as “TRIGGERED but IGNORED” (expected and now clearly logged).

Why this matters

When the LVA is playing audio (TTS or timer alarm), you can now:

See exactly when the Stop model fires.

Confirm that playback is being canceled at the right time.

Understand why you might see multiple “Stop wake word ACTIVATED” messages even though the alarm already stopped (model still “hearing” the tail, but logically ignored).

This matches the behavior seen in your logs:

At lower volumes / with AEC, stop is reliably accepted and playback stops immediately.

At high volumes without AEC, the model sees far less “clean” speech, so activations are rare or absent.
This commit is contained in:
imonlinux
2025-11-20 20:01:13 -08:00
committed by GitHub
parent 8d928c6b02
commit 0151b64810
+126 -39
View File
@@ -33,7 +33,7 @@ class AudioEngine:
self._thread = threading.Thread( self._thread = threading.Thread(
target=self._process_audio, target=self._process_audio,
daemon=True, daemon=True,
name="AudioEngineThread" name="AudioEngineThread",
) )
self._thread.start() self._thread.start()
@@ -48,23 +48,23 @@ class AudioEngine:
def _process_audio(self): def _process_audio(self):
"""Main audio processing loop.""" """Main audio processing loop."""
_LOGGER.debug("Audio engine started.") _LOGGER.debug("Audio engine started.")
micro_features = MicroWakeWordFeatures() micro_features = MicroWakeWordFeatures()
oww_features: Optional[OpenWakeWordFeatures] = None oww_features: Optional[OpenWakeWordFeatures] = None
wake_words: List[Union[MicroWakeWord, OpenWakeWord]] = [] wake_words: List[Union[MicroWakeWord, OpenWakeWord]] = []
micro_inputs: List[np.ndarray] = [] micro_inputs: List[np.ndarray] = []
oww_inputs: List[np.ndarray] = [] oww_inputs: List[np.ndarray] = []
has_oww = False has_oww = False
last_active: Optional[float] = None last_active: Optional[float] = None
try: try:
while True: while True:
# --- Handle Mute State --- # --- Handle Mute State ---
if not self.state.mic_muted_event.is_set(): if not self.state.mic_muted_event.is_set():
_LOGGER.debug("Audio thread muted. Waiting...") _LOGGER.debug("Audio thread muted. Waiting...")
self.state.mic_muted_event.wait() self.state.mic_muted_event.wait()
if self.state.shutdown: if self.state.shutdown:
return return
@@ -73,27 +73,33 @@ class AudioEngine:
micro_features.reset() micro_features.reset()
if oww_features is not None: if oww_features is not None:
oww_features.reset() oww_features.reset()
# FIX: Allow immediate activation (don't set to 'now') # Allow immediate activation (don't enforce refractory here)
last_active = None last_active = None
# Flush hardware buffer # Flush hardware buffer
try: try:
with self.mic.recorder(samplerate=16000, channels=1, blocksize=self.block_size) as mic_in: with self.mic.recorder(
samplerate=16000,
channels=1,
blocksize=self.block_size,
) as mic_in:
mic_in.flush() mic_in.flush()
except Exception as e: except Exception as e:
_LOGGER.warning("Error flushing mic: %s", e) _LOGGER.warning("Error flushing mic: %s", e)
if self.state.shutdown: if self.state.shutdown:
return return
# --- Main Recording Loop --- # --- Main Recording Loop ---
_LOGGER.debug("Opening audio input device: %s", self.mic.name) _LOGGER.debug("Opening audio input device: %s", self.mic.name)
with self.mic.recorder(samplerate=16000, channels=1, blocksize=self.block_size) as mic_in: with self.mic.recorder(
samplerate=16000, channels=1, blocksize=self.block_size
) as mic_in:
while self.state.mic_muted_event.is_set(): while self.state.mic_muted_event.is_set():
if self.state.shutdown: if self.state.shutdown:
return return
audio_chunk_array = mic_in.record(self.block_size).reshape(-1) audio_chunk_array = mic_in.record(self.block_size).reshape(-1)
audio_chunk = ( audio_chunk = (
(np.clip(audio_chunk_array, -1.0, 1.0) * 32767.0) (np.clip(audio_chunk_array, -1.0, 1.0) * 32767.0)
@@ -104,60 +110,141 @@ class AudioEngine:
if self.state.satellite is None: if self.state.satellite is None:
time.sleep(0.01) time.sleep(0.01)
continue continue
# Update active wake words if changed # Update active wake words if changed
if (not wake_words) or (self.state.wake_words_changed): if (not wake_words) or (self.state.wake_words_changed):
self.state.wake_words_changed = False self.state.wake_words_changed = False
wake_words = [ wake_words = [
ww for ww in self.state.wake_words.values() ww
for ww in self.state.wake_words.values()
if ww.id in self.state.active_wake_words if ww.id in self.state.active_wake_words
] ]
has_oww = any(isinstance(ww, OpenWakeWord) for ww in wake_words) has_oww = any(
isinstance(ww, OpenWakeWord) for ww in wake_words
)
if has_oww and (oww_features is None): if has_oww and (oww_features is None):
_LOGGER.debug("Initializing OpenWakeWord features...") _LOGGER.debug(
"Initializing OpenWakeWord features..."
)
oww_features = OpenWakeWordFeatures.from_builtin() oww_features = OpenWakeWordFeatures.from_builtin()
_LOGGER.debug(
"Rebuilt wake_words list: %s (has_oww=%s, active_ids=%s)",
[
getattr(ww, "wake_word", getattr(ww, "id", "?"))
for ww in wake_words
],
has_oww,
self.state.active_wake_words,
)
try: try:
# Feature extraction # Feature extraction
micro_inputs.clear() micro_inputs.clear()
micro_inputs.extend(micro_features.process_streaming(audio_chunk)) micro_inputs.extend(
micro_features.process_streaming(audio_chunk)
)
if has_oww and oww_features: if has_oww and oww_features:
oww_inputs.clear() oww_inputs.clear()
oww_inputs.extend(oww_features.process_streaming(audio_chunk)) oww_inputs.extend(
oww_features.process_streaming(audio_chunk)
# Detection )
# Wake word detection
for wake_word in wake_words: for wake_word in wake_words:
activated = False activated = False
if isinstance(wake_word, MicroWakeWord): if isinstance(wake_word, MicroWakeWord):
if any(wake_word.process_streaming(mi) for mi in micro_inputs): if any(
wake_word.process_streaming(mi)
for mi in micro_inputs
):
activated = True activated = True
elif isinstance(wake_word, OpenWakeWord): elif isinstance(wake_word, OpenWakeWord):
if any(p > 0.5 for oi in oww_inputs for p in wake_word.process_streaming(oi)): if any(
p > 0.5
for oi in oww_inputs
for p in wake_word.process_streaming(oi)
):
activated = True activated = True
if activated: if activated:
wake_phrase = getattr(
wake_word, "wake_word", "wake word"
)
wake_id = getattr(wake_word, "id", None)
_LOGGER.debug(
"Wake word ACTIVATED: %s (id=%s)",
wake_phrase,
wake_id,
)
now = time.monotonic() now = time.monotonic()
if (last_active is None) or ((now - last_active) > self.state.refractory_seconds): if (last_active is None) or (
self.state.loop.call_soon_threadsafe(self.state.satellite.wakeup, wake_word) (now - last_active)
> self.state.refractory_seconds
):
_LOGGER.debug(
"Wake word ACCEPTED (phrase=%s); "
"triggering wakeup (Δt=%.2f, refractory=%.2f)",
wake_phrase,
0.0
if last_active is None
else now - last_active,
self.state.refractory_seconds,
)
self.state.loop.call_soon_threadsafe(
self.state.satellite.wakeup, wake_word
)
last_active = now last_active = now
else:
_LOGGER.debug(
"Wake word REJECTED by refractory period "
"(phrase=%s, Δt=%.2f < %.2f)",
wake_phrase,
now - last_active,
self.state.refractory_seconds,
)
# Stop word detection # Stop word detection
stopped = False stopped = False
for micro_input in micro_inputs: for micro_input in micro_inputs:
if self.state.stop_word.process_streaming(micro_input): if self.state.stop_word.process_streaming(micro_input):
stopped = True stopped = True
if stopped and (self.state.stop_word.id in self.state.active_wake_words): if stopped:
self.state.loop.call_soon_threadsafe(self.state.satellite.stop) _LOGGER.debug(
"Stop wake word ACTIVATED (id=%s, active_wake_words=%s)",
# Pass audio to satellite (for voice streaming) getattr(self.state.stop_word, "id", None),
self.state.loop.call_soon_threadsafe(self.state.satellite.handle_audio, audio_chunk) self.state.active_wake_words,
)
except Exception: if (
stopped
and self.state.stop_word.id
in self.state.active_wake_words
):
_LOGGER.debug(
"Stop wake word ACCEPTED; calling satellite.stop()"
)
self.state.loop.call_soon_threadsafe(
self.state.satellite.stop
)
elif stopped:
_LOGGER.debug(
"Stop wake word TRIGGERED but IGNORED because "
"id=%s not in active_wake_words",
getattr(self.state.stop_word, "id", None),
)
# Pass audio to satellite (for voice streaming)
self.state.loop.call_soon_threadsafe(
self.state.satellite.handle_audio, audio_chunk
)
except Exception:
_LOGGER.exception("Unexpected error handling audio") _LOGGER.exception("Unexpected error handling audio")
except Exception as e: except Exception as e:
_LOGGER.critical("A soundcard error occurred: %s", e) _LOGGER.critical("A soundcard error occurred: %s", e)
self.state.loop.call_soon_threadsafe(self.state.loop.stop) self.state.loop.call_soon_threadsafe(self.state.loop.stop)