Commit ·
b8cfa60
1
Parent(s): 2efbff7
"update"
Browse files- .claude/settings.local.json +42 -0
- app.py +79 -0
- src/reachy_mini_ha_voice/__main__.py +515 -0
- src/reachy_mini_ha_voice/api_server.py +178 -0
- src/reachy_mini_ha_voice/audio_player.py +136 -0
- src/reachy_mini_ha_voice/entity.py +135 -0
- src/reachy_mini_ha_voice/models.py +88 -0
- src/reachy_mini_ha_voice/motion.py +234 -0
- src/reachy_mini_ha_voice/satellite.py +476 -0
- src/reachy_mini_ha_voice/util.py +21 -0
- src/reachy_mini_ha_voice/voice_assistant.py +421 -0
- src/reachy_mini_ha_voice/zeroconf.py +73 -0
- wakewords/stop.json +5 -0
.claude/settings.local.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
| 3 |
+
"includeCoAuthoredBy": false,
|
| 4 |
+
"permissions": {
|
| 5 |
+
"allow": [
|
| 6 |
+
"Bash",
|
| 7 |
+
"BashOutput",
|
| 8 |
+
"Edit",
|
| 9 |
+
"Glob",
|
| 10 |
+
"Grep",
|
| 11 |
+
"KillShell",
|
| 12 |
+
"NotebookEdit",
|
| 13 |
+
"Read",
|
| 14 |
+
"SlashCommand",
|
| 15 |
+
"Task",
|
| 16 |
+
"TodoWrite",
|
| 17 |
+
"WebFetch",
|
| 18 |
+
"WebSearch",
|
| 19 |
+
"Write",
|
| 20 |
+
"mcp__ide",
|
| 21 |
+
"mcp__exa",
|
| 22 |
+
"mcp__context7",
|
| 23 |
+
"mcp__mcp-deepwiki",
|
| 24 |
+
"mcp__Playwright",
|
| 25 |
+
"mcp__spec-workflow",
|
| 26 |
+
"mcp__open-websearch",
|
| 27 |
+
"mcp__serena",
|
| 28 |
+
"all",
|
| 29 |
+
"Bash(cd:*)"
|
| 30 |
+
],
|
| 31 |
+
"deny": [],
|
| 32 |
+
"ask": []
|
| 33 |
+
},
|
| 34 |
+
"hooks": {},
|
| 35 |
+
"alwaysThinkingEnabled": true,
|
| 36 |
+
"outputStyle": "default",
|
| 37 |
+
"statusLine": {
|
| 38 |
+
"type": "command",
|
| 39 |
+
"command": "%USERPROFILE%\\.claude\\ccline\\ccline.exe",
|
| 40 |
+
"padding": 0
|
| 41 |
+
}
|
| 42 |
+
}
|
app.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Reachy Mini Home Assistant Voice Assistant App
|
| 3 |
+
|
| 4 |
+
This app integrates Reachy Mini with Home Assistant via ESPHome protocol,
|
| 5 |
+
allowing voice control through Home Assistant's voice assistant pipeline.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import threading
|
| 9 |
+
import logging
|
| 10 |
+
import asyncio
|
| 11 |
+
from typing import Optional
|
| 12 |
+
|
| 13 |
+
from reachy_mini import ReachyMini
|
| 14 |
+
from reachy_mini.apps import ReachyMiniApp
|
| 15 |
+
|
| 16 |
+
from reachy_mini_ha_voice.voice_assistant import VoiceAssistantService
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class HomeAssistantVoiceApp(ReachyMiniApp):
|
| 22 |
+
"""
|
| 23 |
+
Reachy Mini Home Assistant Voice Assistant Application.
|
| 24 |
+
|
| 25 |
+
This app runs an ESPHome-compatible voice satellite that connects
|
| 26 |
+
to Home Assistant for STT/TTS processing while providing local
|
| 27 |
+
wake word detection and robot motion feedback.
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
# No custom web UI needed - configuration is automatic
|
| 31 |
+
custom_app_url: Optional[str] = None
|
| 32 |
+
|
| 33 |
+
def run(self, reachy_mini: ReachyMini, stop_event: threading.Event) -> None:
|
| 34 |
+
"""
|
| 35 |
+
Main application entry point.
|
| 36 |
+
|
| 37 |
+
Args:
|
| 38 |
+
reachy_mini: The Reachy Mini robot instance
|
| 39 |
+
stop_event: Event to signal graceful shutdown
|
| 40 |
+
"""
|
| 41 |
+
logger.info("Starting Home Assistant Voice Assistant...")
|
| 42 |
+
|
| 43 |
+
# Create and run the voice assistant service
|
| 44 |
+
service = VoiceAssistantService(reachy_mini)
|
| 45 |
+
|
| 46 |
+
# Run the async service in an event loop
|
| 47 |
+
loop = asyncio.new_event_loop()
|
| 48 |
+
asyncio.set_event_loop(loop)
|
| 49 |
+
|
| 50 |
+
try:
|
| 51 |
+
loop.run_until_complete(service.start())
|
| 52 |
+
|
| 53 |
+
logger.info("=" * 50)
|
| 54 |
+
logger.info("Home Assistant Voice Assistant Started!")
|
| 55 |
+
logger.info("=" * 50)
|
| 56 |
+
logger.info("ESPHome Server: 0.0.0.0:6053")
|
| 57 |
+
logger.info("Wake word: Okay Nabu")
|
| 58 |
+
logger.info("=" * 50)
|
| 59 |
+
logger.info("To connect from Home Assistant:")
|
| 60 |
+
logger.info(" Settings -> Devices & Services -> Add Integration")
|
| 61 |
+
logger.info(" -> ESPHome -> Enter this device's IP:6053")
|
| 62 |
+
logger.info("=" * 50)
|
| 63 |
+
|
| 64 |
+
# Wait for stop signal
|
| 65 |
+
while not stop_event.is_set():
|
| 66 |
+
loop.run_until_complete(asyncio.sleep(0.5))
|
| 67 |
+
|
| 68 |
+
except Exception as e:
|
| 69 |
+
logger.error(f"Error running voice assistant: {e}")
|
| 70 |
+
raise
|
| 71 |
+
finally:
|
| 72 |
+
logger.info("Shutting down voice assistant...")
|
| 73 |
+
loop.run_until_complete(service.stop())
|
| 74 |
+
loop.close()
|
| 75 |
+
logger.info("Voice assistant stopped.")
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# Entry point for the app
|
| 79 |
+
App = HomeAssistantVoiceApp
|
src/reachy_mini_ha_voice/__main__.py
ADDED
|
@@ -0,0 +1,515 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Main entry point for Reachy Mini Home Assistant Voice Assistant."""
|
| 3 |
+
|
| 4 |
+
import argparse
|
| 5 |
+
import asyncio
|
| 6 |
+
import json
|
| 7 |
+
import logging
|
| 8 |
+
import sys
|
| 9 |
+
import threading
|
| 10 |
+
import time
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from queue import Queue
|
| 13 |
+
from typing import Dict, List, Optional, Set, Union
|
| 14 |
+
|
| 15 |
+
import numpy as np
|
| 16 |
+
import sounddevice as sd
|
| 17 |
+
|
| 18 |
+
from pymicro_wakeword import MicroWakeWord, MicroWakeWordFeatures
|
| 19 |
+
from pyopen_wakeword import OpenWakeWord, OpenWakeWordFeatures
|
| 20 |
+
|
| 21 |
+
from .models import AvailableWakeWord, Preferences, ServerState, WakeWordType
|
| 22 |
+
from .audio_player import AudioPlayer
|
| 23 |
+
from .satellite import VoiceSatelliteProtocol
|
| 24 |
+
from .util import get_mac
|
| 25 |
+
from .zeroconf import HomeAssistantZeroconf
|
| 26 |
+
|
| 27 |
+
_LOGGER = logging.getLogger(__name__)
|
| 28 |
+
|
| 29 |
+
_MODULE_DIR = Path(__file__).parent
|
| 30 |
+
_REPO_DIR = _MODULE_DIR.parent.parent
|
| 31 |
+
_WAKEWORDS_DIR = _REPO_DIR / "wakewords"
|
| 32 |
+
_SOUNDS_DIR = _REPO_DIR / "sounds"
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def download_required_files():
|
| 36 |
+
"""Download required model and sound files if missing."""
|
| 37 |
+
import urllib.request
|
| 38 |
+
|
| 39 |
+
_WAKEWORDS_DIR.mkdir(parents=True, exist_ok=True)
|
| 40 |
+
_SOUNDS_DIR.mkdir(parents=True, exist_ok=True)
|
| 41 |
+
|
| 42 |
+
# Wake word models
|
| 43 |
+
wakeword_files = {
|
| 44 |
+
"okay_nabu.tflite": "https://github.com/esphome/micro-wake-word-models/raw/main/models/v2/okay_nabu.tflite",
|
| 45 |
+
"okay_nabu.json": "https://github.com/esphome/micro-wake-word-models/raw/main/models/v2/okay_nabu.json",
|
| 46 |
+
"hey_jarvis.tflite": "https://github.com/esphome/micro-wake-word-models/raw/main/models/v2/hey_jarvis.tflite",
|
| 47 |
+
"hey_jarvis.json": "https://github.com/esphome/micro-wake-word-models/raw/main/models/v2/hey_jarvis.json",
|
| 48 |
+
"stop.tflite": "https://github.com/esphome/micro-wake-word-models/raw/main/models/v2/stop.tflite",
|
| 49 |
+
"stop.json": "https://github.com/esphome/micro-wake-word-models/raw/main/models/v2/stop.json",
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
# Sound files
|
| 53 |
+
sound_files = {
|
| 54 |
+
"wake_word_triggered.flac": "https://github.com/OHF-Voice/linux-voice-assistant/raw/main/sounds/wake_word_triggered.flac",
|
| 55 |
+
"timer_finished.flac": "https://github.com/OHF-Voice/linux-voice-assistant/raw/main/sounds/timer_finished.flac",
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
for filename, url in wakeword_files.items():
|
| 59 |
+
dest = _WAKEWORDS_DIR / filename
|
| 60 |
+
if not dest.exists():
|
| 61 |
+
_LOGGER.info("Downloading %s...", filename)
|
| 62 |
+
try:
|
| 63 |
+
urllib.request.urlretrieve(url, dest)
|
| 64 |
+
_LOGGER.info("Downloaded %s", filename)
|
| 65 |
+
except Exception as e:
|
| 66 |
+
_LOGGER.warning("Failed to download %s: %s", filename, e)
|
| 67 |
+
|
| 68 |
+
for filename, url in sound_files.items():
|
| 69 |
+
dest = _SOUNDS_DIR / filename
|
| 70 |
+
if not dest.exists():
|
| 71 |
+
_LOGGER.info("Downloading %s...", filename)
|
| 72 |
+
try:
|
| 73 |
+
urllib.request.urlretrieve(url, dest)
|
| 74 |
+
_LOGGER.info("Downloaded %s", filename)
|
| 75 |
+
except Exception as e:
|
| 76 |
+
_LOGGER.warning("Failed to download %s: %s", filename, e)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
async def main() -> None:
|
| 80 |
+
parser = argparse.ArgumentParser(
|
| 81 |
+
description="Reachy Mini Home Assistant Voice Assistant"
|
| 82 |
+
)
|
| 83 |
+
parser.add_argument(
|
| 84 |
+
"--name",
|
| 85 |
+
default="Reachy Mini",
|
| 86 |
+
help="Name of the voice assistant (default: Reachy Mini)",
|
| 87 |
+
)
|
| 88 |
+
parser.add_argument(
|
| 89 |
+
"--audio-input-device",
|
| 90 |
+
help="Audio input device name or index (see --list-input-devices)",
|
| 91 |
+
)
|
| 92 |
+
parser.add_argument(
|
| 93 |
+
"--list-input-devices",
|
| 94 |
+
action="store_true",
|
| 95 |
+
help="List audio input devices and exit",
|
| 96 |
+
)
|
| 97 |
+
parser.add_argument(
|
| 98 |
+
"--audio-input-block-size",
|
| 99 |
+
type=int,
|
| 100 |
+
default=1024,
|
| 101 |
+
help="Audio input block size (default: 1024)",
|
| 102 |
+
)
|
| 103 |
+
parser.add_argument(
|
| 104 |
+
"--audio-output-device",
|
| 105 |
+
help="Audio output device name or index (see --list-output-devices)",
|
| 106 |
+
)
|
| 107 |
+
parser.add_argument(
|
| 108 |
+
"--list-output-devices",
|
| 109 |
+
action="store_true",
|
| 110 |
+
help="List audio output devices and exit",
|
| 111 |
+
)
|
| 112 |
+
parser.add_argument(
|
| 113 |
+
"--wake-word-dir",
|
| 114 |
+
default=[str(_WAKEWORDS_DIR)],
|
| 115 |
+
action="append",
|
| 116 |
+
help="Directory with wake word models (.tflite) and configs (.json)",
|
| 117 |
+
)
|
| 118 |
+
parser.add_argument(
|
| 119 |
+
"--wake-model",
|
| 120 |
+
default="okay_nabu",
|
| 121 |
+
help="Id of active wake model (default: okay_nabu)",
|
| 122 |
+
)
|
| 123 |
+
parser.add_argument(
|
| 124 |
+
"--stop-model",
|
| 125 |
+
default="stop",
|
| 126 |
+
help="Id of stop model (default: stop)",
|
| 127 |
+
)
|
| 128 |
+
parser.add_argument(
|
| 129 |
+
"--download-dir",
|
| 130 |
+
default=str(_REPO_DIR / "local"),
|
| 131 |
+
help="Directory to download custom wake word models, etc.",
|
| 132 |
+
)
|
| 133 |
+
parser.add_argument(
|
| 134 |
+
"--refractory-seconds",
|
| 135 |
+
default=2.0,
|
| 136 |
+
type=float,
|
| 137 |
+
help="Seconds before wake word can be activated again (default: 2.0)",
|
| 138 |
+
)
|
| 139 |
+
parser.add_argument(
|
| 140 |
+
"--wakeup-sound",
|
| 141 |
+
default=str(_SOUNDS_DIR / "wake_word_triggered.flac"),
|
| 142 |
+
help="Sound to play when wake word is detected",
|
| 143 |
+
)
|
| 144 |
+
parser.add_argument(
|
| 145 |
+
"--timer-finished-sound",
|
| 146 |
+
default=str(_SOUNDS_DIR / "timer_finished.flac"),
|
| 147 |
+
help="Sound to play when timer finishes",
|
| 148 |
+
)
|
| 149 |
+
parser.add_argument(
|
| 150 |
+
"--preferences-file",
|
| 151 |
+
default=str(_REPO_DIR / "preferences.json"),
|
| 152 |
+
help="Path to preferences file",
|
| 153 |
+
)
|
| 154 |
+
parser.add_argument(
|
| 155 |
+
"--host",
|
| 156 |
+
default="0.0.0.0",
|
| 157 |
+
help="Address for ESPHome server (default: 0.0.0.0)",
|
| 158 |
+
)
|
| 159 |
+
parser.add_argument(
|
| 160 |
+
"--port",
|
| 161 |
+
type=int,
|
| 162 |
+
default=6053,
|
| 163 |
+
help="Port for ESPHome server (default: 6053)",
|
| 164 |
+
)
|
| 165 |
+
parser.add_argument(
|
| 166 |
+
"--no-motion",
|
| 167 |
+
action="store_true",
|
| 168 |
+
help="Disable Reachy Mini motion control",
|
| 169 |
+
)
|
| 170 |
+
parser.add_argument(
|
| 171 |
+
"--debug",
|
| 172 |
+
action="store_true",
|
| 173 |
+
help="Print DEBUG messages to console",
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
args = parser.parse_args()
|
| 177 |
+
|
| 178 |
+
# Setup logging
|
| 179 |
+
logging.basicConfig(
|
| 180 |
+
level=logging.DEBUG if args.debug else logging.INFO,
|
| 181 |
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
# List input devices
|
| 185 |
+
if args.list_input_devices:
|
| 186 |
+
print("\nAudio Input Devices")
|
| 187 |
+
print("=" * 40)
|
| 188 |
+
devices = sd.query_devices()
|
| 189 |
+
for idx, device in enumerate(devices):
|
| 190 |
+
if device["max_input_channels"] > 0:
|
| 191 |
+
print(f"[{idx}] {device['name']}")
|
| 192 |
+
return
|
| 193 |
+
|
| 194 |
+
# List output devices
|
| 195 |
+
if args.list_output_devices:
|
| 196 |
+
print("\nAudio Output Devices")
|
| 197 |
+
print("=" * 40)
|
| 198 |
+
devices = sd.query_devices()
|
| 199 |
+
for idx, device in enumerate(devices):
|
| 200 |
+
if device["max_output_channels"] > 0:
|
| 201 |
+
print(f"[{idx}] {device['name']}")
|
| 202 |
+
return
|
| 203 |
+
|
| 204 |
+
_LOGGER.debug(args)
|
| 205 |
+
|
| 206 |
+
# Download required files
|
| 207 |
+
download_required_files()
|
| 208 |
+
|
| 209 |
+
# Setup paths
|
| 210 |
+
download_dir = Path(args.download_dir)
|
| 211 |
+
download_dir.mkdir(parents=True, exist_ok=True)
|
| 212 |
+
|
| 213 |
+
# Resolve audio input device
|
| 214 |
+
input_device = args.audio_input_device
|
| 215 |
+
if input_device is not None:
|
| 216 |
+
try:
|
| 217 |
+
input_device = int(input_device)
|
| 218 |
+
except ValueError:
|
| 219 |
+
pass
|
| 220 |
+
|
| 221 |
+
# Load available wake words
|
| 222 |
+
wake_word_dirs = [Path(ww_dir) for ww_dir in args.wake_word_dir]
|
| 223 |
+
wake_word_dirs.append(download_dir / "external_wake_words")
|
| 224 |
+
|
| 225 |
+
available_wake_words: Dict[str, AvailableWakeWord] = {}
|
| 226 |
+
for wake_word_dir in wake_word_dirs:
|
| 227 |
+
if not wake_word_dir.exists():
|
| 228 |
+
continue
|
| 229 |
+
for model_config_path in wake_word_dir.glob("*.json"):
|
| 230 |
+
model_id = model_config_path.stem
|
| 231 |
+
if model_id == args.stop_model:
|
| 232 |
+
# Don't show stop model as an available wake word
|
| 233 |
+
continue
|
| 234 |
+
|
| 235 |
+
try:
|
| 236 |
+
with open(model_config_path, "r", encoding="utf-8") as model_config_file:
|
| 237 |
+
model_config = json.load(model_config_file)
|
| 238 |
+
|
| 239 |
+
model_type = WakeWordType(model_config.get("type", "micro"))
|
| 240 |
+
|
| 241 |
+
if model_type == WakeWordType.OPEN_WAKE_WORD:
|
| 242 |
+
wake_word_path = model_config_path.parent / model_config["model"]
|
| 243 |
+
else:
|
| 244 |
+
wake_word_path = model_config_path
|
| 245 |
+
|
| 246 |
+
available_wake_words[model_id] = AvailableWakeWord(
|
| 247 |
+
id=model_id,
|
| 248 |
+
type=WakeWordType(model_type),
|
| 249 |
+
wake_word=model_config.get("wake_word", model_id),
|
| 250 |
+
trained_languages=model_config.get("trained_languages", []),
|
| 251 |
+
wake_word_path=wake_word_path,
|
| 252 |
+
)
|
| 253 |
+
except Exception as e:
|
| 254 |
+
_LOGGER.warning("Failed to load wake word config %s: %s", model_config_path, e)
|
| 255 |
+
|
| 256 |
+
_LOGGER.debug("Available wake words: %s", list(sorted(available_wake_words.keys())))
|
| 257 |
+
|
| 258 |
+
# Load preferences
|
| 259 |
+
preferences_path = Path(args.preferences_file)
|
| 260 |
+
if preferences_path.exists():
|
| 261 |
+
_LOGGER.debug("Loading preferences: %s", preferences_path)
|
| 262 |
+
with open(preferences_path, "r", encoding="utf-8") as preferences_file:
|
| 263 |
+
preferences_dict = json.load(preferences_file)
|
| 264 |
+
preferences = Preferences(**preferences_dict)
|
| 265 |
+
else:
|
| 266 |
+
preferences = Preferences()
|
| 267 |
+
|
| 268 |
+
# Load wake/stop models
|
| 269 |
+
active_wake_words: Set[str] = set()
|
| 270 |
+
wake_models: Dict[str, Union[MicroWakeWord, OpenWakeWord]] = {}
|
| 271 |
+
|
| 272 |
+
if preferences.active_wake_words:
|
| 273 |
+
# Load preferred models
|
| 274 |
+
for wake_word_id in preferences.active_wake_words:
|
| 275 |
+
wake_word = available_wake_words.get(wake_word_id)
|
| 276 |
+
if wake_word is None:
|
| 277 |
+
_LOGGER.warning("Unrecognized wake word id: %s", wake_word_id)
|
| 278 |
+
continue
|
| 279 |
+
|
| 280 |
+
_LOGGER.debug("Loading wake model: %s", wake_word_id)
|
| 281 |
+
wake_models[wake_word_id] = wake_word.load()
|
| 282 |
+
active_wake_words.add(wake_word_id)
|
| 283 |
+
|
| 284 |
+
if not wake_models:
|
| 285 |
+
# Load default model
|
| 286 |
+
wake_word_id = args.wake_model
|
| 287 |
+
wake_word = available_wake_words.get(wake_word_id)
|
| 288 |
+
if wake_word:
|
| 289 |
+
_LOGGER.debug("Loading wake model: %s", wake_word_id)
|
| 290 |
+
wake_models[wake_word_id] = wake_word.load()
|
| 291 |
+
active_wake_words.add(wake_word_id)
|
| 292 |
+
else:
|
| 293 |
+
_LOGGER.error("Wake word model not found: %s", wake_word_id)
|
| 294 |
+
_LOGGER.error("Available models: %s", list(available_wake_words.keys()))
|
| 295 |
+
sys.exit(1)
|
| 296 |
+
|
| 297 |
+
# Load stop model
|
| 298 |
+
stop_model: Optional[MicroWakeWord] = None
|
| 299 |
+
for wake_word_dir in wake_word_dirs:
|
| 300 |
+
stop_config_path = wake_word_dir / f"{args.stop_model}.json"
|
| 301 |
+
if not stop_config_path.exists():
|
| 302 |
+
continue
|
| 303 |
+
|
| 304 |
+
_LOGGER.debug("Loading stop model: %s", stop_config_path)
|
| 305 |
+
stop_model = MicroWakeWord.from_config(stop_config_path)
|
| 306 |
+
break
|
| 307 |
+
|
| 308 |
+
if stop_model is None:
|
| 309 |
+
_LOGGER.warning("Stop model not found, timer stop functionality disabled")
|
| 310 |
+
# Create a dummy stop model that never triggers
|
| 311 |
+
stop_model = MicroWakeWord.from_config(
|
| 312 |
+
list(available_wake_words.values())[0].wake_word_path
|
| 313 |
+
)
|
| 314 |
+
|
| 315 |
+
# Initialize Reachy Mini (if available)
|
| 316 |
+
reachy_mini = None
|
| 317 |
+
if not args.no_motion:
|
| 318 |
+
try:
|
| 319 |
+
from reachy_mini import ReachyMini
|
| 320 |
+
reachy_mini = ReachyMini()
|
| 321 |
+
_LOGGER.info("Reachy Mini connected")
|
| 322 |
+
except ImportError:
|
| 323 |
+
_LOGGER.warning("reachy-mini not installed, motion control disabled")
|
| 324 |
+
except Exception as e:
|
| 325 |
+
_LOGGER.warning("Failed to connect to Reachy Mini: %s", e)
|
| 326 |
+
|
| 327 |
+
# Create server state
|
| 328 |
+
state = ServerState(
|
| 329 |
+
name=args.name,
|
| 330 |
+
mac_address=get_mac(),
|
| 331 |
+
audio_queue=Queue(),
|
| 332 |
+
entities=[],
|
| 333 |
+
available_wake_words=available_wake_words,
|
| 334 |
+
wake_words=wake_models,
|
| 335 |
+
active_wake_words=active_wake_words,
|
| 336 |
+
stop_word=stop_model,
|
| 337 |
+
music_player=AudioPlayer(device=args.audio_output_device),
|
| 338 |
+
tts_player=AudioPlayer(device=args.audio_output_device),
|
| 339 |
+
wakeup_sound=args.wakeup_sound,
|
| 340 |
+
timer_finished_sound=args.timer_finished_sound,
|
| 341 |
+
preferences=preferences,
|
| 342 |
+
preferences_path=preferences_path,
|
| 343 |
+
refractory_seconds=args.refractory_seconds,
|
| 344 |
+
download_dir=download_dir,
|
| 345 |
+
reachy_mini=reachy_mini,
|
| 346 |
+
motion_enabled=not args.no_motion and reachy_mini is not None,
|
| 347 |
+
)
|
| 348 |
+
|
| 349 |
+
# Start audio processing thread
|
| 350 |
+
process_audio_thread = threading.Thread(
|
| 351 |
+
target=process_audio,
|
| 352 |
+
args=(state, input_device, args.audio_input_block_size),
|
| 353 |
+
daemon=True,
|
| 354 |
+
)
|
| 355 |
+
process_audio_thread.start()
|
| 356 |
+
|
| 357 |
+
# Create ESPHome server
|
| 358 |
+
loop = asyncio.get_running_loop()
|
| 359 |
+
server = await loop.create_server(
|
| 360 |
+
lambda: VoiceSatelliteProtocol(state), host=args.host, port=args.port
|
| 361 |
+
)
|
| 362 |
+
|
| 363 |
+
# Auto discovery (zeroconf, mDNS)
|
| 364 |
+
discovery = HomeAssistantZeroconf(port=args.port, name=args.name)
|
| 365 |
+
await discovery.register_server()
|
| 366 |
+
|
| 367 |
+
try:
|
| 368 |
+
async with server:
|
| 369 |
+
_LOGGER.info("=" * 50)
|
| 370 |
+
_LOGGER.info("Reachy Mini Voice Assistant Started")
|
| 371 |
+
_LOGGER.info("=" * 50)
|
| 372 |
+
_LOGGER.info("Name: %s", args.name)
|
| 373 |
+
_LOGGER.info("ESPHome Server: %s:%s", args.host, args.port)
|
| 374 |
+
_LOGGER.info("Wake word: %s", list(active_wake_words))
|
| 375 |
+
_LOGGER.info("Motion control: %s", "enabled" if state.motion_enabled else "disabled")
|
| 376 |
+
_LOGGER.info("=" * 50)
|
| 377 |
+
_LOGGER.info("Add this device in Home Assistant:")
|
| 378 |
+
_LOGGER.info(" Settings -> Devices & Services -> Add Integration -> ESPHome")
|
| 379 |
+
_LOGGER.info(" Enter: <this-device-ip>:6053")
|
| 380 |
+
_LOGGER.info("=" * 50)
|
| 381 |
+
|
| 382 |
+
await server.serve_forever()
|
| 383 |
+
except KeyboardInterrupt:
|
| 384 |
+
_LOGGER.info("Shutting down...")
|
| 385 |
+
finally:
|
| 386 |
+
state.audio_queue.put_nowait(None)
|
| 387 |
+
process_audio_thread.join(timeout=2.0)
|
| 388 |
+
await discovery.unregister_server()
|
| 389 |
+
_LOGGER.debug("Server stopped")
|
| 390 |
+
|
| 391 |
+
|
| 392 |
+
def process_audio(state: ServerState, input_device, block_size: int):
|
| 393 |
+
"""Process audio chunks from the microphone."""
|
| 394 |
+
wake_words: List[Union[MicroWakeWord, OpenWakeWord]] = []
|
| 395 |
+
micro_features: Optional[MicroWakeWordFeatures] = None
|
| 396 |
+
micro_inputs: List[np.ndarray] = []
|
| 397 |
+
oww_features: Optional[OpenWakeWordFeatures] = None
|
| 398 |
+
oww_inputs: List[np.ndarray] = []
|
| 399 |
+
has_oww = False
|
| 400 |
+
last_active: Optional[float] = None
|
| 401 |
+
|
| 402 |
+
try:
|
| 403 |
+
_LOGGER.debug("Opening audio input device: %s", input_device or "default")
|
| 404 |
+
|
| 405 |
+
with sd.InputStream(
|
| 406 |
+
device=input_device,
|
| 407 |
+
samplerate=16000,
|
| 408 |
+
channels=1,
|
| 409 |
+
blocksize=block_size,
|
| 410 |
+
dtype="float32",
|
| 411 |
+
) as stream:
|
| 412 |
+
while True:
|
| 413 |
+
audio_chunk_array, overflowed = stream.read(block_size)
|
| 414 |
+
if overflowed:
|
| 415 |
+
_LOGGER.warning("Audio buffer overflow")
|
| 416 |
+
|
| 417 |
+
audio_chunk_array = audio_chunk_array.reshape(-1)
|
| 418 |
+
|
| 419 |
+
# Convert to 16-bit PCM for streaming
|
| 420 |
+
audio_chunk = (
|
| 421 |
+
(np.clip(audio_chunk_array, -1.0, 1.0) * 32767.0)
|
| 422 |
+
.astype("<i2")
|
| 423 |
+
.tobytes()
|
| 424 |
+
)
|
| 425 |
+
|
| 426 |
+
# Stream audio to Home Assistant
|
| 427 |
+
if state.satellite:
|
| 428 |
+
state.satellite.handle_audio(audio_chunk)
|
| 429 |
+
|
| 430 |
+
# Check if wake words changed
|
| 431 |
+
if state.wake_words_changed:
|
| 432 |
+
state.wake_words_changed = False
|
| 433 |
+
wake_words = list(state.wake_words.values())
|
| 434 |
+
has_oww = any(isinstance(ww, OpenWakeWord) for ww in wake_words)
|
| 435 |
+
|
| 436 |
+
if any(isinstance(ww, MicroWakeWord) for ww in wake_words):
|
| 437 |
+
micro_features = MicroWakeWordFeatures()
|
| 438 |
+
else:
|
| 439 |
+
micro_features = None
|
| 440 |
+
|
| 441 |
+
if has_oww:
|
| 442 |
+
oww_features = OpenWakeWordFeatures.from_builtin()
|
| 443 |
+
else:
|
| 444 |
+
oww_features = None
|
| 445 |
+
|
| 446 |
+
# Initialize features if needed
|
| 447 |
+
if not wake_words:
|
| 448 |
+
wake_words = list(state.wake_words.values())
|
| 449 |
+
has_oww = any(isinstance(ww, OpenWakeWord) for ww in wake_words)
|
| 450 |
+
|
| 451 |
+
if any(isinstance(ww, MicroWakeWord) for ww in wake_words):
|
| 452 |
+
micro_features = MicroWakeWordFeatures()
|
| 453 |
+
|
| 454 |
+
if has_oww:
|
| 455 |
+
oww_features = OpenWakeWordFeatures.from_builtin()
|
| 456 |
+
|
| 457 |
+
# Extract features
|
| 458 |
+
micro_inputs.clear()
|
| 459 |
+
oww_inputs.clear()
|
| 460 |
+
|
| 461 |
+
if micro_features:
|
| 462 |
+
micro_inputs = micro_features.process_streaming(audio_chunk_array)
|
| 463 |
+
|
| 464 |
+
if oww_features:
|
| 465 |
+
oww_inputs = oww_features.process_streaming(audio_chunk_array)
|
| 466 |
+
|
| 467 |
+
# Process wake words
|
| 468 |
+
for wake_word in wake_words:
|
| 469 |
+
if wake_word.id not in state.active_wake_words:
|
| 470 |
+
continue
|
| 471 |
+
|
| 472 |
+
activated = False
|
| 473 |
+
|
| 474 |
+
if isinstance(wake_word, MicroWakeWord):
|
| 475 |
+
for micro_input in micro_inputs:
|
| 476 |
+
if wake_word.process_streaming(micro_input):
|
| 477 |
+
activated = True
|
| 478 |
+
elif isinstance(wake_word, OpenWakeWord):
|
| 479 |
+
for oww_input in oww_inputs:
|
| 480 |
+
scores = wake_word.process_streaming(oww_input)
|
| 481 |
+
if any(s > 0.5 for s in scores):
|
| 482 |
+
activated = True
|
| 483 |
+
|
| 484 |
+
if activated:
|
| 485 |
+
# Check refractory period
|
| 486 |
+
now = time.monotonic()
|
| 487 |
+
if (last_active is None) or (
|
| 488 |
+
(now - last_active) > state.refractory_seconds
|
| 489 |
+
):
|
| 490 |
+
if state.satellite:
|
| 491 |
+
state.satellite.wakeup(wake_word)
|
| 492 |
+
last_active = now
|
| 493 |
+
|
| 494 |
+
# Always process stop word to keep state correct
|
| 495 |
+
stopped = False
|
| 496 |
+
for micro_input in micro_inputs:
|
| 497 |
+
if state.stop_word.process_streaming(micro_input):
|
| 498 |
+
stopped = True
|
| 499 |
+
|
| 500 |
+
if stopped and (state.stop_word.id in state.active_wake_words):
|
| 501 |
+
if state.satellite:
|
| 502 |
+
state.satellite.stop()
|
| 503 |
+
|
| 504 |
+
except Exception:
|
| 505 |
+
_LOGGER.exception("Unexpected error processing audio")
|
| 506 |
+
sys.exit(1)
|
| 507 |
+
|
| 508 |
+
|
| 509 |
+
def run():
|
| 510 |
+
"""Entry point for the application."""
|
| 511 |
+
asyncio.run(main())
|
| 512 |
+
|
| 513 |
+
|
| 514 |
+
if __name__ == "__main__":
|
| 515 |
+
run()
|
src/reachy_mini_ha_voice/api_server.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Partial ESPHome server implementation."""
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import logging
|
| 5 |
+
from abc import abstractmethod
|
| 6 |
+
from collections.abc import Iterable
|
| 7 |
+
from typing import TYPE_CHECKING, List, Optional
|
| 8 |
+
|
| 9 |
+
# pylint: disable=no-name-in-module
|
| 10 |
+
from aioesphomeapi._frame_helper.packets import make_plain_text_packets
|
| 11 |
+
from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
|
| 12 |
+
AuthenticationRequest,
|
| 13 |
+
AuthenticationResponse,
|
| 14 |
+
DisconnectRequest,
|
| 15 |
+
DisconnectResponse,
|
| 16 |
+
HelloRequest,
|
| 17 |
+
HelloResponse,
|
| 18 |
+
PingRequest,
|
| 19 |
+
PingResponse,
|
| 20 |
+
)
|
| 21 |
+
from aioesphomeapi.core import MESSAGE_TYPE_TO_PROTO
|
| 22 |
+
from google.protobuf import message
|
| 23 |
+
|
| 24 |
+
PROTO_TO_MESSAGE_TYPE = {v: k for k, v in MESSAGE_TYPE_TO_PROTO.items()}
|
| 25 |
+
|
| 26 |
+
_LOGGER = logging.getLogger(__name__)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class APIServer(asyncio.Protocol):
|
| 30 |
+
"""ESPHome API Server implementation."""
|
| 31 |
+
|
| 32 |
+
def __init__(self, name: str) -> None:
|
| 33 |
+
self.name = name
|
| 34 |
+
self._buffer: Optional[bytes] = None
|
| 35 |
+
self._buffer_len: int = 0
|
| 36 |
+
self._pos: int = 0
|
| 37 |
+
self._transport = None
|
| 38 |
+
self._writelines = None
|
| 39 |
+
|
| 40 |
+
@abstractmethod
|
| 41 |
+
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
|
| 42 |
+
pass
|
| 43 |
+
|
| 44 |
+
def process_packet(self, msg_type: int, packet_data: bytes) -> None:
|
| 45 |
+
msg_class = MESSAGE_TYPE_TO_PROTO[msg_type]
|
| 46 |
+
msg_inst = msg_class.FromString(packet_data)
|
| 47 |
+
|
| 48 |
+
if isinstance(msg_inst, HelloRequest):
|
| 49 |
+
self.send_messages(
|
| 50 |
+
[
|
| 51 |
+
HelloResponse(
|
| 52 |
+
api_version_major=1,
|
| 53 |
+
api_version_minor=10,
|
| 54 |
+
name=self.name,
|
| 55 |
+
)
|
| 56 |
+
]
|
| 57 |
+
)
|
| 58 |
+
return
|
| 59 |
+
|
| 60 |
+
if isinstance(msg_inst, AuthenticationRequest):
|
| 61 |
+
self.send_messages([AuthenticationResponse()])
|
| 62 |
+
elif isinstance(msg_inst, DisconnectRequest):
|
| 63 |
+
self.send_messages([DisconnectResponse()])
|
| 64 |
+
_LOGGER.debug("Disconnect requested")
|
| 65 |
+
if self._transport:
|
| 66 |
+
self._transport.close()
|
| 67 |
+
self._transport = None
|
| 68 |
+
self._writelines = None
|
| 69 |
+
elif isinstance(msg_inst, PingRequest):
|
| 70 |
+
self.send_messages([PingResponse()])
|
| 71 |
+
elif msgs := self.handle_message(msg_inst):
|
| 72 |
+
if isinstance(msgs, message.Message):
|
| 73 |
+
msgs = [msgs]
|
| 74 |
+
self.send_messages(msgs)
|
| 75 |
+
|
| 76 |
+
def send_messages(self, msgs: List[message.Message]):
|
| 77 |
+
if self._writelines is None:
|
| 78 |
+
return
|
| 79 |
+
|
| 80 |
+
packets = [
|
| 81 |
+
(PROTO_TO_MESSAGE_TYPE[msg.__class__], msg.SerializeToString())
|
| 82 |
+
for msg in msgs
|
| 83 |
+
]
|
| 84 |
+
packet_bytes = make_plain_text_packets(packets)
|
| 85 |
+
self._writelines(packet_bytes)
|
| 86 |
+
|
| 87 |
+
def connection_made(self, transport) -> None:
|
| 88 |
+
self._transport = transport
|
| 89 |
+
self._writelines = transport.writelines
|
| 90 |
+
|
| 91 |
+
def data_received(self, data: bytes):
|
| 92 |
+
if self._buffer is None:
|
| 93 |
+
self._buffer = data
|
| 94 |
+
self._buffer_len = len(data)
|
| 95 |
+
else:
|
| 96 |
+
self._buffer += data
|
| 97 |
+
self._buffer_len += len(data)
|
| 98 |
+
|
| 99 |
+
while self._buffer_len >= 3:
|
| 100 |
+
self._pos = 0
|
| 101 |
+
|
| 102 |
+
# Read preamble, which should always 0x00
|
| 103 |
+
if (preamble := self._read_varuint()) != 0x00:
|
| 104 |
+
_LOGGER.error("Incorrect preamble: %s", preamble)
|
| 105 |
+
return
|
| 106 |
+
|
| 107 |
+
if (length := self._read_varuint()) == -1:
|
| 108 |
+
_LOGGER.error("Incorrect length: %s", length)
|
| 109 |
+
return
|
| 110 |
+
|
| 111 |
+
if (msg_type := self._read_varuint()) == -1:
|
| 112 |
+
_LOGGER.error("Incorrect message type: %s", msg_type)
|
| 113 |
+
return
|
| 114 |
+
|
| 115 |
+
if length == 0:
|
| 116 |
+
# Empty message (allowed)
|
| 117 |
+
self._remove_from_buffer()
|
| 118 |
+
self.process_packet(msg_type, b"")
|
| 119 |
+
continue
|
| 120 |
+
|
| 121 |
+
if (packet_data := self._read(length)) is None:
|
| 122 |
+
return
|
| 123 |
+
|
| 124 |
+
self._remove_from_buffer()
|
| 125 |
+
self.process_packet(msg_type, packet_data)
|
| 126 |
+
|
| 127 |
+
def _read(self, length: int) -> bytes | None:
|
| 128 |
+
"""Read exactly length bytes from the buffer or None if all the bytes are not yet available."""
|
| 129 |
+
new_pos = self._pos + length
|
| 130 |
+
if self._buffer_len < new_pos:
|
| 131 |
+
return None
|
| 132 |
+
|
| 133 |
+
original_pos = self._pos
|
| 134 |
+
self._pos = new_pos
|
| 135 |
+
|
| 136 |
+
if TYPE_CHECKING:
|
| 137 |
+
assert self._buffer is not None, "Buffer should be set"
|
| 138 |
+
|
| 139 |
+
cstr = self._buffer
|
| 140 |
+
return cstr[original_pos:new_pos]
|
| 141 |
+
|
| 142 |
+
def connection_lost(self, exc):
|
| 143 |
+
self._transport = None
|
| 144 |
+
self._writelines = None
|
| 145 |
+
|
| 146 |
+
def _read_varuint(self) -> int:
|
| 147 |
+
"""Read a varuint from the buffer or -1 if the buffer runs out of bytes."""
|
| 148 |
+
if not self._buffer:
|
| 149 |
+
return -1
|
| 150 |
+
|
| 151 |
+
result = 0
|
| 152 |
+
bitpos = 0
|
| 153 |
+
cstr = self._buffer
|
| 154 |
+
|
| 155 |
+
while self._buffer_len > self._pos:
|
| 156 |
+
val = cstr[self._pos]
|
| 157 |
+
self._pos += 1
|
| 158 |
+
result |= (val & 0x7F) << bitpos
|
| 159 |
+
if (val & 0x80) == 0:
|
| 160 |
+
return result
|
| 161 |
+
bitpos += 7
|
| 162 |
+
|
| 163 |
+
return -1
|
| 164 |
+
|
| 165 |
+
def _remove_from_buffer(self) -> None:
|
| 166 |
+
"""Remove data from the buffer."""
|
| 167 |
+
end_of_frame_pos = self._pos
|
| 168 |
+
self._buffer_len -= end_of_frame_pos
|
| 169 |
+
|
| 170 |
+
if self._buffer_len == 0:
|
| 171 |
+
self._buffer = None
|
| 172 |
+
return
|
| 173 |
+
|
| 174 |
+
if TYPE_CHECKING:
|
| 175 |
+
assert self._buffer is not None, "Buffer should be set"
|
| 176 |
+
|
| 177 |
+
cstr = self._buffer
|
| 178 |
+
self._buffer = cstr[end_of_frame_pos : self._buffer_len + end_of_frame_pos]
|
src/reachy_mini_ha_voice/audio_player.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Audio player using sounddevice for Reachy Mini."""
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
import threading
|
| 5 |
+
from collections.abc import Callable
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import List, Optional, Union
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
import sounddevice as sd
|
| 11 |
+
|
| 12 |
+
_LOGGER = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class AudioPlayer:
|
| 16 |
+
"""Audio player using sounddevice."""
|
| 17 |
+
|
| 18 |
+
def __init__(self, device: Optional[str] = None) -> None:
|
| 19 |
+
self.device = device
|
| 20 |
+
self.is_playing = False
|
| 21 |
+
self._playlist: List[str] = []
|
| 22 |
+
self._done_callback: Optional[Callable[[], None]] = None
|
| 23 |
+
self._done_callback_lock = threading.Lock()
|
| 24 |
+
self._duck_volume: float = 0.5
|
| 25 |
+
self._unduck_volume: float = 1.0
|
| 26 |
+
self._current_volume: float = 1.0
|
| 27 |
+
self._stop_flag = threading.Event()
|
| 28 |
+
|
| 29 |
+
def play(
|
| 30 |
+
self,
|
| 31 |
+
url: Union[str, List[str]],
|
| 32 |
+
done_callback: Optional[Callable[[], None]] = None,
|
| 33 |
+
stop_first: bool = True,
|
| 34 |
+
) -> None:
|
| 35 |
+
if stop_first:
|
| 36 |
+
self.stop()
|
| 37 |
+
|
| 38 |
+
if isinstance(url, str):
|
| 39 |
+
self._playlist = [url]
|
| 40 |
+
else:
|
| 41 |
+
self._playlist = list(url)
|
| 42 |
+
|
| 43 |
+
self._done_callback = done_callback
|
| 44 |
+
self._stop_flag.clear()
|
| 45 |
+
self._play_next()
|
| 46 |
+
|
| 47 |
+
def _play_next(self) -> None:
|
| 48 |
+
if not self._playlist or self._stop_flag.is_set():
|
| 49 |
+
self._on_playback_finished()
|
| 50 |
+
return
|
| 51 |
+
|
| 52 |
+
next_url = self._playlist.pop(0)
|
| 53 |
+
_LOGGER.debug("Playing %s", next_url)
|
| 54 |
+
self.is_playing = True
|
| 55 |
+
|
| 56 |
+
# Start playback in a thread
|
| 57 |
+
thread = threading.Thread(target=self._play_file, args=(next_url,), daemon=True)
|
| 58 |
+
thread.start()
|
| 59 |
+
|
| 60 |
+
def _play_file(self, file_path: str) -> None:
|
| 61 |
+
"""Play an audio file."""
|
| 62 |
+
try:
|
| 63 |
+
# Try to load the audio file
|
| 64 |
+
if file_path.startswith(("http://", "https://")):
|
| 65 |
+
# For URLs, download first
|
| 66 |
+
import urllib.request
|
| 67 |
+
import tempfile
|
| 68 |
+
import os
|
| 69 |
+
|
| 70 |
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmp:
|
| 71 |
+
urllib.request.urlretrieve(file_path, tmp.name)
|
| 72 |
+
file_path = tmp.name
|
| 73 |
+
|
| 74 |
+
# Load audio file
|
| 75 |
+
import soundfile as sf
|
| 76 |
+
data, samplerate = sf.read(file_path)
|
| 77 |
+
|
| 78 |
+
# Apply volume
|
| 79 |
+
data = data * self._current_volume
|
| 80 |
+
|
| 81 |
+
# Play
|
| 82 |
+
if not self._stop_flag.is_set():
|
| 83 |
+
sd.play(data, samplerate, device=self.device)
|
| 84 |
+
sd.wait()
|
| 85 |
+
|
| 86 |
+
except Exception as e:
|
| 87 |
+
_LOGGER.error("Error playing audio: %s", e)
|
| 88 |
+
finally:
|
| 89 |
+
self.is_playing = False
|
| 90 |
+
# Play next in playlist or finish
|
| 91 |
+
if self._playlist and not self._stop_flag.is_set():
|
| 92 |
+
self._play_next()
|
| 93 |
+
else:
|
| 94 |
+
self._on_playback_finished()
|
| 95 |
+
|
| 96 |
+
def _on_playback_finished(self) -> None:
|
| 97 |
+
"""Called when playback is finished."""
|
| 98 |
+
self.is_playing = False
|
| 99 |
+
todo_callback: Optional[Callable[[], None]] = None
|
| 100 |
+
|
| 101 |
+
with self._done_callback_lock:
|
| 102 |
+
if self._done_callback:
|
| 103 |
+
todo_callback = self._done_callback
|
| 104 |
+
self._done_callback = None
|
| 105 |
+
|
| 106 |
+
if todo_callback:
|
| 107 |
+
try:
|
| 108 |
+
todo_callback()
|
| 109 |
+
except Exception:
|
| 110 |
+
_LOGGER.exception("Unexpected error running done callback")
|
| 111 |
+
|
| 112 |
+
def pause(self) -> None:
|
| 113 |
+
sd.stop()
|
| 114 |
+
self.is_playing = False
|
| 115 |
+
|
| 116 |
+
def resume(self) -> None:
|
| 117 |
+
if self._playlist:
|
| 118 |
+
self._play_next()
|
| 119 |
+
|
| 120 |
+
def stop(self) -> None:
|
| 121 |
+
self._stop_flag.set()
|
| 122 |
+
sd.stop()
|
| 123 |
+
self._playlist.clear()
|
| 124 |
+
self.is_playing = False
|
| 125 |
+
|
| 126 |
+
def duck(self) -> None:
|
| 127 |
+
self._current_volume = self._duck_volume
|
| 128 |
+
|
| 129 |
+
def unduck(self) -> None:
|
| 130 |
+
self._current_volume = self._unduck_volume
|
| 131 |
+
|
| 132 |
+
def set_volume(self, volume: int) -> None:
|
| 133 |
+
volume = max(0, min(100, volume))
|
| 134 |
+
self._unduck_volume = volume / 100.0
|
| 135 |
+
self._duck_volume = self._unduck_volume / 2
|
| 136 |
+
self._current_volume = self._unduck_volume
|
src/reachy_mini_ha_voice/entity.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ESPHome entity definitions."""
|
| 2 |
+
|
| 3 |
+
from abc import abstractmethod
|
| 4 |
+
from collections.abc import Iterable
|
| 5 |
+
from typing import Callable, List, Optional, Union
|
| 6 |
+
|
| 7 |
+
# pylint: disable=no-name-in-module
|
| 8 |
+
from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
|
| 9 |
+
ListEntitiesMediaPlayerResponse,
|
| 10 |
+
ListEntitiesRequest,
|
| 11 |
+
MediaPlayerCommandRequest,
|
| 12 |
+
MediaPlayerStateResponse,
|
| 13 |
+
SubscribeHomeAssistantStatesRequest,
|
| 14 |
+
)
|
| 15 |
+
from aioesphomeapi.model import MediaPlayerCommand, MediaPlayerState
|
| 16 |
+
from google.protobuf import message
|
| 17 |
+
|
| 18 |
+
from .api_server import APIServer
|
| 19 |
+
from .audio_player import AudioPlayer
|
| 20 |
+
from .util import call_all
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class ESPHomeEntity:
|
| 24 |
+
"""Base class for ESPHome entities."""
|
| 25 |
+
|
| 26 |
+
def __init__(self, server: APIServer) -> None:
|
| 27 |
+
self.server = server
|
| 28 |
+
|
| 29 |
+
@abstractmethod
|
| 30 |
+
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
|
| 31 |
+
pass
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class MediaPlayerEntity(ESPHomeEntity):
|
| 35 |
+
"""Media player entity for ESPHome."""
|
| 36 |
+
|
| 37 |
+
def __init__(
|
| 38 |
+
self,
|
| 39 |
+
server: APIServer,
|
| 40 |
+
key: int,
|
| 41 |
+
name: str,
|
| 42 |
+
object_id: str,
|
| 43 |
+
music_player: AudioPlayer,
|
| 44 |
+
announce_player: AudioPlayer,
|
| 45 |
+
) -> None:
|
| 46 |
+
ESPHomeEntity.__init__(self, server)
|
| 47 |
+
self.key = key
|
| 48 |
+
self.name = name
|
| 49 |
+
self.object_id = object_id
|
| 50 |
+
self.state = MediaPlayerState.IDLE
|
| 51 |
+
self.volume = 1.0
|
| 52 |
+
self.muted = False
|
| 53 |
+
self.music_player = music_player
|
| 54 |
+
self.announce_player = announce_player
|
| 55 |
+
|
| 56 |
+
def play(
|
| 57 |
+
self,
|
| 58 |
+
url: Union[str, List[str]],
|
| 59 |
+
announcement: bool = False,
|
| 60 |
+
done_callback: Optional[Callable[[], None]] = None,
|
| 61 |
+
) -> Iterable[message.Message]:
|
| 62 |
+
if announcement:
|
| 63 |
+
if self.music_player.is_playing:
|
| 64 |
+
# Announce, resume music
|
| 65 |
+
self.music_player.pause()
|
| 66 |
+
self.announce_player.play(
|
| 67 |
+
url,
|
| 68 |
+
done_callback=lambda: call_all(
|
| 69 |
+
self.music_player.resume, done_callback
|
| 70 |
+
),
|
| 71 |
+
)
|
| 72 |
+
else:
|
| 73 |
+
# Announce, idle
|
| 74 |
+
self.announce_player.play(
|
| 75 |
+
url,
|
| 76 |
+
done_callback=lambda: call_all(
|
| 77 |
+
lambda: self.server.send_messages(
|
| 78 |
+
[self._update_state(MediaPlayerState.IDLE)]
|
| 79 |
+
),
|
| 80 |
+
done_callback,
|
| 81 |
+
),
|
| 82 |
+
)
|
| 83 |
+
else:
|
| 84 |
+
# Music
|
| 85 |
+
self.music_player.play(
|
| 86 |
+
url,
|
| 87 |
+
done_callback=lambda: call_all(
|
| 88 |
+
lambda: self.server.send_messages(
|
| 89 |
+
[self._update_state(MediaPlayerState.IDLE)]
|
| 90 |
+
),
|
| 91 |
+
done_callback,
|
| 92 |
+
),
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
yield self._update_state(MediaPlayerState.PLAYING)
|
| 96 |
+
|
| 97 |
+
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
|
| 98 |
+
if isinstance(msg, MediaPlayerCommandRequest) and (msg.key == self.key):
|
| 99 |
+
if msg.has_media_url:
|
| 100 |
+
announcement = msg.has_announcement and msg.announcement
|
| 101 |
+
yield from self.play(msg.media_url, announcement=announcement)
|
| 102 |
+
elif msg.has_command:
|
| 103 |
+
if msg.command == MediaPlayerCommand.PAUSE:
|
| 104 |
+
self.music_player.pause()
|
| 105 |
+
yield self._update_state(MediaPlayerState.PAUSED)
|
| 106 |
+
elif msg.command == MediaPlayerCommand.PLAY:
|
| 107 |
+
self.music_player.resume()
|
| 108 |
+
yield self._update_state(MediaPlayerState.PLAYING)
|
| 109 |
+
elif msg.has_volume:
|
| 110 |
+
volume = int(msg.volume * 100)
|
| 111 |
+
self.music_player.set_volume(volume)
|
| 112 |
+
self.announce_player.set_volume(volume)
|
| 113 |
+
self.volume = msg.volume
|
| 114 |
+
yield self._update_state(self.state)
|
| 115 |
+
elif isinstance(msg, ListEntitiesRequest):
|
| 116 |
+
yield ListEntitiesMediaPlayerResponse(
|
| 117 |
+
object_id=self.object_id,
|
| 118 |
+
key=self.key,
|
| 119 |
+
name=self.name,
|
| 120 |
+
supports_pause=True,
|
| 121 |
+
)
|
| 122 |
+
elif isinstance(msg, SubscribeHomeAssistantStatesRequest):
|
| 123 |
+
yield self._get_state_message()
|
| 124 |
+
|
| 125 |
+
def _update_state(self, new_state: MediaPlayerState) -> MediaPlayerStateResponse:
|
| 126 |
+
self.state = new_state
|
| 127 |
+
return self._get_state_message()
|
| 128 |
+
|
| 129 |
+
def _get_state_message(self) -> MediaPlayerStateResponse:
|
| 130 |
+
return MediaPlayerStateResponse(
|
| 131 |
+
key=self.key,
|
| 132 |
+
state=self.state,
|
| 133 |
+
volume=self.volume,
|
| 134 |
+
muted=self.muted,
|
| 135 |
+
)
|
src/reachy_mini_ha_voice/models.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared models for Reachy Mini Voice Assistant."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import logging
|
| 5 |
+
from dataclasses import asdict, dataclass, field
|
| 6 |
+
from enum import Enum
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from queue import Queue
|
| 9 |
+
from typing import TYPE_CHECKING, Dict, List, Optional, Set, Union
|
| 10 |
+
|
| 11 |
+
if TYPE_CHECKING:
|
| 12 |
+
from pymicro_wakeword import MicroWakeWord
|
| 13 |
+
from pyopen_wakeword import OpenWakeWord
|
| 14 |
+
from .entity import ESPHomeEntity, MediaPlayerEntity
|
| 15 |
+
from .audio_player import AudioPlayer
|
| 16 |
+
from .satellite import VoiceSatelliteProtocol
|
| 17 |
+
|
| 18 |
+
_LOGGER = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class WakeWordType(str, Enum):
|
| 22 |
+
MICRO_WAKE_WORD = "micro"
|
| 23 |
+
OPEN_WAKE_WORD = "openWakeWord"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@dataclass
|
| 27 |
+
class AvailableWakeWord:
|
| 28 |
+
id: str
|
| 29 |
+
type: WakeWordType
|
| 30 |
+
wake_word: str
|
| 31 |
+
trained_languages: List[str]
|
| 32 |
+
wake_word_path: Path
|
| 33 |
+
|
| 34 |
+
def load(self) -> "Union[MicroWakeWord, OpenWakeWord]":
|
| 35 |
+
if self.type == WakeWordType.MICRO_WAKE_WORD:
|
| 36 |
+
from pymicro_wakeword import MicroWakeWord
|
| 37 |
+
return MicroWakeWord.from_config(config_path=self.wake_word_path)
|
| 38 |
+
|
| 39 |
+
if self.type == WakeWordType.OPEN_WAKE_WORD:
|
| 40 |
+
from pyopen_wakeword import OpenWakeWord
|
| 41 |
+
oww_model = OpenWakeWord.from_model(model_path=self.wake_word_path)
|
| 42 |
+
setattr(oww_model, "wake_word", self.wake_word)
|
| 43 |
+
return oww_model
|
| 44 |
+
|
| 45 |
+
raise ValueError(f"Unexpected wake word type: {self.type}")
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@dataclass
|
| 49 |
+
class Preferences:
|
| 50 |
+
active_wake_words: List[str] = field(default_factory=list)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@dataclass
|
| 54 |
+
class ServerState:
|
| 55 |
+
"""Global server state."""
|
| 56 |
+
name: str
|
| 57 |
+
mac_address: str
|
| 58 |
+
audio_queue: "Queue[Optional[bytes]]"
|
| 59 |
+
entities: "List[ESPHomeEntity]"
|
| 60 |
+
available_wake_words: "Dict[str, AvailableWakeWord]"
|
| 61 |
+
wake_words: "Dict[str, Union[MicroWakeWord, OpenWakeWord]]"
|
| 62 |
+
active_wake_words: Set[str]
|
| 63 |
+
stop_word: "MicroWakeWord"
|
| 64 |
+
music_player: "AudioPlayer"
|
| 65 |
+
tts_player: "AudioPlayer"
|
| 66 |
+
wakeup_sound: str
|
| 67 |
+
timer_finished_sound: str
|
| 68 |
+
preferences: Preferences
|
| 69 |
+
preferences_path: Path
|
| 70 |
+
download_dir: Path
|
| 71 |
+
|
| 72 |
+
# Reachy Mini specific
|
| 73 |
+
reachy_mini: Optional[object] = None
|
| 74 |
+
motion_enabled: bool = True
|
| 75 |
+
|
| 76 |
+
media_player_entity: "Optional[MediaPlayerEntity]" = None
|
| 77 |
+
satellite: "Optional[VoiceSatelliteProtocol]" = None
|
| 78 |
+
wake_words_changed: bool = False
|
| 79 |
+
refractory_seconds: float = 2.0
|
| 80 |
+
|
| 81 |
+
def save_preferences(self) -> None:
|
| 82 |
+
"""Save preferences as JSON."""
|
| 83 |
+
_LOGGER.debug("Saving preferences: %s", self.preferences_path)
|
| 84 |
+
self.preferences_path.parent.mkdir(parents=True, exist_ok=True)
|
| 85 |
+
with open(self.preferences_path, "w", encoding="utf-8") as preferences_file:
|
| 86 |
+
json.dump(
|
| 87 |
+
asdict(self.preferences), preferences_file, ensure_ascii=False, indent=4
|
| 88 |
+
)
|
src/reachy_mini_ha_voice/motion.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Reachy Mini motion control integration."""
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import logging
|
| 5 |
+
import threading
|
| 6 |
+
from typing import Optional
|
| 7 |
+
import numpy as np
|
| 8 |
+
|
| 9 |
+
_LOGGER = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class ReachyMiniMotion:
|
| 13 |
+
"""Reachy Mini motion controller for voice assistant."""
|
| 14 |
+
|
| 15 |
+
def __init__(self, reachy_mini=None):
|
| 16 |
+
self.reachy_mini = reachy_mini
|
| 17 |
+
self._is_speaking = False
|
| 18 |
+
self._speech_task: Optional[asyncio.Task] = None
|
| 19 |
+
self._lock = threading.Lock()
|
| 20 |
+
|
| 21 |
+
def set_reachy_mini(self, reachy_mini):
|
| 22 |
+
"""Set the Reachy Mini instance."""
|
| 23 |
+
self.reachy_mini = reachy_mini
|
| 24 |
+
|
| 25 |
+
def on_wakeup(self):
|
| 26 |
+
"""Called when wake word is detected - nod to acknowledge."""
|
| 27 |
+
if not self.reachy_mini:
|
| 28 |
+
return
|
| 29 |
+
|
| 30 |
+
try:
|
| 31 |
+
# Quick nod to acknowledge
|
| 32 |
+
self._nod(count=1, amplitude=10, duration=0.3)
|
| 33 |
+
_LOGGER.debug("Reachy Mini: Wake up nod")
|
| 34 |
+
except Exception as e:
|
| 35 |
+
_LOGGER.error("Motion error on wakeup: %s", e)
|
| 36 |
+
|
| 37 |
+
def on_listening(self):
|
| 38 |
+
"""Called when listening for speech - tilt head slightly."""
|
| 39 |
+
if not self.reachy_mini:
|
| 40 |
+
return
|
| 41 |
+
|
| 42 |
+
try:
|
| 43 |
+
# Tilt head slightly to show attention
|
| 44 |
+
self._look_at_user()
|
| 45 |
+
_LOGGER.debug("Reachy Mini: Listening pose")
|
| 46 |
+
except Exception as e:
|
| 47 |
+
_LOGGER.error("Motion error on listening: %s", e)
|
| 48 |
+
|
| 49 |
+
def on_thinking(self):
|
| 50 |
+
"""Called when processing speech - look up slightly."""
|
| 51 |
+
if not self.reachy_mini:
|
| 52 |
+
return
|
| 53 |
+
|
| 54 |
+
try:
|
| 55 |
+
# Look up slightly as if thinking
|
| 56 |
+
self._think_pose()
|
| 57 |
+
_LOGGER.debug("Reachy Mini: Thinking pose")
|
| 58 |
+
except Exception as e:
|
| 59 |
+
_LOGGER.error("Motion error on thinking: %s", e)
|
| 60 |
+
|
| 61 |
+
def on_speaking_start(self):
|
| 62 |
+
"""Called when TTS starts - start speech-reactive motion."""
|
| 63 |
+
if not self.reachy_mini:
|
| 64 |
+
return
|
| 65 |
+
|
| 66 |
+
try:
|
| 67 |
+
self._is_speaking = True
|
| 68 |
+
# Start subtle head movements during speech
|
| 69 |
+
self._start_speech_motion()
|
| 70 |
+
_LOGGER.debug("Reachy Mini: Speaking started")
|
| 71 |
+
except Exception as e:
|
| 72 |
+
_LOGGER.error("Motion error on speaking start: %s", e)
|
| 73 |
+
|
| 74 |
+
def on_speaking_end(self):
|
| 75 |
+
"""Called when TTS ends - stop speech-reactive motion."""
|
| 76 |
+
if not self.reachy_mini:
|
| 77 |
+
return
|
| 78 |
+
|
| 79 |
+
try:
|
| 80 |
+
self._is_speaking = False
|
| 81 |
+
self._stop_speech_motion()
|
| 82 |
+
_LOGGER.debug("Reachy Mini: Speaking ended")
|
| 83 |
+
except Exception as e:
|
| 84 |
+
_LOGGER.error("Motion error on speaking end: %s", e)
|
| 85 |
+
|
| 86 |
+
def on_idle(self):
|
| 87 |
+
"""Called when returning to idle state."""
|
| 88 |
+
if not self.reachy_mini:
|
| 89 |
+
return
|
| 90 |
+
|
| 91 |
+
try:
|
| 92 |
+
self._is_speaking = False
|
| 93 |
+
self._stop_speech_motion()
|
| 94 |
+
self._return_to_neutral()
|
| 95 |
+
_LOGGER.debug("Reachy Mini: Idle pose")
|
| 96 |
+
except Exception as e:
|
| 97 |
+
_LOGGER.error("Motion error on idle: %s", e)
|
| 98 |
+
|
| 99 |
+
def on_timer_finished(self):
|
| 100 |
+
"""Called when a timer finishes - alert animation."""
|
| 101 |
+
if not self.reachy_mini:
|
| 102 |
+
return
|
| 103 |
+
|
| 104 |
+
try:
|
| 105 |
+
# Shake head to get attention
|
| 106 |
+
self._shake(count=2, amplitude=15, duration=0.4)
|
| 107 |
+
_LOGGER.debug("Reachy Mini: Timer finished animation")
|
| 108 |
+
except Exception as e:
|
| 109 |
+
_LOGGER.error("Motion error on timer finished: %s", e)
|
| 110 |
+
|
| 111 |
+
def on_error(self):
|
| 112 |
+
"""Called on error - shake head."""
|
| 113 |
+
if not self.reachy_mini:
|
| 114 |
+
return
|
| 115 |
+
|
| 116 |
+
try:
|
| 117 |
+
self._shake(count=1, amplitude=10, duration=0.3)
|
| 118 |
+
_LOGGER.debug("Reachy Mini: Error animation")
|
| 119 |
+
except Exception as e:
|
| 120 |
+
_LOGGER.error("Motion error on error: %s", e)
|
| 121 |
+
|
| 122 |
+
# -------------------------------------------------------------------------
|
| 123 |
+
# Low-level motion methods
|
| 124 |
+
# -------------------------------------------------------------------------
|
| 125 |
+
|
| 126 |
+
def _nod(self, count: int = 1, amplitude: float = 15, duration: float = 0.5):
|
| 127 |
+
"""Nod head up and down."""
|
| 128 |
+
if not self.reachy_mini:
|
| 129 |
+
return
|
| 130 |
+
|
| 131 |
+
try:
|
| 132 |
+
from scipy.spatial.transform import Rotation as R
|
| 133 |
+
|
| 134 |
+
for _ in range(count):
|
| 135 |
+
# Nod down
|
| 136 |
+
pose_down = np.eye(4)
|
| 137 |
+
pose_down[:3, :3] = R.from_euler('xyz', [amplitude, 0, 0], degrees=True).as_matrix()
|
| 138 |
+
self.reachy_mini.head.goto(pose_down, duration=duration / 2)
|
| 139 |
+
|
| 140 |
+
# Nod up
|
| 141 |
+
pose_up = np.eye(4)
|
| 142 |
+
pose_up[:3, :3] = R.from_euler('xyz', [-amplitude / 2, 0, 0], degrees=True).as_matrix()
|
| 143 |
+
self.reachy_mini.head.goto(pose_up, duration=duration / 2)
|
| 144 |
+
|
| 145 |
+
# Return to neutral
|
| 146 |
+
self._return_to_neutral()
|
| 147 |
+
except Exception as e:
|
| 148 |
+
_LOGGER.error("Nod error: %s", e)
|
| 149 |
+
|
| 150 |
+
def _shake(self, count: int = 1, amplitude: float = 20, duration: float = 0.5):
|
| 151 |
+
"""Shake head left and right."""
|
| 152 |
+
if not self.reachy_mini:
|
| 153 |
+
return
|
| 154 |
+
|
| 155 |
+
try:
|
| 156 |
+
from scipy.spatial.transform import Rotation as R
|
| 157 |
+
|
| 158 |
+
for _ in range(count):
|
| 159 |
+
# Shake left
|
| 160 |
+
pose_left = np.eye(4)
|
| 161 |
+
pose_left[:3, :3] = R.from_euler('xyz', [0, 0, -amplitude], degrees=True).as_matrix()
|
| 162 |
+
self.reachy_mini.head.goto(pose_left, duration=duration / 2)
|
| 163 |
+
|
| 164 |
+
# Shake right
|
| 165 |
+
pose_right = np.eye(4)
|
| 166 |
+
pose_right[:3, :3] = R.from_euler('xyz', [0, 0, amplitude], degrees=True).as_matrix()
|
| 167 |
+
self.reachy_mini.head.goto(pose_right, duration=duration / 2)
|
| 168 |
+
|
| 169 |
+
# Return to neutral
|
| 170 |
+
self._return_to_neutral()
|
| 171 |
+
except Exception as e:
|
| 172 |
+
_LOGGER.error("Shake error: %s", e)
|
| 173 |
+
|
| 174 |
+
def _look_at_user(self):
|
| 175 |
+
"""Look at user (neutral forward position)."""
|
| 176 |
+
if not self.reachy_mini:
|
| 177 |
+
return
|
| 178 |
+
|
| 179 |
+
try:
|
| 180 |
+
pose = np.eye(4)
|
| 181 |
+
self.reachy_mini.head.goto(pose, duration=0.3)
|
| 182 |
+
except Exception as e:
|
| 183 |
+
_LOGGER.error("Look at user error: %s", e)
|
| 184 |
+
|
| 185 |
+
def _think_pose(self):
|
| 186 |
+
"""Thinking pose - look up slightly."""
|
| 187 |
+
if not self.reachy_mini:
|
| 188 |
+
return
|
| 189 |
+
|
| 190 |
+
try:
|
| 191 |
+
from scipy.spatial.transform import Rotation as R
|
| 192 |
+
|
| 193 |
+
pose = np.eye(4)
|
| 194 |
+
pose[:3, :3] = R.from_euler('xyz', [-10, 0, 5], degrees=True).as_matrix()
|
| 195 |
+
self.reachy_mini.head.goto(pose, duration=0.4)
|
| 196 |
+
except Exception as e:
|
| 197 |
+
_LOGGER.error("Think pose error: %s", e)
|
| 198 |
+
|
| 199 |
+
def _return_to_neutral(self):
|
| 200 |
+
"""Return to neutral position."""
|
| 201 |
+
if not self.reachy_mini:
|
| 202 |
+
return
|
| 203 |
+
|
| 204 |
+
try:
|
| 205 |
+
pose = np.eye(4)
|
| 206 |
+
self.reachy_mini.head.goto(pose, duration=0.5)
|
| 207 |
+
except Exception as e:
|
| 208 |
+
_LOGGER.error("Return to neutral error: %s", e)
|
| 209 |
+
|
| 210 |
+
def _start_speech_motion(self):
|
| 211 |
+
"""Start subtle speech-reactive motion."""
|
| 212 |
+
# This would ideally run in a separate thread with subtle movements
|
| 213 |
+
pass
|
| 214 |
+
|
| 215 |
+
def _stop_speech_motion(self):
|
| 216 |
+
"""Stop speech-reactive motion."""
|
| 217 |
+
pass
|
| 218 |
+
|
| 219 |
+
def wiggle_antennas(self, happy: bool = True):
|
| 220 |
+
"""Wiggle antennas to show emotion."""
|
| 221 |
+
if not self.reachy_mini:
|
| 222 |
+
return
|
| 223 |
+
|
| 224 |
+
try:
|
| 225 |
+
if happy:
|
| 226 |
+
# Happy wiggle - both up
|
| 227 |
+
self.reachy_mini.head.l_antenna.goto(30, duration=0.2)
|
| 228 |
+
self.reachy_mini.head.r_antenna.goto(-30, duration=0.2)
|
| 229 |
+
else:
|
| 230 |
+
# Sad - both down
|
| 231 |
+
self.reachy_mini.head.l_antenna.goto(-20, duration=0.2)
|
| 232 |
+
self.reachy_mini.head.r_antenna.goto(20, duration=0.2)
|
| 233 |
+
except Exception as e:
|
| 234 |
+
_LOGGER.error("Antenna wiggle error: %s", e)
|
src/reachy_mini_ha_voice/satellite.py
ADDED
|
@@ -0,0 +1,476 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Voice satellite protocol for Reachy Mini."""
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
import logging
|
| 5 |
+
import posixpath
|
| 6 |
+
import shutil
|
| 7 |
+
import time
|
| 8 |
+
from collections.abc import Iterable
|
| 9 |
+
from typing import Dict, Optional, Set, Union
|
| 10 |
+
from urllib.parse import urlparse, urlunparse
|
| 11 |
+
from urllib.request import urlopen
|
| 12 |
+
|
| 13 |
+
# pylint: disable=no-name-in-module
|
| 14 |
+
from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
|
| 15 |
+
DeviceInfoRequest,
|
| 16 |
+
DeviceInfoResponse,
|
| 17 |
+
ListEntitiesDoneResponse,
|
| 18 |
+
ListEntitiesRequest,
|
| 19 |
+
MediaPlayerCommandRequest,
|
| 20 |
+
SubscribeHomeAssistantStatesRequest,
|
| 21 |
+
VoiceAssistantAnnounceFinished,
|
| 22 |
+
VoiceAssistantAnnounceRequest,
|
| 23 |
+
VoiceAssistantAudio,
|
| 24 |
+
VoiceAssistantConfigurationRequest,
|
| 25 |
+
VoiceAssistantConfigurationResponse,
|
| 26 |
+
VoiceAssistantEventResponse,
|
| 27 |
+
VoiceAssistantExternalWakeWord,
|
| 28 |
+
VoiceAssistantRequest,
|
| 29 |
+
VoiceAssistantSetConfiguration,
|
| 30 |
+
VoiceAssistantTimerEventResponse,
|
| 31 |
+
VoiceAssistantWakeWord,
|
| 32 |
+
)
|
| 33 |
+
from aioesphomeapi.model import (
|
| 34 |
+
VoiceAssistantEventType,
|
| 35 |
+
VoiceAssistantFeature,
|
| 36 |
+
VoiceAssistantTimerEventType,
|
| 37 |
+
)
|
| 38 |
+
from google.protobuf import message
|
| 39 |
+
from pymicro_wakeword import MicroWakeWord
|
| 40 |
+
from pyopen_wakeword import OpenWakeWord
|
| 41 |
+
|
| 42 |
+
from .api_server import APIServer
|
| 43 |
+
from .entity import MediaPlayerEntity
|
| 44 |
+
from .models import AvailableWakeWord, ServerState, WakeWordType
|
| 45 |
+
from .util import call_all
|
| 46 |
+
|
| 47 |
+
_LOGGER = logging.getLogger(__name__)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class VoiceSatelliteProtocol(APIServer):
|
| 51 |
+
"""Voice satellite protocol handler for ESPHome."""
|
| 52 |
+
|
| 53 |
+
def __init__(self, state: ServerState) -> None:
|
| 54 |
+
super().__init__(state.name)
|
| 55 |
+
self.state = state
|
| 56 |
+
self.state.satellite = self
|
| 57 |
+
|
| 58 |
+
if self.state.media_player_entity is None:
|
| 59 |
+
self.state.media_player_entity = MediaPlayerEntity(
|
| 60 |
+
server=self,
|
| 61 |
+
key=len(state.entities),
|
| 62 |
+
name="Media Player",
|
| 63 |
+
object_id="reachy_mini_media_player",
|
| 64 |
+
music_player=state.music_player,
|
| 65 |
+
announce_player=state.tts_player,
|
| 66 |
+
)
|
| 67 |
+
self.state.entities.append(self.state.media_player_entity)
|
| 68 |
+
|
| 69 |
+
self._is_streaming_audio = False
|
| 70 |
+
self._tts_url: Optional[str] = None
|
| 71 |
+
self._tts_played = False
|
| 72 |
+
self._continue_conversation = False
|
| 73 |
+
self._timer_finished = False
|
| 74 |
+
self._external_wake_words: Dict[str, VoiceAssistantExternalWakeWord] = {}
|
| 75 |
+
|
| 76 |
+
def handle_voice_event(
|
| 77 |
+
self, event_type: VoiceAssistantEventType, data: Dict[str, str]
|
| 78 |
+
) -> None:
|
| 79 |
+
_LOGGER.debug("Voice event: type=%s, data=%s", event_type.name, data)
|
| 80 |
+
|
| 81 |
+
if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_START:
|
| 82 |
+
self._tts_url = data.get("url")
|
| 83 |
+
self._tts_played = False
|
| 84 |
+
self._continue_conversation = False
|
| 85 |
+
# Reachy Mini: Start listening animation
|
| 86 |
+
self._reachy_on_listening()
|
| 87 |
+
|
| 88 |
+
elif event_type in (
|
| 89 |
+
VoiceAssistantEventType.VOICE_ASSISTANT_STT_VAD_END,
|
| 90 |
+
VoiceAssistantEventType.VOICE_ASSISTANT_STT_END,
|
| 91 |
+
):
|
| 92 |
+
self._is_streaming_audio = False
|
| 93 |
+
# Reachy Mini: Stop listening, start thinking
|
| 94 |
+
self._reachy_on_thinking()
|
| 95 |
+
|
| 96 |
+
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_PROGRESS:
|
| 97 |
+
if data.get("tts_start_streaming") == "1":
|
| 98 |
+
# Start streaming early
|
| 99 |
+
self.play_tts()
|
| 100 |
+
|
| 101 |
+
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END:
|
| 102 |
+
if data.get("continue_conversation") == "1":
|
| 103 |
+
self._continue_conversation = True
|
| 104 |
+
|
| 105 |
+
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START:
|
| 106 |
+
# Reachy Mini: Start speaking animation
|
| 107 |
+
self._reachy_on_speaking()
|
| 108 |
+
|
| 109 |
+
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END:
|
| 110 |
+
self._tts_url = data.get("url")
|
| 111 |
+
self.play_tts()
|
| 112 |
+
|
| 113 |
+
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_END:
|
| 114 |
+
self._is_streaming_audio = False
|
| 115 |
+
if not self._tts_played:
|
| 116 |
+
self._tts_finished()
|
| 117 |
+
self._tts_played = False
|
| 118 |
+
# Reachy Mini: Return to idle
|
| 119 |
+
self._reachy_on_idle()
|
| 120 |
+
|
| 121 |
+
def handle_timer_event(
|
| 122 |
+
self,
|
| 123 |
+
event_type: VoiceAssistantTimerEventType,
|
| 124 |
+
msg: VoiceAssistantTimerEventResponse,
|
| 125 |
+
) -> None:
|
| 126 |
+
_LOGGER.debug("Timer event: type=%s", event_type.name)
|
| 127 |
+
|
| 128 |
+
if event_type == VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_FINISHED:
|
| 129 |
+
if not self._timer_finished:
|
| 130 |
+
self.state.active_wake_words.add(self.state.stop_word.id)
|
| 131 |
+
self._timer_finished = True
|
| 132 |
+
self.duck()
|
| 133 |
+
self._play_timer_finished()
|
| 134 |
+
# Reachy Mini: Timer finished animation
|
| 135 |
+
self._reachy_on_timer_finished()
|
| 136 |
+
|
| 137 |
+
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
|
| 138 |
+
if isinstance(msg, VoiceAssistantEventResponse):
|
| 139 |
+
# Pipeline event
|
| 140 |
+
data: Dict[str, str] = {}
|
| 141 |
+
for arg in msg.data:
|
| 142 |
+
data[arg.name] = arg.value
|
| 143 |
+
self.handle_voice_event(VoiceAssistantEventType(msg.event_type), data)
|
| 144 |
+
|
| 145 |
+
elif isinstance(msg, VoiceAssistantAnnounceRequest):
|
| 146 |
+
_LOGGER.debug("Announcing: %s", msg.text)
|
| 147 |
+
assert self.state.media_player_entity is not None
|
| 148 |
+
|
| 149 |
+
urls = []
|
| 150 |
+
if msg.preannounce_media_id:
|
| 151 |
+
urls.append(msg.preannounce_media_id)
|
| 152 |
+
urls.append(msg.media_id)
|
| 153 |
+
|
| 154 |
+
self.state.active_wake_words.add(self.state.stop_word.id)
|
| 155 |
+
self._continue_conversation = msg.start_conversation
|
| 156 |
+
self.duck()
|
| 157 |
+
|
| 158 |
+
yield from self.state.media_player_entity.play(
|
| 159 |
+
urls, announcement=True, done_callback=self._tts_finished
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
elif isinstance(msg, VoiceAssistantTimerEventResponse):
|
| 163 |
+
self.handle_timer_event(VoiceAssistantTimerEventType(msg.event_type), msg)
|
| 164 |
+
|
| 165 |
+
elif isinstance(msg, DeviceInfoRequest):
|
| 166 |
+
yield DeviceInfoResponse(
|
| 167 |
+
uses_password=False,
|
| 168 |
+
name=self.state.name,
|
| 169 |
+
mac_address=self.state.mac_address,
|
| 170 |
+
voice_assistant_feature_flags=(
|
| 171 |
+
VoiceAssistantFeature.VOICE_ASSISTANT
|
| 172 |
+
| VoiceAssistantFeature.API_AUDIO
|
| 173 |
+
| VoiceAssistantFeature.ANNOUNCE
|
| 174 |
+
| VoiceAssistantFeature.START_CONVERSATION
|
| 175 |
+
| VoiceAssistantFeature.TIMERS
|
| 176 |
+
),
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
elif isinstance(
|
| 180 |
+
msg,
|
| 181 |
+
(
|
| 182 |
+
ListEntitiesRequest,
|
| 183 |
+
SubscribeHomeAssistantStatesRequest,
|
| 184 |
+
MediaPlayerCommandRequest,
|
| 185 |
+
),
|
| 186 |
+
):
|
| 187 |
+
for entity in self.state.entities:
|
| 188 |
+
yield from entity.handle_message(msg)
|
| 189 |
+
|
| 190 |
+
if isinstance(msg, ListEntitiesRequest):
|
| 191 |
+
yield ListEntitiesDoneResponse()
|
| 192 |
+
|
| 193 |
+
elif isinstance(msg, VoiceAssistantConfigurationRequest):
|
| 194 |
+
available_wake_words = [
|
| 195 |
+
VoiceAssistantWakeWord(
|
| 196 |
+
id=ww.id,
|
| 197 |
+
wake_word=ww.wake_word,
|
| 198 |
+
trained_languages=ww.trained_languages,
|
| 199 |
+
)
|
| 200 |
+
for ww in self.state.available_wake_words.values()
|
| 201 |
+
]
|
| 202 |
+
|
| 203 |
+
for eww in msg.external_wake_words:
|
| 204 |
+
if eww.model_type != "micro":
|
| 205 |
+
continue
|
| 206 |
+
|
| 207 |
+
available_wake_words.append(
|
| 208 |
+
VoiceAssistantWakeWord(
|
| 209 |
+
id=eww.id,
|
| 210 |
+
wake_word=eww.wake_word,
|
| 211 |
+
trained_languages=eww.trained_languages,
|
| 212 |
+
)
|
| 213 |
+
)
|
| 214 |
+
self._external_wake_words[eww.id] = eww
|
| 215 |
+
|
| 216 |
+
yield VoiceAssistantConfigurationResponse(
|
| 217 |
+
available_wake_words=available_wake_words,
|
| 218 |
+
active_wake_words=[
|
| 219 |
+
ww.id
|
| 220 |
+
for ww in self.state.wake_words.values()
|
| 221 |
+
if ww.id in self.state.active_wake_words
|
| 222 |
+
],
|
| 223 |
+
max_active_wake_words=2,
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
_LOGGER.info("Connected to Home Assistant")
|
| 227 |
+
|
| 228 |
+
elif isinstance(msg, VoiceAssistantSetConfiguration):
|
| 229 |
+
# Change active wake words
|
| 230 |
+
active_wake_words: Set[str] = set()
|
| 231 |
+
|
| 232 |
+
for wake_word_id in msg.active_wake_words:
|
| 233 |
+
if wake_word_id in self.state.wake_words:
|
| 234 |
+
# Already active
|
| 235 |
+
active_wake_words.add(wake_word_id)
|
| 236 |
+
continue
|
| 237 |
+
|
| 238 |
+
model_info = self.state.available_wake_words.get(wake_word_id)
|
| 239 |
+
if not model_info:
|
| 240 |
+
# Check external wake words (may require download)
|
| 241 |
+
external_wake_word = self._external_wake_words.get(wake_word_id)
|
| 242 |
+
if not external_wake_word:
|
| 243 |
+
continue
|
| 244 |
+
|
| 245 |
+
model_info = self._download_external_wake_word(external_wake_word)
|
| 246 |
+
if not model_info:
|
| 247 |
+
continue
|
| 248 |
+
|
| 249 |
+
self.state.available_wake_words[wake_word_id] = model_info
|
| 250 |
+
|
| 251 |
+
_LOGGER.debug("Loading wake word: %s", model_info.wake_word_path)
|
| 252 |
+
self.state.wake_words[wake_word_id] = model_info.load()
|
| 253 |
+
_LOGGER.info("Wake word set: %s", wake_word_id)
|
| 254 |
+
active_wake_words.add(wake_word_id)
|
| 255 |
+
break
|
| 256 |
+
|
| 257 |
+
self.state.active_wake_words = active_wake_words
|
| 258 |
+
_LOGGER.debug("Active wake words: %s", active_wake_words)
|
| 259 |
+
|
| 260 |
+
self.state.preferences.active_wake_words = list(active_wake_words)
|
| 261 |
+
self.state.save_preferences()
|
| 262 |
+
self.state.wake_words_changed = True
|
| 263 |
+
|
| 264 |
+
def handle_audio(self, audio_chunk: bytes) -> None:
|
| 265 |
+
if not self._is_streaming_audio:
|
| 266 |
+
return
|
| 267 |
+
self.send_messages([VoiceAssistantAudio(data=audio_chunk)])
|
| 268 |
+
|
| 269 |
+
def wakeup(self, wake_word: Union[MicroWakeWord, OpenWakeWord]) -> None:
|
| 270 |
+
if self._timer_finished:
|
| 271 |
+
# Stop timer instead
|
| 272 |
+
self._timer_finished = False
|
| 273 |
+
self.state.tts_player.stop()
|
| 274 |
+
_LOGGER.debug("Stopping timer finished sound")
|
| 275 |
+
return
|
| 276 |
+
|
| 277 |
+
wake_word_phrase = wake_word.wake_word
|
| 278 |
+
_LOGGER.debug("Detected wake word: %s", wake_word_phrase)
|
| 279 |
+
|
| 280 |
+
self.send_messages(
|
| 281 |
+
[VoiceAssistantRequest(start=True, wake_word_phrase=wake_word_phrase)]
|
| 282 |
+
)
|
| 283 |
+
self.duck()
|
| 284 |
+
self._is_streaming_audio = True
|
| 285 |
+
self.state.tts_player.play(self.state.wakeup_sound)
|
| 286 |
+
|
| 287 |
+
# Reachy Mini: Wake up animation
|
| 288 |
+
self._reachy_on_wakeup()
|
| 289 |
+
|
| 290 |
+
def stop(self) -> None:
|
| 291 |
+
self.state.active_wake_words.discard(self.state.stop_word.id)
|
| 292 |
+
self.state.tts_player.stop()
|
| 293 |
+
|
| 294 |
+
if self._timer_finished:
|
| 295 |
+
self._timer_finished = False
|
| 296 |
+
_LOGGER.debug("Stopping timer finished sound")
|
| 297 |
+
else:
|
| 298 |
+
_LOGGER.debug("TTS response stopped manually")
|
| 299 |
+
|
| 300 |
+
self._tts_finished()
|
| 301 |
+
|
| 302 |
+
def play_tts(self) -> None:
|
| 303 |
+
if (not self._tts_url) or self._tts_played:
|
| 304 |
+
return
|
| 305 |
+
|
| 306 |
+
self._tts_played = True
|
| 307 |
+
_LOGGER.debug("Playing TTS response: %s", self._tts_url)
|
| 308 |
+
|
| 309 |
+
self.state.active_wake_words.add(self.state.stop_word.id)
|
| 310 |
+
self.state.tts_player.play(self._tts_url, done_callback=self._tts_finished)
|
| 311 |
+
|
| 312 |
+
def duck(self) -> None:
|
| 313 |
+
_LOGGER.debug("Ducking music")
|
| 314 |
+
self.state.music_player.duck()
|
| 315 |
+
|
| 316 |
+
def unduck(self) -> None:
|
| 317 |
+
_LOGGER.debug("Unducking music")
|
| 318 |
+
self.state.music_player.unduck()
|
| 319 |
+
|
| 320 |
+
def _tts_finished(self) -> None:
|
| 321 |
+
self.state.active_wake_words.discard(self.state.stop_word.id)
|
| 322 |
+
self.send_messages([VoiceAssistantAnnounceFinished()])
|
| 323 |
+
|
| 324 |
+
if self._continue_conversation:
|
| 325 |
+
self.send_messages([VoiceAssistantRequest(start=True)])
|
| 326 |
+
self._is_streaming_audio = True
|
| 327 |
+
_LOGGER.debug("Continuing conversation")
|
| 328 |
+
else:
|
| 329 |
+
self.unduck()
|
| 330 |
+
_LOGGER.debug("TTS response finished")
|
| 331 |
+
# Reachy Mini: Return to idle
|
| 332 |
+
self._reachy_on_idle()
|
| 333 |
+
|
| 334 |
+
def _play_timer_finished(self) -> None:
|
| 335 |
+
if not self._timer_finished:
|
| 336 |
+
self.unduck()
|
| 337 |
+
return
|
| 338 |
+
|
| 339 |
+
self.state.tts_player.play(
|
| 340 |
+
self.state.timer_finished_sound,
|
| 341 |
+
done_callback=lambda: call_all(
|
| 342 |
+
lambda: time.sleep(1.0), self._play_timer_finished
|
| 343 |
+
),
|
| 344 |
+
)
|
| 345 |
+
|
| 346 |
+
def connection_lost(self, exc):
|
| 347 |
+
super().connection_lost(exc)
|
| 348 |
+
_LOGGER.info("Disconnected from Home Assistant")
|
| 349 |
+
|
| 350 |
+
def _download_external_wake_word(
|
| 351 |
+
self, external_wake_word: VoiceAssistantExternalWakeWord
|
| 352 |
+
) -> Optional[AvailableWakeWord]:
|
| 353 |
+
eww_dir = self.state.download_dir / "external_wake_words"
|
| 354 |
+
eww_dir.mkdir(parents=True, exist_ok=True)
|
| 355 |
+
|
| 356 |
+
config_path = eww_dir / f"{external_wake_word.id}.json"
|
| 357 |
+
should_download_config = not config_path.exists()
|
| 358 |
+
|
| 359 |
+
# Check if we need to download the model file
|
| 360 |
+
model_path = eww_dir / f"{external_wake_word.id}.tflite"
|
| 361 |
+
should_download_model = True
|
| 362 |
+
|
| 363 |
+
if model_path.exists():
|
| 364 |
+
model_size = model_path.stat().st_size
|
| 365 |
+
if model_size == external_wake_word.model_size:
|
| 366 |
+
with open(model_path, "rb") as model_file:
|
| 367 |
+
model_hash = hashlib.sha256(model_file.read()).hexdigest()
|
| 368 |
+
|
| 369 |
+
if model_hash == external_wake_word.model_hash:
|
| 370 |
+
should_download_model = False
|
| 371 |
+
_LOGGER.debug(
|
| 372 |
+
"Model size and hash match for %s. Skipping download.",
|
| 373 |
+
external_wake_word.id,
|
| 374 |
+
)
|
| 375 |
+
|
| 376 |
+
if should_download_config or should_download_model:
|
| 377 |
+
# Download config
|
| 378 |
+
_LOGGER.debug("Downloading %s to %s", external_wake_word.url, config_path)
|
| 379 |
+
with urlopen(external_wake_word.url) as request:
|
| 380 |
+
if request.status != 200:
|
| 381 |
+
_LOGGER.warning(
|
| 382 |
+
"Failed to download: %s, status=%s",
|
| 383 |
+
external_wake_word.url,
|
| 384 |
+
request.status,
|
| 385 |
+
)
|
| 386 |
+
return None
|
| 387 |
+
|
| 388 |
+
with open(config_path, "wb") as model_file:
|
| 389 |
+
shutil.copyfileobj(request, model_file)
|
| 390 |
+
|
| 391 |
+
if should_download_model:
|
| 392 |
+
# Download model file
|
| 393 |
+
parsed_url = urlparse(external_wake_word.url)
|
| 394 |
+
parsed_url = parsed_url._replace(
|
| 395 |
+
path=posixpath.join(posixpath.dirname(parsed_url.path), model_path.name)
|
| 396 |
+
)
|
| 397 |
+
model_url = urlunparse(parsed_url)
|
| 398 |
+
|
| 399 |
+
_LOGGER.debug("Downloading %s to %s", model_url, model_path)
|
| 400 |
+
with urlopen(model_url) as request:
|
| 401 |
+
if request.status != 200:
|
| 402 |
+
_LOGGER.warning(
|
| 403 |
+
"Failed to download: %s, status=%s", model_url, request.status
|
| 404 |
+
)
|
| 405 |
+
return None
|
| 406 |
+
|
| 407 |
+
with open(model_path, "wb") as model_file:
|
| 408 |
+
shutil.copyfileobj(request, model_file)
|
| 409 |
+
|
| 410 |
+
return AvailableWakeWord(
|
| 411 |
+
id=external_wake_word.id,
|
| 412 |
+
type=WakeWordType.MICRO_WAKE_WORD,
|
| 413 |
+
wake_word=external_wake_word.wake_word,
|
| 414 |
+
trained_languages=external_wake_word.trained_languages,
|
| 415 |
+
wake_word_path=config_path,
|
| 416 |
+
)
|
| 417 |
+
|
| 418 |
+
# -------------------------------------------------------------------------
|
| 419 |
+
# Reachy Mini Motion Control
|
| 420 |
+
# -------------------------------------------------------------------------
|
| 421 |
+
|
| 422 |
+
def _reachy_on_wakeup(self) -> None:
|
| 423 |
+
"""Called when wake word is detected."""
|
| 424 |
+
if not self.state.motion_enabled or not self.state.reachy_mini:
|
| 425 |
+
return
|
| 426 |
+
try:
|
| 427 |
+
# Nod to acknowledge
|
| 428 |
+
_LOGGER.debug("Reachy Mini: Wake up animation")
|
| 429 |
+
# Will be implemented with actual Reachy Mini SDK
|
| 430 |
+
except Exception as e:
|
| 431 |
+
_LOGGER.error("Reachy Mini motion error: %s", e)
|
| 432 |
+
|
| 433 |
+
def _reachy_on_listening(self) -> None:
|
| 434 |
+
"""Called when listening for speech."""
|
| 435 |
+
if not self.state.motion_enabled or not self.state.reachy_mini:
|
| 436 |
+
return
|
| 437 |
+
try:
|
| 438 |
+
_LOGGER.debug("Reachy Mini: Listening animation")
|
| 439 |
+
except Exception as e:
|
| 440 |
+
_LOGGER.error("Reachy Mini motion error: %s", e)
|
| 441 |
+
|
| 442 |
+
def _reachy_on_thinking(self) -> None:
|
| 443 |
+
"""Called when processing speech."""
|
| 444 |
+
if not self.state.motion_enabled or not self.state.reachy_mini:
|
| 445 |
+
return
|
| 446 |
+
try:
|
| 447 |
+
_LOGGER.debug("Reachy Mini: Thinking animation")
|
| 448 |
+
except Exception as e:
|
| 449 |
+
_LOGGER.error("Reachy Mini motion error: %s", e)
|
| 450 |
+
|
| 451 |
+
def _reachy_on_speaking(self) -> None:
|
| 452 |
+
"""Called when TTS is playing."""
|
| 453 |
+
if not self.state.motion_enabled or not self.state.reachy_mini:
|
| 454 |
+
return
|
| 455 |
+
try:
|
| 456 |
+
_LOGGER.debug("Reachy Mini: Speaking animation")
|
| 457 |
+
except Exception as e:
|
| 458 |
+
_LOGGER.error("Reachy Mini motion error: %s", e)
|
| 459 |
+
|
| 460 |
+
def _reachy_on_idle(self) -> None:
|
| 461 |
+
"""Called when returning to idle state."""
|
| 462 |
+
if not self.state.motion_enabled or not self.state.reachy_mini:
|
| 463 |
+
return
|
| 464 |
+
try:
|
| 465 |
+
_LOGGER.debug("Reachy Mini: Idle animation")
|
| 466 |
+
except Exception as e:
|
| 467 |
+
_LOGGER.error("Reachy Mini motion error: %s", e)
|
| 468 |
+
|
| 469 |
+
def _reachy_on_timer_finished(self) -> None:
|
| 470 |
+
"""Called when a timer finishes."""
|
| 471 |
+
if not self.state.motion_enabled or not self.state.reachy_mini:
|
| 472 |
+
return
|
| 473 |
+
try:
|
| 474 |
+
_LOGGER.debug("Reachy Mini: Timer finished animation")
|
| 475 |
+
except Exception as e:
|
| 476 |
+
_LOGGER.error("Reachy Mini motion error: %s", e)
|
src/reachy_mini_ha_voice/util.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Utility functions."""
|
| 2 |
+
|
| 3 |
+
import uuid
|
| 4 |
+
from collections.abc import Callable
|
| 5 |
+
from typing import Optional
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def call_all(*funcs: Optional[Callable[[], None]]) -> None:
|
| 9 |
+
"""Call all non-None functions."""
|
| 10 |
+
for func in funcs:
|
| 11 |
+
if func is not None:
|
| 12 |
+
func()
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def get_mac() -> str:
|
| 16 |
+
"""Return MAC address formatted as hex with no colons."""
|
| 17 |
+
return "".join(
|
| 18 |
+
["{:02x}".format((uuid.getnode() >> ele) & 0xFF) for ele in range(0, 8 * 6, 8)][
|
| 19 |
+
::-1
|
| 20 |
+
]
|
| 21 |
+
)
|
src/reachy_mini_ha_voice/voice_assistant.py
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Voice Assistant Service for Reachy Mini.
|
| 3 |
+
|
| 4 |
+
This module provides the main voice assistant service that integrates
|
| 5 |
+
with Home Assistant via ESPHome protocol.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import asyncio
|
| 9 |
+
import json
|
| 10 |
+
import logging
|
| 11 |
+
import threading
|
| 12 |
+
import time
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
from queue import Queue
|
| 15 |
+
from typing import Dict, List, Optional, Set, Union
|
| 16 |
+
|
| 17 |
+
import numpy as np
|
| 18 |
+
import sounddevice as sd
|
| 19 |
+
|
| 20 |
+
from reachy_mini import ReachyMini
|
| 21 |
+
|
| 22 |
+
from .models import AvailableWakeWord, Preferences, ServerState, WakeWordType
|
| 23 |
+
from .audio_player import AudioPlayer
|
| 24 |
+
from .satellite import VoiceSatelliteProtocol
|
| 25 |
+
from .util import get_mac
|
| 26 |
+
from .zeroconf import HomeAssistantZeroconf
|
| 27 |
+
from .motion import ReachyMiniMotion
|
| 28 |
+
|
| 29 |
+
_LOGGER = logging.getLogger(__name__)
|
| 30 |
+
|
| 31 |
+
_MODULE_DIR = Path(__file__).parent
|
| 32 |
+
_REPO_DIR = _MODULE_DIR.parent.parent
|
| 33 |
+
_WAKEWORDS_DIR = _REPO_DIR / "wakewords"
|
| 34 |
+
_SOUNDS_DIR = _REPO_DIR / "sounds"
|
| 35 |
+
_LOCAL_DIR = _REPO_DIR / "local"
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class VoiceAssistantService:
|
| 39 |
+
"""Voice assistant service that runs ESPHome protocol server."""
|
| 40 |
+
|
| 41 |
+
def __init__(
|
| 42 |
+
self,
|
| 43 |
+
reachy_mini: Optional[ReachyMini] = None,
|
| 44 |
+
name: str = "Reachy Mini",
|
| 45 |
+
host: str = "0.0.0.0",
|
| 46 |
+
port: int = 6053,
|
| 47 |
+
wake_model: str = "okay_nabu",
|
| 48 |
+
):
|
| 49 |
+
self.reachy_mini = reachy_mini
|
| 50 |
+
self.name = name
|
| 51 |
+
self.host = host
|
| 52 |
+
self.port = port
|
| 53 |
+
self.wake_model = wake_model
|
| 54 |
+
|
| 55 |
+
self._server = None
|
| 56 |
+
self._discovery = None
|
| 57 |
+
self._audio_thread = None
|
| 58 |
+
self._running = False
|
| 59 |
+
self._state: Optional[ServerState] = None
|
| 60 |
+
self._motion = ReachyMiniMotion(reachy_mini)
|
| 61 |
+
|
| 62 |
+
async def start(self) -> None:
|
| 63 |
+
"""Start the voice assistant service."""
|
| 64 |
+
_LOGGER.info("Initializing voice assistant service...")
|
| 65 |
+
|
| 66 |
+
# Ensure directories exist
|
| 67 |
+
_WAKEWORDS_DIR.mkdir(parents=True, exist_ok=True)
|
| 68 |
+
_SOUNDS_DIR.mkdir(parents=True, exist_ok=True)
|
| 69 |
+
_LOCAL_DIR.mkdir(parents=True, exist_ok=True)
|
| 70 |
+
|
| 71 |
+
# Download required files
|
| 72 |
+
await self._download_required_files()
|
| 73 |
+
|
| 74 |
+
# Load wake words
|
| 75 |
+
available_wake_words = self._load_available_wake_words()
|
| 76 |
+
_LOGGER.debug("Available wake words: %s", list(available_wake_words.keys()))
|
| 77 |
+
|
| 78 |
+
# Load preferences
|
| 79 |
+
preferences_path = _LOCAL_DIR / "preferences.json"
|
| 80 |
+
preferences = self._load_preferences(preferences_path)
|
| 81 |
+
|
| 82 |
+
# Load wake word models
|
| 83 |
+
wake_models, active_wake_words = self._load_wake_models(
|
| 84 |
+
available_wake_words, preferences
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
# Load stop model
|
| 88 |
+
stop_model = self._load_stop_model()
|
| 89 |
+
|
| 90 |
+
# Create server state
|
| 91 |
+
self._state = ServerState(
|
| 92 |
+
name=self.name,
|
| 93 |
+
mac_address=get_mac(),
|
| 94 |
+
audio_queue=Queue(),
|
| 95 |
+
entities=[],
|
| 96 |
+
available_wake_words=available_wake_words,
|
| 97 |
+
wake_words=wake_models,
|
| 98 |
+
active_wake_words=active_wake_words,
|
| 99 |
+
stop_word=stop_model,
|
| 100 |
+
music_player=AudioPlayer(),
|
| 101 |
+
tts_player=AudioPlayer(),
|
| 102 |
+
wakeup_sound=str(_SOUNDS_DIR / "wake_word_triggered.flac"),
|
| 103 |
+
timer_finished_sound=str(_SOUNDS_DIR / "timer_finished.flac"),
|
| 104 |
+
preferences=preferences,
|
| 105 |
+
preferences_path=preferences_path,
|
| 106 |
+
refractory_seconds=2.0,
|
| 107 |
+
download_dir=_LOCAL_DIR,
|
| 108 |
+
reachy_mini=self.reachy_mini,
|
| 109 |
+
motion_enabled=self.reachy_mini is not None,
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
# Set motion controller reference in state
|
| 113 |
+
self._state.motion = self._motion
|
| 114 |
+
|
| 115 |
+
# Start audio processing thread
|
| 116 |
+
self._running = True
|
| 117 |
+
self._audio_thread = threading.Thread(
|
| 118 |
+
target=self._process_audio,
|
| 119 |
+
daemon=True,
|
| 120 |
+
)
|
| 121 |
+
self._audio_thread.start()
|
| 122 |
+
|
| 123 |
+
# Create ESPHome server
|
| 124 |
+
loop = asyncio.get_running_loop()
|
| 125 |
+
self._server = await loop.create_server(
|
| 126 |
+
lambda: VoiceSatelliteProtocol(self._state),
|
| 127 |
+
host=self.host,
|
| 128 |
+
port=self.port,
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
# Start mDNS discovery
|
| 132 |
+
self._discovery = HomeAssistantZeroconf(port=self.port, name=self.name)
|
| 133 |
+
await self._discovery.register_server()
|
| 134 |
+
|
| 135 |
+
_LOGGER.info("Voice assistant service started on %s:%s", self.host, self.port)
|
| 136 |
+
|
| 137 |
+
async def stop(self) -> None:
|
| 138 |
+
"""Stop the voice assistant service."""
|
| 139 |
+
_LOGGER.info("Stopping voice assistant service...")
|
| 140 |
+
|
| 141 |
+
self._running = False
|
| 142 |
+
|
| 143 |
+
if self._audio_thread:
|
| 144 |
+
self._audio_thread.join(timeout=2.0)
|
| 145 |
+
|
| 146 |
+
if self._server:
|
| 147 |
+
self._server.close()
|
| 148 |
+
await self._server.wait_closed()
|
| 149 |
+
|
| 150 |
+
if self._discovery:
|
| 151 |
+
await self._discovery.unregister_server()
|
| 152 |
+
|
| 153 |
+
_LOGGER.info("Voice assistant service stopped.")
|
| 154 |
+
|
| 155 |
+
async def _download_required_files(self) -> None:
|
| 156 |
+
"""Download required model and sound files if missing."""
|
| 157 |
+
import urllib.request
|
| 158 |
+
|
| 159 |
+
# Wake word models
|
| 160 |
+
wakeword_files = {
|
| 161 |
+
"okay_nabu.tflite": "https://github.com/esphome/micro-wake-word-models/raw/main/models/v2/okay_nabu.tflite",
|
| 162 |
+
"hey_jarvis.tflite": "https://github.com/esphome/micro-wake-word-models/raw/main/models/v2/hey_jarvis.tflite",
|
| 163 |
+
"stop.tflite": "https://github.com/esphome/micro-wake-word-models/raw/main/models/v2/stop.tflite",
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
# Sound files
|
| 167 |
+
sound_files = {
|
| 168 |
+
"wake_word_triggered.flac": "https://github.com/OHF-Voice/linux-voice-assistant/raw/main/sounds/wake_word_triggered.flac",
|
| 169 |
+
"timer_finished.flac": "https://github.com/OHF-Voice/linux-voice-assistant/raw/main/sounds/timer_finished.flac",
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
for filename, url in wakeword_files.items():
|
| 173 |
+
dest = _WAKEWORDS_DIR / filename
|
| 174 |
+
if not dest.exists():
|
| 175 |
+
_LOGGER.info("Downloading %s...", filename)
|
| 176 |
+
try:
|
| 177 |
+
urllib.request.urlretrieve(url, dest)
|
| 178 |
+
_LOGGER.info("Downloaded %s", filename)
|
| 179 |
+
except Exception as e:
|
| 180 |
+
_LOGGER.warning("Failed to download %s: %s", filename, e)
|
| 181 |
+
|
| 182 |
+
for filename, url in sound_files.items():
|
| 183 |
+
dest = _SOUNDS_DIR / filename
|
| 184 |
+
if not dest.exists():
|
| 185 |
+
_LOGGER.info("Downloading %s...", filename)
|
| 186 |
+
try:
|
| 187 |
+
urllib.request.urlretrieve(url, dest)
|
| 188 |
+
_LOGGER.info("Downloaded %s", filename)
|
| 189 |
+
except Exception as e:
|
| 190 |
+
_LOGGER.warning("Failed to download %s: %s", filename, e)
|
| 191 |
+
|
| 192 |
+
def _load_available_wake_words(self) -> Dict[str, AvailableWakeWord]:
|
| 193 |
+
"""Load available wake word configurations."""
|
| 194 |
+
available_wake_words: Dict[str, AvailableWakeWord] = {}
|
| 195 |
+
|
| 196 |
+
wake_word_dirs = [_WAKEWORDS_DIR, _LOCAL_DIR / "external_wake_words"]
|
| 197 |
+
|
| 198 |
+
for wake_word_dir in wake_word_dirs:
|
| 199 |
+
if not wake_word_dir.exists():
|
| 200 |
+
continue
|
| 201 |
+
|
| 202 |
+
for config_path in wake_word_dir.glob("*.json"):
|
| 203 |
+
model_id = config_path.stem
|
| 204 |
+
if model_id == "stop":
|
| 205 |
+
continue
|
| 206 |
+
|
| 207 |
+
try:
|
| 208 |
+
with open(config_path, "r", encoding="utf-8") as f:
|
| 209 |
+
config = json.load(f)
|
| 210 |
+
|
| 211 |
+
model_type = WakeWordType(config.get("type", "micro"))
|
| 212 |
+
|
| 213 |
+
if model_type == WakeWordType.OPEN_WAKE_WORD:
|
| 214 |
+
wake_word_path = config_path.parent / config["model"]
|
| 215 |
+
else:
|
| 216 |
+
wake_word_path = config_path
|
| 217 |
+
|
| 218 |
+
available_wake_words[model_id] = AvailableWakeWord(
|
| 219 |
+
id=model_id,
|
| 220 |
+
type=model_type,
|
| 221 |
+
wake_word=config.get("wake_word", model_id),
|
| 222 |
+
trained_languages=config.get("trained_languages", []),
|
| 223 |
+
wake_word_path=wake_word_path,
|
| 224 |
+
)
|
| 225 |
+
except Exception as e:
|
| 226 |
+
_LOGGER.warning("Failed to load wake word %s: %s", config_path, e)
|
| 227 |
+
|
| 228 |
+
return available_wake_words
|
| 229 |
+
|
| 230 |
+
def _load_preferences(self, preferences_path: Path) -> Preferences:
|
| 231 |
+
"""Load user preferences."""
|
| 232 |
+
if preferences_path.exists():
|
| 233 |
+
try:
|
| 234 |
+
with open(preferences_path, "r", encoding="utf-8") as f:
|
| 235 |
+
data = json.load(f)
|
| 236 |
+
return Preferences(**data)
|
| 237 |
+
except Exception as e:
|
| 238 |
+
_LOGGER.warning("Failed to load preferences: %s", e)
|
| 239 |
+
|
| 240 |
+
return Preferences()
|
| 241 |
+
|
| 242 |
+
def _load_wake_models(
|
| 243 |
+
self,
|
| 244 |
+
available_wake_words: Dict[str, AvailableWakeWord],
|
| 245 |
+
preferences: Preferences,
|
| 246 |
+
):
|
| 247 |
+
"""Load wake word models."""
|
| 248 |
+
from pymicro_wakeword import MicroWakeWord
|
| 249 |
+
from pyopen_wakeword import OpenWakeWord
|
| 250 |
+
|
| 251 |
+
wake_models: Dict[str, Union[MicroWakeWord, OpenWakeWord]] = {}
|
| 252 |
+
active_wake_words: Set[str] = set()
|
| 253 |
+
|
| 254 |
+
# Try to load preferred models
|
| 255 |
+
if preferences.active_wake_words:
|
| 256 |
+
for wake_word_id in preferences.active_wake_words:
|
| 257 |
+
wake_word = available_wake_words.get(wake_word_id)
|
| 258 |
+
if wake_word is None:
|
| 259 |
+
_LOGGER.warning("Unknown wake word: %s", wake_word_id)
|
| 260 |
+
continue
|
| 261 |
+
|
| 262 |
+
try:
|
| 263 |
+
_LOGGER.debug("Loading wake model: %s", wake_word_id)
|
| 264 |
+
wake_models[wake_word_id] = wake_word.load()
|
| 265 |
+
active_wake_words.add(wake_word_id)
|
| 266 |
+
except Exception as e:
|
| 267 |
+
_LOGGER.warning("Failed to load wake model %s: %s", wake_word_id, e)
|
| 268 |
+
|
| 269 |
+
# Load default model if none loaded
|
| 270 |
+
if not wake_models:
|
| 271 |
+
wake_word = available_wake_words.get(self.wake_model)
|
| 272 |
+
if wake_word:
|
| 273 |
+
try:
|
| 274 |
+
_LOGGER.debug("Loading default wake model: %s", self.wake_model)
|
| 275 |
+
wake_models[self.wake_model] = wake_word.load()
|
| 276 |
+
active_wake_words.add(self.wake_model)
|
| 277 |
+
except Exception as e:
|
| 278 |
+
_LOGGER.error("Failed to load default wake model: %s", e)
|
| 279 |
+
|
| 280 |
+
return wake_models, active_wake_words
|
| 281 |
+
|
| 282 |
+
def _load_stop_model(self):
|
| 283 |
+
"""Load the stop word model."""
|
| 284 |
+
from pymicro_wakeword import MicroWakeWord
|
| 285 |
+
|
| 286 |
+
stop_config = _WAKEWORDS_DIR / "stop.json"
|
| 287 |
+
if stop_config.exists():
|
| 288 |
+
try:
|
| 289 |
+
return MicroWakeWord.from_config(stop_config)
|
| 290 |
+
except Exception as e:
|
| 291 |
+
_LOGGER.warning("Failed to load stop model: %s", e)
|
| 292 |
+
|
| 293 |
+
# Return a dummy model if stop model not available
|
| 294 |
+
_LOGGER.warning("Stop model not available, using fallback")
|
| 295 |
+
okay_nabu_config = _WAKEWORDS_DIR / "okay_nabu.json"
|
| 296 |
+
if okay_nabu_config.exists():
|
| 297 |
+
return MicroWakeWord.from_config(okay_nabu_config)
|
| 298 |
+
|
| 299 |
+
return None
|
| 300 |
+
|
| 301 |
+
def _process_audio(self) -> None:
|
| 302 |
+
"""Process audio from microphone in a separate thread."""
|
| 303 |
+
from pymicro_wakeword import MicroWakeWord, MicroWakeWordFeatures
|
| 304 |
+
from pyopen_wakeword import OpenWakeWord, OpenWakeWordFeatures
|
| 305 |
+
|
| 306 |
+
wake_words: List[Union[MicroWakeWord, OpenWakeWord]] = []
|
| 307 |
+
micro_features: Optional[MicroWakeWordFeatures] = None
|
| 308 |
+
micro_inputs: List[np.ndarray] = []
|
| 309 |
+
oww_features: Optional[OpenWakeWordFeatures] = None
|
| 310 |
+
oww_inputs: List[np.ndarray] = []
|
| 311 |
+
has_oww = False
|
| 312 |
+
last_active: Optional[float] = None
|
| 313 |
+
|
| 314 |
+
block_size = 1024
|
| 315 |
+
|
| 316 |
+
try:
|
| 317 |
+
_LOGGER.info("Starting audio processing...")
|
| 318 |
+
|
| 319 |
+
with sd.InputStream(
|
| 320 |
+
samplerate=16000,
|
| 321 |
+
channels=1,
|
| 322 |
+
blocksize=block_size,
|
| 323 |
+
dtype="float32",
|
| 324 |
+
) as stream:
|
| 325 |
+
while self._running:
|
| 326 |
+
audio_chunk_array, overflowed = stream.read(block_size)
|
| 327 |
+
if overflowed:
|
| 328 |
+
_LOGGER.warning("Audio buffer overflow")
|
| 329 |
+
|
| 330 |
+
audio_chunk_array = audio_chunk_array.reshape(-1)
|
| 331 |
+
|
| 332 |
+
# Convert to 16-bit PCM for streaming
|
| 333 |
+
audio_chunk = (
|
| 334 |
+
(np.clip(audio_chunk_array, -1.0, 1.0) * 32767.0)
|
| 335 |
+
.astype("<i2")
|
| 336 |
+
.tobytes()
|
| 337 |
+
)
|
| 338 |
+
|
| 339 |
+
# Stream audio to Home Assistant
|
| 340 |
+
if self._state and self._state.satellite:
|
| 341 |
+
self._state.satellite.handle_audio(audio_chunk)
|
| 342 |
+
|
| 343 |
+
# Check if wake words changed
|
| 344 |
+
if self._state and self._state.wake_words_changed:
|
| 345 |
+
self._state.wake_words_changed = False
|
| 346 |
+
wake_words = list(self._state.wake_words.values())
|
| 347 |
+
has_oww = any(isinstance(ww, OpenWakeWord) for ww in wake_words)
|
| 348 |
+
|
| 349 |
+
if any(isinstance(ww, MicroWakeWord) for ww in wake_words):
|
| 350 |
+
micro_features = MicroWakeWordFeatures()
|
| 351 |
+
else:
|
| 352 |
+
micro_features = None
|
| 353 |
+
|
| 354 |
+
if has_oww:
|
| 355 |
+
oww_features = OpenWakeWordFeatures.from_builtin()
|
| 356 |
+
else:
|
| 357 |
+
oww_features = None
|
| 358 |
+
|
| 359 |
+
# Initialize features if needed
|
| 360 |
+
if not wake_words and self._state:
|
| 361 |
+
wake_words = list(self._state.wake_words.values())
|
| 362 |
+
has_oww = any(isinstance(ww, OpenWakeWord) for ww in wake_words)
|
| 363 |
+
|
| 364 |
+
if any(isinstance(ww, MicroWakeWord) for ww in wake_words):
|
| 365 |
+
micro_features = MicroWakeWordFeatures()
|
| 366 |
+
|
| 367 |
+
if has_oww:
|
| 368 |
+
oww_features = OpenWakeWordFeatures.from_builtin()
|
| 369 |
+
|
| 370 |
+
# Extract features
|
| 371 |
+
micro_inputs.clear()
|
| 372 |
+
oww_inputs.clear()
|
| 373 |
+
|
| 374 |
+
if micro_features:
|
| 375 |
+
micro_inputs = micro_features.process_streaming(audio_chunk_array)
|
| 376 |
+
|
| 377 |
+
if oww_features:
|
| 378 |
+
oww_inputs = oww_features.process_streaming(audio_chunk_array)
|
| 379 |
+
|
| 380 |
+
# Process wake words
|
| 381 |
+
if self._state:
|
| 382 |
+
for wake_word in wake_words:
|
| 383 |
+
if wake_word.id not in self._state.active_wake_words:
|
| 384 |
+
continue
|
| 385 |
+
|
| 386 |
+
activated = False
|
| 387 |
+
|
| 388 |
+
if isinstance(wake_word, MicroWakeWord):
|
| 389 |
+
for micro_input in micro_inputs:
|
| 390 |
+
if wake_word.process_streaming(micro_input):
|
| 391 |
+
activated = True
|
| 392 |
+
elif isinstance(wake_word, OpenWakeWord):
|
| 393 |
+
for oww_input in oww_inputs:
|
| 394 |
+
scores = wake_word.process_streaming(oww_input)
|
| 395 |
+
if any(s > 0.5 for s in scores):
|
| 396 |
+
activated = True
|
| 397 |
+
|
| 398 |
+
if activated:
|
| 399 |
+
now = time.monotonic()
|
| 400 |
+
if (last_active is None) or (
|
| 401 |
+
(now - last_active) > self._state.refractory_seconds
|
| 402 |
+
):
|
| 403 |
+
if self._state.satellite:
|
| 404 |
+
self._state.satellite.wakeup(wake_word)
|
| 405 |
+
# Trigger motion
|
| 406 |
+
self._motion.on_wakeup()
|
| 407 |
+
last_active = now
|
| 408 |
+
|
| 409 |
+
# Process stop word
|
| 410 |
+
if self._state.stop_word:
|
| 411 |
+
stopped = False
|
| 412 |
+
for micro_input in micro_inputs:
|
| 413 |
+
if self._state.stop_word.process_streaming(micro_input):
|
| 414 |
+
stopped = True
|
| 415 |
+
|
| 416 |
+
if stopped and (self._state.stop_word.id in self._state.active_wake_words):
|
| 417 |
+
if self._state.satellite:
|
| 418 |
+
self._state.satellite.stop()
|
| 419 |
+
|
| 420 |
+
except Exception:
|
| 421 |
+
_LOGGER.exception("Error processing audio")
|
src/reachy_mini_ha_voice/zeroconf.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Runs mDNS zeroconf service for Home Assistant discovery."""
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
import socket
|
| 5 |
+
from typing import Optional
|
| 6 |
+
|
| 7 |
+
_LOGGER = logging.getLogger(__name__)
|
| 8 |
+
|
| 9 |
+
try:
|
| 10 |
+
from zeroconf.asyncio import AsyncServiceInfo, AsyncZeroconf
|
| 11 |
+
except ImportError:
|
| 12 |
+
_LOGGER.fatal("pip install zeroconf")
|
| 13 |
+
raise
|
| 14 |
+
|
| 15 |
+
MDNS_TARGET_IP = "224.0.0.251"
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class HomeAssistantZeroconf:
|
| 19 |
+
"""Zeroconf service for Home Assistant discovery."""
|
| 20 |
+
|
| 21 |
+
def __init__(
|
| 22 |
+
self, port: int, name: Optional[str] = None, host: Optional[str] = None
|
| 23 |
+
) -> None:
|
| 24 |
+
self.port = port
|
| 25 |
+
self.name = name or _get_mac_address()
|
| 26 |
+
|
| 27 |
+
if not host:
|
| 28 |
+
test_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
| 29 |
+
test_sock.setblocking(False)
|
| 30 |
+
try:
|
| 31 |
+
test_sock.connect((MDNS_TARGET_IP, 1))
|
| 32 |
+
host = test_sock.getsockname()[0]
|
| 33 |
+
except Exception:
|
| 34 |
+
host = "127.0.0.1"
|
| 35 |
+
finally:
|
| 36 |
+
test_sock.close()
|
| 37 |
+
_LOGGER.debug("Detected IP: %s", host)
|
| 38 |
+
|
| 39 |
+
assert host
|
| 40 |
+
self.host = host
|
| 41 |
+
self._aiozc = AsyncZeroconf()
|
| 42 |
+
|
| 43 |
+
async def register_server(self) -> None:
|
| 44 |
+
service_info = AsyncServiceInfo(
|
| 45 |
+
"_esphomelib._tcp.local.",
|
| 46 |
+
f"{self.name}._esphomelib._tcp.local.",
|
| 47 |
+
addresses=[socket.inet_aton(self.host)],
|
| 48 |
+
port=self.port,
|
| 49 |
+
properties={
|
| 50 |
+
"version": "2025.9.0",
|
| 51 |
+
"mac": _get_mac_address(),
|
| 52 |
+
"board": "reachy_mini",
|
| 53 |
+
"platform": "REACHY_MINI",
|
| 54 |
+
"network": "ethernet",
|
| 55 |
+
},
|
| 56 |
+
server=f"{self.name}.local.",
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
await self._aiozc.async_register_service(service_info)
|
| 60 |
+
_LOGGER.debug("Zeroconf discovery enabled: %s", service_info)
|
| 61 |
+
|
| 62 |
+
async def unregister_server(self) -> None:
|
| 63 |
+
await self._aiozc.async_close()
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _get_mac_address() -> str:
|
| 67 |
+
"""Return MAC address formatted as hex with no colons."""
|
| 68 |
+
import uuid
|
| 69 |
+
return "".join(
|
| 70 |
+
["{:02x}".format((uuid.getnode() >> ele) & 0xFF) for ele in range(0, 8 * 6, 8)][
|
| 71 |
+
::-1
|
| 72 |
+
]
|
| 73 |
+
)
|
wakewords/stop.json
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"type": "micro",
|
| 3 |
+
"wake_word": "Stop",
|
| 4 |
+
"trained_languages": ["en"]
|
| 5 |
+
}
|