Desmond-Dong commited on
Commit
b8cfa60
·
1 Parent(s): 2efbff7
.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
+ }