Rename to Linux Voice Assistant
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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:])
|
||||||
|
|||||||
@@ -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.
Reference in New Issue
Block a user