Create audio_engine.py

Brand new file. Encapsulates audio logic, suppresses warnings, fixes busy wait.
This commit is contained in:
imonlinux
2025-11-19 18:48:44 -08:00
committed by GitHub
parent 6f5444237c
commit 6eb3f46cbe
+163
View File
@@ -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("<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()
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))
# 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:
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)