From 6eb3f46cbe7cabb1b5e2bbe7c9643b3e2795daaa Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Wed, 19 Nov 2025 18:48:44 -0800 Subject: [PATCH] Create audio_engine.py Brand new file. Encapsulates audio logic, suppresses warnings, fixes busy wait. --- linux_voice_assistant/audio_engine.py | 163 ++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 linux_voice_assistant/audio_engine.py diff --git a/linux_voice_assistant/audio_engine.py b/linux_voice_assistant/audio_engine.py new file mode 100644 index 0000000..546b91e --- /dev/null +++ b/linux_voice_assistant/audio_engine.py @@ -0,0 +1,163 @@ +"""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)