Update satellite.py

This is the major change. All the _is_speaking, _run_finished, etc. flags are gone, replaced by a single self._state variable and a _set_state method. The logic is now much cleaner.
This commit is contained in:
imonlinux
2025-11-17 15:08:10 -08:00
committed by GitHub
parent 0f1ceefc5c
commit f1137232e0
+94 -51
View File
@@ -34,7 +34,7 @@ from google.protobuf import message
from .api_server import APIServer from .api_server import APIServer
from .entity import MediaPlayerEntity from .entity import MediaPlayerEntity
from .microwakeword import MicroWakeWord from .microwakeword import MicroWakeWord
from .models import ServerState from .models import ServerState, SatelliteState
from .openwakeword import OpenWakeWord from .openwakeword import OpenWakeWord
from .util import call_all from .util import call_all
@@ -48,8 +48,7 @@ class VoiceSatelliteProtocol(APIServer):
self.state = state self.state = state
self.state.satellite = self self.state.satellite = self
if self.state.media_player_entity is None: self.media_player_entity = MediaPlayerEntity(
self.state.media_player_entity = MediaPlayerEntity(
server=self, server=self,
state=state, state=state,
key=len(state.entities), key=len(state.entities),
@@ -58,15 +57,44 @@ class VoiceSatelliteProtocol(APIServer):
music_player=state.music_player, music_player=state.music_player,
announce_player=state.tts_player, announce_player=state.tts_player,
) )
self.state.entities.append(self.state.media_player_entity) 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_url: Optional[str] = None
self._tts_played = False self._continue_conversation: bool = False
self._continue_conversation = False self._timer_finished: bool = False
self._timer_finished = False
self._is_speaking = False # Flags set by events, processed in _determine_final_state
self._run_finished = False 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( def handle_voice_event(
self, event_type: VoiceAssistantEventType, data: Dict[str, str] 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) _LOGGER.debug("Voice event: type=%s, data=%s", event_type.name, data)
if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_START: 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_url = data.get("url")
self._tts_played = False
self._continue_conversation = 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: 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: elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_VAD_START:
self.state.event_bus.publish("voice_vad_start", data) self.state.event_bus.publish("voice_vad_start", data)
elif event_type in (
VoiceAssistantEventType.VOICE_ASSISTANT_STT_VAD_END, elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_END:
VoiceAssistantEventType.VOICE_ASSISTANT_STT_END,
):
self.state.event_bus.publish("voice_stt_end", data)
self._is_streaming_audio = False self._is_streaming_audio = False
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_PROGRESS: # THIS is when we move to THINKING
if data.get("tts_start_streaming") == "1": self._set_state(SatelliteState.THINKING)
self.play_tts()
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: elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END:
if data.get("continue_conversation") == "1": if data.get("continue_conversation") == "1":
self._continue_conversation = True 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: elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END:
self._tts_url = data.get("url") 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: elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_END:
self._run_finished = True self._run_end_received = True
if not self._is_speaking: # If TTS is already done, or we're not speaking, we can finalize.
if self._state != SatelliteState.RESPONDING:
self._determine_final_state() self._determine_final_state()
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_ERROR: elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_ERROR:
self.state.event_bus.publish("voice_error", data) self._set_state(SatelliteState.ERROR)
self._determine_final_state() # 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( def handle_timer_event(
self, self,
@@ -128,7 +169,6 @@ class VoiceSatelliteProtocol(APIServer):
self.handle_voice_event(VoiceAssistantEventType(msg.event_type), data) self.handle_voice_event(VoiceAssistantEventType(msg.event_type), data)
elif isinstance(msg, VoiceAssistantAnnounceRequest): elif isinstance(msg, VoiceAssistantAnnounceRequest):
_LOGGER.debug("Announcing: %s", msg.text) _LOGGER.debug("Announcing: %s", msg.text)
assert self.state.media_player_entity is not None
urls = [] urls = []
if msg.preannounce_media_id: if msg.preannounce_media_id:
urls.append(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.state.stop_word.is_active = True
self._continue_conversation = msg.start_conversation self._continue_conversation = msg.start_conversation
self.duck() 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 urls, announcement=True, done_callback=self._tts_finished
) )
elif isinstance(msg, VoiceAssistantTimerEventResponse): elif isinstance(msg, VoiceAssistantTimerEventResponse):
@@ -183,6 +224,7 @@ class VoiceSatelliteProtocol(APIServer):
max_active_wake_words=2, max_active_wake_words=2,
) )
_LOGGER.info("Connected to Home Assistant") _LOGGER.info("Connected to Home Assistant")
self._set_state(SatelliteState.IDLE) # Connected, move to idle
self.state.event_bus.publish("ha_connected") self.state.event_bus.publish("ha_connected")
elif isinstance(msg, VoiceAssistantSetConfiguration): elif isinstance(msg, VoiceAssistantSetConfiguration):
active_wake_words: Set[str] = set() active_wake_words: Set[str] = set()
@@ -213,45 +255,41 @@ class VoiceSatelliteProtocol(APIServer):
self.send_messages([VoiceAssistantAudio(data=audio_chunk)]) self.send_messages([VoiceAssistantAudio(data=audio_chunk)])
def wakeup(self, wake_word: Union[MicroWakeWord, OpenWakeWord]) -> None: 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: if self._timer_finished:
self._timer_finished = False self._timer_finished = False
self.state.tts_player.stop() self.state.tts_player.stop()
_LOGGER.debug("Stopping timer finished sound") _LOGGER.debug("Stopping timer finished sound")
return return
wake_word_phrase = wake_word.wake_word wake_word_phrase = wake_word.wake_word
_LOGGER.debug("Detected wake word: %s", wake_word_phrase) _LOGGER.debug("Detected wake word: %s", wake_word_phrase)
self.send_messages( self.send_messages(
[VoiceAssistantRequest(start=True, wake_word_phrase=wake_word_phrase)] [VoiceAssistantRequest(start=True, wake_word_phrase=wake_word_phrase)]
) )
self.duck() self._set_state(SatelliteState.LISTENING)
self._is_streaming_audio = True self._is_streaming_audio = True
self.state.tts_player.play(self.state.wakeup_sound) self.state.tts_player.play(self.state.wakeup_sound)
def stop(self) -> None: def stop(self) -> None:
self.state.stop_word.is_active = False
self.state.tts_player.stop() self.state.tts_player.stop()
if self._timer_finished: if self._timer_finished:
self._timer_finished = False self._timer_finished = False
_LOGGER.debug("Stopping timer finished sound") _LOGGER.debug("Stopping timer finished sound")
else: else:
_LOGGER.debug("TTS response stopped manually") _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: def play_tts(self) -> None:
if (not self._tts_url) or self._tts_played: if not self._tts_url:
return 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) _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.state.tts_player.play(self._tts_url, done_callback=self._tts_finished)
self._tts_url = None # Clear after starting playback
def duck(self) -> None: def duck(self) -> None:
_LOGGER.debug("Ducking music") _LOGGER.debug("Ducking music")
@@ -263,28 +301,32 @@ class VoiceSatelliteProtocol(APIServer):
def _determine_final_state(self) -> None: def _determine_final_state(self) -> None:
"""Called when both TTS and the pipeline run are finished.""" """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: if self._continue_conversation:
self.send_messages([VoiceAssistantRequest(start=True)]) self.send_messages([VoiceAssistantRequest(start=True)])
self._is_streaming_audio = True self._is_streaming_audio = True
_LOGGER.debug("Continuing conversation") _LOGGER.debug("Continuing conversation")
self.state.event_bus.publish("voice_listen") self._set_state(SatelliteState.LISTENING)
else: else:
self.unduck() self._set_state(SatelliteState.IDLE)
self.state.event_bus.publish("voice_run_end")
self.state.event_bus.publish("voice_idle")
_LOGGER.debug("Final state determined") _LOGGER.debug("Final state determined")
def _tts_finished(self) -> None: def _tts_finished(self) -> None:
self._is_speaking = False """Callback when TTS audio finishes playing."""
self.send_messages([VoiceAssistantAnnounceFinished()]) self.send_messages([VoiceAssistantAnnounceFinished()])
_LOGGER.debug("TTS audio playback finished") _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() 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: def _play_timer_finished(self) -> None:
if not self._timer_finished: if not self._timer_finished:
@@ -299,4 +341,5 @@ class VoiceSatelliteProtocol(APIServer):
def connection_lost(self, exc): def connection_lost(self, exc):
super().connection_lost(exc) super().connection_lost(exc)
self._set_state(SatelliteState.ERROR)
_LOGGER.info("Disconnected from Home Assistant") _LOGGER.info("Disconnected from Home Assistant")