diff --git a/README.md b/README.md index e084dd4..a7e1f33 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# Home Assistant Voice Satellite (HAV-SAT) +# Linux Voice Assistant -Experimental voice satellite for [Home Assistant][homeassistant] that uses the [ESPHome][esphome] protocol. +Experimental Linux voice assistant 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. @@ -16,14 +16,14 @@ Install system dependencies (`apt-get`): Clone and install project: ``` sh -git clone https://github.com/OHF-Voice/hav-sat.git -cd hav-sat +git clone https://github.com/OHF-Voice/linux-voice-assistant.git +cd linux-voice-assistant script/setup ``` ## Running -Use `script/run` or `python3 -m hav_sat` +Use `script/run` or `python3 -m linux_voice_assistant` See `--help` for more options. diff --git a/hav_sat/__init__.py b/linux_voice_assistant/__init__.py similarity index 100% rename from hav_sat/__init__.py rename to linux_voice_assistant/__init__.py diff --git a/hav_sat/__main__.py b/linux_voice_assistant/__main__.py similarity index 82% rename from hav_sat/__main__.py rename to linux_voice_assistant/__main__.py index 1104805..094388c 100644 --- a/hav_sat/__main__.py +++ b/linux_voice_assistant/__main__.py @@ -6,11 +6,16 @@ import json import logging import threading import time +import tempfile +import shutil +import posixpath from collections.abc import Iterable -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from queue import Queue from typing import Dict, List, Optional +from urllib.request import urlopen +from urllib.parse import urlparse, urlunparse # pylint: disable=no-name-in-module import sounddevice as sd @@ -63,6 +68,7 @@ class AvailableWakeWord: wake_word: str trained_languages: List[str] config_path: Path + url: Optional[str] = None @dataclass @@ -81,6 +87,15 @@ class ServerState: media_player_entity: Optional[MediaPlayerEntity] = None satellite: "Optional[VoiceSatelliteProtocol]" = None + _external_wake_word_dir: Optional[tempfile.TemporaryDirectory] = None + + @property + def external_wake_word_dir(self) -> Path: + if self._external_wake_word_dir is None: + self._external_wake_word_dir = tempfile.TemporaryDirectory() + + return Path(self._external_wake_word_dir.name) + # ----------------------------------------------------------------------------- @@ -98,7 +113,7 @@ class VoiceSatelliteProtocol(APIServer): server=self, key=len(state.entities), name="Media Player", - object_id="hav_sat_media_player", + object_id="linux_voice_assistant_media_player", music_player=state.music_player, announce_player=state.tts_player, ) @@ -211,6 +226,26 @@ class VoiceSatelliteProtocol(APIServer): if isinstance(msg, ListEntitiesRequest): yield ListEntitiesDoneResponse() elif isinstance(msg, VoiceAssistantConfigurationRequest): + external_wake_words = { + external_ww.wake_word.id: external_ww + for external_ww in msg.external_wake_words + if external_ww.model_type == "micro_wake_word" + } + + for wake_word_id, external_ww in external_wake_words.items(): + if wake_word_id in self.state.available_wake_words: + # Skip if already added + continue + + self.state.available_wake_words[wake_word_id] = AvailableWakeWord( + id=external_ww.wake_word.id, + wake_word=external_ww.wake_word.wake_word, + trained_languages=external_ww.wake_word.trained_languages, + config_path=self.state.external_wake_word_dir + / f"{external_ww.wake_word.id}.json", + url=external_ww.url, + ) + yield VoiceAssistantConfigurationResponse( available_wake_words=[ VoiceAssistantWakeWord( @@ -235,6 +270,48 @@ class VoiceSatelliteProtocol(APIServer): if not model_info: continue + if (not model_info.config_path.exists()) and model_info.url: + _LOGGER.debug("Downloading wake word config: %s", model_info.url) + with ( + open(model_info.config_path, "wb") as config_file, + urlopen(model_info.url) as response, + ): + if response.status != 200: + _LOGGER.error( + "Download failed with status: %s", response.status + ) + continue + + shutil.copyfileobj(response, config_file) + + with open( + model_info.config_path, "r", encoding="utf-8" + ) as config_file: + config_dict = json.load(config_file) + parsed_config_url = urlparse(model_info.url) + model_url = urlunparse( + parsed_config_url._replace( + path=posixpath.join( + posixpath.dirname(parsed_config_url.path), + config_dict["model"], + ) + ) + ) + + _LOGGER.debug("Downloading wake word model: %s", model_url) + model_path = model_info.config_path.parent / config_dict["model"] + with ( + open(model_path, "wb") as model_file, + urlopen(model_url) as response, + ): + if response.status != 200: + _LOGGER.error( + "Download failed with status: %s", response.status + ) + continue + + shutil.copyfileobj(response, model_file) + _LOGGER.debug("Loading wake word: %s", model_info.config_path) self.state.wake_word = MicroWakeWord.from_config( model_info.config_path, diff --git a/hav_sat/api_server.py b/linux_voice_assistant/api_server.py similarity index 100% rename from hav_sat/api_server.py rename to linux_voice_assistant/api_server.py diff --git a/hav_sat/entity.py b/linux_voice_assistant/entity.py similarity index 100% rename from hav_sat/entity.py rename to linux_voice_assistant/entity.py diff --git a/hav_sat/microwakeword.py b/linux_voice_assistant/microwakeword.py similarity index 100% rename from hav_sat/microwakeword.py rename to linux_voice_assistant/microwakeword.py diff --git a/hav_sat/mpv_player.py b/linux_voice_assistant/mpv_player.py similarity index 100% rename from hav_sat/mpv_player.py rename to linux_voice_assistant/mpv_player.py diff --git a/hav_sat/util.py b/linux_voice_assistant/util.py similarity index 100% rename from hav_sat/util.py rename to linux_voice_assistant/util.py diff --git a/pyproject.toml b/pyproject.toml index 517afb1..17011ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,15 +3,15 @@ requires = ["setuptools>=62.3"] build-backend = "setuptools.build_meta" [project] -name = "hav-satellite" +name = "linux-voice-assistant" version = "1.0.0" license = {text = "Apache-2.0"} -description = "Voice satellite for Home Assistant using the ESPHome protocol" +description = "Linux voice assistant 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"] +keywords = ["home", "assistant", "voice", "esphome", "linux"] classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", @@ -41,7 +41,7 @@ dev = [ ] [project.urls] -"Source Code" = "http://github.com/OHF-Voice/hav-sat" +"Source Code" = "http://github.com/OHF-Voice/linux-voice-assistant" [tool.setuptools] platforms = ["any"] @@ -49,5 +49,5 @@ zip-safe = true include-package-data = true [tool.setuptools.packages.find] -include = ["hav_sat"] +include = ["linux_voice_assistant"] exclude = ["tests", "tests.*"] diff --git a/script/format b/script/format index 3c6b5f2..11d9218 100755 --- a/script/format +++ b/script/format @@ -6,7 +6,7 @@ from pathlib import Path _DIR = Path(__file__).parent _PROGRAM_DIR = _DIR.parent _VENV_DIR = _PROGRAM_DIR / ".venv" -_MODULE_DIR = _PROGRAM_DIR / "hav_sat" +_MODULE_DIR = _PROGRAM_DIR / "linux_voice_assistant" _FORMAT_DIRS = [_MODULE_DIR] diff --git a/script/lint b/script/lint index af247c0..3ab1387 100755 --- a/script/lint +++ b/script/lint @@ -6,7 +6,7 @@ from pathlib import Path _DIR = Path(__file__).parent _PROGRAM_DIR = _DIR.parent _VENV_DIR = _PROGRAM_DIR / ".venv" -_MODULE_DIR = _PROGRAM_DIR / "hav_sat" +_MODULE_DIR = _PROGRAM_DIR / "linux_voice_assistant" _LINT_DIRS = [_MODULE_DIR] diff --git a/script/run b/script/run index ce2e06f..88a7170 100755 --- a/script/run +++ b/script/run @@ -14,4 +14,4 @@ if _VENV_DIR.exists(): else: python_exe = "python3" -subprocess.check_call([python_exe, "-m", "hav_sat"] + sys.argv[1:]) +subprocess.check_call([python_exe, "-m", "linux_voice_assistant"] + sys.argv[1:]) diff --git a/wakewords/hey_peppa_pig.json b/wakewords/hey_peppa_pig.json deleted file mode 100644 index 0d3d107..0000000 --- a/wakewords/hey_peppa_pig.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index 45c3dac..0000000 Binary files a/wakewords/hey_peppa_pig.tflite and /dev/null differ