Files
linux-voice-assistant/linux_voice_assistant/audio_engine.py
T
imonlinux 6eb3f46cbe Create audio_engine.py
Brand new file. Encapsulates audio logic, suppresses warnings, fixes busy wait.
2025-11-19 18:48:44 -08:00

164 lines
7.1 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()
# 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)