Desmond-Dong commited on
Commit
380eed8
·
1 Parent(s): 878ce1b

Add comprehensive error handling and logging

Browse files

- Add try-catch blocks in all major functions
- Add detailed logging for startup sequence
- Log thread creation and errors
- Log wake word loading details
- Log audio processing errors

Files changed (1) hide show
  1. src/reachy_mini_ha_voice/app.py +197 -130
src/reachy_mini_ha_voice/app.py CHANGED
@@ -39,16 +39,26 @@ class ReachyMiniHAVoiceApp(ReachyMiniApp):
39
 
40
  def run(self, reachy_mini: ReachyMini, stop_event: threading.Event) -> None:
41
  """Run the Reachy Mini voice assistant app."""
42
- loop = asyncio.new_event_loop()
43
- asyncio.set_event_loop(loop)
 
44
 
45
- instance_path = self._get_instance_path().parent
46
- _run(
47
- robot=reachy_mini,
48
- app_stop_event=stop_event,
49
- settings_app=self.settings_app,
50
- instance_path=instance_path,
51
- )
 
 
 
 
 
 
 
 
 
52
 
53
 
54
  def _run(
@@ -58,38 +68,52 @@ def _run(
58
  instance_path: Optional[str] = None,
59
  ) -> None:
60
  """Run the voice assistant."""
61
- _LOGGER.info("Starting Reachy Mini Home Assistant Voice Assistant")
62
-
63
- # Initialize server state
64
- state = _init_state(robot)
65
 
66
- # Start audio processing thread
67
- audio_thread = threading.Thread(
68
- target=_process_audio,
69
- args=(state,),
70
- daemon=True,
71
- )
72
- audio_thread.start()
73
-
74
- # Start ESPHome server in background thread
75
- server_thread = threading.Thread(
76
- target=_run_server,
77
- args=(state, app_stop_event),
78
- daemon=True,
79
- )
80
- server_thread.start()
 
 
 
 
 
 
 
 
 
 
 
 
81
 
82
- # Main loop - wait for stop event
83
- while not app_stop_event.is_set():
84
- time.sleep(0.1)
 
85
 
86
- _LOGGER.info("Shutting down voice assistant")
 
 
 
87
 
88
 
89
  def _init_state(robot: ReachyMini) -> ServerState:
90
  """Initialize server state."""
91
- # Load wake words
92
  available_wake_words = _load_wake_words()
 
93
 
94
  # Load active wake words
95
  active_wake_words = set()
@@ -97,6 +121,7 @@ def _init_state(robot: ReachyMini) -> ServerState:
97
 
98
  # Use default wake word
99
  default_wake_word = "okay_nabu"
 
100
  if default_wake_word in available_wake_words:
101
  try:
102
  wake_word = available_wake_words[default_wake_word]
@@ -105,10 +130,15 @@ def _init_state(robot: ReachyMini) -> ServerState:
105
  _LOGGER.info("Loaded wake word: %s", default_wake_word)
106
  except Exception as e:
107
  _LOGGER.error("Failed to load wake word %s: %s", default_wake_word, e)
 
 
108
 
109
  # Load stop model
 
110
  stop_model = _load_stop_model()
 
111
 
 
112
  return ServerState(
113
  name="ReachyMini",
114
  mac_address=get_mac(),
@@ -135,10 +165,15 @@ def _load_wake_words() -> Dict[str, AvailableWakeWord]:
135
  """Load available wake words."""
136
  available_wake_words: Dict[str, AvailableWakeWord] = {}
137
 
 
 
138
  for wake_word_dir in [_WAKEWORDS_DIR]:
139
  if not wake_word_dir.exists():
 
140
  continue
141
 
 
 
142
  for model_config_path in wake_word_dir.glob("*.json"):
143
  model_id = model_config_path.stem
144
  if model_id == "stop":
@@ -162,148 +197,180 @@ def _load_wake_words() -> Dict[str, AvailableWakeWord]:
162
  trained_languages=model_config.get("trained_languages", []),
163
  wake_word_path=wake_word_path,
164
  )
 
165
  except Exception as e:
166
  _LOGGER.error("Error loading wake word config %s: %s", model_config_path, e)
167
 
168
- return available_wake_words
 
169
 
170
 
171
  def _load_stop_model() -> Optional[MicroWakeWord]:
172
  """Load stop word model."""
173
  stop_config_path = _WAKEWORDS_DIR / "stop.json"
 
 
174
  if not stop_config_path.exists():
 
175
  return None
176
 
177
  try:
178
- return MicroWakeWord.from_config(stop_config_path)
 
 
179
  except Exception as e:
180
- _LOGGER.error("Failed to load stop model: %s", e)
181
  return None
182
 
183
 
184
  def _run_server(state: ServerState, stop_event: threading.Event):
185
  """Run ESPHome server in a separate thread."""
 
 
186
  async def server_loop():
 
187
  loop = asyncio.get_running_loop()
188
-
 
189
  server = await loop.create_server(
190
  lambda: VoiceSatelliteProtocol(state), host="0.0.0.0", port=6053
191
  )
 
192
 
193
  # Auto discovery (zeroconf, mDNS)
 
194
  discovery = HomeAssistantZeroconf(port=6053, name="ReachyMini")
195
  await discovery.register_server()
 
196
 
197
  try:
198
  async with server:
199
- _LOGGER.info("ESPHome server started on port 6053")
200
- _LOGGER.info("mDNS service registered for auto-discovery")
201
 
202
  while not stop_event.is_set():
203
  await asyncio.sleep(0.1)
 
 
204
  finally:
 
205
  await discovery.unregister_server()
206
- _LOGGER.info("ESPHome server stopped")
207
 
208
- asyncio.run(server_loop())
 
 
 
209
 
210
 
211
  def _process_audio(state: ServerState):
212
  """Process audio from microphone."""
213
- # Start media
214
- state.music_player._robot.media.start_recording()
215
- state.music_player._robot.media.start_playing()
216
- time.sleep(1)
 
 
 
 
 
 
217
 
218
- wake_words: List[Union[MicroWakeWord, OpenWakeWord]] = []
219
- micro_features: Optional[MicroWakeWordFeatures] = None
220
- micro_inputs: List[np.ndarray] = []
221
 
222
- oww_features: Optional[OpenWakeWordFeatures] = None
223
- oww_inputs: List[np.ndarray] = []
224
- has_oww = False
225
 
226
- last_active: Optional[float] = None
227
 
228
- _LOGGER.info("Audio processing started")
229
 
230
- while True:
231
- # Get audio sample from Reachy Mini
232
- audio_frame = state.music_player._robot.media.get_audio_sample()
233
-
234
- if audio_frame is not None:
235
- # Send to satellite if connected
236
- if state.satellite is not None:
237
- # Convert to bytes for satellite
238
- audio_bytes = (audio_frame * 32767.0).astype(np.int16).tobytes()
239
- state.satellite.handle_audio(audio_bytes)
240
-
241
- # Update wake word models
242
- if (not wake_words) or (state.wake_words_changed and state.wake_words):
243
- state.wake_words_changed = False
244
- wake_words = [
245
- ww
246
- for ww in state.wake_words.values()
247
- if ww.id in state.active_wake_words
248
- ]
249
-
250
- has_oww = False
251
- for wake_word in wake_words:
252
- if isinstance(wake_word, OpenWakeWord):
253
- has_oww = True
254
-
255
- if micro_features is None:
256
- micro_features = MicroWakeWordFeatures()
257
-
258
- if has_oww and (oww_features is None):
259
- oww_features = OpenWakeWordFeatures.from_builtin()
260
-
261
- # Process wake words
262
- if wake_words:
263
- assert micro_features is not None
264
- micro_inputs.clear()
265
- # Convert float32 audio to int16 for microWakeWord
266
- audio_int16 = (audio_frame * 32767.0).astype(np.int16)
267
- micro_inputs.extend(micro_features.process_streaming(audio_int16))
268
-
269
- if has_oww:
270
- assert oww_features is not None
271
- oww_inputs.clear()
272
- oww_inputs.extend(oww_features.process_streaming(audio_frame))
273
-
274
- for wake_word in wake_words:
275
- activated = False
276
- if isinstance(wake_word, MicroWakeWord):
277
- for micro_input in micro_inputs:
278
- if wake_word.process_streaming(micro_input):
279
- activated = True
280
- elif isinstance(wake_word, OpenWakeWord):
281
- for oww_input in oww_inputs:
282
- for prob in wake_word.process_streaming(oww_input):
283
- if prob > 0.5:
284
- activated = True
285
-
286
- if activated:
287
- now = time.monotonic()
288
- if (last_active is None) or (
289
- (now - last_active) > state.refractory_seconds
290
- ):
291
- if state.satellite:
292
- state.satellite.wakeup(wake_word)
293
- last_active = now
294
-
295
- # Process stop word
296
- if state.stop_word is not None:
297
- stopped = False
298
- for micro_input in micro_inputs:
299
- if state.stop_word.process_streaming(micro_input):
300
- stopped = True
301
-
302
- if stopped and (state.stop_word.id in state.active_wake_words):
303
- if state.satellite:
304
- state.satellite.stop()
305
-
306
- time.sleep(0.001)
 
 
 
 
 
 
307
 
308
 
309
  class ReachyMiniAudioPlayer:
 
39
 
40
  def run(self, reachy_mini: ReachyMini, stop_event: threading.Event) -> None:
41
  """Run the Reachy Mini voice assistant app."""
42
+ _LOGGER.info("Reachy Mini HA Voice App: Starting...")
43
+ _LOGGER.info(f"Reachy Mini connected: {reachy_mini is not None}")
44
+ _LOGGER.info(f"Settings app: {self.settings_app is not None}")
45
 
46
+ try:
47
+ loop = asyncio.new_event_loop()
48
+ asyncio.set_event_loop(loop)
49
+
50
+ instance_path = self._get_instance_path().parent
51
+ _LOGGER.info(f"Instance path: {instance_path}")
52
+
53
+ _run(
54
+ robot=reachy_mini,
55
+ app_stop_event=stop_event,
56
+ settings_app=self.settings_app,
57
+ instance_path=instance_path,
58
+ )
59
+ except Exception as e:
60
+ _LOGGER.error(f"Error in run(): {e}", exc_info=True)
61
+ raise
62
 
63
 
64
  def _run(
 
68
  instance_path: Optional[str] = None,
69
  ) -> None:
70
  """Run the voice assistant."""
71
+ _LOGGER.info("=== Starting Reachy Mini Home Assistant Voice Assistant ===")
 
 
 
72
 
73
+ try:
74
+ # Initialize server state
75
+ _LOGGER.info("Initializing server state...")
76
+ state = _init_state(robot)
77
+ _LOGGER.info(f"Server state initialized: {state.name}")
78
+
79
+ # Start audio processing thread
80
+ _LOGGER.info("Starting audio processing thread...")
81
+ audio_thread = threading.Thread(
82
+ target=_process_audio,
83
+ args=(state,),
84
+ daemon=True,
85
+ name="AudioProcessor",
86
+ )
87
+ audio_thread.start()
88
+ _LOGGER.info("Audio processing thread started")
89
+
90
+ # Start ESPHome server in background thread
91
+ _LOGGER.info("Starting ESPHome server thread...")
92
+ server_thread = threading.Thread(
93
+ target=_run_server,
94
+ args=(state, app_stop_event),
95
+ daemon=True,
96
+ name="ESPServer",
97
+ )
98
+ server_thread.start()
99
+ _LOGGER.info("ESPHome server thread started")
100
 
101
+ # Main loop - wait for stop event
102
+ _LOGGER.info("Entering main loop...")
103
+ while not app_stop_event.is_set():
104
+ time.sleep(0.1)
105
 
106
+ _LOGGER.info("=== Shutting down voice assistant ===")
107
+ except Exception as e:
108
+ _LOGGER.error(f"Error in _run(): {e}", exc_info=True)
109
+ raise
110
 
111
 
112
  def _init_state(robot: ReachyMini) -> ServerState:
113
  """Initialize server state."""
114
+ _LOGGER.info("Loading wake words...")
115
  available_wake_words = _load_wake_words()
116
+ _LOGGER.info(f"Found {len(available_wake_words)} available wake words")
117
 
118
  # Load active wake words
119
  active_wake_words = set()
 
121
 
122
  # Use default wake word
123
  default_wake_word = "okay_nabu"
124
+ _LOGGER.info(f"Loading default wake word: {default_wake_word}")
125
  if default_wake_word in available_wake_words:
126
  try:
127
  wake_word = available_wake_words[default_wake_word]
 
130
  _LOGGER.info("Loaded wake word: %s", default_wake_word)
131
  except Exception as e:
132
  _LOGGER.error("Failed to load wake word %s: %s", default_wake_word, e)
133
+ else:
134
+ _LOGGER.warning(f"Wake word {default_wake_word} not found in available wake words")
135
 
136
  # Load stop model
137
+ _LOGGER.info("Loading stop model...")
138
  stop_model = _load_stop_model()
139
+ _LOGGER.info(f"Stop model loaded: {stop_model is not None}")
140
 
141
+ _LOGGER.info("Creating ServerState...")
142
  return ServerState(
143
  name="ReachyMini",
144
  mac_address=get_mac(),
 
165
  """Load available wake words."""
166
  available_wake_words: Dict[str, AvailableWakeWord] = {}
167
 
168
+ _LOGGER.info(f"Loading wake words from: {_WAKEWORDS_DIR}")
169
+
170
  for wake_word_dir in [_WAKEWORDS_DIR]:
171
  if not wake_word_dir.exists():
172
+ _LOGGER.warning(f"Wake word directory not found: {wake_word_dir}")
173
  continue
174
 
175
+ _LOGGER.info(f"Scanning wake word directory: {wake_word_dir}")
176
+
177
  for model_config_path in wake_word_dir.glob("*.json"):
178
  model_id = model_config_path.stem
179
  if model_id == "stop":
 
197
  trained_languages=model_config.get("trained_languages", []),
198
  wake_word_path=wake_word_path,
199
  )
200
+ _LOGGER.debug(f"Loaded wake word config: {model_id}")
201
  except Exception as e:
202
  _LOGGER.error("Error loading wake word config %s: %s", model_config_path, e)
203
 
204
+ _LOGGER.info(f"Loaded {len(available_wake_words)} wake word configurations")
205
+ return available_wake_words
206
 
207
 
208
  def _load_stop_model() -> Optional[MicroWakeWord]:
209
  """Load stop word model."""
210
  stop_config_path = _WAKEWORDS_DIR / "stop.json"
211
+ _LOGGER.info(f"Loading stop model from: {stop_config_path}")
212
+
213
  if not stop_config_path.exists():
214
+ _LOGGER.warning(f"Stop model config not found: {stop_config_path}")
215
  return None
216
 
217
  try:
218
+ model = MicroWakeWord.from_config(stop_config_path)
219
+ _LOGGER.info("Stop model loaded successfully")
220
+ return model
221
  except Exception as e:
222
+ _LOGGER.error("Failed to load stop model: %s", e, exc_info=True)
223
  return None
224
 
225
 
226
  def _run_server(state: ServerState, stop_event: threading.Event):
227
  """Run ESPHome server in a separate thread."""
228
+ _LOGGER.info("ESPHome server thread: Starting...")
229
+
230
  async def server_loop():
231
+ _LOGGER.info("ESPHome server: Creating event loop...")
232
  loop = asyncio.get_running_loop()
233
+
234
+ _LOGGER.info("ESPHome server: Creating server on port 6053...")
235
  server = await loop.create_server(
236
  lambda: VoiceSatelliteProtocol(state), host="0.0.0.0", port=6053
237
  )
238
+ _LOGGER.info("ESPHome server: Server created successfully")
239
 
240
  # Auto discovery (zeroconf, mDNS)
241
+ _LOGGER.info("ESPHome server: Registering mDNS service...")
242
  discovery = HomeAssistantZeroconf(port=6053, name="ReachyMini")
243
  await discovery.register_server()
244
+ _LOGGER.info("ESPHome server: mDNS service registered")
245
 
246
  try:
247
  async with server:
248
+ _LOGGER.info("ESPHome server: Server started on port 6053")
249
+ _LOGGER.info("ESPHome server: mDNS service registered for auto-discovery")
250
 
251
  while not stop_event.is_set():
252
  await asyncio.sleep(0.1)
253
+ except Exception as e:
254
+ _LOGGER.error(f"ESPHome server: Error in server loop: {e}", exc_info=True)
255
  finally:
256
+ _LOGGER.info("ESPHome server: Unregistering mDNS service...")
257
  await discovery.unregister_server()
258
+ _LOGGER.info("ESPHome server: Stopped")
259
 
260
+ try:
261
+ asyncio.run(server_loop())
262
+ except Exception as e:
263
+ _LOGGER.error(f"ESPHome server thread: Fatal error: {e}", exc_info=True)
264
 
265
 
266
  def _process_audio(state: ServerState):
267
  """Process audio from microphone."""
268
+ _LOGGER.info("Audio processor thread: Starting...")
269
+
270
+ try:
271
+ # Start media
272
+ _LOGGER.info("Audio processor: Starting media recording...")
273
+ state.music_player._robot.media.start_recording()
274
+ _LOGGER.info("Audio processor: Starting media playback...")
275
+ state.music_player._robot.media.start_playing()
276
+ _LOGGER.info("Audio processor: Media started, waiting 1 second...")
277
+ time.sleep(1)
278
 
279
+ wake_words: List[Union[MicroWakeWord, OpenWakeWord]] = []
280
+ micro_features: Optional[MicroWakeWordFeatures] = None
281
+ micro_inputs: List[np.ndarray] = []
282
 
283
+ oww_features: Optional[OpenWakeWordFeatures] = None
284
+ oww_inputs: List[np.ndarray] = []
285
+ has_oww = False
286
 
287
+ last_active: Optional[float] = None
288
 
289
+ _LOGGER.info("Audio processor: Audio processing loop started")
290
 
291
+ while True:
292
+ try:
293
+ # Get audio sample from Reachy Mini
294
+ audio_frame = state.music_player._robot.media.get_audio_sample()
295
+
296
+ if audio_frame is not None:
297
+ # Send to satellite if connected
298
+ if state.satellite is not None:
299
+ # Convert to bytes for satellite
300
+ audio_bytes = (audio_frame * 32767.0).astype(np.int16).tobytes()
301
+ state.satellite.handle_audio(audio_bytes)
302
+
303
+ # Update wake word models
304
+ if (not wake_words) or (state.wake_words_changed and state.wake_words):
305
+ state.wake_words_changed = False
306
+ wake_words = [
307
+ ww
308
+ for ww in state.wake_words.values()
309
+ if ww.id in state.active_wake_words
310
+ ]
311
+
312
+ has_oww = False
313
+ for wake_word in wake_words:
314
+ if isinstance(wake_word, OpenWakeWord):
315
+ has_oww = True
316
+
317
+ if micro_features is None:
318
+ micro_features = MicroWakeWordFeatures()
319
+
320
+ if has_oww and (oww_features is None):
321
+ oww_features = OpenWakeWordFeatures.from_builtin()
322
+
323
+ # Process wake words
324
+ if wake_words:
325
+ assert micro_features is not None
326
+ micro_inputs.clear()
327
+ # Convert float32 audio to int16 for microWakeWord
328
+ audio_int16 = (audio_frame * 32767.0).astype(np.int16)
329
+ micro_inputs.extend(micro_features.process_streaming(audio_int16))
330
+
331
+ if has_oww:
332
+ assert oww_features is not None
333
+ oww_inputs.clear()
334
+ oww_inputs.extend(oww_features.process_streaming(audio_frame))
335
+
336
+ for wake_word in wake_words:
337
+ activated = False
338
+ if isinstance(wake_word, MicroWakeWord):
339
+ for micro_input in micro_inputs:
340
+ if wake_word.process_streaming(micro_input):
341
+ activated = True
342
+ elif isinstance(wake_word, OpenWakeWord):
343
+ for oww_input in oww_inputs:
344
+ for prob in wake_word.process_streaming(oww_input):
345
+ if prob > 0.5:
346
+ activated = True
347
+
348
+ if activated:
349
+ now = time.monotonic()
350
+ if (last_active is None) or (
351
+ (now - last_active) > state.refractory_seconds
352
+ ):
353
+ if state.satellite:
354
+ state.satellite.wakeup(wake_word)
355
+ last_active = now
356
+
357
+ # Process stop word
358
+ if state.stop_word is not None:
359
+ stopped = False
360
+ for micro_input in micro_inputs:
361
+ if state.stop_word.process_streaming(micro_input):
362
+ stopped = True
363
+
364
+ if stopped and (state.stop_word.id in state.active_wake_words):
365
+ if state.satellite:
366
+ state.satellite.stop()
367
+
368
+ time.sleep(0.001)
369
+ except Exception as e:
370
+ _LOGGER.error(f"Audio processor: Error in processing loop: {e}", exc_info=True)
371
+ time.sleep(0.1)
372
+ except Exception as e:
373
+ _LOGGER.error(f"Audio processor thread: Fatal error: {e}", exc_info=True)
374
 
375
 
376
  class ReachyMiniAudioPlayer: