diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a8f5b64 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +.DS_Store +.idea +*.log +tmp/ + +*.py[cod] +*.egg +build +htmlcov + +.projectile +.venv/ +venv/ +.tox/ +.mypy_cache/ +*.egg-info/ + +dist/ diff --git a/.isort.cfg b/.isort.cfg new file mode 100644 index 0000000..ba2778d --- /dev/null +++ b/.isort.cfg @@ -0,0 +1,6 @@ +[settings] +multi_line_output=3 +include_trailing_comma=True +force_grid_wrap=0 +use_parentheses=True +line_length=88 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c6dc197 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 1.0.0 + +- Initial release diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e084dd4 --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# Home Assistant Voice Satellite (HAV-SAT) + +Experimental voice satellite for [Home Assistant][homeassistant] that uses the [ESPHome][esphome] protocol. + +Runs on Linux `aarch64` and `x86_64` platforms. Tested with Python 3.13 and Python 3.11. +Supports announcments, start/continue conversation, and timers. + +## Installation + +Install system dependencies (`apt-get`): + +* `libportaudio2` (for `sounddevice`) +* `build-essential` (for `pymicro-features`) +* `libmpv-dev` (for `python-mpv`) + +Clone and install project: + +``` sh +git clone https://github.com/OHF-Voice/hav-sat.git +cd hav-sat +script/setup +``` + +## Running + +Use `script/run` or `python3 -m hav_sat` + +See `--help` for more options. + +## Connecting to Home Assistant + +1. In Home Assistant, go to "Settings" -> "Device & services" +2. Click the "Add integration" button +3. Choose "ESPHome" and then "Set up another instance of ESPHome" +4. Enter the IP address of your voice satellite with port 6053 +5. Click "Submit" + + +[homeassistant]: https://www.home-assistant.io/ +[esphome]: https://esphome.io/ diff --git a/hav_sat/__main__.py b/hav_sat/__main__.py index b3631fd..bbd38b9 100644 --- a/hav_sat/__main__.py +++ b/hav_sat/__main__.py @@ -1,66 +1,58 @@ #!/usr/bin/env python3 -import asyncio import argparse +import asyncio import logging import threading import time from collections.abc import Iterable -from enum import Enum, auto -from pathlib import Path from dataclasses import dataclass -from typing import TYPE_CHECKING, Optional, List, Union, Dict +from pathlib import Path from queue import Queue +from typing import Dict, List, Optional -from google.protobuf import message +# pylint: disable=no-name-in-module import sounddevice as sd -from aioesphomeapi.api_pb2 import ( +from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined] DeviceInfoRequest, DeviceInfoResponse, - ListEntitiesRequest, ListEntitiesDoneResponse, - VoiceAssistantConfigurationRequest, - VoiceAssistantConfigurationResponse, - VoiceAssistantWakeWord, - SubscribeVoiceAssistantRequest, - VoiceAssistantEventResponse, - VoiceAssistantRequest, - VoiceAssistantAudio, - ListEntitiesMediaPlayerResponse, - MediaPlayerStateResponse, + ListEntitiesRequest, MediaPlayerCommandRequest, SubscribeHomeAssistantStatesRequest, VoiceAssistantAnnounceFinished, VoiceAssistantAnnounceRequest, + VoiceAssistantAudio, + VoiceAssistantConfigurationRequest, + VoiceAssistantConfigurationResponse, + VoiceAssistantEventResponse, + VoiceAssistantRequest, VoiceAssistantTimerEventResponse, + VoiceAssistantWakeWord, ) from aioesphomeapi.model import ( - VoiceAssistantFeature, VoiceAssistantEventType, + VoiceAssistantFeature, VoiceAssistantTimerEventType, ) +from google.protobuf import message from .api_server import APIServer -from .microwakeword import MicroWakeWord from .entity import ESPHomeEntity, MediaPlayerEntity -from .util import get_mac, call_all +from .microwakeword import MicroWakeWord from .mpv_player import MpvMediaPlayer +from .util import call_all, get_mac, is_arm _LOGGER = logging.getLogger(__name__) +_MODULE_DIR = Path(__file__).parent +_REPO_DIR = _MODULE_DIR.parent +_WAKEWORDS_DIR = _REPO_DIR / "wakewords" +_SOUNDS_DIR = _REPO_DIR / "sounds" - -class Sound(Enum): - WAKEUP = auto() - TIMER_FINISHED = auto() - - -_DIR = Path(__file__).parent -_SOUNDS_DIR = _DIR / "sounds" - -DEFAULT_SOUNDS: Dict[Sound, Path] = { - Sound.WAKEUP: _SOUNDS_DIR / "wake_word_triggered.flac", - Sound.TIMER_FINISHED: _SOUNDS_DIR / "timer_finished.flac", -} +if is_arm(): + _LIB_DIR = _REPO_DIR / "lib" / "linux_arm64" +else: + _LIB_DIR = _REPO_DIR / "lib" / "linux_amd64" @dataclass @@ -73,10 +65,15 @@ class ServerState: stop_word: MicroWakeWord music_player: MpvMediaPlayer tts_player: MpvMediaPlayer + wakeup_sound: str + timer_finished_sound: str media_player_entity: Optional[MediaPlayerEntity] = None satellite: "Optional[VoiceSatelliteProtocol]" = None +# ----------------------------------------------------------------------------- + + class VoiceSatelliteProtocol(APIServer): def __init__(self, state: ServerState) -> None: @@ -105,7 +102,7 @@ class VoiceSatelliteProtocol(APIServer): def handle_voice_event( self, event_type: VoiceAssistantEventType, data: Dict[str, str] ) -> None: - _LOGGER.debug("%s: %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: self._tts_url = data.get("url") @@ -122,7 +119,7 @@ class VoiceSatelliteProtocol(APIServer): self.play_tts() elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END: if data.get("continue_conversation") == "1": - self._continue_conversation + self._continue_conversation = True elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END: self._tts_url = data.get("url") self.play_tts() @@ -131,6 +128,8 @@ class VoiceSatelliteProtocol(APIServer): if not self._tts_played: self._tts_finished() + self._tts_played = False + # TODO: handle error def handle_timer_event( @@ -138,6 +137,7 @@ class VoiceSatelliteProtocol(APIServer): event_type: VoiceAssistantTimerEventType, msg: VoiceAssistantTimerEventResponse, ) -> None: + _LOGGER.debug("Timer event: type=%s", event_type.name) if event_type == VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_FINISHED: if not self._timer_finished: self.state.stop_word.is_active = True @@ -211,7 +211,7 @@ class VoiceSatelliteProtocol(APIServer): active_wake_words=[self.state.wake_word.id], max_active_wake_words=1, ) - _LOGGER.debug("Registered voice assistant") + _LOGGER.info("Connected to Home Assistant") def handle_audio(self, audio_chunk: bytes) -> None: @@ -224,6 +224,8 @@ class VoiceSatelliteProtocol(APIServer): 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 = self.state.wake_word.wake_word @@ -233,17 +235,17 @@ class VoiceSatelliteProtocol(APIServer): ) self.duck() self._is_streaming_audio = True - self.state.tts_player.play(str(DEFAULT_SOUNDS[Sound.WAKEUP])) + 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 - self.state.tts_player.stop() + _LOGGER.debug("Stopping timer finished sound") else: _LOGGER.debug("TTS response stopped manually") - self.state.tts_player.stop() self._tts_finished() def play_tts(self) -> None: @@ -283,12 +285,16 @@ class VoiceSatelliteProtocol(APIServer): return self.state.tts_player.play( - str(DEFAULT_SOUNDS[Sound.TIMER_FINISHED]), + self.state.timer_finished_sound, done_callback=lambda: call_all( lambda: time.sleep(1.0), self._play_timer_finished ), ) + def connection_lost(self, exc): + super().connection_lost(exc) + _LOGGER.info("Disconnected from Home Assistant") + def process_audio(state: ServerState): @@ -313,30 +319,72 @@ def process_audio(state: ServerState): audio_chunk ): state.satellite.stop() - except: + except Exception: _LOGGER.exception("Unexpected error handling audio") - except: + except Exception: _LOGGER.exception("Unexpected error processing audio") +# ----------------------------------------------------------------------------- + + async def main() -> None: parser = argparse.ArgumentParser() - parser.add_argument("--audio-input-device", default="default") - parser.add_argument("--audio-output-device") + parser.add_argument("--name", required=True) + parser.add_argument( + "--audio-input-device", + default="default", + help="sounddevice name for input device", + ) + parser.add_argument("--audio-input-block-size", type=int, default=1024) + parser.add_argument("--audio-output-device", help="mpv name for output device") + parser.add_argument( + "--wake-model", + default=_WAKEWORDS_DIR / "okay_nabu.json", + help="Path to microWakeWord model config for wake word", + ) + parser.add_argument( + "--stop-model", + default=_WAKEWORDS_DIR / "stop.json", + help="Path to microWakeWord model config for stop word", + ) + 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( + "--host", + default="0.0.0.0", + help="Address for ESPHome server (default: 0.0.0.0)", + ) + parser.add_argument( + "--port", type=int, default=6053, help="Port for ESPHome server (default: 6053)" + ) + parser.add_argument( + "--debug", action="store_true", help="Print DEBUG messages to console" + ) args = parser.parse_args() - logging.basicConfig(level=logging.DEBUG) + logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO) + _LOGGER.debug(args) + + libtensorflowlite_c_path = _LIB_DIR / "libtensorflowlite_c.so" + _LOGGER.debug("libtensorflowlite_c path: %s", libtensorflowlite_c_path) state = ServerState( - name="test", + name=args.name, mac_address=get_mac(), audio_queue=Queue(), entities=[], - wake_word=MicroWakeWord.from_config("wakewords/okay_nabu.json"), - stop_word=MicroWakeWord.from_config("wakewords/stop.json"), + wake_word=MicroWakeWord.from_config(args.wake_model, libtensorflowlite_c_path), + stop_word=MicroWakeWord.from_config(args.stop_model, libtensorflowlite_c_path), music_player=MpvMediaPlayer(device=args.audio_output_device), tts_player=MpvMediaPlayer(device=args.audio_output_device), + wakeup_sound=args.wakeup_sound, + timer_finished_sound=args.timer_finished_sound, ) process_audio_thread = threading.Thread( @@ -344,25 +392,26 @@ async def main() -> None: ) process_audio_thread.start() - def sd_callback(indata, frames, time, status): + 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="0.0.0.0", port=6053 + lambda: VoiceSatelliteProtocol(state), host=args.host, port=args.port ) try: + _LOGGER.debug("Opening audio input device: %s", args.audio_input_device) with sd.RawInputStream( samplerate=16000, - blocksize=1024, + blocksize=args.audio_input_block_size, device=args.audio_input_device, dtype="int16", channels=1, callback=sd_callback, ): async with server: - _LOGGER.info("Server started") + _LOGGER.info("Server started (host=%s, port=%s)", args.host, args.port) await server.serve_forever() except KeyboardInterrupt: pass diff --git a/hav_sat/api_server.py b/hav_sat/api_server.py index 3a36f79..3d3f58e 100644 --- a/hav_sat/api_server.py +++ b/hav_sat/api_server.py @@ -1,25 +1,25 @@ """Partial ESPHome server implementation.""" -import logging import asyncio -from typing import Optional, TYPE_CHECKING, List, Union - - +import logging from abc import abstractmethod from collections.abc import Iterable +from typing import TYPE_CHECKING, List, Optional + +# pylint: disable=no-name-in-module from aioesphomeapi._frame_helper.packets import make_plain_text_packets -from google.protobuf import message -from aioesphomeapi.core import MESSAGE_TYPE_TO_PROTO -from aioesphomeapi.api_pb2 import ( - HelloRequest, - HelloResponse, +from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined] ConnectRequest, ConnectResponse, DisconnectRequest, DisconnectResponse, + HelloRequest, + HelloResponse, PingRequest, PingResponse, ) +from aioesphomeapi.core import MESSAGE_TYPE_TO_PROTO +from google.protobuf import message PROTO_TO_MESSAGE_TYPE = {v: k for k, v in MESSAGE_TYPE_TO_PROTO.items()} @@ -62,10 +62,10 @@ class APIServer(asyncio.Protocol): elif isinstance(msg_inst, DisconnectRequest): self.send_messages([DisconnectResponse()]) _LOGGER.debug("Disconnect requested") - if self.transport: - self.transport.close() - self.transport = None - self.writelines = None + if self._transport: + self._transport.close() + self._transport = None + self._writelines = None elif isinstance(msg_inst, PingRequest): self.send_messages([PingResponse()]) elif msgs := self.handle_message(msg_inst): @@ -75,7 +75,7 @@ class APIServer(asyncio.Protocol): self.send_messages(msgs) def send_messages(self, msgs: List[message.Message]): - if self.writelines is None: + if self._writelines is None: return packets = [ @@ -83,11 +83,11 @@ class APIServer(asyncio.Protocol): for msg in msgs ] packet_bytes = make_plain_text_packets(packets) - self.writelines(packet_bytes) + self._writelines(packet_bytes) def connection_made(self, transport) -> None: - self.transport = transport - self.writelines = self.transport.writelines + self._transport = transport + self._writelines = transport.writelines def data_received(self, data: bytes): if self._buffer is None: @@ -139,9 +139,8 @@ class APIServer(asyncio.Protocol): return cstr[original_pos:new_pos] def connection_lost(self, exc): - self.transport = None - self.writelines = None - _LOGGER.debug("Connection closed") + self._transport = None + self._writelines = None def _read_varuint(self) -> int: """Read a varuint from the buffer or -1 if the buffer runs out of bytes.""" diff --git a/hav_sat/entity.py b/hav_sat/entity.py index 4240241..6914665 100644 --- a/hav_sat/entity.py +++ b/hav_sat/entity.py @@ -1,17 +1,17 @@ from abc import abstractmethod from collections.abc import Iterable -from typing import Callable, Optional, List, Union +from typing import Callable, List, Optional, Union -from aioesphomeapi import MediaPlayerState -from google.protobuf import message -from aioesphomeapi.api_pb2 import ( - ListEntitiesRequest, - MediaPlayerStateResponse, +# pylint: disable=no-name-in-module +from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined] ListEntitiesMediaPlayerResponse, - SubscribeHomeAssistantStatesRequest, + ListEntitiesRequest, MediaPlayerCommandRequest, + MediaPlayerStateResponse, + SubscribeHomeAssistantStatesRequest, ) from aioesphomeapi.model import MediaPlayerCommand, MediaPlayerState +from google.protobuf import message from .api_server import APIServer from .mpv_player import MpvMediaPlayer diff --git a/hav_sat/microwakeword.py b/hav_sat/microwakeword.py index 0fd8219..f757ffa 100644 --- a/hav_sat/microwakeword.py +++ b/hav_sat/microwakeword.py @@ -1,11 +1,11 @@ import ctypes import json -import numpy as np import statistics from collections import deque from pathlib import Path -from typing import Union, List, Deque +from typing import Deque, List, Union +import numpy as np from pymicro_features import MicroFrontend _SAMPLES_PER_SECOND = 16000 @@ -24,13 +24,14 @@ class TfLiteQuantizationParams(ctypes.Structure): class MicroWakeWord: def __init__( self, - id: str, + id: str, # pylint: disable=redefined-builtin wake_word: str, tflite_model: Union[str, Path], probability_cutoff: float, sliding_window_size: int, refractory_seconds: float, trained_languages: List[str], + libtensorflowlite_c_path: Union[str, Path], ): self.id = id self.wake_word = wake_word @@ -44,7 +45,7 @@ class MicroWakeWord: # Load the shared library self.lib = ctypes.cdll.LoadLibrary( - str(Path("libtensorflowlite_c.so").resolve()) + str(Path(libtensorflowlite_c_path).resolve()) ) # Define required argument/return types for C API @@ -153,6 +154,7 @@ class MicroWakeWord: @staticmethod def from_config( config_path: Union[str, Path], + libtensorflowlite_c_path: Union[str, Path], refractory_seconds: float = _DEFAULT_REFRACTORY, ) -> "MicroWakeWord": """Load a microWakeWord model from a JSON config file. @@ -178,6 +180,7 @@ class MicroWakeWord: sliding_window_size=micro_config["sliding_window_size"], refractory_seconds=refractory_seconds, trained_languages=micro_config.get("trained_languages", []), + libtensorflowlite_c_path=libtensorflowlite_c_path, ) def process_streaming(self, audio_bytes: bytes) -> bool: diff --git a/hav_sat/mpv_player.py b/hav_sat/mpv_player.py index 5eb5288..b4762cb 100644 --- a/hav_sat/mpv_player.py +++ b/hav_sat/mpv_player.py @@ -2,9 +2,8 @@ import logging from collections.abc import Callable -from typing import Optional, Union, List, Any -from pathlib import Path from threading import Lock +from typing import List, Optional, Union from mpv import MPV @@ -24,8 +23,8 @@ class MpvMediaPlayer: self._done_callback: Optional[Callable[[], None]] = None self._done_callback_lock = Lock() - self._duck_volume:int = 50 - self._unduck_volume:int = 100 + self._duck_volume: int = 50 + self._unduck_volume: int = 100 self.player.event_callback("end-file")(self._on_end_file) @@ -33,6 +32,7 @@ class MpvMediaPlayer: self, url: Union[str, List[str]], done_callback: Optional[Callable[[], None]] = None, + stop_first: bool = True, ) -> None: self.stop() @@ -41,9 +41,12 @@ class MpvMediaPlayer: else: self._playlist = url + next_url = self._playlist.pop(0) + _LOGGER.debug("Playing %s", next_url) + self._done_callback = done_callback self.is_playing = True - self.player.play(self._playlist.pop(0)) + self.player.play(next_url) def pause(self) -> None: self.player.pause = True @@ -71,7 +74,7 @@ class MpvMediaPlayer: self._unduck_volume = volume self._duck_volume = volume // 2 - def _on_end_file(self, event): + def _on_end_file(self, event) -> None: if self._playlist: self.player.play(self._playlist.pop(0)) return @@ -87,5 +90,5 @@ class MpvMediaPlayer: if todo_callback: try: todo_callback() - except: - pass + except Exception: + _LOGGER.exception("Unexpected error running done callback") diff --git a/hav_sat/util.py b/hav_sat/util.py index 8e247bd..92726ef 100644 --- a/hav_sat/util.py +++ b/hav_sat/util.py @@ -1,7 +1,8 @@ """Utility methods.""" -from collections.abc import Callable +import platform import uuid +from collections.abc import Callable from typing import Optional @@ -14,3 +15,8 @@ def get_mac() -> str: def call_all(*callables: Optional[Callable[[], None]]) -> None: for item in filter(None, callables): item() + + +def is_arm() -> bool: + machine = platform.machine() + return ("arm" in machine) or ("aarch" in machine) diff --git a/lib/SOURCE b/lib/SOURCE new file mode 100644 index 0000000..cff51ab --- /dev/null +++ b/lib/SOURCE @@ -0,0 +1 @@ +https://github.com/tphakala/tflite_c/ diff --git a/lib/linux_amd64/libtensorflowlite_c.so b/lib/linux_amd64/libtensorflowlite_c.so new file mode 100644 index 0000000..34090dd Binary files /dev/null and b/lib/linux_amd64/libtensorflowlite_c.so differ diff --git a/lib/linux_arm64/libtensorflowlite_c.so b/lib/linux_arm64/libtensorflowlite_c.so new file mode 100644 index 0000000..ea2b03c Binary files /dev/null and b/lib/linux_arm64/libtensorflowlite_c.so differ diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..2a3b8f7 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,20 @@ + +[mypy] + +[mypy-setuptools.*] +ignore_missing_imports = True + +[mypy-aioesphomeapi.api_pb2.*] +ignore_missing_imports = True + +[mypy-pymicro_features.*] +ignore_missing_imports = True + +[mypy-sounddevice.*] +ignore_missing_imports = True + +[mypy-mpv.*] +ignore_missing_imports = True + +[mypy-google.protobuf.*] +ignore_missing_imports = True diff --git a/pylintrc b/pylintrc new file mode 100644 index 0000000..22a70d0 --- /dev/null +++ b/pylintrc @@ -0,0 +1,37 @@ +[MESSAGES CONTROL] +disable= + format, + abstract-method, + cyclic-import, + duplicate-code, + global-statement, + import-outside-toplevel, + inconsistent-return-statements, + locally-disabled, + not-context-manager, + too-few-public-methods, + too-many-arguments, + too-many-branches, + too-many-instance-attributes, + too-many-lines, + too-many-locals, + too-many-public-methods, + too-many-return-statements, + too-many-statements, + too-many-boolean-expressions, + unnecessary-pass, + unused-argument, + broad-except, + too-many-nested-blocks, + invalid-name, + unused-import, + fixme, + useless-super-delegation, + missing-module-docstring, + missing-class-docstring, + missing-function-docstring, + import-error, + consider-using-with + +[FORMAT] +expected-line-ending-format=LF diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..517afb1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,53 @@ +[build-system] +requires = ["setuptools>=62.3"] +build-backend = "setuptools.build_meta" + +[project] +name = "hav-satellite" +version = "1.0.0" +license = {text = "Apache-2.0"} +description = "Voice satellite for Home Assistant using the ESPHome protocol" +readme = "README.md" +authors = [ + {name = "The Home Assistant Authors", email = "hello@home-assistant.io"} +] +keywords = ["home", "assistant", "voice", "esphome"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Topic :: Text Processing :: Linguistic", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] +requires-python = ">=3.9.0" +dependencies = [ + "aioesphomeapi==37.2.1", + "sounddevice<1", + "numpy>=2,<3", + "pymicro-features==1.0.0", +] + +[project.optional-dependencies] +dev = [ + "black==24.8.0", + "flake8==7.2.0", + "mypy==1.14.0", + "pylint==3.2.7", + "pytest==8.3.5", +] + +[project.urls] +"Source Code" = "http://github.com/OHF-Voice/hav-sat" + +[tool.setuptools] +platforms = ["any"] +zip-safe = true +include-package-data = true + +[tool.setuptools.packages.find] +include = ["hav_sat"] +exclude = ["tests", "tests.*"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..be3245c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +aioesphomeapi +sounddevice diff --git a/script/format b/script/format new file mode 100755 index 0000000..3c6b5f2 --- /dev/null +++ b/script/format @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +import subprocess +import venv +from pathlib import Path + +_DIR = Path(__file__).parent +_PROGRAM_DIR = _DIR.parent +_VENV_DIR = _PROGRAM_DIR / ".venv" +_MODULE_DIR = _PROGRAM_DIR / "hav_sat" + +_FORMAT_DIRS = [_MODULE_DIR] + +if _VENV_DIR.exists(): + context = venv.EnvBuilder().ensure_directories(_VENV_DIR) + python_exe = context.env_exe +else: + python_exe = "python3" + +subprocess.check_call([python_exe, "-m", "black"] + _FORMAT_DIRS) +subprocess.check_call([python_exe, "-m", "isort"] + _FORMAT_DIRS) diff --git a/script/lint b/script/lint new file mode 100755 index 0000000..af247c0 --- /dev/null +++ b/script/lint @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +import subprocess +import venv +from pathlib import Path + +_DIR = Path(__file__).parent +_PROGRAM_DIR = _DIR.parent +_VENV_DIR = _PROGRAM_DIR / ".venv" +_MODULE_DIR = _PROGRAM_DIR / "hav_sat" + +_LINT_DIRS = [_MODULE_DIR] + +if _VENV_DIR.exists(): + context = venv.EnvBuilder().ensure_directories(_VENV_DIR) + python_exe = context.env_exe +else: + python_exe = "python3" + +subprocess.check_call([python_exe, "-m", "black"] + _LINT_DIRS + ["--check"]) +subprocess.check_call([python_exe, "-m", "isort"] + _LINT_DIRS + ["--check"]) +subprocess.check_call([python_exe, "-m", "flake8"] + _LINT_DIRS) +subprocess.check_call([python_exe, "-m", "pylint"] + _LINT_DIRS) +subprocess.check_call([python_exe, "-m", "mypy"] + _LINT_DIRS) diff --git a/script/run b/script/run new file mode 100755 index 0000000..ce2e06f --- /dev/null +++ b/script/run @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +import sys +import subprocess +import venv +from pathlib import Path + +_DIR = Path(__file__).parent +_PROGRAM_DIR = _DIR.parent +_VENV_DIR = _PROGRAM_DIR / ".venv" + +if _VENV_DIR.exists(): + context = venv.EnvBuilder().ensure_directories(_VENV_DIR) + python_exe = context.env_exe +else: + python_exe = "python3" + +subprocess.check_call([python_exe, "-m", "hav_sat"] + sys.argv[1:]) diff --git a/script/setup b/script/setup new file mode 100755 index 0000000..0ff2e95 --- /dev/null +++ b/script/setup @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +import argparse +import subprocess +import venv +from pathlib import Path + +_DIR = Path(__file__).parent +_PROGRAM_DIR = _DIR.parent +_VENV_DIR = _PROGRAM_DIR / ".venv" + +parser = argparse.ArgumentParser() +parser.add_argument("--dev", action="store_true", help="Install dev requirements") +args = parser.parse_args() + +# Create virtual environment +builder = venv.EnvBuilder(with_pip=True) +context = builder.ensure_directories(_VENV_DIR) +builder.create(_VENV_DIR) + +# Upgrade dependencies +pip = [context.env_exe, "-m", "pip"] +subprocess.check_call(pip + ["install", "--upgrade", "pip"]) +subprocess.check_call(pip + ["install", "--upgrade", "setuptools", "wheel"]) + +# Install requirements +subprocess.check_call(pip + ["install", "-e", str(_PROGRAM_DIR)]) + +if args.dev: + # Install dev requirements + subprocess.check_call( + pip + ["install", "-e", f"{_PROGRAM_DIR}[dev]"] + ) diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..0076bbf --- /dev/null +++ b/setup.cfg @@ -0,0 +1,22 @@ +[flake8] +# To work with Black +max-line-length = 88 +# E501: line too long +# W503: Line break occurred before a binary operator +# E203: Whitespace before ':' +# D202 No blank lines allowed after function docstring +# W504 line break after binary operator +ignore = + E501, + W503, + E203, + D202, + W504 + +[isort] +multi_line_output = 3 +include_trailing_comma=True +force_grid_wrap=0 +use_parentheses=True +line_length=88 +indent = " " diff --git a/sounds/LICENSE.md b/sounds/LICENSE.md new file mode 100644 index 0000000..4872f4f --- /dev/null +++ b/sounds/LICENSE.md @@ -0,0 +1 @@ +[Home Assistant Voice Preview Edition Sounds](https://github.com/esphome/home-assistant-voice-pe/tree/dev/sounds) © 2024 by [Clayton Charles Tapp](https://www.cctaudio.com/) is licensed under [Creative Commons Attribution 4.0 International](https://creativecommons.org/licenses/by/4.0/?ref=chooser-v1) diff --git a/sounds/timer_finished.flac b/sounds/timer_finished.flac new file mode 100644 index 0000000..2968607 Binary files /dev/null and b/sounds/timer_finished.flac differ diff --git a/sounds/wake_word_triggered.flac b/sounds/wake_word_triggered.flac new file mode 100644 index 0000000..414e808 Binary files /dev/null and b/sounds/wake_word_triggered.flac differ diff --git a/wakewords/alexa.json b/wakewords/alexa.json new file mode 100644 index 0000000..dff427a --- /dev/null +++ b/wakewords/alexa.json @@ -0,0 +1,16 @@ +{ + "type": "micro", + "wake_word": "Alexa", + "author": "Kevin Ahrendt", + "website": "https://www.kevinahrendt.com/", + "model": "alexa.tflite", + "trained_languages": ["en"], + "version": 2, + "micro": { + "probability_cutoff": 0.9, + "sliding_window_size": 5, + "feature_step_size": 10, + "tensor_arena_size": 22348, + "minimum_esphome_version": "2024.7.0" + } +} diff --git a/wakewords/alexa.tflite b/wakewords/alexa.tflite new file mode 100644 index 0000000..b9a87dd Binary files /dev/null and b/wakewords/alexa.tflite differ diff --git a/wakewords/choo_choo_homie.json b/wakewords/choo_choo_homie.json new file mode 100644 index 0000000..070fd58 --- /dev/null +++ b/wakewords/choo_choo_homie.json @@ -0,0 +1,18 @@ +{ + "type": "micro", + "wake_word": "Choo Choo Homie", + "author": "Michael Hansen", + "website": "https://www.home-assistant.io", + "model": "choo_choo_homie.tflite", + "trained_languages": [ + "en" + ], + "version": 2, + "micro": { + "probability_cutoff": 0.97, + "feature_step_size": 10, + "sliding_window_size": 5, + "tensor_arena_size": 30000, + "minimum_esphome_version": "2024.7.0" + } +} \ No newline at end of file diff --git a/wakewords/choo_choo_homie.tflite b/wakewords/choo_choo_homie.tflite new file mode 100644 index 0000000..d03f2a4 Binary files /dev/null and b/wakewords/choo_choo_homie.tflite differ diff --git a/wakewords/hey_home_assistant.json b/wakewords/hey_home_assistant.json new file mode 100644 index 0000000..b49f8af --- /dev/null +++ b/wakewords/hey_home_assistant.json @@ -0,0 +1,16 @@ +{ + "type": "micro", + "wake_word": "Hey Home Assistant", + "author": "Michael Hansen", + "website": "https://www.home-assistant.io", + "model": "hey_home_assistant.tflite", + "trained_languages": ["en"], + "version": 2, + "micro": { + "probability_cutoff": 0.97, + "feature_step_size": 10, + "sliding_window_size": 5, + "tensor_arena_size": 30000, + "minimum_esphome_version": "2024.7.0" + } +} diff --git a/wakewords/hey_home_assistant.tflite b/wakewords/hey_home_assistant.tflite new file mode 100644 index 0000000..c6dc40a Binary files /dev/null and b/wakewords/hey_home_assistant.tflite differ diff --git a/wakewords/hey_jarvis.json b/wakewords/hey_jarvis.json new file mode 100644 index 0000000..e6733fe --- /dev/null +++ b/wakewords/hey_jarvis.json @@ -0,0 +1,16 @@ +{ + "type": "micro", + "wake_word": "Hey Jarvis", + "author": "Kevin Ahrendt", + "website": "https://www.kevinahrendt.com/", + "model": "hey_jarvis.tflite", + "trained_languages": ["en"], + "version": 2, + "micro": { + "probability_cutoff": 0.97, + "feature_step_size": 10, + "sliding_window_size": 5, + "tensor_arena_size": 22860, + "minimum_esphome_version": "2024.7.0" + } +} diff --git a/wakewords/hey_jarvis.tflite b/wakewords/hey_jarvis.tflite new file mode 100644 index 0000000..0075302 Binary files /dev/null and b/wakewords/hey_jarvis.tflite differ diff --git a/wakewords/hey_luna.json b/wakewords/hey_luna.json new file mode 100644 index 0000000..a293cbc --- /dev/null +++ b/wakewords/hey_luna.json @@ -0,0 +1,16 @@ +{ + "type": "micro", + "wake_word": "hey_luna", + "author": "adamlonsdale", + "website": "https://github.com/adamlonsdale", + "model": "hey_luna.tflite", + "version": 2, + "trained_languages": ["en"], + "micro": { + "probability_cutoff": 0.63, + "sliding_window_size": 5, + "feature_step_size": 10, + "tensor_arena_size": 22860, + "minimum_esphome_version": "2024.7.0" + } + } diff --git a/wakewords/hey_luna.tflite b/wakewords/hey_luna.tflite new file mode 100644 index 0000000..fcc01ed Binary files /dev/null and b/wakewords/hey_luna.tflite differ diff --git a/wakewords/hey_mycroft.json b/wakewords/hey_mycroft.json new file mode 100644 index 0000000..995c188 --- /dev/null +++ b/wakewords/hey_mycroft.json @@ -0,0 +1,16 @@ +{ + "type": "micro", + "wake_word": "Hey Mycroft", + "author": "Kevin Ahrendt", + "website": "https://www.kevinahrendt.com/", + "model": "hey_mycroft.tflite", + "trained_languages": ["en"], + "version": 2, + "micro": { + "probability_cutoff": 0.95, + "sliding_window_size": 5, + "feature_step_size": 10, + "tensor_arena_size": 23628, + "minimum_esphome_version": "2024.7.0" + } +} diff --git a/wakewords/hey_mycroft.tflite b/wakewords/hey_mycroft.tflite new file mode 100644 index 0000000..0ab2f75 Binary files /dev/null and b/wakewords/hey_mycroft.tflite differ diff --git a/wakewords/hey_peppa_pig.json b/wakewords/hey_peppa_pig.json new file mode 100644 index 0000000..0d3d107 --- /dev/null +++ b/wakewords/hey_peppa_pig.json @@ -0,0 +1,18 @@ +{ + "type": "micro", + "wake_word": "Hey Peppa Pig", + "author": "Michael Hansen", + "website": "https://www.home-assistant.io", + "model": "hey_peppa_pig.tflite", + "trained_languages": [ + "en" + ], + "version": 2, + "micro": { + "probability_cutoff": 0.97, + "feature_step_size": 10, + "sliding_window_size": 5, + "tensor_arena_size": 30000, + "minimum_esphome_version": "2024.7.0" + } +} \ No newline at end of file diff --git a/wakewords/hey_peppa_pig.tflite b/wakewords/hey_peppa_pig.tflite new file mode 100644 index 0000000..45c3dac Binary files /dev/null and b/wakewords/hey_peppa_pig.tflite differ diff --git a/wakewords/okay_computer.json b/wakewords/okay_computer.json new file mode 100644 index 0000000..4b738ea --- /dev/null +++ b/wakewords/okay_computer.json @@ -0,0 +1,18 @@ +{ + "type": "micro", + "wake_word": "Okay Computer", + "author": "Michael Hansen", + "website": "https://www.home-assistant.io", + "model": "okay_computer.tflite", + "trained_languages": [ + "en" + ], + "version": 2, + "micro": { + "probability_cutoff": 0.97, + "feature_step_size": 10, + "sliding_window_size": 5, + "tensor_arena_size": 30000, + "minimum_esphome_version": "2024.7.0" + } +} diff --git a/wakewords/okay_computer.tflite b/wakewords/okay_computer.tflite new file mode 100644 index 0000000..939a303 Binary files /dev/null and b/wakewords/okay_computer.tflite differ diff --git a/wakewords/okay_nabu.json b/wakewords/okay_nabu.json new file mode 100644 index 0000000..6fd7b80 --- /dev/null +++ b/wakewords/okay_nabu.json @@ -0,0 +1,16 @@ +{ + "type": "micro", + "wake_word": "Okay Nabu", + "author": "Kevin Ahrendt", + "website": "https://www.kevinahrendt.com/", + "model": "okay_nabu.tflite", + "trained_languages": ["en","nl","fr","de","it","es","sv"], + "version": 2, + "micro": { + "probability_cutoff": 0.85, + "feature_step_size": 10, + "sliding_window_size": 5, + "tensor_arena_size": 37000, + "minimum_esphome_version": "2024.7.0" + } +} diff --git a/wakewords/okay_nabu.tflite b/wakewords/okay_nabu.tflite new file mode 100644 index 0000000..00379f1 Binary files /dev/null and b/wakewords/okay_nabu.tflite differ diff --git a/wakewords/stop.json b/wakewords/stop.json new file mode 100644 index 0000000..d6b5a70 --- /dev/null +++ b/wakewords/stop.json @@ -0,0 +1,16 @@ +{ + "type": "micro", + "wake_word": "Stop", + "author": "Kevin Ahrendt", + "website": "https://www.kevinahrendt.com/", + "model": "stop.tflite", + "trained_languages": ["en"], + "version": 2, + "micro": { + "probability_cutoff": 0.5, + "feature_step_size": 10, + "sliding_window_size": 5, + "tensor_arena_size": 21000, + "minimum_esphome_version": "2024.7.0" + } +} diff --git a/wakewords/stop.tflite b/wakewords/stop.tflite new file mode 100644 index 0000000..1cc08ad Binary files /dev/null and b/wakewords/stop.tflite differ