Desmond-Dong commited on
Commit
29b7fc2
·
1 Parent(s): ae47b88

v0.2.20: Revert audio/satellite/voice_assistant/models/main to v0.2.9 working state

Browse files

Reverted files to the last known working state before audio changes broke everything.
Keep entity_registry changes from v0.2.17.

pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
 
5
  [project]
6
  name = "reachy_mini_ha_voice"
7
- version = "0.2.19"
8
  description = "Home Assistant Voice Assistant for Reachy Mini"
9
  readme = "README.md"
10
  requires-python = ">=3.10"
 
4
 
5
  [project]
6
  name = "reachy_mini_ha_voice"
7
+ version = "0.2.20"
8
  description = "Home Assistant Voice Assistant for Reachy Mini"
9
  readme = "README.md"
10
  requires-python = ">=3.10"
reachy_mini_ha_voice/__init__.py CHANGED
@@ -11,7 +11,7 @@ Key features:
11
  - Reachy Mini motion control integration
12
  """
13
 
14
- __version__ = "0.2.19"
15
  __author__ = "Desmond Dong"
16
 
17
  # Don't import main module here to avoid runpy warning
 
11
  - Reachy Mini motion control integration
12
  """
13
 
14
+ __version__ = "0.2.20"
15
  __author__ = "Desmond Dong"
16
 
17
  # Don't import main module here to avoid runpy warning
reachy_mini_ha_voice/audio_player.py CHANGED
@@ -1,35 +1,20 @@
1
- """Audio player using Reachy Mini's media system.
2
-
3
- This module provides audio playback functionality similar to linux-voice-assistant's
4
- MpvMediaPlayer, but using Reachy Mini's GStreamer-based audio system.
5
-
6
- For local files: Uses play_sound() which creates an independent playbin pipeline.
7
- For URLs (TTS): Downloads to temp file, then uses play_sound().
8
-
9
- This approach avoids conflicts with the recording pipeline.
10
- """
11
 
12
  import logging
13
- import os
14
- import tempfile
15
  import threading
16
  import time
17
- import urllib.request
18
  from collections.abc import Callable
 
19
  from typing import List, Optional, Union
20
 
21
  import numpy as np
22
- import soundfile as sf
23
  import scipy.signal
24
 
25
  _LOGGER = logging.getLogger(__name__)
26
 
27
 
28
  class AudioPlayer:
29
- """Audio player using Reachy Mini's media system.
30
-
31
- Similar to linux-voice-assistant's MpvMediaPlayer but using GStreamer.
32
- """
33
 
34
  def __init__(self, reachy_mini=None) -> None:
35
  self.reachy_mini = reachy_mini
@@ -41,7 +26,6 @@ class AudioPlayer:
41
  self._unduck_volume: float = 1.0
42
  self._current_volume: float = 1.0
43
  self._stop_flag = threading.Event()
44
- self._playback_thread: Optional[threading.Thread] = None
45
 
46
  def set_reachy_mini(self, reachy_mini) -> None:
47
  """Set the Reachy Mini instance."""
@@ -53,13 +37,6 @@ class AudioPlayer:
53
  done_callback: Optional[Callable[[], None]] = None,
54
  stop_first: bool = True,
55
  ) -> None:
56
- """Play audio file(s) or URL(s).
57
-
58
- Args:
59
- url: Single URL/path or list of URLs/paths to play
60
- done_callback: Called when all playback is finished
61
- stop_first: Stop current playback before starting new
62
- """
63
  if stop_first:
64
  self.stop()
65
 
@@ -73,7 +50,6 @@ class AudioPlayer:
73
  self._play_next()
74
 
75
  def _play_next(self) -> None:
76
- """Play the next item in the playlist."""
77
  if not self._playlist or self._stop_flag.is_set():
78
  self._on_playback_finished()
79
  return
@@ -83,126 +59,78 @@ class AudioPlayer:
83
  self.is_playing = True
84
 
85
  # Start playback in a thread
86
- self._playback_thread = threading.Thread(
87
- target=self._play_file,
88
- args=(next_url,),
89
- daemon=True
90
- )
91
- self._playback_thread.start()
92
 
93
  def _play_file(self, file_path: str) -> None:
94
- """Play an audio file.
95
-
96
- For URLs: Download to temp file first.
97
- Then use push_audio_sample() to play through the GStreamer pipeline.
98
- """
99
- temp_file = None
100
  try:
101
  # Handle URLs - download first
102
  if file_path.startswith(("http://", "https://")):
103
- _LOGGER.debug("Downloading audio from %s", file_path)
104
- temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".wav")
105
- temp_file.close()
106
- urllib.request.urlretrieve(file_path, temp_file.name)
107
- file_path = temp_file.name
108
- _LOGGER.debug("Downloaded to %s", file_path)
109
 
110
- if self._stop_flag.is_set():
111
- return
 
112
 
113
- if not os.path.exists(file_path):
114
- _LOGGER.error("Audio file not found: %s", file_path)
115
  return
116
 
117
- # Play using Reachy Mini's audio system
118
  if self.reachy_mini is not None:
119
- self._play_via_push_audio(file_path)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  else:
121
- _LOGGER.warning("No reachy_mini instance, cannot play audio")
122
 
123
  except Exception as e:
124
  _LOGGER.error("Error playing audio: %s", e)
125
  finally:
126
- # Clean up temp file
127
- if temp_file is not None:
128
- try:
129
- os.unlink(temp_file.name)
130
- except Exception:
131
- pass
132
-
133
  self.is_playing = False
134
-
135
  # Play next in playlist or finish
136
  if self._playlist and not self._stop_flag.is_set():
137
  self._play_next()
138
  else:
139
  self._on_playback_finished()
140
 
141
- def _play_via_push_audio(self, file_path: str) -> None:
142
- """Play audio using push_audio_sample().
143
-
144
- This pushes audio data to the GStreamer playback pipeline.
145
- Recording and playback pipelines are separate in GStreamer,
146
- so they can run simultaneously (like in conversation_app).
147
- """
148
- # Read audio file
149
- data, input_samplerate = sf.read(file_path, dtype='float32')
150
-
151
- # Get output sample rate
152
- output_samplerate = self.reachy_mini.media.get_output_audio_samplerate()
153
-
154
- # Convert to mono if stereo
155
- if data.ndim == 2:
156
- data = data.mean(axis=1)
157
-
158
  # Apply volume
159
  data = data * self._current_volume
160
-
161
- # Resample if needed
162
- if input_samplerate != output_samplerate:
163
- num_samples = int(len(data) * output_samplerate / input_samplerate)
164
- data = scipy.signal.resample(data, num_samples)
165
-
166
- total_duration = len(data) / output_samplerate
167
- _LOGGER.debug("Playing %.2fs audio at %dHz", total_duration, output_samplerate)
168
-
169
- # Push audio in chunks (like conversation_app's play_loop)
170
- chunk_duration = 0.02 # 20ms chunks
171
- chunk_size = int(output_samplerate * chunk_duration)
172
-
173
- start_time = time.monotonic()
174
- samples_pushed = 0
175
-
176
- for i in range(0, len(data), chunk_size):
177
- if self._stop_flag.is_set():
178
- _LOGGER.debug("Playback stopped")
179
- return
180
-
181
- chunk = data[i:i + chunk_size].astype(np.float32)
182
- self.reachy_mini.media.push_audio_sample(chunk)
183
- samples_pushed += len(chunk)
184
-
185
- # Pace the pushing to avoid buffer overflow
186
- # Calculate how much time should have elapsed
187
- expected_time = samples_pushed / output_samplerate
188
- actual_time = time.monotonic() - start_time
189
- sleep_time = expected_time - actual_time - 0.01 # 10ms ahead
190
-
191
- if sleep_time > 0:
192
- time.sleep(sleep_time)
193
-
194
- # Wait for playback to complete
195
- remaining = total_duration - (time.monotonic() - start_time)
196
- if remaining > 0:
197
- time.sleep(remaining + 0.05) # Small buffer
198
-
199
- _LOGGER.debug("Audio playback complete")
200
 
201
  def _on_playback_finished(self) -> None:
202
- """Called when all playback is finished."""
203
  self.is_playing = False
204
-
205
  todo_callback: Optional[Callable[[], None]] = None
 
206
  with self._done_callback_lock:
207
  if self._done_callback:
208
  todo_callback = self._done_callback
@@ -215,41 +143,29 @@ class AudioPlayer:
215
  _LOGGER.exception("Unexpected error running done callback")
216
 
217
  def pause(self) -> None:
218
- """Pause playback."""
219
  self.is_playing = False
220
 
221
  def resume(self) -> None:
222
- """Resume playback."""
223
  if self._playlist:
224
  self._play_next()
225
 
226
  def stop(self) -> None:
227
- """Stop playback and clear playlist."""
228
  self._stop_flag.set()
229
-
230
- # Clear the playback buffer
231
  if self.reachy_mini is not None:
232
  try:
233
- if hasattr(self.reachy_mini.media, 'audio'):
234
- audio = self.reachy_mini.media.audio
235
- if hasattr(audio, 'clear_player'):
236
- audio.clear_player()
237
- except Exception as e:
238
- _LOGGER.debug("Error clearing player: %s", e)
239
-
240
  self._playlist.clear()
241
  self.is_playing = False
242
 
243
  def duck(self) -> None:
244
- """Lower volume for ducking."""
245
  self._current_volume = self._duck_volume
246
 
247
  def unduck(self) -> None:
248
- """Restore volume after ducking."""
249
  self._current_volume = self._unduck_volume
250
 
251
  def set_volume(self, volume: int) -> None:
252
- """Set volume (0-100)."""
253
  volume = max(0, min(100, volume))
254
  self._unduck_volume = volume / 100.0
255
  self._duck_volume = self._unduck_volume / 2
 
1
+ """Audio player using Reachy Mini's media system."""
 
 
 
 
 
 
 
 
 
2
 
3
  import logging
 
 
4
  import threading
5
  import time
 
6
  from collections.abc import Callable
7
+ from pathlib import Path
8
  from typing import List, Optional, Union
9
 
10
  import numpy as np
 
11
  import scipy.signal
12
 
13
  _LOGGER = logging.getLogger(__name__)
14
 
15
 
16
  class AudioPlayer:
17
+ """Audio player using Reachy Mini's media system."""
 
 
 
18
 
19
  def __init__(self, reachy_mini=None) -> None:
20
  self.reachy_mini = reachy_mini
 
26
  self._unduck_volume: float = 1.0
27
  self._current_volume: float = 1.0
28
  self._stop_flag = threading.Event()
 
29
 
30
  def set_reachy_mini(self, reachy_mini) -> None:
31
  """Set the Reachy Mini instance."""
 
37
  done_callback: Optional[Callable[[], None]] = None,
38
  stop_first: bool = True,
39
  ) -> None:
 
 
 
 
 
 
 
40
  if stop_first:
41
  self.stop()
42
 
 
50
  self._play_next()
51
 
52
  def _play_next(self) -> None:
 
53
  if not self._playlist or self._stop_flag.is_set():
54
  self._on_playback_finished()
55
  return
 
59
  self.is_playing = True
60
 
61
  # Start playback in a thread
62
+ thread = threading.Thread(target=self._play_file, args=(next_url,), daemon=True)
63
+ thread.start()
 
 
 
 
64
 
65
  def _play_file(self, file_path: str) -> None:
66
+ """Play an audio file."""
 
 
 
 
 
67
  try:
68
  # Handle URLs - download first
69
  if file_path.startswith(("http://", "https://")):
70
+ import urllib.request
71
+ import tempfile
 
 
 
 
72
 
73
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmp:
74
+ urllib.request.urlretrieve(file_path, tmp.name)
75
+ file_path = tmp.name
76
 
77
+ if self._stop_flag.is_set():
 
78
  return
79
 
80
+ # Use Reachy Mini's media system if available
81
  if self.reachy_mini is not None:
82
+ try:
83
+ # Use Reachy Mini's play_sound method
84
+ self.reachy_mini.media.play_sound(file_path)
85
+
86
+ # Estimate playback duration and wait
87
+ import soundfile as sf
88
+ data, samplerate = sf.read(file_path)
89
+ duration = len(data) / samplerate
90
+
91
+ # Wait for playback to complete (with stop check)
92
+ start_time = time.time()
93
+ while time.time() - start_time < duration:
94
+ if self._stop_flag.is_set():
95
+ self.reachy_mini.media.clear_output_buffer()
96
+ break
97
+ time.sleep(0.1)
98
+
99
+ except Exception as e:
100
+ _LOGGER.warning("Reachy Mini audio failed, falling back to sounddevice: %s", e)
101
+ self._play_file_fallback(file_path)
102
  else:
103
+ self._play_file_fallback(file_path)
104
 
105
  except Exception as e:
106
  _LOGGER.error("Error playing audio: %s", e)
107
  finally:
 
 
 
 
 
 
 
108
  self.is_playing = False
 
109
  # Play next in playlist or finish
110
  if self._playlist and not self._stop_flag.is_set():
111
  self._play_next()
112
  else:
113
  self._on_playback_finished()
114
 
115
+ def _play_file_fallback(self, file_path: str) -> None:
116
+ """Fallback to sounddevice for audio playback."""
117
+ import sounddevice as sd
118
+ import soundfile as sf
119
+
120
+ data, samplerate = sf.read(file_path)
121
+
 
 
 
 
 
 
 
 
 
 
122
  # Apply volume
123
  data = data * self._current_volume
124
+
125
+ if not self._stop_flag.is_set():
126
+ sd.play(data, samplerate)
127
+ sd.wait()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
 
129
  def _on_playback_finished(self) -> None:
130
+ """Called when playback is finished."""
131
  self.is_playing = False
 
132
  todo_callback: Optional[Callable[[], None]] = None
133
+
134
  with self._done_callback_lock:
135
  if self._done_callback:
136
  todo_callback = self._done_callback
 
143
  _LOGGER.exception("Unexpected error running done callback")
144
 
145
  def pause(self) -> None:
 
146
  self.is_playing = False
147
 
148
  def resume(self) -> None:
 
149
  if self._playlist:
150
  self._play_next()
151
 
152
  def stop(self) -> None:
 
153
  self._stop_flag.set()
 
 
154
  if self.reachy_mini is not None:
155
  try:
156
+ self.reachy_mini.media.clear_output_buffer()
157
+ except Exception:
158
+ pass
 
 
 
 
159
  self._playlist.clear()
160
  self.is_playing = False
161
 
162
  def duck(self) -> None:
 
163
  self._current_volume = self._duck_volume
164
 
165
  def unduck(self) -> None:
 
166
  self._current_volume = self._unduck_volume
167
 
168
  def set_volume(self, volume: int) -> None:
 
169
  volume = max(0, min(100, volume))
170
  self._unduck_volume = volume / 100.0
171
  self._duck_volume = self._unduck_volume / 2
reachy_mini_ha_voice/main.py CHANGED
@@ -62,20 +62,12 @@ class ReachyMiniHaVoice(ReachyMiniApp):
62
 
63
  # No custom web UI needed - configuration is automatic via Home Assistant
64
  custom_app_url: Optional[str] = None
65
-
66
- # Use GStreamer backend for wireless version (same as conversation_app)
67
- # This is required for proper audio playback via push_audio_sample()
68
- request_media_backend: str = "gstreamer"
69
 
70
  def __init__(self, *args, **kwargs):
71
  """Initialize the app."""
72
  super().__init__(*args, **kwargs)
73
  if not hasattr(self, 'stop_event'):
74
  self.stop_event = threading.Event()
75
-
76
- # Force localhost connection mode since this app runs on the robot
77
- # This prevents WebRTC connection attempts that can fail
78
- self.daemon_on_localhost = True
79
 
80
  def wrapped_run(self, *args, **kwargs) -> None:
81
  """
@@ -126,12 +118,9 @@ class ReachyMiniHaVoice(ReachyMiniApp):
126
  stop_event: Event to signal graceful shutdown
127
  """
128
  logger.info("Starting Home Assistant Voice Assistant...")
129
- logger.warning("run() called with reachy_mini=%s (type=%s)", reachy_mini, type(reachy_mini).__name__)
130
 
131
  # Create and run the voice assistant service
132
  service = VoiceAssistantService(reachy_mini)
133
- logger.warning("VoiceAssistantService created, motion._movement_manager=%s",
134
- service._motion._movement_manager if service._motion else None)
135
 
136
  # Always create a new event loop to avoid conflicts with SDK
137
  loop = asyncio.new_event_loop()
 
62
 
63
  # No custom web UI needed - configuration is automatic via Home Assistant
64
  custom_app_url: Optional[str] = None
 
 
 
 
65
 
66
  def __init__(self, *args, **kwargs):
67
  """Initialize the app."""
68
  super().__init__(*args, **kwargs)
69
  if not hasattr(self, 'stop_event'):
70
  self.stop_event = threading.Event()
 
 
 
 
71
 
72
  def wrapped_run(self, *args, **kwargs) -> None:
73
  """
 
118
  stop_event: Event to signal graceful shutdown
119
  """
120
  logger.info("Starting Home Assistant Voice Assistant...")
 
121
 
122
  # Create and run the voice assistant service
123
  service = VoiceAssistantService(reachy_mini)
 
 
124
 
125
  # Always create a new event loop to avoid conflicts with SDK
126
  loop = asyncio.new_event_loop()
reachy_mini_ha_voice/satellite.py CHANGED
@@ -328,7 +328,6 @@ class VoiceSatelliteProtocol(APIServer):
328
  )
329
  self.duck()
330
  self._is_streaming_audio = True
331
- # Play wakeup sound (like linux-voice-assistant does)
332
  self.state.tts_player.play(self.state.wakeup_sound)
333
 
334
  def stop(self) -> None:
 
328
  )
329
  self.duck()
330
  self._is_streaming_audio = True
 
331
  self.state.tts_player.play(self.state.wakeup_sound)
332
 
333
  def stop(self) -> None:
reachy_mini_ha_voice/voice_assistant.py CHANGED
@@ -134,19 +134,32 @@ class VoiceAssistantService:
134
  self._state.motion = self._motion
135
 
136
  # Start Reachy Mini media system if available
137
- # Reference: conversation_app/console.py launch() method
138
  if self.reachy_mini is not None:
139
  try:
 
140
  media = self.reachy_mini.media
141
  if media.audio is not None:
142
- # Start recording and playback pipelines
143
- media.start_recording()
144
- media.start_playing()
145
- _LOGGER.info("Reachy Mini media system started (recording + playback)")
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  else:
147
  _LOGGER.warning("Reachy Mini audio system not available")
148
  except Exception as e:
149
- _LOGGER.warning("Failed to start Reachy Mini media: %s", e)
150
 
151
  # Start motion controller (5Hz control loop)
152
  if self._motion is not None:
@@ -195,14 +208,13 @@ class VoiceAssistantService:
195
  """Stop the voice assistant service."""
196
  _LOGGER.info("Stopping voice assistant service...")
197
 
198
- # 1. Stop media recording first to prevent new audio data
199
- # Reference: conversation_app/console.py close() method
200
  if self.reachy_mini is not None:
201
  try:
202
  self.reachy_mini.media.stop_recording()
203
  _LOGGER.debug("Reachy Mini recording stopped")
204
  except Exception as e:
205
- _LOGGER.debug("Error stopping recording: %s", e)
206
 
207
  # 2. Set stop flag
208
  self._running = False
@@ -213,13 +225,13 @@ class VoiceAssistantService:
213
  if self._audio_thread.is_alive():
214
  _LOGGER.warning("Audio thread did not stop in time")
215
 
216
- # 4. Stop media playback
217
  if self.reachy_mini is not None:
218
  try:
219
  self.reachy_mini.media.stop_playing()
220
  _LOGGER.debug("Reachy Mini playback stopped")
221
  except Exception as e:
222
- _LOGGER.debug("Error stopping playback: %s", e)
223
 
224
  # 5. Stop ESPHome server
225
  if self._server:
@@ -533,8 +545,6 @@ class VoiceAssistantService:
533
 
534
  def _convert_to_pcm(self, audio_chunk_array: np.ndarray) -> bytes:
535
  """Convert float32 audio array to 16-bit PCM bytes."""
536
- # Replace NaN/Inf with 0 to avoid microwakeword cast warnings
537
- audio_chunk_array = np.nan_to_num(audio_chunk_array, nan=0.0, posinf=1.0, neginf=-1.0)
538
  return (
539
  (np.clip(audio_chunk_array, -1.0, 1.0) * 32767.0)
540
  .astype("<i2")
 
134
  self._state.motion = self._motion
135
 
136
  # Start Reachy Mini media system if available
 
137
  if self.reachy_mini is not None:
138
  try:
139
+ # Check if media system is already running to avoid conflicts
140
  media = self.reachy_mini.media
141
  if media.audio is not None:
142
+ # Check recording state
143
+ is_recording = getattr(media, '_recording', False)
144
+ if not is_recording:
145
+ media.start_recording()
146
+ _LOGGER.info("Started Reachy Mini recording")
147
+ else:
148
+ _LOGGER.debug("Reachy Mini recording already active")
149
+
150
+ # Check playback state
151
+ is_playing = getattr(media, '_playing', False)
152
+ if not is_playing:
153
+ media.start_playing()
154
+ _LOGGER.info("Started Reachy Mini playback")
155
+ else:
156
+ _LOGGER.debug("Reachy Mini playback already active")
157
+
158
+ _LOGGER.info("Reachy Mini media system initialized")
159
  else:
160
  _LOGGER.warning("Reachy Mini audio system not available")
161
  except Exception as e:
162
+ _LOGGER.warning("Failed to initialize Reachy Mini media: %s", e)
163
 
164
  # Start motion controller (5Hz control loop)
165
  if self._motion is not None:
 
208
  """Stop the voice assistant service."""
209
  _LOGGER.info("Stopping voice assistant service...")
210
 
211
+ # 1. First stop audio recording to prevent new data from coming in
 
212
  if self.reachy_mini is not None:
213
  try:
214
  self.reachy_mini.media.stop_recording()
215
  _LOGGER.debug("Reachy Mini recording stopped")
216
  except Exception as e:
217
+ _LOGGER.warning("Error stopping Reachy Mini recording: %s", e)
218
 
219
  # 2. Set stop flag
220
  self._running = False
 
225
  if self._audio_thread.is_alive():
226
  _LOGGER.warning("Audio thread did not stop in time")
227
 
228
+ # 4. Stop playback
229
  if self.reachy_mini is not None:
230
  try:
231
  self.reachy_mini.media.stop_playing()
232
  _LOGGER.debug("Reachy Mini playback stopped")
233
  except Exception as e:
234
+ _LOGGER.warning("Error stopping Reachy Mini playback: %s", e)
235
 
236
  # 5. Stop ESPHome server
237
  if self._server:
 
545
 
546
  def _convert_to_pcm(self, audio_chunk_array: np.ndarray) -> bytes:
547
  """Convert float32 audio array to 16-bit PCM bytes."""
 
 
548
  return (
549
  (np.clip(audio_chunk_array, -1.0, 1.0) * 32767.0)
550
  .astype("<i2")