diff --git a/linux_voice_assistant/__main__.py b/linux_voice_assistant/__main__.py index 91b702d..cd6692a 100644 --- a/linux_voice_assistant/__main__.py +++ b/linux_voice_assistant/__main__.py @@ -12,7 +12,7 @@ from typing import Dict, List, Optional, Union import numpy as np import sounddevice as sd -from .event_bus import EventBus, EventHandler, subscribe +from .event_bus import EventBus from .led_controller import LedController from .microwakeword import MicroWakeWord, MicroWakeWordFeatures from .models import AvailableWakeWord, Preferences, ServerState, WakeWordType @@ -34,31 +34,6 @@ if is_arm(): else: _LIB_DIR = _REPO_DIR / "lib" / "linux_amd64" -# ----------------------------------------------------------------------------- -# --- MIC MUTE HANDLER --- -# ----------------------------------------------------------------------------- - -class MicMuteHandler(EventHandler): - """Event handler for mic mute switch.""" - @subscribe - def set_mic_mute(self, data: dict): - is_muted = data.get("state", False) - if self.state.mic_muted != is_muted: - self.state.mic_muted = is_muted - _LOGGER.debug("Mic muted = %s", is_muted) - if is_muted: - self.state.event_bus.publish("mic_muted") - else: - # --- THIS IS THE FIX --- - # Clear the audio queue of stale data before unmuting - _LOGGER.debug("Clearing stale audio queue...") - while not self.state.audio_queue.empty(): - try: - self.state.audio_queue.get_nowait() - except Queue.Empty: - break - # ------------------------- - self.state.event_bus.publish("mic_unmuted") # ----------------------------------------------------------------------------- @@ -238,7 +213,6 @@ async def main() -> None: clock_pin=args.led_clock_pin, data_pin=args.led_data_pin, ) - mic_mute_handler = MicMuteHandler(state) process_audio_thread = threading.Thread( target=process_audio, args=(state,), daemon=True @@ -292,10 +266,6 @@ def process_audio(state: ServerState): try: while True: - if state.mic_muted: - time.sleep(0.1) - continue - audio_chunk = state.audio_queue.get() if audio_chunk is None: break diff --git a/linux_voice_assistant/entity.py b/linux_voice_assistant/entity.py index 9b86fdf..ee443b9 100644 --- a/linux_voice_assistant/entity.py +++ b/linux_voice_assistant/entity.py @@ -6,12 +6,9 @@ from typing import TYPE_CHECKING, Callable, List, Optional, Union from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined] ListEntitiesMediaPlayerResponse, ListEntitiesRequest, - ListEntitiesSwitchResponse, # ADDED MediaPlayerCommandRequest, MediaPlayerStateResponse, SubscribeHomeAssistantStatesRequest, - SwitchCommandRequest, # ADDED - SwitchStateResponse, # ADDED ) from aioesphomeapi.model import MediaPlayerCommand, MediaPlayerState from google.protobuf import message @@ -34,44 +31,6 @@ class ESPHomeEntity: pass -# ----------------------------------------------------------------------------- -# --- ADDED MIC MUTE SWITCH ENTITY --- -# ----------------------------------------------------------------------------- - -class MicMuteSwitchEntity(ESPHomeEntity): - def __init__( - self, - server: APIServer, - state: "ServerState", - key: int, - name: str, - object_id: str, - ) -> None: - super().__init__(server, state) - self.key = key - self.name = name - self.object_id = object_id - self.is_on = False # Mute is OFF by default - - def handle_message(self, msg: message.Message) -> Iterable[message.Message]: - if isinstance(msg, ListEntitiesRequest): - yield ListEntitiesSwitchResponse( - object_id=self.object_id, - key=self.key, - name=self.name, - icon="mdi:microphone-off", # Show a mic-off icon in HA - ) - elif isinstance(msg, SwitchCommandRequest) and (msg.key == self.key): - self.is_on = msg.state - self.state.event_bus.publish("set_mic_mute", {"state": self.is_on}) - yield self._get_state_message() - elif isinstance(msg, SubscribeHomeAssistantStatesRequest): - yield self._get_state_message() - - def _get_state_message(self) -> SwitchStateResponse: - return SwitchStateResponse(key=self.key, state=self.is_on) - - # ----------------------------------------------------------------------------- @@ -92,7 +51,7 @@ class MediaPlayerEntity(ESPHomeEntity): self.name = name self.object_id = object_id self.state_enum = MediaPlayerState.IDLE - self.volume = state.preferences.volume_level + self.volume = state.preferences.volume_level # Initialize with saved volume self.muted = False self.music_player = music_player self.announce_player = announce_player @@ -105,6 +64,7 @@ class MediaPlayerEntity(ESPHomeEntity): ) -> Iterable[message.Message]: if announcement: if self.music_player.is_playing: + # Announce, resume music self.music_player.pause() self.announce_player.play( url, @@ -113,6 +73,7 @@ class MediaPlayerEntity(ESPHomeEntity): ), ) else: + # Announce, idle self.announce_player.play( url, done_callback=lambda: call_all( @@ -123,6 +84,7 @@ class MediaPlayerEntity(ESPHomeEntity): ), ) else: + # Music self.music_player.play( url, done_callback=lambda: call_all( @@ -148,11 +110,13 @@ class MediaPlayerEntity(ESPHomeEntity): self.music_player.resume() yield self._update_state(MediaPlayerState.PLAYING) if msg.has_volume: - self.volume = msg.volume + # This block is called when the volume slider changes in HA + self.volume = msg.volume # HA sends volume as 0.0-1.0 volume_int = int(self.volume * 100) self.music_player.set_volume(volume_int) self.announce_player.set_volume(volume_int) + # Save the new volume level to preferences self.state.preferences.volume_level = self.volume self.state.save_preferences() diff --git a/linux_voice_assistant/led_controller.py b/linux_voice_assistant/led_controller.py index 0916c40..01cd527 100644 --- a/linux_voice_assistant/led_controller.py +++ b/linux_voice_assistant/led_controller.py @@ -15,7 +15,6 @@ _OFF = (0, 0, 0) _BLUE = (0, 0, 255) _YELLOW = (255, 255, 0) _GREEN = (0, 255, 0) -_DIM_RED = (50, 0, 0) # ADDED for Mute class LedController(EventHandler): @@ -34,6 +33,7 @@ class LedController(EventHandler): try: if interface == "gpio": _LOGGER.debug(f"Initializing LEDs on GPIO data={data_pin}, clock={clock_pin}") + # Dynamically get GPIO pin objects from the board library data_pin_obj = getattr(board, f"D{data_pin}") clock_pin_obj = getattr(board, f"D{clock_pin}") self.leds = adafruit_dotstar.DotStar( @@ -47,6 +47,7 @@ class LedController(EventHandler): self._is_ready = True _LOGGER.info(f"LED Controller initialized using {interface} interface.") + # Signal that the system is ready self.run_action("blink", _GREEN, 2) except Exception: @@ -78,10 +79,12 @@ class LedController(EventHandler): try: r, g, b = color while True: + # Fade in for i in range(0, 101, 5): brightness = i / 100.0 await self.color((int(r * brightness), int(g * brightness), int(b * brightness))) await asyncio.sleep(speed) + # Fade out for i in range(100, -1, -5): brightness = i / 100.0 await self.color((int(r * brightness), int(g * brightness), int(b * brightness))) @@ -89,8 +92,6 @@ class LedController(EventHandler): except asyncio.CancelledError: await self.color(_OFF) - # --- EVENT HANDLERS --- - @subscribe def ha_connected(self, data: dict): _LOGGER.debug("HA Connected, setting idle LED state.") @@ -109,6 +110,7 @@ class LedController(EventHandler): @subscribe def voice_vad_start(self, data: dict): _LOGGER.debug("LED Event: voice_vad_start (user speaking)") + # Do nothing, allow the blue pulse to continue pass @subscribe @@ -125,15 +127,3 @@ class LedController(EventHandler): def voice_run_end(self, data: dict): _LOGGER.debug("LED Event: voice_run_end") self.run_action("color", _OFF) - - # --- ADDED: Mute State Handlers --- - @subscribe - def mic_muted(self, data: dict): - _LOGGER.debug("LED Event: Mic muted") - self.run_action("color", _DIM_RED) - - @subscribe - def mic_unmuted(self, data: dict): - _LOGGER.debug("LED Event: Mic unmuted") - # Return to the normal idle state (off) - self.voice_run_end(data) diff --git a/linux_voice_assistant/models.py b/linux_voice_assistant/models.py index f007e68..bbc9ced 100644 --- a/linux_voice_assistant/models.py +++ b/linux_voice_assistant/models.py @@ -89,7 +89,6 @@ class ServerState: satellite: "Optional[VoiceSatelliteProtocol]" = None wake_words_changed: bool = False refractory_seconds: float = 2.0 - mic_muted: bool = False # ADD THIS LINE def save_preferences(self) -> None: """Save preferences as JSON.""" diff --git a/linux_voice_assistant/satellite.py b/linux_voice_assistant/satellite.py index 1104b89..7046af7 100644 --- a/linux_voice_assistant/satellite.py +++ b/linux_voice_assistant/satellite.py @@ -11,10 +11,8 @@ from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined] DeviceInfoResponse, ListEntitiesDoneResponse, ListEntitiesRequest, - ListEntitiesSwitchResponse, # ADDED MediaPlayerCommandRequest, SubscribeHomeAssistantStatesRequest, - SwitchCommandRequest, # ADDED VoiceAssistantAnnounceFinished, VoiceAssistantAnnounceRequest, VoiceAssistantAudio, @@ -34,7 +32,7 @@ from aioesphomeapi.model import ( from google.protobuf import message from .api_server import APIServer -from .entity import MediaPlayerEntity, MicMuteSwitchEntity # ADDED MicMuteSwitchEntity +from .entity import MediaPlayerEntity from .microwakeword import MicroWakeWord from .models import ServerState from .openwakeword import OpenWakeWord @@ -61,17 +59,6 @@ class VoiceSatelliteProtocol(APIServer): announce_player=state.tts_player, ) self.state.entities.append(self.state.media_player_entity) - - # --- ADDED: Create Mic Mute Switch Entity --- - mic_mute_entity = MicMuteSwitchEntity( - server=self, - state=state, - key=len(state.entities), - name=f"{state.name} Mute Microphone", - object_id="linux_voice_assistant_mute", - ) - self.state.entities.append(mic_mute_entity) - # -------------------------------------------- self._is_streaming_audio = False self._tts_url: Optional[str] = None @@ -100,6 +87,7 @@ class VoiceSatelliteProtocol(APIServer): self._is_streaming_audio = False elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_PROGRESS: if data.get("tts_start_streaming") == "1": + # Start streaming early self.play_tts() elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END: if data.get("continue_conversation") == "1": @@ -115,6 +103,8 @@ class VoiceSatelliteProtocol(APIServer): self._tts_played = False self.state.event_bus.publish("voice_run_end") + # TODO: handle error + def handle_timer_event( self, event_type: VoiceAssistantTimerEventType, @@ -130,19 +120,26 @@ class VoiceSatelliteProtocol(APIServer): def handle_message(self, msg: message.Message) -> Iterable[message.Message]: if isinstance(msg, VoiceAssistantEventResponse): + # Pipeline event data: Dict[str, str] = {} for arg in msg.data: data[arg.name] = arg.value + self.handle_voice_event(VoiceAssistantEventType(msg.event_type), data) elif isinstance(msg, VoiceAssistantAnnounceRequest): _LOGGER.debug("Announcing: %s", msg.text) + assert self.state.media_player_entity is not None + urls = [] if msg.preannounce_media_id: urls.append(msg.preannounce_media_id) + urls.append(msg.media_id) + self.state.stop_word.is_active = True self._continue_conversation = msg.start_conversation + self.duck() yield from self.state.media_player_entity.play( urls, announcement=True, done_callback=self._tts_finished @@ -168,7 +165,6 @@ class VoiceSatelliteProtocol(APIServer): ListEntitiesRequest, SubscribeHomeAssistantStatesRequest, MediaPlayerCommandRequest, - SwitchCommandRequest, # ADDED ), ): for entity in self.state.entities: @@ -192,26 +188,37 @@ class VoiceSatelliteProtocol(APIServer): max_active_wake_words=2, ) _LOGGER.info("Connected to Home Assistant") + # --- THIS IS THE CHANGE --- + # Announce the connection so other components (like LEDs) can react. self.state.event_bus.publish("ha_connected") elif isinstance(msg, VoiceAssistantSetConfiguration): + # Change active wake words active_wake_words: Set[str] = set() + for wake_word_id in msg.active_wake_words: if wake_word_id in self.state.wake_words: + # Already active active_wake_words.add(wake_word_id) continue + model_info = self.state.available_wake_words.get(wake_word_id) if not model_info: continue + _LOGGER.debug("Loading wake word: %s", model_info.config_path) self.state.wake_words[wake_word_id] = model_info.load( self.state.libtensorflowlite_c_path ) + _LOGGER.info("Wake word set: %s", wake_word_id) active_wake_words.add(wake_word_id) break + for wake_word in self.state.wake_words.values(): wake_word.is_active = wake_word.id in active_wake_words + _LOGGER.debug("Active wake words: %s", active_wake_words) + self.state.preferences.active_wake_words = list(active_wake_words) self.state.save_preferences() self.state.wake_words_changed = True @@ -219,15 +226,19 @@ class VoiceSatelliteProtocol(APIServer): def handle_audio(self, audio_chunk: bytes) -> None: if not self._is_streaming_audio: return + self.send_messages([VoiceAssistantAudio(data=audio_chunk)]) def wakeup(self, wake_word: Union[MicroWakeWord, OpenWakeWord]) -> None: self.state.event_bus.publish("voice_wakeword") + if self._timer_finished: + # Stop timer instead self._timer_finished = False self.state.tts_player.stop() _LOGGER.debug("Stopping timer finished sound") return + wake_word_phrase = wake_word.wake_word _LOGGER.debug("Detected wake word: %s", wake_word_phrase) self.send_messages( @@ -240,6 +251,7 @@ class VoiceSatelliteProtocol(APIServer): def stop(self) -> None: self.state.stop_word.is_active = False self.state.tts_player.stop() + if self._timer_finished: self._timer_finished = False _LOGGER.debug("Stopping timer finished sound") @@ -250,9 +262,12 @@ class VoiceSatelliteProtocol(APIServer): def play_tts(self) -> None: if (not self._tts_url) or self._tts_played: return + self.state.event_bus.publish("voice_tts_start", {"url": self._tts_url}) + self._tts_played = True _LOGGER.debug("Playing TTS response: %s", self._tts_url) + self.state.stop_word.is_active = True self.state.tts_player.play(self._tts_url, done_callback=self._tts_finished) @@ -268,18 +283,21 @@ class VoiceSatelliteProtocol(APIServer): self.state.event_bus.publish("voice_run_end") self.state.stop_word.is_active = False self.send_messages([VoiceAssistantAnnounceFinished()]) + if self._continue_conversation: self.send_messages([VoiceAssistantRequest(start=True)]) self._is_streaming_audio = True _LOGGER.debug("Continuing conversation") else: self.unduck() + _LOGGER.debug("TTS response finished") def _play_timer_finished(self) -> None: if not self._timer_finished: self.unduck() return + self.state.tts_player.play( self.state.timer_finished_sound, done_callback=lambda: call_all(