From 4616645c40ee725f663c52033d4a0f96d8e692e0 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Wed, 19 Nov 2025 18:49:43 -0800 Subject: [PATCH] Update __main__.py Entry point. Clean imports, timestamp logging, integration of AudioEngine. --- linux_voice_assistant/__main__.py | 178 ++++++------------------------ 1 file changed, 31 insertions(+), 147 deletions(-) diff --git a/linux_voice_assistant/__main__.py b/linux_voice_assistant/__main__.py index 764e2be..707fc89 100644 --- a/linux_voice_assistant/__main__.py +++ b/linux_voice_assistant/__main__.py @@ -4,25 +4,25 @@ import asyncio import json import logging import sys -import threading -import time from dataclasses import dataclass from pathlib import Path -from typing import Dict, List, Optional, Set, Tuple, Union +from typing import Dict, Optional, Set, Tuple, Union import numpy as np import soundcard as sc -from pymicro_wakeword import MicroWakeWord, MicroWakeWordFeatures -from pyopen_wakeword import OpenWakeWord, OpenWakeWordFeatures -from .config import Config, LedConfig, MqttConfig, load_config_from_json +from pymicro_wakeword import MicroWakeWord, MicroWakeWordFeatures +from pyopen_wakeword import OpenWakeWord + +from .audio_engine import AudioEngine +from .config import Config, load_config_from_json from .event_bus import EventBus, EventHandler, subscribe from .led_controller import LedController from .mqtt_controller import MqttController from .models import AvailableWakeWord, Preferences, ServerState, WakeWordType from .mpv_player import MpvMediaPlayer from .satellite import VoiceSatelliteProtocol -from .util import get_mac +from .util import get_mac_address, format_mac from .zeroconf import HomeAssistantZeroconf _LOGGER = logging.getLogger(__name__) @@ -73,6 +73,13 @@ class MicMuteHandler(EventHandler): is_muted = data.get("state", False) if self.state.mic_muted != is_muted: self.state.mic_muted = is_muted + + # Synchronize the threading event for the audio loop + if is_muted: + self.state.mic_muted_event.clear() # Pauses audio thread + else: + self.state.mic_muted_event.set() # Resumes audio thread + _LOGGER.debug("Mic muted = %s", is_muted) if self.mqtt_controller: @@ -120,13 +127,9 @@ async def main() -> None: # --- 7. Initialize Controllers --- _init_controllers(loop, event_bus, state, config, preferences) - # --- 8. Start Audio Thread --- - audio_thread = threading.Thread( - target=_process_audio, - args=(state, mic, config.audio.input_block_size), - daemon=True - ) - audio_thread.start() + # --- 8. Start Audio Engine --- + audio_engine = AudioEngine(state, mic, config.audio.input_block_size) + audio_engine.start() # --- 9. Run Server --- try: @@ -134,8 +137,7 @@ async def main() -> None: finally: # --- 10. Cleanup --- _LOGGER.debug("Shutting down...") - state.shutdown = True # Signal audio thread to stop - audio_thread.join() + audio_engine.stop() if hasattr(state, "mqtt_controller") and state.mqtt_controller: _LOGGER.debug("Stopping MQTT controller...") @@ -158,11 +160,13 @@ def _init_basics() -> Tuple[Config, asyncio.AbstractEventLoop, EventBus]: parser.add_argument("--list-output-devices", action="store_true", help="List audio output devices") args = parser.parse_args() - # --- List devices and exit --- if args.list_input_devices: print("Input devices\n" + "=" * 13) - for idx, mic in enumerate(sc.all_microphones(include_loopback=False)): - print(f"[{idx}]", mic.name) + try: + for idx, mic in enumerate(sc.all_microphones(include_loopback=False)): + print(f"[{idx}]", mic.name) + except Exception as e: + _LOGGER.error("Error listing input devices (ensure audio backend is working): %s", e) sys.exit(0) if args.list_output_devices: @@ -175,7 +179,6 @@ def _init_basics() -> Tuple[Config, asyncio.AbstractEventLoop, EventBus]: _LOGGER.error("Failed to list output devices: %s", e) sys.exit(1) - # --- Load config --- config_path = args.config if not config_path.is_absolute(): config_path = _REPO_DIR / config_path @@ -184,10 +187,12 @@ def _init_basics() -> Tuple[Config, asyncio.AbstractEventLoop, EventBus]: if args.debug: config.app.debug = True - # --- Setup logging --- - logging.basicConfig(level=logging.DEBUG if config.app.debug else logging.INFO) + logging.basicConfig( + level=logging.DEBUG if config.app.debug else logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S" + ) _LOGGER.info("Loading configuration from: %s", config_path) - _LOGGER.debug("Configuration loaded: %s", config) loop = asyncio.get_running_loop() event_bus = EventBus() @@ -220,7 +225,7 @@ def _get_microphone(config: Config): mic = sc.default_microphone() if mic is None: - _LOGGER.critical("No microphone found. Use --list-input-devices to see options.") + _LOGGER.critical("No microphone found.") sys.exit(1) _LOGGER.info("Using audio input device: %s", mic.name) @@ -228,13 +233,11 @@ def _get_microphone(config: Config): def _load_wake_words(config: Config, preferences: Preferences) -> WakeWordData: """Loads all available and active wake word models.""" - if not config.wake_word.directories: config.wake_word.directories = ["wakewords", "wakewords/openWakeWord"] download_dir = _REPO_DIR / config.wake_word.download_dir download_dir.mkdir(parents=True, exist_ok=True) - _LOGGER.debug("Download directory: %s", download_dir) wake_word_dirs = [_REPO_DIR / d for d in config.wake_word.directories] wake_word_dirs.append(download_dir / "external_wake_words") @@ -264,7 +267,6 @@ def _load_wake_words(config: Config, preferences: Preferences) -> WakeWordData: trained_languages=model_config.get("trained_languages", []), wake_word_path=wake_word_path, ) - _LOGGER.debug("Available wake words: %s", list(sorted(available.keys()))) active: Set[str] = set() models: Dict[str, Union[MicroWakeWord, OpenWakeWord]] = {} @@ -272,14 +274,12 @@ def _load_wake_words(config: Config, preferences: Preferences) -> WakeWordData: if preferences.active_wake_words: for ww_id in preferences.active_wake_words: if ww_id in available: - _LOGGER.debug("Loading wake model (from preferences): %s", ww_id) models[ww_id] = available[ww_id].load() active.add(ww_id) if not models: ww_id = config.wake_word.model if ww_id in available: - _LOGGER.debug("Loading wake model (from config): %s", ww_id) models[ww_id] = available[ww_id].load() active.add(ww_id) @@ -287,7 +287,6 @@ def _load_wake_words(config: Config, preferences: Preferences) -> WakeWordData: for ww_dir_str in config.wake_word.directories: stop_config_path = _REPO_DIR / ww_dir_str / f"{config.wake_word.stop_model}.json" if stop_config_path.exists(): - _LOGGER.debug("Loading stop model: %s", stop_config_path) stop_model = MicroWakeWord.from_config(stop_config_path) break assert stop_model is not None, "Stop model not found" @@ -296,7 +295,6 @@ def _load_wake_words(config: Config, preferences: Preferences) -> WakeWordData: def _init_media_players(loop: asyncio.AbstractEventLoop, config: Config, preferences: Preferences) -> MediaPlayers: """Initializes the music and TTS media players.""" - _LOGGER.debug("Initializing media players") music_player = MpvMediaPlayer( loop=loop, device=config.audio.output_device, @@ -318,10 +316,10 @@ def _create_server_state( media_players: MediaPlayers, ) -> ServerState: """Creates the global ServerState object.""" - _LOGGER.debug("Creating server state") + stable_mac = format_mac(get_mac_address()) return ServerState( name=config.app.name, - mac_address=get_mac(), + mac_address=stable_mac, event_bus=event_bus, loop=loop, entities=[], @@ -347,8 +345,6 @@ def _init_controllers( preferences: Preferences, ): """Initializes all decoupled controllers.""" - _LOGGER.debug("Initializing controllers") - led_controller = LedController( loop=loop, event_bus=event_bus, @@ -377,7 +373,6 @@ def _init_controllers( async def _run_server(state: ServerState, config: Config): """Starts the ESPHome server and ZeroConf discovery.""" - _LOGGER.debug("Starting ESPHome server") server = await state.loop.create_server( lambda: VoiceSatelliteProtocol(state), host=config.esphome.host, @@ -391,116 +386,5 @@ async def _run_server(state: ServerState, config: Config): await server.serve_forever() -def _process_audio(state: ServerState, mic, block_size: int): - """Main audio processing loop. Runs in a separate thread.""" - - micro_features: MicroWakeWordFeatures = 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: - # --- THIS IS THE FIX --- - # Wait while muted. This loop will spin until unmuted. - if state.mic_muted: - _LOGGER.debug("Audio thread muted.") - while state.mic_muted: - if state.shutdown: - _LOGGER.debug("Shutdown signal received, stopping audio thread.") - return # Exit thread - time.sleep(0.1) - - # --- Just unmuted --- - _LOGGER.debug("Audio thread unmuted, clearing buffers.") - micro_features.reset() - if oww_features is not None: - oww_features.reset() - last_active = time.monotonic() # Start refractory period - - # Re-open microphone to flush OS buffer - _LOGGER.debug("Re-opening microphone to flush OS buffers...") - with mic.recorder(samplerate=16000, channels=1, blocksize=block_size) as mic_in: - mic_in.flush() # Discard any stale data - _LOGGER.debug("Microphone flushed.") - # Fall through to the main recording loop - # --- END FIX --- - - _LOGGER.debug("Opening audio input device: %s", mic.name) - with mic.recorder(samplerate=16000, channels=1, blocksize=block_size) as mic_in: - # --- This is now the main recording loop --- - while not state.mic_muted: - if state.shutdown: - _LOGGER.debug("Shutdown signal received, stopping audio thread.") - return # Exit thread - - audio_chunk_array = mic_in.record(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) > state.refractory_seconds): - state.loop.call_soon_threadsafe(state.satellite.wakeup, wake_word) - last_active = now - - stopped = False - for micro_input in micro_inputs: - if state.stop_word.process_streaming(micro_input): - stopped = True - - if stopped and (state.stop_word.id in state.active_wake_words): - state.loop.call_soon_threadsafe(state.satellite.stop) - - state.loop.call_soon_threadsafe(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) - state.loop.call_soon_threadsafe(state.loop.stop) - - -# ----------------------------------------------------------------------------- - if __name__ == "__main__": - print("--- __main__ block executing ---") asyncio.run(main())