"""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() # FIX: Allow immediate activation (don't set to 'now') 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(" 0.5 for oi in oww_inputs for p in wake_word.process_streaming(oi)): activated = True if activated: now = time.monotonic() if (last_active is None) or ((now - last_active) > self.state.refractory_seconds): self.state.loop.call_soon_threadsafe(self.state.satellite.wakeup, wake_word) last_active = now # Stop word detection stopped = False for micro_input in micro_inputs: if self.state.stop_word.process_streaming(micro_input): stopped = True if stopped and (self.state.stop_word.id in self.state.active_wake_words): self.state.loop.call_soon_threadsafe(self.state.satellite.stop) # 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)