From abcc7aeaef6c0e79481de51b40815e75ed7368c6 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Sat, 11 Oct 2025 12:39:57 -0700 Subject: [PATCH] Updates for EventBus and LedController. Passes the event_bus and loop instances into the ServerState object upon creation. Adds new command-line arguments (--led-interface, --led-clock-pin, --led-data-pin) to allow the user to select and configure the LED hardware interface at runtime. --- linux_voice_assistant/__main__.py | 85 ++++++++++++++++++++++++++----- 1 file changed, 72 insertions(+), 13 deletions(-) diff --git a/linux_voice_assistant/__main__.py b/linux_voice_assistant/__main__.py index b362073..4630265 100644 --- a/linux_voice_assistant/__main__.py +++ b/linux_voice_assistant/__main__.py @@ -12,9 +12,9 @@ from typing import Dict, List, Optional, Union import numpy as np import sounddevice as sd -from .microwakeword import MicroWakeWord, MicroWakeWordFeatures from .event_bus import EventBus -from .event_led import LedEvent +from .led_controller import LedController +from .microwakeword import MicroWakeWord, MicroWakeWordFeatures from .models import AvailableWakeWord, Preferences, ServerState, WakeWordType from .mpv_player import MpvMediaPlayer from .openwakeword import OpenWakeWord, OpenWakeWordFeatures @@ -64,6 +64,7 @@ async def main() -> None: type=float, help="Seconds before wake word can be activated again", ) + # parser.add_argument( "--oww-melspectrogram-model", default=_OWW_DIR / "melspectrogram.tflite", @@ -74,13 +75,16 @@ async def main() -> None: default=_OWW_DIR / "embedding_model.tflite", help="Path to openWakeWord embedding model", ) + # parser.add_argument( "--wakeup-sound", default=str(_SOUNDS_DIR / "wake_word_triggered.flac") ) parser.add_argument( "--timer-finished-sound", default=str(_SOUNDS_DIR / "timer_finished.flac") ) + # parser.add_argument("--preferences-file", default=_REPO_DIR / "preferences.json") + # parser.add_argument( "--host", default="0.0.0.0", @@ -89,6 +93,26 @@ async def main() -> None: parser.add_argument( "--port", type=int, default=6053, help="Port for ESPHome server (default: 6053)" ) + # + parser.add_argument( + "--led-interface", + choices=["spi", "gpio"], + default="spi", + help="Interface for LEDs (default: spi)", + ) + parser.add_argument( + "--led-clock-pin", + type=int, + default=13, + help="GPIO pin for LED clock (for Grove)", + ) + parser.add_argument( + "--led-data-pin", + type=int, + default=12, + help="GPIO pin for LED data (for Grove)", + ) + # parser.add_argument( "--debug", action="store_true", help="Print DEBUG messages to console" ) @@ -96,6 +120,12 @@ async def main() -> None: logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO) _LOGGER.debug(args) + + # Get the asyncio event loop + loop = asyncio.get_running_loop() + + # Create the event bus + event_bus = EventBus() # Load available wake words wake_word_dirs = [Path(ww_dir) for ww_dir in args.wake_word_dir] @@ -105,6 +135,7 @@ async def main() -> None: for model_config_path in wake_word_dir.glob("*.json"): model_id = model_config_path.stem if model_id == args.stop_model: + # Don't show stop model as an available wake word continue with open(model_config_path, "r", encoding="utf-8") as model_config_file: @@ -136,6 +167,7 @@ async def main() -> None: # Load wake/stop models wake_models: Dict[str, Union[MicroWakeWord, OpenWakeWord]] = {} if preferences.active_wake_words: + # Load preferred models for wake_word_id in preferences.active_wake_words: wake_word = available_wake_words.get(wake_word_id) if wake_word is None: @@ -146,11 +178,14 @@ async def main() -> None: wake_models[wake_word_id] = wake_word.load(libtensorflowlite_c_path) if not wake_models: + # Load default model wake_word_id = args.wake_model wake_word = available_wake_words[wake_word_id] + _LOGGER.debug("Loading wake model: %s", wake_word_id) wake_models[wake_word_id] = wake_word.load(libtensorflowlite_c_path) + # TODO: allow openWakeWord for "stop" stop_model: Optional[MicroWakeWord] = None for wake_word_dir in wake_word_dirs: stop_config_path = wake_word_dir / f"{args.stop_model}.json" @@ -180,15 +215,20 @@ async def main() -> None: preferences=preferences, preferences_path=preferences_path, libtensorflowlite_c_path=libtensorflowlite_c_path, + event_bus=event_bus, + loop=loop, oww_melspectrogram_path=Path(args.oww_melspectrogram_model), oww_embedding_path=Path(args.oww_embedding_model), refractory_seconds=args.refractory_seconds, ) - - # --- LED EVENT SYSTEM (from LVA) --- - state.event_bus = EventBus() - LedEvent(state) - state.event_bus.publish("ready", {}) + + # Initialize the LED controller AFTER state is created + led_controller = LedController( + state, + interface=args.led_interface, + clock_pin=args.led_clock_pin, + data_pin=args.led_data_pin, + ) process_audio_thread = threading.Thread( target=process_audio, args=(state,), daemon=True @@ -197,12 +237,12 @@ async def main() -> None: def sd_callback(indata, _frames, _time, _status): state.audio_queue.put_nowait(bytes(indata)) - - loop = asyncio.get_running_loop() + server = await loop.create_server( lambda: VoiceSatelliteProtocol(state), host=args.host, port=args.port ) + # Auto discovery (zeroconf, mDNS) discovery = HomeAssistantZeroconf(port=args.port, name=args.name) await discovery.register_server() @@ -228,6 +268,9 @@ async def main() -> None: _LOGGER.debug("Server stopped") +# ----------------------------------------------------------------------------- + + def process_audio(state: ServerState): """Process audio chunks from the microphone.""" @@ -238,6 +281,7 @@ def process_audio(state: ServerState): oww_features: Optional[OpenWakeWordFeatures] = None oww_inputs: List[np.ndarray] = [] has_oww = False + last_active: Optional[float] = None try: @@ -245,18 +289,25 @@ def process_audio(state: ServerState): audio_chunk = state.audio_queue.get() if audio_chunk is None: break + if state.satellite is None: continue if (not wake_words) or (state.wake_words_changed and state.wake_words): + # Update list of wake word models to process state.wake_words_changed = False wake_words = [ww for ww in state.wake_words.values() if ww.is_active] - has_oww = any(isinstance(ww, OpenWakeWord) for ww in wake_words) + has_oww = False + for wake_word in wake_words: + if isinstance(wake_word, OpenWakeWord): + has_oww = True + if micro_features is None: micro_features = MicroWakeWordFeatures( libtensorflowlite_c_path=state.libtensorflowlite_c_path, ) + if has_oww and (oww_features is None): oww_features = OpenWakeWordFeatures( melspectrogram_model=state.oww_melspectrogram_path, @@ -266,6 +317,7 @@ def process_audio(state: ServerState): try: state.satellite.handle_audio(audio_chunk) + assert micro_features is not None micro_inputs.clear() micro_inputs.extend(micro_features.process_streaming(audio_chunk)) @@ -288,6 +340,7 @@ def process_audio(state: ServerState): activated = True if activated: + # Check refractory now = time.monotonic() if (last_active is None) or ( (now - last_active) > state.refractory_seconds @@ -295,16 +348,22 @@ def process_audio(state: ServerState): state.satellite.wakeup(wake_word) last_active = now - stopped = any( - state.stop_word.process_streaming(mi) for mi in micro_inputs - ) + # Always process to keep state correct + stopped = False + for micro_input in micro_inputs: + if state.stop_word.process_streaming(micro_input): + stopped = True + if stopped and state.stop_word.is_active: state.satellite.stop() except Exception: _LOGGER.exception("Unexpected error handling audio") + except Exception: _LOGGER.exception("Unexpected error processing audio") +# ----------------------------------------------------------------------------- + if __name__ == "__main__": asyncio.run(main())