Create audio_engine.py
Brand new file. Encapsulates audio logic, suppresses warnings, fixes busy wait.
This commit is contained in:
@@ -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)
|
||||||
Reference in New Issue
Block a user