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.
251 lines
11 KiB
Python
251 lines
11 KiB
Python
"""Audio processing engine."""
|
|
|
|
import asyncio
|
|
import logging
|
|
import threading
|
|
import time
|
|
import warnings
|
|
from typing import List, Optional, Union
|
|
|
|
import numpy as np
|
|
import soundcard as sc
|
|
|
|
from pymicro_wakeword import MicroWakeWord, MicroWakeWordFeatures
|
|
from pyopen_wakeword import OpenWakeWord, OpenWakeWordFeatures
|
|
|
|
from .models import ServerState
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
# Suppress the "log(0)" warning from pymicro_wakeword
|
|
warnings.filterwarnings("ignore", category=RuntimeWarning, module="pymicro_wakeword")
|
|
|
|
|
|
class AudioEngine:
|
|
def __init__(self, state: ServerState, mic, block_size: int):
|
|
self.state = state
|
|
self.mic = mic
|
|
self.block_size = block_size
|
|
self._thread: Optional[threading.Thread] = None
|
|
|
|
def start(self):
|
|
"""Starts the audio processing thread."""
|
|
self._thread = threading.Thread(
|
|
target=self._process_audio,
|
|
daemon=True,
|
|
name="AudioEngineThread",
|
|
)
|
|
self._thread.start()
|
|
|
|
def stop(self):
|
|
"""Stops the audio processing thread."""
|
|
self.state.shutdown = True
|
|
# Ensure thread wakes up if waiting
|
|
self.state.mic_muted_event.set()
|
|
if self._thread:
|
|
self._thread.join()
|
|
|
|
def _process_audio(self):
|
|
"""Main audio processing loop."""
|
|
_LOGGER.debug("Audio engine started.")
|
|
|
|
micro_features = MicroWakeWordFeatures()
|
|
oww_features: Optional[OpenWakeWordFeatures] = None
|
|
|
|
wake_words: List[Union[MicroWakeWord, OpenWakeWord]] = []
|
|
micro_inputs: List[np.ndarray] = []
|
|
oww_inputs: List[np.ndarray] = []
|
|
has_oww = False
|
|
last_active: Optional[float] = None
|
|
|
|
try:
|
|
while True:
|
|
# --- Handle Mute State ---
|
|
if not self.state.mic_muted_event.is_set():
|
|
_LOGGER.debug("Audio thread muted. Waiting...")
|
|
self.state.mic_muted_event.wait()
|
|
|
|
if self.state.shutdown:
|
|
return
|
|
|
|
# Unmuted: Clear buffers and prepare
|
|
_LOGGER.debug("Audio thread unmuted. Flushing...")
|
|
micro_features.reset()
|
|
if oww_features is not None:
|
|
oww_features.reset()
|
|
|
|
# Allow immediate activation (don't enforce refractory here)
|
|
last_active = None
|
|
|
|
# Flush hardware buffer
|
|
try:
|
|
with self.mic.recorder(
|
|
samplerate=16000,
|
|
channels=1,
|
|
blocksize=self.block_size,
|
|
) as mic_in:
|
|
mic_in.flush()
|
|
except Exception as e:
|
|
_LOGGER.warning("Error flushing mic: %s", e)
|
|
|
|
if self.state.shutdown:
|
|
return
|
|
|
|
# --- Main Recording Loop ---
|
|
_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:
|
|
while self.state.mic_muted_event.is_set():
|
|
if self.state.shutdown:
|
|
return
|
|
|
|
audio_chunk_array = mic_in.record(self.block_size).reshape(-1)
|
|
audio_chunk = (
|
|
(np.clip(audio_chunk_array, -1.0, 1.0) * 32767.0)
|
|
.astype("<i2")
|
|
.tobytes()
|
|
)
|
|
|
|
if self.state.satellite is None:
|
|
time.sleep(0.01)
|
|
continue
|
|
|
|
# Update active wake words if changed
|
|
if (not wake_words) or (self.state.wake_words_changed):
|
|
self.state.wake_words_changed = False
|
|
wake_words = [
|
|
ww
|
|
for ww in self.state.wake_words.values()
|
|
if ww.id in self.state.active_wake_words
|
|
]
|
|
has_oww = any(
|
|
isinstance(ww, OpenWakeWord) for ww in wake_words
|
|
)
|
|
|
|
if has_oww and (oww_features is None):
|
|
_LOGGER.debug(
|
|
"Initializing OpenWakeWord features..."
|
|
)
|
|
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:
|
|
# Feature extraction
|
|
micro_inputs.clear()
|
|
micro_inputs.extend(
|
|
micro_features.process_streaming(audio_chunk)
|
|
)
|
|
|
|
if has_oww and oww_features:
|
|
oww_inputs.clear()
|
|
oww_inputs.extend(
|
|
oww_features.process_streaming(audio_chunk)
|
|
)
|
|
|
|
# Wake word detection
|
|
for wake_word in wake_words:
|
|
activated = False
|
|
if isinstance(wake_word, MicroWakeWord):
|
|
if any(
|
|
wake_word.process_streaming(mi)
|
|
for mi in micro_inputs
|
|
):
|
|
activated = True
|
|
elif isinstance(wake_word, OpenWakeWord):
|
|
if any(
|
|
p > 0.5
|
|
for oi in oww_inputs
|
|
for p in wake_word.process_streaming(oi)
|
|
):
|
|
activated = True
|
|
|
|
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()
|
|
if (last_active is None) or (
|
|
(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
|
|
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
|
|
stopped = False
|
|
for micro_input in micro_inputs:
|
|
if self.state.stop_word.process_streaming(micro_input):
|
|
stopped = True
|
|
|
|
if stopped:
|
|
_LOGGER.debug(
|
|
"Stop wake word ACTIVATED (id=%s, active_wake_words=%s)",
|
|
getattr(self.state.stop_word, "id", None),
|
|
self.state.active_wake_words,
|
|
)
|
|
|
|
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")
|
|
|
|
except Exception as e:
|
|
_LOGGER.critical("A soundcard error occurred: %s", e)
|
|
self.state.loop.call_soon_threadsafe(self.state.loop.stop)
|