diff --git a/linux_voice_assistant/satellite.py b/linux_voice_assistant/satellite.py index 48b9a9a..9648111 100644 --- a/linux_voice_assistant/satellite.py +++ b/linux_voice_assistant/satellite.py @@ -34,7 +34,7 @@ from google.protobuf import message from .api_server import APIServer from .entity import MediaPlayerEntity from .microwakeword import MicroWakeWord -from .models import ServerState +from .models import ServerState, SatelliteState from .openwakeword import OpenWakeWord from .util import call_all @@ -48,25 +48,53 @@ class VoiceSatelliteProtocol(APIServer): self.state = state self.state.satellite = self - if self.state.media_player_entity is None: - self.state.media_player_entity = MediaPlayerEntity( - server=self, - state=state, - key=len(state.entities), - name="Media Player", - object_id="linux_voice_assistant_media_player", - music_player=state.music_player, - announce_player=state.tts_player, - ) - self.state.entities.append(self.state.media_player_entity) + self.media_player_entity = MediaPlayerEntity( + server=self, + state=state, + key=len(state.entities), + name="Media Player", + object_id="linux_voice_assistant_media_player", + music_player=state.music_player, + announce_player=state.tts_player, + ) + self.state.entities.append(self.media_player_entity) - self._is_streaming_audio = False + # State machine + self._state: SatelliteState = SatelliteState.IDLE + self._is_streaming_audio: bool = False # Is HA requesting audio? + + # Announce/TTS state self._tts_url: Optional[str] = None - self._tts_played = False - self._continue_conversation = False - self._timer_finished = False - self._is_speaking = False - self._run_finished = False + self._continue_conversation: bool = False + self._timer_finished: bool = False + + # Flags set by events, processed in _determine_final_state + self._run_end_received: bool = False + self._tts_end_received: bool = False + + def _set_state(self, new_state: SatelliteState): + """Manages state transitions and publishes events.""" + if self._state == new_state: + return + + _LOGGER.debug(f"State transition: {self._state} -> {new_state}") + self._state = new_state + + # Publish events for LEDs, etc. + if new_state == SatelliteState.IDLE: + self.unduck() + self.state.stop_word.is_active = False + self.state.event_bus.publish("voice_idle") + elif new_state == SatelliteState.LISTENING: + self.duck() + self.state.event_bus.publish("voice_listen") + elif new_state == SatelliteState.THINKING: + self.state.event_bus.publish("voice_thinking") + elif new_state == SatelliteState.RESPONDING: + self.state.stop_word.is_active = True + self.state.event_bus.publish("voice_responding") + elif new_state == SatelliteState.ERROR: + self.state.event_bus.publish("voice_error") def handle_voice_event( self, event_type: VoiceAssistantEventType, data: Dict[str, str] @@ -74,38 +102,51 @@ class VoiceSatelliteProtocol(APIServer): _LOGGER.debug("Voice event: type=%s, data=%s", event_type.name, data) if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_START: + self._run_end_received = False + self._tts_end_received = False self._tts_url = data.get("url") - self._tts_played = False self._continue_conversation = False - self._run_finished = False + # DO NOT transition to THINKING here. Stay in LISTENING. + elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_START: - self.state.event_bus.publish("voice_stt_start", data) + # This confirms we are in the LISTENING state + self._set_state(SatelliteState.LISTENING) + elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_VAD_START: self.state.event_bus.publish("voice_vad_start", data) - elif event_type in ( - VoiceAssistantEventType.VOICE_ASSISTANT_STT_VAD_END, - VoiceAssistantEventType.VOICE_ASSISTANT_STT_END, - ): - self.state.event_bus.publish("voice_stt_end", data) + + elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_END: self._is_streaming_audio = False - elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_PROGRESS: - if data.get("tts_start_streaming") == "1": - self.play_tts() + # THIS is when we move to THINKING + self._set_state(SatelliteState.THINKING) + + elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_VAD_END: + # We wait for STT_END before moving to THINKING + pass + elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END: if data.get("continue_conversation") == "1": self._continue_conversation = True - # --- MODIFIED: Removed TTS_START handler from here --- + + elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START: + self._set_state(SatelliteState.RESPONDING) + elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END: self._tts_url = data.get("url") - self.play_tts() + self._tts_end_received = True + self.play_tts() # Play the final TTS URL + elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_END: - self._run_finished = True - if not self._is_speaking: + self._run_end_received = True + # If TTS is already done, or we're not speaking, we can finalize. + if self._state != SatelliteState.RESPONDING: self._determine_final_state() elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_ERROR: - self.state.event_bus.publish("voice_error", data) - self._determine_final_state() + self._set_state(SatelliteState.ERROR) + # Use a timer to reset to idle after error + self.state.loop.call_later(5.0, self._set_state, SatelliteState.IDLE) + def handle_timer_event( self, @@ -128,7 +169,6 @@ class VoiceSatelliteProtocol(APIServer): 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) @@ -136,7 +176,8 @@ class VoiceSatelliteProtocol(APIServer): self.state.stop_word.is_active = True self._continue_conversation = msg.start_conversation self.duck() - yield from self.state.media_player_entity.play( + self._set_state(SatelliteState.RESPONDING) # Announce is a response + yield from self.media_player_entity.play( urls, announcement=True, done_callback=self._tts_finished ) elif isinstance(msg, VoiceAssistantTimerEventResponse): @@ -183,6 +224,7 @@ class VoiceSatelliteProtocol(APIServer): max_active_wake_words=2, ) _LOGGER.info("Connected to Home Assistant") + self._set_state(SatelliteState.IDLE) # Connected, move to idle self.state.event_bus.publish("ha_connected") elif isinstance(msg, VoiceAssistantSetConfiguration): active_wake_words: Set[str] = set() @@ -213,45 +255,41 @@ class VoiceSatelliteProtocol(APIServer): 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._state != SatelliteState.IDLE: + return # Already awake + if self._timer_finished: 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( [VoiceAssistantRequest(start=True, wake_word_phrase=wake_word_phrase)] ) - self.duck() + self._set_state(SatelliteState.LISTENING) self._is_streaming_audio = True self.state.tts_player.play(self.state.wakeup_sound) 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") else: _LOGGER.debug("TTS response stopped manually") - self._tts_finished() + self._tts_finished() # Manually trigger TTS finished logic - # --- MODIFIED: Added event publish to sync with audio start --- def play_tts(self) -> None: - if (not self._tts_url) or self._tts_played: + if not self._tts_url: return - # Only publish the start event the first time we begin speaking - if not self._is_speaking: - self.state.event_bus.publish("voice_tts_start", {}) - - self._is_speaking = True - self._tts_played = True _LOGGER.debug("Playing TTS response: %s", self._tts_url) - self.state.stop_word.is_active = True + self._set_state(SatelliteState.RESPONDING) self.state.tts_player.play(self._tts_url, done_callback=self._tts_finished) + self._tts_url = None # Clear after starting playback def duck(self) -> None: _LOGGER.debug("Ducking music") @@ -263,28 +301,32 @@ class VoiceSatelliteProtocol(APIServer): def _determine_final_state(self) -> None: """Called when both TTS and the pipeline run are finished.""" - self._is_streaming_audio = False - self.state.stop_word.is_active = False - if self._continue_conversation: self.send_messages([VoiceAssistantRequest(start=True)]) self._is_streaming_audio = True _LOGGER.debug("Continuing conversation") - self.state.event_bus.publish("voice_listen") + self._set_state(SatelliteState.LISTENING) else: - self.unduck() - self.state.event_bus.publish("voice_run_end") - self.state.event_bus.publish("voice_idle") + self._set_state(SatelliteState.IDLE) _LOGGER.debug("Final state determined") def _tts_finished(self) -> None: - self._is_speaking = False + """Callback when TTS audio finishes playing.""" self.send_messages([VoiceAssistantAnnounceFinished()]) _LOGGER.debug("TTS audio playback finished") - if self._run_finished: + # If the run already ended, we can move to the final state. + # Otherwise, we wait for the RUN_END event. + if self._run_end_received: self._determine_final_state() + else: + # We've finished speaking but the pipeline is still running + # (e.g., waiting for 'continue_conversation') + # Move to THINKING state + if self._state == SatelliteState.RESPONDING: + self._set_state(SatelliteState.THINKING) + def _play_timer_finished(self) -> None: if not self._timer_finished: @@ -299,4 +341,5 @@ class VoiceSatelliteProtocol(APIServer): def connection_lost(self, exc): super().connection_lost(exc) + self._set_state(SatelliteState.ERROR) _LOGGER.info("Disconnected from Home Assistant")