Desmond-Dong commited on
Commit
89da2bd
·
1 Parent(s): 6d6af93

优化代码

Browse files
reachy_mini_ha_voice/__main__.py CHANGED
@@ -1,81 +1,17 @@
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 .motion import ReachyMiniMotion
24
- from .satellite import VoiceSatelliteProtocol
25
- from .util import get_mac
26
- from .zeroconf import HomeAssistantZeroconf
27
 
28
  _LOGGER = logging.getLogger(__name__)
29
 
30
- _MODULE_DIR = Path(__file__).parent
31
- _REPO_DIR = _MODULE_DIR.parent
32
- _WAKEWORDS_DIR = _REPO_DIR / "wakewords"
33
- _SOUNDS_DIR = _REPO_DIR / "sounds"
34
-
35
-
36
- def download_required_files():
37
- """Download required model and sound files if missing."""
38
- import urllib.request
39
-
40
- _WAKEWORDS_DIR.mkdir(parents=True, exist_ok=True)
41
- _SOUNDS_DIR.mkdir(parents=True, exist_ok=True)
42
-
43
- # Wake word models
44
- wakeword_files = {
45
- "okay_nabu.tflite": "https://github.com/esphome/micro-wake-word-models/raw/main/models/v2/okay_nabu.tflite",
46
- "okay_nabu.json": "https://github.com/esphome/micro-wake-word-models/raw/main/models/v2/okay_nabu.json",
47
- "hey_jarvis.tflite": "https://github.com/esphome/micro-wake-word-models/raw/main/models/v2/hey_jarvis.tflite",
48
- "hey_jarvis.json": "https://github.com/esphome/micro-wake-word-models/raw/main/models/v2/hey_jarvis.json",
49
- "stop.tflite": "https://github.com/esphome/micro-wake-word-models/raw/main/models/v2/stop.tflite",
50
- "stop.json": "https://github.com/esphome/micro-wake-word-models/raw/main/models/v2/stop.json",
51
- }
52
-
53
- # Sound files
54
- sound_files = {
55
- "wake_word_triggered.flac": "https://github.com/OHF-Voice/linux-voice-assistant/raw/main/sounds/wake_word_triggered.flac",
56
- "timer_finished.flac": "https://github.com/OHF-Voice/linux-voice-assistant/raw/main/sounds/timer_finished.flac",
57
- }
58
-
59
- for filename, url in wakeword_files.items():
60
- dest = _WAKEWORDS_DIR / filename
61
- if not dest.exists():
62
- _LOGGER.info("Downloading %s...", filename)
63
- try:
64
- urllib.request.urlretrieve(url, dest)
65
- _LOGGER.info("Downloaded %s", filename)
66
- except Exception as e:
67
- _LOGGER.warning("Failed to download %s: %s", filename, e)
68
-
69
- for filename, url in sound_files.items():
70
- dest = _SOUNDS_DIR / filename
71
- if not dest.exists():
72
- _LOGGER.info("Downloading %s...", filename)
73
- try:
74
- urllib.request.urlretrieve(url, dest)
75
- _LOGGER.info("Downloaded %s", filename)
76
- except Exception as e:
77
- _LOGGER.warning("Failed to download %s: %s", filename, e)
78
-
79
 
80
  async def main() -> None:
81
  parser = argparse.ArgumentParser(
@@ -87,34 +23,15 @@ async def main() -> None:
87
  help="Name of the voice assistant (default: Reachy Mini)",
88
  )
89
  parser.add_argument(
90
- "--audio-input-device",
91
- help="Audio input device name or index (see --list-input-devices)",
92
- )
93
- parser.add_argument(
94
- "--list-input-devices",
95
- action="store_true",
96
- help="List audio input devices and exit",
97
  )
98
  parser.add_argument(
99
- "--audio-input-block-size",
100
  type=int,
101
- default=1024,
102
- help="Audio input block size (default: 1024)",
103
- )
104
- parser.add_argument(
105
- "--audio-output-device",
106
- help="Audio output device name or index (see --list-output-devices)",
107
- )
108
- parser.add_argument(
109
- "--list-output-devices",
110
- action="store_true",
111
- help="List audio output devices and exit",
112
- )
113
- parser.add_argument(
114
- "--wake-word-dir",
115
- default=[str(_WAKEWORDS_DIR)],
116
- action="append",
117
- help="Directory with wake word models (.tflite) and configs (.json)",
118
  )
119
  parser.add_argument(
120
  "--wake-model",
@@ -122,46 +39,15 @@ async def main() -> None:
122
  help="Id of active wake model (default: okay_nabu)",
123
  )
124
  parser.add_argument(
125
- "--stop-model",
126
- default="stop",
127
- help="Id of stop model (default: stop)",
128
- )
129
- parser.add_argument(
130
- "--download-dir",
131
- default=str(_REPO_DIR / "local"),
132
- help="Directory to download custom wake word models, etc.",
133
- )
134
- parser.add_argument(
135
- "--refractory-seconds",
136
- default=2.0,
137
- type=float,
138
- help="Seconds before wake word can be activated again (default: 2.0)",
139
- )
140
- parser.add_argument(
141
- "--wakeup-sound",
142
- default=str(_SOUNDS_DIR / "wake_word_triggered.flac"),
143
- help="Sound to play when wake word is detected",
144
- )
145
- parser.add_argument(
146
- "--timer-finished-sound",
147
- default=str(_SOUNDS_DIR / "timer_finished.flac"),
148
- help="Sound to play when timer finishes",
149
- )
150
- parser.add_argument(
151
- "--preferences-file",
152
- default=str(_REPO_DIR / "preferences.json"),
153
- help="Path to preferences file",
154
- )
155
- parser.add_argument(
156
- "--host",
157
- default="0.0.0.0",
158
- help="Address for ESPHome server (default: 0.0.0.0)",
159
  )
160
  parser.add_argument(
161
- "--port",
162
- type=int,
163
- default=6053,
164
- help="Port for ESPHome server (default: 6053)",
165
  )
166
  parser.add_argument(
167
  "--no-motion",
@@ -182,332 +68,59 @@ async def main() -> None:
182
  format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
183
  )
184
 
185
- # List input devices
186
- if args.list_input_devices:
187
- print("\nAudio Input Devices")
188
- print("=" * 40)
189
- devices = sd.query_devices()
190
- for idx, device in enumerate(devices):
191
- if device["max_input_channels"] > 0:
192
- print(f"[{idx}] {device['name']}")
193
- return
194
-
195
- # List output devices
196
- if args.list_output_devices:
197
- print("\nAudio Output Devices")
198
- print("=" * 40)
199
- devices = sd.query_devices()
200
- for idx, device in enumerate(devices):
201
- if device["max_output_channels"] > 0:
202
- print(f"[{idx}] {device['name']}")
203
- return
204
-
205
- _LOGGER.debug(args)
206
-
207
- # Download required files
208
- download_required_files()
209
-
210
- # Setup paths
211
- download_dir = Path(args.download_dir)
212
- download_dir.mkdir(parents=True, exist_ok=True)
213
-
214
- # Resolve audio input device
215
- input_device = args.audio_input_device
216
- if input_device is not None:
217
- try:
218
- input_device = int(input_device)
219
- except ValueError:
220
- pass
221
-
222
- # Load available wake words
223
- wake_word_dirs = [Path(ww_dir) for ww_dir in args.wake_word_dir]
224
- wake_word_dirs.append(download_dir / "external_wake_words")
225
-
226
- available_wake_words: Dict[str, AvailableWakeWord] = {}
227
- for wake_word_dir in wake_word_dirs:
228
- if not wake_word_dir.exists():
229
- continue
230
- for model_config_path in wake_word_dir.glob("*.json"):
231
- model_id = model_config_path.stem
232
- if model_id == args.stop_model:
233
- # Don't show stop model as an available wake word
234
- continue
235
-
236
- try:
237
- with open(model_config_path, "r", encoding="utf-8") as model_config_file:
238
- model_config = json.load(model_config_file)
239
-
240
- model_type = WakeWordType(model_config.get("type", "micro"))
241
-
242
- if model_type == WakeWordType.OPEN_WAKE_WORD:
243
- wake_word_path = model_config_path.parent / model_config["model"]
244
- else:
245
- wake_word_path = model_config_path
246
-
247
- available_wake_words[model_id] = AvailableWakeWord(
248
- id=model_id,
249
- type=WakeWordType(model_type),
250
- wake_word=model_config.get("wake_word", model_id),
251
- trained_languages=model_config.get("trained_languages", []),
252
- wake_word_path=wake_word_path,
253
- )
254
- except Exception as e:
255
- _LOGGER.warning("Failed to load wake word config %s: %s", model_config_path, e)
256
-
257
- _LOGGER.debug("Available wake words: %s", list(sorted(available_wake_words.keys())))
258
-
259
- # Load preferences
260
- preferences_path = Path(args.preferences_file)
261
- if preferences_path.exists():
262
- _LOGGER.debug("Loading preferences: %s", preferences_path)
263
- with open(preferences_path, "r", encoding="utf-8") as preferences_file:
264
- preferences_dict = json.load(preferences_file)
265
- preferences = Preferences(**preferences_dict)
266
- else:
267
- preferences = Preferences()
268
-
269
- # Load wake/stop models
270
- active_wake_words: Set[str] = set()
271
- wake_models: Dict[str, Union[MicroWakeWord, OpenWakeWord]] = {}
272
-
273
- if preferences.active_wake_words:
274
- # Load preferred models
275
- for wake_word_id in preferences.active_wake_words:
276
- wake_word = available_wake_words.get(wake_word_id)
277
- if wake_word is None:
278
- _LOGGER.warning("Unrecognized wake word id: %s", wake_word_id)
279
- continue
280
-
281
- _LOGGER.debug("Loading wake model: %s", wake_word_id)
282
- wake_models[wake_word_id] = wake_word.load()
283
- active_wake_words.add(wake_word_id)
284
-
285
- if not wake_models:
286
- # Load default model
287
- wake_word_id = args.wake_model
288
- wake_word = available_wake_words.get(wake_word_id)
289
- if wake_word:
290
- _LOGGER.debug("Loading wake model: %s", wake_word_id)
291
- wake_models[wake_word_id] = wake_word.load()
292
- active_wake_words.add(wake_word_id)
293
- else:
294
- _LOGGER.error("Wake word model not found: %s", wake_word_id)
295
- _LOGGER.error("Available models: %s", list(available_wake_words.keys()))
296
- sys.exit(1)
297
-
298
- # Load stop model
299
- stop_model: Optional[MicroWakeWord] = None
300
- for wake_word_dir in wake_word_dirs:
301
- stop_config_path = wake_word_dir / f"{args.stop_model}.json"
302
- if not stop_config_path.exists():
303
- continue
304
-
305
- _LOGGER.debug("Loading stop model: %s", stop_config_path)
306
- stop_model = MicroWakeWord.from_config(stop_config_path)
307
- break
308
-
309
- if stop_model is None:
310
- _LOGGER.warning("Stop model not found, timer stop functionality disabled")
311
- # Create a dummy stop model that never triggers
312
- stop_model = MicroWakeWord.from_config(
313
- list(available_wake_words.values())[0].wake_word_path
314
- )
315
-
316
  # Initialize Reachy Mini (if available)
317
  reachy_mini = None
318
- motion = None
319
  if not args.no_motion:
320
  try:
321
  from reachy_mini import ReachyMini
322
  reachy_mini = ReachyMini()
323
- motion = ReachyMiniMotion(reachy_mini)
324
  _LOGGER.info("Reachy Mini connected")
325
  except ImportError:
326
  _LOGGER.warning("reachy-mini not installed, motion control disabled")
327
  except Exception as e:
328
  _LOGGER.warning("Failed to connect to Reachy Mini: %s", e)
329
 
330
- # Create server state
331
- state = ServerState(
332
- name=args.name,
333
- mac_address=get_mac(),
334
- audio_queue=Queue(),
335
- entities=[],
336
- available_wake_words=available_wake_words,
337
- wake_words=wake_models,
338
- active_wake_words=active_wake_words,
339
- stop_word=stop_model,
340
- music_player=AudioPlayer(device=args.audio_output_device),
341
- tts_player=AudioPlayer(device=args.audio_output_device),
342
- wakeup_sound=args.wakeup_sound,
343
- timer_finished_sound=args.timer_finished_sound,
344
- preferences=preferences,
345
- preferences_path=preferences_path,
346
- refractory_seconds=args.refractory_seconds,
347
- download_dir=download_dir,
348
- reachy_mini=reachy_mini,
349
- motion=motion,
350
- motion_enabled=not args.no_motion and reachy_mini is not None,
351
- )
352
 
353
- # Start audio processing thread
354
- process_audio_thread = threading.Thread(
355
- target=process_audio,
356
- args=(state, input_device, args.audio_input_block_size),
357
- daemon=True,
358
- )
359
- process_audio_thread.start()
360
-
361
- # Create ESPHome server
362
- loop = asyncio.get_running_loop()
363
- server = await loop.create_server(
364
- lambda: VoiceSatelliteProtocol(state), host=args.host, port=args.port
365
  )
366
 
367
- # Auto discovery (zeroconf, mDNS)
368
- discovery = HomeAssistantZeroconf(port=args.port, name=args.name)
369
- await discovery.register_server()
370
 
371
  try:
372
- async with server:
373
- _LOGGER.info("=" * 50)
374
- _LOGGER.info("Reachy Mini Voice Assistant Started")
375
- _LOGGER.info("=" * 50)
376
- _LOGGER.info("Name: %s", args.name)
377
- _LOGGER.info("ESPHome Server: %s:%s", args.host, args.port)
378
- _LOGGER.info("Wake word: %s", list(active_wake_words))
379
- _LOGGER.info("Motion control: %s", "enabled" if state.motion_enabled else "disabled")
380
- _LOGGER.info("=" * 50)
381
- _LOGGER.info("Add this device in Home Assistant:")
382
- _LOGGER.info(" Settings -> Devices & Services -> Add Integration -> ESPHome")
383
- _LOGGER.info(" Enter: <this-device-ip>:6053")
384
- _LOGGER.info("=" * 50)
 
 
 
 
 
385
 
386
- await server.serve_forever()
387
  except KeyboardInterrupt:
388
  _LOGGER.info("Shutting down...")
389
  finally:
390
- state.audio_queue.put_nowait(None)
391
- process_audio_thread.join(timeout=2.0)
392
- await discovery.unregister_server()
393
- _LOGGER.debug("Server stopped")
394
-
395
-
396
- def process_audio(state: ServerState, input_device, block_size: int):
397
- """Process audio chunks from the microphone."""
398
- wake_words: List[Union[MicroWakeWord, OpenWakeWord]] = []
399
- micro_features: Optional[MicroWakeWordFeatures] = None
400
- micro_inputs: List[np.ndarray] = []
401
- oww_features: Optional[OpenWakeWordFeatures] = None
402
- oww_inputs: List[np.ndarray] = []
403
- has_oww = False
404
- last_active: Optional[float] = None
405
-
406
- try:
407
- _LOGGER.debug("Opening audio input device: %s", input_device or "default")
408
-
409
- with sd.InputStream(
410
- device=input_device,
411
- samplerate=16000,
412
- channels=1,
413
- blocksize=block_size,
414
- dtype="float32",
415
- ) as stream:
416
- while True:
417
- audio_chunk_array, overflowed = stream.read(block_size)
418
- if overflowed:
419
- _LOGGER.warning("Audio buffer overflow")
420
-
421
- audio_chunk_array = audio_chunk_array.reshape(-1)
422
-
423
- # Convert to 16-bit PCM for streaming
424
- audio_chunk = (
425
- (np.clip(audio_chunk_array, -1.0, 1.0) * 32767.0)
426
- .astype("<i2")
427
- .tobytes()
428
- )
429
-
430
- # Stream audio to Home Assistant
431
- if state.satellite:
432
- state.satellite.handle_audio(audio_chunk)
433
-
434
- # Check if wake words changed
435
- if state.wake_words_changed:
436
- state.wake_words_changed = False
437
- wake_words = list(state.wake_words.values())
438
- has_oww = any(isinstance(ww, OpenWakeWord) for ww in wake_words)
439
-
440
- if any(isinstance(ww, MicroWakeWord) for ww in wake_words):
441
- micro_features = MicroWakeWordFeatures()
442
- else:
443
- micro_features = None
444
-
445
- if has_oww:
446
- oww_features = OpenWakeWordFeatures.from_builtin()
447
- else:
448
- oww_features = None
449
-
450
- # Initialize features if needed
451
- if not wake_words:
452
- wake_words = list(state.wake_words.values())
453
- has_oww = any(isinstance(ww, OpenWakeWord) for ww in wake_words)
454
-
455
- if any(isinstance(ww, MicroWakeWord) for ww in wake_words):
456
- micro_features = MicroWakeWordFeatures()
457
-
458
- if has_oww:
459
- oww_features = OpenWakeWordFeatures.from_builtin()
460
-
461
- # Extract features
462
- micro_inputs.clear()
463
- oww_inputs.clear()
464
-
465
- if micro_features:
466
- micro_inputs = micro_features.process_streaming(audio_chunk_array)
467
-
468
- if oww_features:
469
- oww_inputs = oww_features.process_streaming(audio_chunk_array)
470
-
471
- # Process wake words
472
- for wake_word in wake_words:
473
- if wake_word.id not in state.active_wake_words:
474
- continue
475
-
476
- activated = False
477
-
478
- if isinstance(wake_word, MicroWakeWord):
479
- for micro_input in micro_inputs:
480
- if wake_word.process_streaming(micro_input):
481
- activated = True
482
- elif isinstance(wake_word, OpenWakeWord):
483
- for oww_input in oww_inputs:
484
- scores = wake_word.process_streaming(oww_input)
485
- if any(s > 0.5 for s in scores):
486
- activated = True
487
-
488
- if activated:
489
- # Check refractory period
490
- now = time.monotonic()
491
- if (last_active is None) or (
492
- (now - last_active) > state.refractory_seconds
493
- ):
494
- if state.satellite:
495
- state.satellite.wakeup(wake_word)
496
- last_active = now
497
-
498
- # Always process stop word to keep state correct
499
- stopped = False
500
- for micro_input in micro_inputs:
501
- if state.stop_word.process_streaming(micro_input):
502
- stopped = True
503
-
504
- if stopped and (state.stop_word.id in state.active_wake_words):
505
- if state.satellite:
506
- state.satellite.stop()
507
-
508
- except Exception:
509
- _LOGGER.exception("Unexpected error processing audio")
510
- sys.exit(1)
511
 
512
 
513
  def run():
 
1
  #!/usr/bin/env python3
2
+ """Main entry point for Reachy Mini Home Assistant Voice Assistant.
3
+
4
+ This module provides a command-line interface for running the voice assistant
5
+ in standalone mode (without the ReachyMini App framework).
6
+ """
7
 
8
  import argparse
9
  import asyncio
 
10
  import logging
 
11
  import threading
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
  _LOGGER = logging.getLogger(__name__)
14
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
  async def main() -> None:
17
  parser = argparse.ArgumentParser(
 
23
  help="Name of the voice assistant (default: Reachy Mini)",
24
  )
25
  parser.add_argument(
26
+ "--host",
27
+ default="0.0.0.0",
28
+ help="Address for ESPHome server (default: 0.0.0.0)",
 
 
 
 
29
  )
30
  parser.add_argument(
31
+ "--port",
32
  type=int,
33
+ default=6053,
34
+ help="Port for ESPHome server (default: 6053)",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  )
36
  parser.add_argument(
37
  "--wake-model",
 
39
  help="Id of active wake model (default: okay_nabu)",
40
  )
41
  parser.add_argument(
42
+ "--camera-port",
43
+ type=int,
44
+ default=8081,
45
+ help="Port for camera server (default: 8081)",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  )
47
  parser.add_argument(
48
+ "--no-camera",
49
+ action="store_true",
50
+ help="Disable camera server",
 
51
  )
52
  parser.add_argument(
53
  "--no-motion",
 
68
  format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
69
  )
70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  # Initialize Reachy Mini (if available)
72
  reachy_mini = None
 
73
  if not args.no_motion:
74
  try:
75
  from reachy_mini import ReachyMini
76
  reachy_mini = ReachyMini()
 
77
  _LOGGER.info("Reachy Mini connected")
78
  except ImportError:
79
  _LOGGER.warning("reachy-mini not installed, motion control disabled")
80
  except Exception as e:
81
  _LOGGER.warning("Failed to connect to Reachy Mini: %s", e)
82
 
83
+ # Import and create VoiceAssistantService
84
+ from .voice_assistant import VoiceAssistantService
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
 
86
+ service = VoiceAssistantService(
87
+ reachy_mini=reachy_mini,
88
+ name=args.name,
89
+ host=args.host,
90
+ port=args.port,
91
+ wake_model=args.wake_model,
92
+ camera_port=args.camera_port,
93
+ camera_enabled=not args.no_camera,
 
 
 
 
94
  )
95
 
96
+ # Create stop event for graceful shutdown
97
+ stop_event = threading.Event()
 
98
 
99
  try:
100
+ await service.start()
101
+
102
+ _LOGGER.info("=" * 50)
103
+ _LOGGER.info("Reachy Mini Voice Assistant Started")
104
+ _LOGGER.info("=" * 50)
105
+ _LOGGER.info("Name: %s", args.name)
106
+ _LOGGER.info("ESPHome Server: %s:%s", args.host, args.port)
107
+ _LOGGER.info("Camera Server: %s:%s", args.host, args.camera_port)
108
+ _LOGGER.info("Motion control: %s", "enabled" if reachy_mini else "disabled")
109
+ _LOGGER.info("=" * 50)
110
+ _LOGGER.info("Add this device in Home Assistant:")
111
+ _LOGGER.info(" Settings -> Devices & Services -> Add Integration -> ESPHome")
112
+ _LOGGER.info(" Enter: <this-device-ip>:%s", args.port)
113
+ _LOGGER.info("=" * 50)
114
+
115
+ # Wait for stop signal
116
+ while not stop_event.is_set():
117
+ await asyncio.sleep(0.5)
118
 
 
119
  except KeyboardInterrupt:
120
  _LOGGER.info("Shutting down...")
121
  finally:
122
+ await service.stop()
123
+ _LOGGER.info("Voice assistant stopped")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
 
125
 
126
  def run():
reachy_mini_ha_voice/motion.py CHANGED
@@ -147,17 +147,19 @@ class ReachyMiniMotion:
147
  # -------------------------------------------------------------------------
148
 
149
  def _do_wakeup(self, doa_angle_deg: Optional[float] = None):
150
- """Actual wakeup motion (blocking, runs in thread pool)."""
 
 
 
151
  with self._lock:
152
  try:
153
- _LOGGER.info("_do_wakeup: doa_angle_deg=%s", doa_angle_deg)
154
  if doa_angle_deg is not None:
155
  _LOGGER.info("Turning to sound source at %s degrees", doa_angle_deg)
156
- self._turn_to_sound_source(doa_angle_deg)
157
  else:
158
- _LOGGER.warning("DOA angle is None, skipping turn to sound source")
159
- self._nod(count=1, amplitude=10, duration=0.3)
160
- _LOGGER.debug("Reachy Mini: Wake up nod (DOA: %s)", doa_angle_deg)
161
  except Exception as e:
162
  _LOGGER.error("Motion error on wakeup: %s", e)
163
 
@@ -344,7 +346,7 @@ class ReachyMiniMotion:
344
  """Stop speech-reactive motion - return to neutral."""
345
  pass # Will return to neutral in on_idle()
346
 
347
- def _turn_to_sound_source(self, doa_angle_deg: float):
348
  """Turn head to face the sound source based on DOA angle.
349
 
350
  Args:
@@ -353,6 +355,7 @@ class ReachyMiniMotion:
353
  0 = left, π/2 = front/back, π = right
354
  We convert to head yaw where:
355
  0 = front, positive = right, negative = left
 
356
  """
357
  if not self.reachy_mini:
358
  return
@@ -368,7 +371,7 @@ class ReachyMiniMotion:
368
  pose[:3, :3] = R.from_euler('xyz', [0, 0, yaw_deg], degrees=True).as_matrix()
369
 
370
  # Turn head to face the sound source
371
- self.reachy_mini.goto_target(head=pose, duration=0.4)
372
  _LOGGER.debug("Reachy Mini: Turned to sound source at %s degrees", yaw_deg)
373
  except Exception as e:
374
  _LOGGER.error("Turn to sound source error: %s", e)
 
147
  # -------------------------------------------------------------------------
148
 
149
  def _do_wakeup(self, doa_angle_deg: Optional[float] = None):
150
+ """Actual wakeup motion (blocking, runs in thread pool).
151
+
152
+ Slowly turn to face the sound source.
153
+ """
154
  with self._lock:
155
  try:
 
156
  if doa_angle_deg is not None:
157
  _LOGGER.info("Turning to sound source at %s degrees", doa_angle_deg)
158
+ self._turn_to_sound_source(doa_angle_deg, duration=0.8)
159
  else:
160
+ _LOGGER.debug("DOA angle is None, looking forward")
161
+ self._look_at_user()
162
+ _LOGGER.debug("Reachy Mini: Wake up (DOA: %s)", doa_angle_deg)
163
  except Exception as e:
164
  _LOGGER.error("Motion error on wakeup: %s", e)
165
 
 
346
  """Stop speech-reactive motion - return to neutral."""
347
  pass # Will return to neutral in on_idle()
348
 
349
+ def _turn_to_sound_source(self, doa_angle_deg: float, duration: float = 0.8):
350
  """Turn head to face the sound source based on DOA angle.
351
 
352
  Args:
 
355
  0 = left, π/2 = front/back, π = right
356
  We convert to head yaw where:
357
  0 = front, positive = right, negative = left
358
+ duration: Duration of the turn motion in seconds (default: 0.8 for slow turn)
359
  """
360
  if not self.reachy_mini:
361
  return
 
371
  pose[:3, :3] = R.from_euler('xyz', [0, 0, yaw_deg], degrees=True).as_matrix()
372
 
373
  # Turn head to face the sound source
374
+ self.reachy_mini.goto_target(head=pose, duration=duration)
375
  _LOGGER.debug("Reachy Mini: Turned to sound source at %s degrees", yaw_deg)
376
  except Exception as e:
377
  _LOGGER.error("Turn to sound source error: %s", e)
reachy_mini_ha_voice/reachy_controller.py CHANGED
@@ -125,15 +125,21 @@ class ReachyController:
125
  if respeaker is None:
126
  return getattr(self, '_microphone_volume', 50.0)
127
 
128
- try:
129
- # Try APMGR_MICGAIN first (0-31 range)
130
- result = respeaker.read("APMGR_MICGAIN")
131
- if result is not None:
132
- # Convert 0-31 to 0-100%
133
- self._microphone_volume = (result[0] / 31.0) * 100.0
134
- return self._microphone_volume
135
- except Exception as e:
136
- logger.debug(f"Could not get microphone volume: {e}")
 
 
 
 
 
 
137
 
138
  return getattr(self, '_microphone_volume', 50.0)
139
 
@@ -152,13 +158,22 @@ class ReachyController:
152
  logger.warning("Cannot set microphone volume: ReSpeaker not available")
153
  return
154
 
155
- try:
156
- # Convert 0-100% to 0-31 range for APMGR_MICGAIN
157
- gain = int((volume / 100.0) * 31.0)
158
- respeaker.write("APMGR_MICGAIN", [gain])
159
- logger.info(f"Microphone volume set to {volume}% (gain: {gain})")
160
- except Exception as e:
161
- logger.error(f"Failed to set microphone volume: {e}")
 
 
 
 
 
 
 
 
 
162
 
163
  # ========== Phase 2: Motor Control ==========
164
 
 
125
  if respeaker is None:
126
  return getattr(self, '_microphone_volume', 50.0)
127
 
128
+ # Try different gain parameters (varies by ReSpeaker model)
129
+ gain_params = [
130
+ ("AGCGAIN", 31.0), # AGC target level (0-31)
131
+ ("MICGAIN", 31.0), # Microphone gain
132
+ ]
133
+
134
+ for param_name, max_val in gain_params:
135
+ try:
136
+ result = respeaker.read(param_name)
137
+ if result is not None:
138
+ self._microphone_volume = (result[0] / max_val) * 100.0
139
+ logger.debug(f"Read microphone volume: {self._microphone_volume}% ({param_name}: {result[0]})")
140
+ return self._microphone_volume
141
+ except Exception as e:
142
+ logger.debug(f"Could not read {param_name}: {e}")
143
 
144
  return getattr(self, '_microphone_volume', 50.0)
145
 
 
158
  logger.warning("Cannot set microphone volume: ReSpeaker not available")
159
  return
160
 
161
+ # Try different gain parameters (varies by ReSpeaker model)
162
+ gain_params = [
163
+ ("AGCGAIN", 31.0), # AGC target level (0-31)
164
+ ("MICGAIN", 31.0), # Microphone gain
165
+ ]
166
+
167
+ for param_name, max_val in gain_params:
168
+ try:
169
+ gain = int((volume / 100.0) * max_val)
170
+ respeaker.write(param_name, [gain])
171
+ logger.info(f"Microphone volume set to {volume}% ({param_name}: {gain})")
172
+ return # Success, stop trying other params
173
+ except Exception as e:
174
+ logger.debug(f"Could not write {param_name}: {e}")
175
+
176
+ logger.error("Failed to set microphone volume: no supported parameter found")
177
 
178
  # ========== Phase 2: Motor Control ==========
179