diff --git a/linux_voice_assistant/__main__.py b/linux_voice_assistant/__main__.py index e625e6c..e6015bd 100644 --- a/linux_voice_assistant/__main__.py +++ b/linux_voice_assistant/__main__.py @@ -82,17 +82,20 @@ async def main() -> None: "--timer-finished-sound", default=str(_SOUNDS_DIR / "timer_finished.flac") ) parser.add_argument( - "--processing-sound", default=str(_SOUNDS_DIR / "processing.wav"), - help="Short sound to play while assistant is processing (thinking)" + "--processing-sound", + default=str(_SOUNDS_DIR / "processing.wav"), + help="Short sound to play while assistant is processing (thinking)", ) parser.add_argument( - "--mute-sound", default=str(_SOUNDS_DIR / "mute_switch_on.flac"), - help="Sound to play when muting the assistant" + "--mute-sound", + default=str(_SOUNDS_DIR / "mute_switch_on.flac"), + help="Sound to play when muting the assistant", ) parser.add_argument( - "--unmute-sound", default=str(_SOUNDS_DIR / "mute_switch_off.flac"), - help="Sound to play when unmuting the assistant" - ) + "--unmute-sound", + default=str(_SOUNDS_DIR / "mute_switch_off.flac"), + help="Sound to play when unmuting the assistant", + ) # parser.add_argument("--preferences-file", default=_REPO_DIR / "preferences.json") # @@ -105,8 +108,10 @@ async def main() -> None: "--port", type=int, default=6053, help="Port for ESPHome server (default: 6053)" ) parser.add_argument( - "--enable-thinking-sound", action="store_true", help="Enable thinking sound on startup" - ) + "--enable-thinking-sound", + action="store_true", + help="Enable thinking sound on startup", + ) parser.add_argument( "--debug", action="store_true", help="Print DEBUG messages to console" ) @@ -242,7 +247,7 @@ async def main() -> None: timer_finished_sound=args.timer_finished_sound, processing_sound=args.processing_sound, mute_sound=args.mute_sound, - unmute_sound=args.unmute_sound, + unmute_sound=args.unmute_sound, preferences=preferences, preferences_path=preferences_path, refractory_seconds=args.refractory_seconds, @@ -250,7 +255,7 @@ async def main() -> None: ) if args.enable_thinking_sound: - state.save_preferences() + state.save_preferences() process_audio_thread = threading.Thread( target=process_audio, @@ -370,7 +375,11 @@ def process_audio(state: ServerState, mic, block_size: int): if state.stop_word.process_streaming(micro_input): stopped = True - if stopped and (state.stop_word.id in state.active_wake_words) and not state.muted: + if ( + stopped + and (state.stop_word.id in state.active_wake_words) + and not state.muted + ): state.satellite.stop() except Exception: _LOGGER.exception("Unexpected error handling audio") diff --git a/linux_voice_assistant/api_server.py b/linux_voice_assistant/api_server.py index 574ae38..9f005be 100644 --- a/linux_voice_assistant/api_server.py +++ b/linux_voice_assistant/api_server.py @@ -39,7 +39,7 @@ class APIServer(asyncio.Protocol): self._writelines = None self._loop: Optional[asyncio.AbstractEventLoop] = None self._loop_thread_id: Optional[int] = None - + @abstractmethod def handle_message(self, msg: message.Message) -> Iterable[message.Message]: pass @@ -93,7 +93,7 @@ class APIServer(asyncio.Protocol): ): self._loop.call_soon_threadsafe(self._writelines, packet_bytes) return - + self._writelines(packet_bytes) def connection_made(self, transport) -> None: @@ -105,7 +105,7 @@ class APIServer(asyncio.Protocol): self._loop = None else: self._loop_thread_id = threading.get_ident() - + def data_received(self, data: bytes): if self._buffer is None: self._buffer = data @@ -160,7 +160,7 @@ class APIServer(asyncio.Protocol): self._writelines = None self._loop = None self._loop_thread_id = None - + def _read_varuint(self) -> int: """Read a varuint from the buffer or -1 if the buffer runs out of bytes.""" if not self._buffer: diff --git a/linux_voice_assistant/entity.py b/linux_voice_assistant/entity.py index a91af07..93f322f 100644 --- a/linux_voice_assistant/entity.py +++ b/linux_voice_assistant/entity.py @@ -7,17 +7,17 @@ from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined] ListEntitiesMediaPlayerResponse, ListEntitiesRequest, ListEntitiesSwitchResponse, - SwitchCommandRequest, - SwitchStateResponse, MediaPlayerCommandRequest, MediaPlayerStateResponse, SubscribeHomeAssistantStatesRequest, + SwitchCommandRequest, + SwitchStateResponse, ) from aioesphomeapi.model import ( + EntityCategory, MediaPlayerCommand, MediaPlayerEntityFeature, MediaPlayerState, - EntityCategory, ) from google.protobuf import message @@ -35,6 +35,7 @@ SUPPORTED_MEDIA_PLAYER_FEATURES = ( | MediaPlayerEntityFeature.MEDIA_ANNOUNCE ) + class ESPHomeEntity: def __init__(self, server: APIServer) -> None: self.server = server @@ -137,7 +138,7 @@ class MediaPlayerEntity(ESPHomeEntity): self.music_player.set_volume(int(self.volume * 100)) self.announce_player.set_volume(int(self.volume * 100)) self.muted = False - yield self._update_state(self.state) + yield self._update_state(self.state) elif msg.has_volume: volume = int(msg.volume * 100) self.music_player.set_volume(volume) @@ -167,8 +168,10 @@ class MediaPlayerEntity(ESPHomeEntity): muted=self.muted, ) + # ----------------------------------------------------------------------------- + class MuteSwitchEntity(ESPHomeEntity): def __init__( self, @@ -186,12 +189,14 @@ class MuteSwitchEntity(ESPHomeEntity): self.object_id = object_id self._get_muted = get_muted self._set_muted = set_muted - self._switch_state = self._get_muted() # Sync internal state with actual muted value on init + self._switch_state = ( + self._get_muted() + ) # Sync internal state with actual muted value on init def update_set_muted(self, set_muted: Callable[[bool], None]) -> None: # Update the callback used to change the mute state. self._set_muted = set_muted - + def update_get_muted(self, get_muted: Callable[[], bool]) -> None: # Update the callback used to read the mute state. self._get_muted = get_muted @@ -220,7 +225,8 @@ class MuteSwitchEntity(ESPHomeEntity): # Always return our internal switch state self.sync_with_state() yield SwitchStateResponse(key=self.key, state=self._switch_state) - + + class ThinkingSoundEntity(ESPHomeEntity): def __init__( self, @@ -239,12 +245,16 @@ class ThinkingSoundEntity(ESPHomeEntity): self._get_thinking_sound_enabled = get_thinking_sound_enabled self._set_thinking_sound_enabled = set_thinking_sound_enabled self._switch_state = self._get_thinking_sound_enabled() # Sync internal state - - def update_get_thinking_sound_enabled(self, get_thinking_sound_enabled: Callable[[], bool]) -> None: + + def update_get_thinking_sound_enabled( + self, get_thinking_sound_enabled: Callable[[], bool] + ) -> None: # Update the callback used to read the thinking sound enabled state. self._get_thinking_sound_enabled = get_thinking_sound_enabled - def update_set_thinking_sound_enabled(self, set_thinking_sound_enabled: Callable[[bool], None]) -> None: + def update_set_thinking_sound_enabled( + self, set_thinking_sound_enabled: Callable[[bool], None] + ) -> None: # Update the callback used to change the thinking sound enabled state. self._set_thinking_sound_enabled = set_thinking_sound_enabled @@ -271,4 +281,4 @@ class ThinkingSoundEntity(ESPHomeEntity): elif isinstance(msg, SubscribeHomeAssistantStatesRequest): # Always return our internal switch state self.sync_with_state() - yield SwitchStateResponse(key=self.key, state=self._switch_state) + yield SwitchStateResponse(key=self.key, state=self._switch_state) diff --git a/linux_voice_assistant/models.py b/linux_voice_assistant/models.py index c927ce2..805a2aa 100644 --- a/linux_voice_assistant/models.py +++ b/linux_voice_assistant/models.py @@ -12,8 +12,12 @@ if TYPE_CHECKING: from pymicro_wakeword import MicroWakeWord from pyopen_wakeword import OpenWakeWord - from .entity import ESPHomeEntity, MediaPlayerEntity - from .entity import ESPHomeEntity, MediaPlayerEntity, MuteSwitchEntity, ThinkingSoundEntity + from .entity import ( + ESPHomeEntity, + MediaPlayerEntity, + MuteSwitchEntity, + ThinkingSoundEntity, + ) from .mpv_player import MpvMediaPlayer from .satellite import VoiceSatelliteProtocol @@ -55,6 +59,7 @@ class Preferences: active_wake_words: List[str] = field(default_factory=list) thinking_sound: int = 0 # 0 = disabled, 1 = enabled + @dataclass class ServerState: name: str @@ -71,7 +76,7 @@ class ServerState: processing_sound: str timer_finished_sound: str mute_sound: str - unmute_sound: str + unmute_sound: str preferences: Preferences preferences_path: Path download_dir: Path @@ -85,7 +90,7 @@ class ServerState: thinking_sound_enabled: bool = False muted: bool = False connected: bool = False - + def save_preferences(self) -> None: """Save preferences as JSON.""" _LOGGER.debug("Saving preferences: %s", self.preferences_path) diff --git a/linux_voice_assistant/satellite.py b/linux_voice_assistant/satellite.py index ea65a3d..ab33c20 100644 --- a/linux_voice_assistant/satellite.py +++ b/linux_voice_assistant/satellite.py @@ -1,10 +1,10 @@ """Voice satellite protocol.""" import asyncio -import re import hashlib import logging import posixpath +import re import shutil import time from collections.abc import Iterable @@ -14,6 +14,7 @@ from urllib.request import urlopen # pylint: disable=no-name-in-module from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined] + AuthenticationRequest, DeviceInfoRequest, DeviceInfoResponse, ListEntitiesDoneResponse, @@ -32,7 +33,6 @@ from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined] VoiceAssistantSetConfiguration, VoiceAssistantTimerEventResponse, VoiceAssistantWakeWord, - AuthenticationRequest, ) from aioesphomeapi.core import MESSAGE_TYPE_TO_PROTO from aioesphomeapi.model import ( @@ -53,11 +53,12 @@ _LOGGER = logging.getLogger(__name__) PROTO_TO_MESSAGE_TYPE = {v: k for k, v in MESSAGE_TYPE_TO_PROTO.items()} + class VoiceSatelliteProtocol(APIServer): def __init__(self, state: ServerState) -> None: super().__init__(state.name) - + self.state = state self.state.satellite = self self.state.connected = False @@ -82,7 +83,7 @@ class VoiceSatelliteProtocol(APIServer): self.state.mute_switch_entity = existing_mute_switches[0] for extra in existing_mute_switches[1:]: self.state.entities.remove(extra) - + if self.state.media_player_entity is None: self.state.media_player_entity = MediaPlayerEntity( server=self, @@ -118,7 +119,7 @@ class VoiceSatelliteProtocol(APIServer): mute_switch.update_get_muted(lambda: self.state.muted) mute_switch.update_set_muted(self._set_muted) mute_switch.sync_with_state() - + existing_thinking_sound_switches = [ entity for entity in self.state.entities @@ -146,14 +147,22 @@ class VoiceSatelliteProtocol(APIServer): self.state.entities.append(thinking_sound_switch) # Load thinking sound enabled state from preferences (default to False if not set or unknown) - if hasattr(self.state.preferences, 'thinking_sound') and self.state.preferences.thinking_sound in (0, 1): - self.state.thinking_sound_enabled = bool(self.state.preferences.thinking_sound) + if hasattr( + self.state.preferences, "thinking_sound" + ) and self.state.preferences.thinking_sound in (0, 1): + self.state.thinking_sound_enabled = bool( + self.state.preferences.thinking_sound + ) else: self.state.thinking_sound_enabled = False thinking_sound_switch.server = self - thinking_sound_switch.update_get_thinking_sound_enabled(lambda: self.state.thinking_sound_enabled) - thinking_sound_switch.update_set_thinking_sound_enabled(self._set_thinking_sound_enabled) + thinking_sound_switch.update_get_thinking_sound_enabled( + lambda: self.state.thinking_sound_enabled + ) + thinking_sound_switch.update_set_thinking_sound_enabled( + self._set_thinking_sound_enabled + ) thinking_sound_switch.sync_with_state() self._is_streaming_audio = False @@ -165,10 +174,12 @@ class VoiceSatelliteProtocol(APIServer): self._pipeline_active = False self._external_wake_words: Dict[str, VoiceAssistantExternalWakeWord] = {} self._disconnect_event = asyncio.Event() - + def _set_thinking_sound_enabled(self, new_state: bool) -> None: self.state.thinking_sound_enabled = bool(new_state) - self.state.preferences.thinking_sound = 1 if self.state.thinking_sound_enabled else 0 + self.state.preferences.thinking_sound = ( + 1 if self.state.thinking_sound_enabled else 0 + ) if self.state.thinking_sound_enabled: _LOGGER.debug("Thinking sound enabled") @@ -194,7 +205,7 @@ class VoiceSatelliteProtocol(APIServer): self.state.tts_player.play(self.state.unmute_sound) # Resume normal operation - wake word detection will be active again pass - + def handle_voice_event( self, event_type: VoiceAssistantEventType, data: Dict[str, str] ) -> None: @@ -204,7 +215,10 @@ class VoiceSatelliteProtocol(APIServer): self._tts_url = data.get("url") self._tts_played = False self._continue_conversation = False - elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_START and self.state.thinking_sound_enabled: + elif ( + event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_START + and self.state.thinking_sound_enabled + ): # Play short "thinking/processing" sound if configured processing = getattr(self.state, "processing_sound", None) if processing: @@ -212,7 +226,7 @@ class VoiceSatelliteProtocol(APIServer): self.state.stop_word.is_active = True self._processing = True self.duck() - self.state.tts_player.play(self.state.processing_sound) + self.state.tts_player.play(self.state.processing_sound) elif event_type in ( VoiceAssistantEventType.VOICE_ASSISTANT_STT_VAD_END, VoiceAssistantEventType.VOICE_ASSISTANT_STT_END, @@ -280,17 +294,17 @@ class VoiceSatelliteProtocol(APIServer): self.handle_timer_event(VoiceAssistantTimerEventType(msg.event_type), msg) elif isinstance(msg, DeviceInfoRequest): # Compute dynamic device name - base_name = re.sub(r'[\s-]+', '-', self.state.name.lower()).strip('-') + base_name = re.sub(r"[\s-]+", "-", self.state.name.lower()).strip("-") mac_no_colon = self.state.mac_address.replace(":", "").lower() mac_last6 = mac_no_colon[-6:] device_name = f"{base_name}-{mac_last6}" - + yield DeviceInfoResponse( uses_password=False, name=device_name, mac_address=self.state.mac_address, manufacturer="Open Home Foundation", - model="Linux Voice Assistant", + model="Linux Voice Assistant", voice_assistant_feature_flags=( VoiceAssistantFeature.VOICE_ASSISTANT | VoiceAssistantFeature.API_AUDIO @@ -402,7 +416,7 @@ class VoiceSatelliteProtocol(APIServer): if self.state.muted: # Don't respond to wake words when muted (voice_assistant.stop behavior) return - + wake_word_phrase = wake_word.wake_word _LOGGER.debug("Detected wake word: %s", wake_word_phrase) self.send_messages( @@ -505,9 +519,11 @@ class VoiceSatelliteProtocol(APIServer): # Send states after connect states = [] for entity in self.state.entities: - states.extend(entity.handle_message(SubscribeHomeAssistantStatesRequest())) + states.extend( + entity.handle_message(SubscribeHomeAssistantStatesRequest()) + ) self.send_messages(states) - _LOGGER.debug("Sent entity states after connect") + _LOGGER.debug("Sent entity states after connect") _LOGGER.info("Disconnected from Home Assistant") def _download_external_wake_word( @@ -576,15 +592,3 @@ class VoiceSatelliteProtocol(APIServer): trained_languages=external_wake_word.trained_languages, wake_word_path=config_path, ) - - def process_packet(self, msg_type: int, packet_data: bytes) -> None: - super().process_packet(msg_type, packet_data) - - if msg_type == PROTO_TO_MESSAGE_TYPE[AuthenticationRequest]: - self.state.connected = True - # Send states after connect - states = [] - for entity in self.state.entities: - states.extend(entity.handle_message(SubscribeHomeAssistantStatesRequest())) - self.send_messages(states) - _LOGGER.debug("Sent entity states after connect")