Rename to Linux Voice Assistant

This commit is contained in:
Michael Hansen
2025-08-26 09:30:58 -05:00
parent 551cb35735
commit 4356633ed6
14 changed files with 92 additions and 33 deletions
+5 -5
View File
@@ -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. Runs on Linux `aarch64` and `x86_64` platforms. Tested with Python 3.13 and Python 3.11.
Supports announcments, start/continue conversation, and timers. Supports announcments, start/continue conversation, and timers.
@@ -16,14 +16,14 @@ Install system dependencies (`apt-get`):
Clone and install project: Clone and install project:
``` sh ``` sh
git clone https://github.com/OHF-Voice/hav-sat.git git clone https://github.com/OHF-Voice/linux-voice-assistant.git
cd hav-sat cd linux-voice-assistant
script/setup script/setup
``` ```
## Running ## Running
Use `script/run` or `python3 -m hav_sat` Use `script/run` or `python3 -m linux_voice_assistant`
See `--help` for more options. See `--help` for more options.
@@ -6,11 +6,16 @@ import json
import logging import logging
import threading import threading
import time import time
import tempfile
import shutil
import posixpath
from collections.abc import Iterable from collections.abc import Iterable
from dataclasses import dataclass from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from queue import Queue from queue import Queue
from typing import Dict, List, Optional from typing import Dict, List, Optional
from urllib.request import urlopen
from urllib.parse import urlparse, urlunparse
# pylint: disable=no-name-in-module # pylint: disable=no-name-in-module
import sounddevice as sd import sounddevice as sd
@@ -63,6 +68,7 @@ class AvailableWakeWord:
wake_word: str wake_word: str
trained_languages: List[str] trained_languages: List[str]
config_path: Path config_path: Path
url: Optional[str] = None
@dataclass @dataclass
@@ -81,6 +87,15 @@ class ServerState:
media_player_entity: Optional[MediaPlayerEntity] = None media_player_entity: Optional[MediaPlayerEntity] = None
satellite: "Optional[VoiceSatelliteProtocol]" = 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, server=self,
key=len(state.entities), key=len(state.entities),
name="Media Player", name="Media Player",
object_id="hav_sat_media_player", object_id="linux_voice_assistant_media_player",
music_player=state.music_player, music_player=state.music_player,
announce_player=state.tts_player, announce_player=state.tts_player,
) )
@@ -211,6 +226,26 @@ class VoiceSatelliteProtocol(APIServer):
if isinstance(msg, ListEntitiesRequest): if isinstance(msg, ListEntitiesRequest):
yield ListEntitiesDoneResponse() yield ListEntitiesDoneResponse()
elif isinstance(msg, VoiceAssistantConfigurationRequest): 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( yield VoiceAssistantConfigurationResponse(
available_wake_words=[ available_wake_words=[
VoiceAssistantWakeWord( VoiceAssistantWakeWord(
@@ -235,6 +270,48 @@ class VoiceSatelliteProtocol(APIServer):
if not model_info: if not model_info:
continue 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) _LOGGER.debug("Loading wake word: %s", model_info.config_path)
self.state.wake_word = MicroWakeWord.from_config( self.state.wake_word = MicroWakeWord.from_config(
model_info.config_path, model_info.config_path,
+5 -5
View File
@@ -3,15 +3,15 @@ requires = ["setuptools>=62.3"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[project] [project]
name = "hav-satellite" name = "linux-voice-assistant"
version = "1.0.0" version = "1.0.0"
license = {text = "Apache-2.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" readme = "README.md"
authors = [ authors = [
{name = "The Home Assistant Authors", email = "hello@home-assistant.io"} {name = "The Home Assistant Authors", email = "hello@home-assistant.io"}
] ]
keywords = ["home", "assistant", "voice", "esphome"] keywords = ["home", "assistant", "voice", "esphome", "linux"]
classifiers = [ classifiers = [
"Development Status :: 3 - Alpha", "Development Status :: 3 - Alpha",
"Intended Audience :: Developers", "Intended Audience :: Developers",
@@ -41,7 +41,7 @@ dev = [
] ]
[project.urls] [project.urls]
"Source Code" = "http://github.com/OHF-Voice/hav-sat" "Source Code" = "http://github.com/OHF-Voice/linux-voice-assistant"
[tool.setuptools] [tool.setuptools]
platforms = ["any"] platforms = ["any"]
@@ -49,5 +49,5 @@ zip-safe = true
include-package-data = true include-package-data = true
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
include = ["hav_sat"] include = ["linux_voice_assistant"]
exclude = ["tests", "tests.*"] exclude = ["tests", "tests.*"]
+1 -1
View File
@@ -6,7 +6,7 @@ from pathlib import Path
_DIR = Path(__file__).parent _DIR = Path(__file__).parent
_PROGRAM_DIR = _DIR.parent _PROGRAM_DIR = _DIR.parent
_VENV_DIR = _PROGRAM_DIR / ".venv" _VENV_DIR = _PROGRAM_DIR / ".venv"
_MODULE_DIR = _PROGRAM_DIR / "hav_sat" _MODULE_DIR = _PROGRAM_DIR / "linux_voice_assistant"
_FORMAT_DIRS = [_MODULE_DIR] _FORMAT_DIRS = [_MODULE_DIR]
+1 -1
View File
@@ -6,7 +6,7 @@ from pathlib import Path
_DIR = Path(__file__).parent _DIR = Path(__file__).parent
_PROGRAM_DIR = _DIR.parent _PROGRAM_DIR = _DIR.parent
_VENV_DIR = _PROGRAM_DIR / ".venv" _VENV_DIR = _PROGRAM_DIR / ".venv"
_MODULE_DIR = _PROGRAM_DIR / "hav_sat" _MODULE_DIR = _PROGRAM_DIR / "linux_voice_assistant"
_LINT_DIRS = [_MODULE_DIR] _LINT_DIRS = [_MODULE_DIR]
+1 -1
View File
@@ -14,4 +14,4 @@ if _VENV_DIR.exists():
else: else:
python_exe = "python3" 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:])
-18
View File
@@ -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"
}
}
Binary file not shown.