Desmond-Dong commited on
Commit
bc98bd5
·
1 Parent(s): 6b00c9d

"cleanup-old-modules"

Browse files
src/reachy_mini_ha_voice/app.py DELETED
@@ -1,282 +0,0 @@
1
- """
2
- Main application for Reachy Mini Home Assistant Voice Assistant
3
- """
4
-
5
- import asyncio
6
- import logging
7
- import threading
8
- from typing import Optional
9
-
10
- from reachy_mini import ReachyMini, ReachyMiniApp
11
-
12
- from .config.manager import ConfigManager
13
- from .audio.adapter import MicrophoneArray, Speaker
14
- from .audio.processor import AudioProcessor
15
- from .voice.detector import WakeWordDetector, load_wake_word_detector
16
- from .motion.controller import MotionController, ReachyMiniMotionController, MockMotionController
17
- from .motion.queue import MotionQueue, MotionPriority
18
- from .esphome.server import ESPHomeServer, VoiceSatelliteProtocol
19
-
20
- logger = logging.getLogger(__name__)
21
-
22
-
23
- class ServerState:
24
- """Global server state"""
25
-
26
- def __init__(self, name: str):
27
- self.name = name
28
- self.config = None
29
- self.microphone = None
30
- self.speaker = None
31
- self.audio_processor = None
32
- self.wake_word_detector = None
33
- self.motion_controller = None
34
- self.motion_queue = None
35
- self.esphome_server = None
36
- self.voice_satellite = None
37
- self._is_running = False
38
-
39
- async def initialize(self, config: ConfigManager):
40
- """Initialize all components"""
41
- self.config = config
42
-
43
- # Initialize audio
44
- self.microphone = MicrophoneArray(
45
- sample_rate=config.get("audio.sample_rate", 16000),
46
- channels=config.get("audio.channels", 1)
47
- )
48
- self.speaker = Speaker(
49
- sample_rate=config.get("audio.sample_rate", 16000)
50
- )
51
-
52
- # Initialize audio processor
53
- self.audio_processor = AudioProcessor(
54
- sample_rate=config.get("audio.sample_rate", 16000),
55
- channels=config.get("audio.channels", 1),
56
- block_size=config.get("audio.block_size", 1024)
57
- )
58
-
59
- # Initialize wake word detector
60
- wake_word_model = config.get("voice.wake_word", "okay_nabu")
61
- self.wake_word_detector = await load_wake_word_detector(
62
- f"wakewords/{wake_word_model}.tflite",
63
- detector_type="micro"
64
- )
65
-
66
- # Initialize motion controller
67
- robot_host = config.get("robot.host", "localhost")
68
- if robot_host == "mock":
69
- self.motion_controller = MockMotionController()
70
- else:
71
- self.motion_controller = ReachyMiniMotionController()
72
-
73
- await self.motion_controller.connect(robot_host)
74
- await self.motion_controller.wake_up()
75
-
76
- # Initialize motion queue
77
- self.motion_queue = MotionQueue()
78
- await self.motion_queue.start()
79
-
80
- # Initialize ESPHome server
81
- esphome_host = config.get("esphome.host", "0.0.0.0")
82
- esphome_port = config.get("esphome.port", 6053)
83
- self.esphome_server = ESPHomeServer(esphome_host, esphome_port)
84
-
85
- # Initialize voice satellite protocol
86
- self.voice_satellite = VoiceSatelliteProtocol(self)
87
-
88
- logger.info("Server state initialized")
89
-
90
- async def cleanup(self):
91
- """Cleanup all components"""
92
- if self.microphone:
93
- await self.microphone.stop_recording()
94
-
95
- if self.motion_controller:
96
- await self.motion_controller.stop_speech_reactive_motion()
97
- await self.motion_controller.turn_off()
98
- await self.motion_controller.disconnect()
99
-
100
- if self.motion_queue:
101
- await self.motion_queue.stop()
102
-
103
- if self.esphome_server:
104
- await self.esphome_server.stop()
105
-
106
- logger.info("Server state cleaned up")
107
-
108
-
109
- class ReachyMiniVoiceApp(ReachyMiniApp):
110
- """Main application class for Reachy Mini Home Assistant Voice Assistant"""
111
-
112
- custom_app_url: Optional[str] = None # Optional custom UI URL
113
-
114
- def run(self, reachy_mini: ReachyMini, stop_event: threading.Event):
115
- """
116
- Main entry point for the application
117
-
118
- Args:
119
- reachy_mini: Reachy Mini instance (already initialized and connected)
120
- stop_event: Threading event to signal graceful shutdown
121
- """
122
- logger.info("Starting Reachy Mini Home Assistant Voice Assistant")
123
-
124
- # Initialize configuration
125
- config = ConfigManager("config.json")
126
-
127
- # Create event loop for async operations
128
- loop = asyncio.new_event_loop()
129
- asyncio.set_event_loop(loop)
130
-
131
- # Create application instance
132
- app = ReachyMiniVoiceApp(
133
- name="Reachy Mini",
134
- config=config,
135
- robot_host="localhost",
136
- wireless=False
137
- )
138
-
139
- # Initialize state
140
- try:
141
- loop.run_until_complete(app.state.initialize(config))
142
-
143
- # Setup callbacks
144
- app._setup_callbacks()
145
-
146
- # Start audio recording
147
- loop.run_until_complete(app.state.microphone.start_recording(
148
- None,
149
- app._audio_callback,
150
- sample_rate=16000,
151
- channels=1,
152
- block_size=1024
153
- ))
154
-
155
- # Start ESPHome server
156
- loop.run_until_complete(app.state.esphome_server.start())
157
-
158
- # Register mDNS discovery
159
- loop.run_until_complete(app._register_mdns())
160
-
161
- logger.info("Application started successfully")
162
-
163
- # Main loop - check stop_event periodically
164
- while not stop_event.is_set():
165
- try:
166
- loop.run_until_complete(asyncio.sleep(1))
167
- except Exception as e:
168
- logger.error(f"Error in main loop: {e}", exc_info=True)
169
- break
170
-
171
- except Exception as e:
172
- logger.error(f"Error starting application: {e}", exc_info=True)
173
- finally:
174
- # Cleanup
175
- logger.info("Shutting down application...")
176
- loop.run_until_complete(app.state.cleanup())
177
- loop.close()
178
- logger.info("Application stopped")
179
-
180
- def _setup_callbacks(self):
181
- """Setup callbacks for audio processing"""
182
- # Add wake word callback
183
- self.state.audio_processor.add_wake_word_callback(self._on_audio_chunk)
184
-
185
- # Add stream callback
186
- self.state.audio_processor.add_stream_callback(self._on_stream_audio)
187
-
188
- async def _audio_callback(self, audio_chunk: bytes):
189
- """Callback for audio recording"""
190
- # Process audio chunk
191
- await self.state.audio_processor.process_audio_chunk(audio_chunk)
192
-
193
- async def _on_audio_chunk(self, audio_chunk: bytes):
194
- """Handle audio chunk for wake word detection"""
195
- if self.state.wake_word_detector:
196
- detected = await self.state.wake_word_detector.process_audio(audio_chunk)
197
- if detected:
198
- await self._on_wake_word_detected()
199
-
200
- async def _on_stream_audio(self, audio_chunk: bytes):
201
- """Handle audio chunk for streaming to Home Assistant"""
202
- if self.state.voice_satellite:
203
- await self.state.voice_satellite.handle_audio(audio_chunk)
204
-
205
- async def _on_wake_word_detected(self):
206
- """Handle wake word detection"""
207
- logger.info("Wake word detected!")
208
-
209
- # Nod to acknowledge
210
- if self.state.motion_controller:
211
- await self.state.motion_controller.nod(count=1, duration=0.3)
212
-
213
- # Trigger voice satellite
214
- if self.state.voice_satellite:
215
- await self.state.voice_satellite.handle_wake_word()
216
-
217
- async def _register_mdns(self):
218
- """Register mDNS service discovery"""
219
- try:
220
- from zeroconf import ServiceInfo, Zeroconf
221
-
222
- info = ServiceInfo(
223
- "_esphomelib._tcp.local.",
224
- f"{self.state.name}._esphomelib._tcp.local.",
225
- addresses=[],
226
- port=6053,
227
- properties={
228
- "version": "1.0",
229
- "name": self.state.name,
230
- "platform": "reachy_mini"
231
- }
232
- )
233
-
234
- zeroconf = Zeroconf()
235
- zeroconf.register_service(info)
236
-
237
- logger.info(f"Registered mDNS service: {self.state.name}")
238
- except ImportError:
239
- logger.warning("zeroconf not installed, mDNS discovery not available")
240
- except Exception as e:
241
- logger.error(f"Failed to register mDNS service: {e}")
242
-
243
- async def handle_tts_audio(self, audio_data: bytes):
244
- """Handle TTS audio from Home Assistant"""
245
- logger.info("Received TTS audio from Home Assistant")
246
-
247
- # Play audio
248
- if self.state.speaker:
249
- await self.state.speaker.play_audio(
250
- audio_data,
251
- None,
252
- sample_rate=16000,
253
- channels=1
254
- )
255
-
256
- async def handle_stt_result(self, text: str):
257
- """Handle STT result from Home Assistant"""
258
- logger.info(f"Received STT result: {text}")
259
-
260
- # Process text (add custom logic here)
261
- if "你好" in text or "hello" in text.lower():
262
- await self._say_hello()
263
- elif "跳舞" in text or "dance" in text.lower():
264
- await self._dance()
265
-
266
- async def _say_hello(self):
267
- """Say hello with motion"""
268
- if self.state.motion_controller:
269
- # Nod
270
- await self.state.motion_controller.nod(count=2, duration=0.3)
271
- # Look up
272
- import numpy as np
273
- from scipy.spatial.transform import Rotation as R
274
- pose = np.eye(4)
275
- pose[:3, :3] = R.from_euler('xyz', [15, 0, 0], degrees=True).as_matrix()
276
- await self.state.motion_controller.move_head(pose, duration=0.5)
277
-
278
- async def _dance(self):
279
- """Perform a dance"""
280
- if self.state.motion_controller:
281
- # Simple dance: shake head
282
- await self.state.motion_controller.shake(count=3, duration=0.4)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/reachy_mini_ha_voice/audio/__init__.py DELETED
@@ -1,8 +0,0 @@
1
- """
2
- Audio processing module for Reachy Mini Voice Assistant
3
- """
4
-
5
- from .adapter import AudioAdapter, MicrophoneArray, Speaker
6
- from .processor import AudioProcessor
7
-
8
- __all__ = ["AudioAdapter", "MicrophoneArray", "Speaker", "AudioProcessor"]
 
 
 
 
 
 
 
 
 
src/reachy_mini_ha_voice/audio/adapter.py DELETED
@@ -1,260 +0,0 @@
1
- """
2
- Audio device adapter for Reachy Mini Voice Assistant
3
- """
4
-
5
- import asyncio
6
- import logging
7
- from abc import ABC, abstractmethod
8
- from dataclasses import dataclass
9
- from typing import Callable, List, Optional
10
- import sounddevice as sd
11
- import numpy as np
12
-
13
- logger = logging.getLogger(__name__)
14
-
15
-
16
- @dataclass
17
- class AudioDevice:
18
- """Audio device information"""
19
- index: int
20
- name: str
21
- sample_rate: int
22
- channels: int
23
- is_input: bool
24
- is_output: bool
25
-
26
-
27
- class AudioAdapter(ABC):
28
- """Abstract base class for audio device adapter"""
29
-
30
- @abstractmethod
31
- async def list_input_devices(self) -> List[AudioDevice]:
32
- """List available audio input devices"""
33
- pass
34
-
35
- @abstractmethod
36
- async def list_output_devices(self) -> List[AudioDevice]:
37
- """List available audio output devices"""
38
- pass
39
-
40
- @abstractmethod
41
- async def start_recording(
42
- self,
43
- device_id: Optional[str],
44
- callback: Callable[[bytes], None],
45
- sample_rate: int = 16000,
46
- channels: int = 1,
47
- block_size: int = 1024
48
- ):
49
- """Start recording audio"""
50
- pass
51
-
52
- @abstractmethod
53
- async def stop_recording(self):
54
- """Stop recording audio"""
55
- pass
56
-
57
- @abstractmethod
58
- async def play_audio(
59
- self,
60
- audio_data: bytes,
61
- device_id: Optional[str],
62
- sample_rate: int = 16000,
63
- channels: int = 1
64
- ):
65
- """Play audio"""
66
- pass
67
-
68
-
69
- class MicrophoneArray(AudioAdapter):
70
- """Microphone array adapter for Reachy Mini"""
71
-
72
- def __init__(self, sample_rate: int = 16000, channels: int = 1):
73
- self.sample_rate = sample_rate
74
- self.channels = channels
75
- self._stream = None
76
- self._is_recording = False
77
- self._callback = None
78
- self._loop = None
79
-
80
- async def list_input_devices(self) -> List[AudioDevice]:
81
- """List available audio input devices"""
82
- devices = []
83
- for i, device in enumerate(sd.query_devices()):
84
- if device['max_input_channels'] > 0:
85
- devices.append(AudioDevice(
86
- index=i,
87
- name=device['name'],
88
- sample_rate=int(device['default_samplerate']),
89
- channels=device['max_input_channels'],
90
- is_input=True,
91
- is_output=False
92
- ))
93
- return devices
94
-
95
- async def list_output_devices(self) -> List[AudioDevice]:
96
- """List available audio output devices"""
97
- devices = []
98
- for i, device in enumerate(sd.query_devices()):
99
- if device['max_output_channels'] > 0:
100
- devices.append(AudioDevice(
101
- index=i,
102
- name=device['name'],
103
- sample_rate=int(device['default_samplerate']),
104
- channels=device['max_output_channels'],
105
- is_input=False,
106
- is_output=True
107
- ))
108
- return devices
109
-
110
- async def start_recording(
111
- self,
112
- device_id: Optional[str],
113
- callback: Callable[[bytes], None],
114
- sample_rate: int = 16000,
115
- channels: int = 1,
116
- block_size: int = 1024
117
- ):
118
- """Start recording from microphone"""
119
- if self._is_recording:
120
- logger.warning("Already recording")
121
- return
122
-
123
- self._callback = callback
124
- self._loop = asyncio.get_event_loop()
125
- self._is_recording = True
126
-
127
- def audio_callback(indata, frames, time, status):
128
- """Callback for audio stream"""
129
- if status:
130
- logger.warning(f"Audio callback status: {status}")
131
-
132
- if not self._is_recording:
133
- return
134
-
135
- # Convert to 16-bit PCM
136
- audio_data = (
137
- (np.clip(indata, -1.0, 1.0) * 32767.0)
138
- .astype("<i2")
139
- .tobytes()
140
- )
141
-
142
- # Call the callback in the event loop
143
- if self._loop and self._callback:
144
- self._loop.call_soon_threadsafe(self._callback, audio_data)
145
-
146
- try:
147
- self._stream = sd.InputStream(
148
- device=device_id,
149
- samplerate=sample_rate,
150
- channels=channels,
151
- blocksize=block_size,
152
- callback=audio_callback,
153
- dtype='float32'
154
- )
155
- self._stream.start()
156
- logger.info(f"Started recording from device: {device_id or 'default'}")
157
- except Exception as e:
158
- logger.error(f"Failed to start recording: {e}")
159
- self._is_recording = False
160
- raise
161
-
162
- async def stop_recording(self):
163
- """Stop recording"""
164
- if not self._is_recording:
165
- return
166
-
167
- self._is_recording = False
168
-
169
- if self._stream:
170
- try:
171
- self._stream.stop()
172
- self._stream.close()
173
- logger.info("Stopped recording")
174
- except Exception as e:
175
- logger.error(f"Failed to stop recording: {e}")
176
- finally:
177
- self._stream = None
178
-
179
- self._callback = None
180
- self._loop = None
181
-
182
-
183
- class Speaker(AudioAdapter):
184
- """Speaker adapter for Reachy Mini"""
185
-
186
- def __init__(self, sample_rate: int = 16000):
187
- self.sample_rate = sample_rate
188
- self._stream = None
189
- self._is_playing = False
190
-
191
- async def list_input_devices(self) -> List[AudioDevice]:
192
- """List available audio input devices (not applicable for speaker)"""
193
- return []
194
-
195
- async def list_output_devices(self) -> List[AudioDevice]:
196
- """List available audio output devices"""
197
- devices = []
198
- for i, device in enumerate(sd.query_devices()):
199
- if device['max_output_channels'] > 0:
200
- devices.append(AudioDevice(
201
- index=i,
202
- name=device['name'],
203
- sample_rate=int(device['default_samplerate']),
204
- channels=device['max_output_channels'],
205
- is_input=False,
206
- is_output=True
207
- ))
208
- return devices
209
-
210
- async def start_recording(
211
- self,
212
- device_id: Optional[str],
213
- callback: Callable[[bytes], None],
214
- sample_rate: int = 16000,
215
- channels: int = 1,
216
- block_size: int = 1024
217
- ):
218
- """Start recording (not applicable for speaker)"""
219
- raise NotImplementedError("Speaker does not support recording")
220
-
221
- async def stop_recording(self):
222
- """Stop recording (not applicable for speaker)"""
223
- raise NotImplementedError("Speaker does not support recording")
224
-
225
- async def play_audio(
226
- self,
227
- audio_data: bytes,
228
- device_id: Optional[str],
229
- sample_rate: int = 16000,
230
- channels: int = 1
231
- ):
232
- """Play audio to speaker"""
233
- try:
234
- # Convert from 16-bit PCM to float32
235
- audio_array = np.frombuffer(audio_data, dtype="<i2").astype(np.float32) / 32768.0
236
-
237
- # Play audio
238
- sd.play(audio_array, samplerate=sample_rate, device=device_id)
239
- sd.wait()
240
- logger.debug("Audio playback completed")
241
- except Exception as e:
242
- logger.error(f"Failed to play audio: {e}")
243
- raise
244
-
245
-
246
- async def list_audio_devices():
247
- """List all available audio devices"""
248
- microphone = MicrophoneArray()
249
-
250
- print("\n=== Audio Input Devices ===")
251
- input_devices = await microphone.list_input_devices()
252
- for device in input_devices:
253
- print(f"{device.index}: {device.name} ({device.sample_rate}Hz, {device.channels}ch)")
254
-
255
- print("\n=== Audio Output Devices ===")
256
- output_devices = await microphone.list_output_devices()
257
- for device in output_devices:
258
- print(f"{device.index}: {device.name} ({device.sample_rate}Hz, {device.channels}ch)")
259
-
260
- print()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/reachy_mini_ha_voice/audio/processor.py DELETED
@@ -1,94 +0,0 @@
1
- """
2
- Audio processor for Reachy Mini Voice Assistant
3
- """
4
-
5
- import asyncio
6
- import logging
7
- from queue import Queue
8
- from typing import Optional, Callable
9
- import numpy as np
10
-
11
- logger = logging.getLogger(__name__)
12
-
13
-
14
- class AudioProcessor:
15
- """Process audio chunks for wake word detection and streaming"""
16
-
17
- def __init__(
18
- self,
19
- sample_rate: int = 16000,
20
- channels: int = 1,
21
- block_size: int = 1024
22
- ):
23
- self.sample_rate = sample_rate
24
- self.channels = channels
25
- self.block_size = block_size
26
-
27
- self._audio_queue: Queue[bytes] = Queue()
28
- self._is_processing = False
29
- self._wake_word_callbacks: list[Callable[[bytes], None]] = []
30
- self._stream_callbacks: list[Callable[[bytes], None]] = []
31
-
32
- def add_wake_word_callback(self, callback: Callable[[bytes], None]):
33
- """Add a callback for wake word detection"""
34
- self._wake_word_callbacks.append(callback)
35
-
36
- def add_stream_callback(self, callback: Callable[[bytes], None]):
37
- """Add a callback for audio streaming"""
38
- self._stream_callbacks.append(callback)
39
-
40
- async def process_audio_chunk(self, audio_chunk: bytes):
41
- """Process an audio chunk"""
42
- # Convert to numpy array for processing
43
- audio_array = np.frombuffer(audio_chunk, dtype=np.int16).astype(np.float32) / 32768.0
44
-
45
- # Call wake word callbacks
46
- for callback in self._wake_word_callbacks:
47
- try:
48
- callback(audio_chunk)
49
- except Exception as e:
50
- logger.error(f"Error in wake word callback: {e}")
51
-
52
- # Call stream callbacks
53
- for callback in self._stream_callbacks:
54
- try:
55
- callback(audio_chunk)
56
- except Exception as e:
57
- logger.error(f"Error in stream callback: {e}")
58
-
59
- async def start_processing(self):
60
- """Start processing audio"""
61
- if self._is_processing:
62
- logger.warning("Already processing audio")
63
- return
64
-
65
- self._is_processing = True
66
- logger.info("Started audio processing")
67
-
68
- async def stop_processing(self):
69
- """Stop processing audio"""
70
- if not self._is_processing:
71
- return
72
-
73
- self._is_processing = False
74
- logger.info("Stopped audio processing")
75
-
76
- def is_processing(self) -> bool:
77
- """Check if processing audio"""
78
- return self._is_processing
79
-
80
- async def process_audio_stream(self, audio_stream: Callable[[], bytes]):
81
- """Process a continuous audio stream"""
82
- await self.start_processing()
83
-
84
- try:
85
- while self._is_processing:
86
- audio_chunk = audio_stream()
87
- if audio_chunk:
88
- await self.process_audio_chunk(audio_chunk)
89
- else:
90
- await asyncio.sleep(0.001)
91
- except Exception as e:
92
- logger.error(f"Error processing audio stream: {e}")
93
- finally:
94
- await self.stop_processing()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/reachy_mini_ha_voice/config/__init__.py DELETED
@@ -1,7 +0,0 @@
1
- """
2
- Configuration management module for Reachy Mini Voice Assistant
3
- """
4
-
5
- from .manager import ConfigManager
6
-
7
- __all__ = ["ConfigManager"]
 
 
 
 
 
 
 
 
src/reachy_mini_ha_voice/config/manager.py DELETED
@@ -1,159 +0,0 @@
1
- """
2
- Configuration manager for Reachy Mini Voice Assistant
3
- """
4
-
5
- import json
6
- import logging
7
- import os
8
- from pathlib import Path
9
- from typing import Any, Optional
10
-
11
- logger = logging.getLogger(__name__)
12
-
13
-
14
- class ConfigManager:
15
- """Manage application configuration"""
16
-
17
- def __init__(self, config_path: str = "config.json"):
18
- self.config_path = Path(config_path)
19
- self.config = self.load_config()
20
-
21
- def load_config(self) -> dict:
22
- """Load configuration from file"""
23
- if self.config_path.exists():
24
- try:
25
- with open(self.config_path, 'r', encoding='utf-8') as f:
26
- config = json.load(f)
27
- logger.info(f"Loaded configuration from {self.config_path}")
28
- return config
29
- except Exception as e:
30
- logger.error(f"Failed to load configuration: {e}")
31
- logger.info("Using default configuration")
32
- return self.get_default_config()
33
- else:
34
- logger.info("Configuration file not found, using defaults")
35
- return self.get_default_config()
36
-
37
- def save_config(self):
38
- """Save configuration to file"""
39
- try:
40
- with open(self.config_path, 'w', encoding='utf-8') as f:
41
- json.dump(self.config, f, indent=2, ensure_ascii=False)
42
- logger.info(f"Saved configuration to {self.config_path}")
43
- except Exception as e:
44
- logger.error(f"Failed to save configuration: {e}")
45
- raise
46
-
47
- def get_default_config(self) -> dict:
48
- """Get default configuration"""
49
- return {
50
- "audio": {
51
- "input_device": None,
52
- "output_device": None,
53
- "sample_rate": 16000,
54
- "channels": 1,
55
- "block_size": 1024
56
- },
57
- "voice": {
58
- "wake_word": "okay_nabu",
59
- "wake_word_dirs": ["wakewords"]
60
- },
61
- "motion": {
62
- "enabled": True,
63
- "speech_reactive": True,
64
- "face_tracking": False
65
- },
66
- "esphome": {
67
- "host": "0.0.0.0",
68
- "port": 6053,
69
- "name": "Reachy Mini"
70
- },
71
- "robot": {
72
- "host": "localhost",
73
- "wireless": False
74
- },
75
- "logging": {
76
- "level": "INFO",
77
- "file": "reachy_mini_ha_voice.log"
78
- }
79
- }
80
-
81
- def get(self, key: str, default: Any = None) -> Any:
82
- """Get configuration value by key (supports nested keys with dots)"""
83
- keys = key.split('.')
84
- value = self.config
85
- for k in keys:
86
- if isinstance(value, dict):
87
- value = value.get(k, default)
88
- else:
89
- return default
90
- return value
91
-
92
- def set(self, key: str, value: Any):
93
- """Set configuration value by key (supports nested keys with dots)"""
94
- keys = key.split('.')
95
- config = self.config
96
- for k in keys[:-1]:
97
- config = config.setdefault(k, {})
98
- config[keys[-1]] = value
99
- self.save_config()
100
-
101
- def get_audio_config(self) -> dict:
102
- """Get audio configuration"""
103
- return self.get("audio", {})
104
-
105
- def get_voice_config(self) -> dict:
106
- """Get voice configuration"""
107
- return self.get("voice", {})
108
-
109
- def get_motion_config(self) -> dict:
110
- """Get motion configuration"""
111
- return self.get("motion", {})
112
-
113
- def get_esphome_config(self) -> dict:
114
- """Get ESPHome configuration"""
115
- return self.get("esphome", {})
116
-
117
- def get_robot_config(self) -> dict:
118
- """Get robot configuration"""
119
- return self.get("robot", {})
120
-
121
- def get_gradio_config(self) -> dict:
122
- """Get Gradio configuration"""
123
- return self.get("gradio", {})
124
-
125
- def update_audio_config(self, **kwargs):
126
- """Update audio configuration"""
127
- audio_config = self.config.setdefault("audio", {})
128
- audio_config.update(kwargs)
129
- self.save_config()
130
-
131
- def update_voice_config(self, **kwargs):
132
- """Update voice configuration"""
133
- voice_config = self.config.setdefault("voice", {})
134
- voice_config.update(kwargs)
135
- self.save_config()
136
-
137
- def update_motion_config(self, **kwargs):
138
- """Update motion configuration"""
139
- motion_config = self.config.setdefault("motion", {})
140
- motion_config.update(kwargs)
141
- self.save_config()
142
-
143
- def update_esphome_config(self, **kwargs):
144
- """Update ESPHome configuration"""
145
- esphome_config = self.config.setdefault("esphome", {})
146
- esphome_config.update(kwargs)
147
- self.save_config()
148
-
149
- def update_robot_config(self, **kwargs):
150
- """Update robot configuration"""
151
- robot_config = self.config.setdefault("robot", {})
152
- robot_config.update(kwargs)
153
- self.save_config()
154
-
155
- def update_gradio_config(self, **kwargs):
156
- """Update Gradio configuration"""
157
- gradio_config = self.config.setdefault("gradio", {})
158
- gradio_config.update(kwargs)
159
- self.save_config()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/reachy_mini_ha_voice/esphome/__init__.py DELETED
@@ -1,8 +0,0 @@
1
- """
2
- ESPHome protocol module for Reachy Mini Voice Assistant
3
- """
4
-
5
- from .server import ESPHomeServer, VoiceSatelliteProtocol
6
- from .protocol import VoiceAssistantEventType
7
-
8
- __all__ = ["ESPHomeServer", "VoiceSatelliteProtocol", "VoiceAssistantEventType"]
 
 
 
 
 
 
 
 
 
src/reachy_mini_ha_voice/esphome/protocol.py DELETED
@@ -1,34 +0,0 @@
1
- """
2
- ESPHome protocol for Reachy Mini Voice Assistant
3
- """
4
-
5
- import asyncio
6
- import logging
7
- from enum import Enum
8
- from typing import Optional
9
-
10
- logger = logging.getLogger(__name__)
11
-
12
-
13
- class VoiceAssistantEventType(Enum):
14
- """Voice assistant event types"""
15
- VOICE_ASSISTANT_START = 0
16
- VOICE_ASSISTANT_END = 1
17
- VOICE_ASSISTANT_ERROR = 2
18
- VOICE_ASSISTANT_STT_START = 3
19
- VOICE_ASSISTANT_STT_END = 4
20
- VOICE_ASSISTANT_TTS_START = 5
21
- VOICE_ASSISTANT_TTS_END = 6
22
- VOICE_ASSISTANT_INTENT_START = 7
23
- VOICE_ASSISTANT_INTENT_END = 8
24
- VOICE_ASSISTANT_WAKE_WORD_START = 9
25
- VOICE_ASSISTANT_WAKE_WORD_END = 10
26
-
27
-
28
- class VoiceAssistantFeature(Enum):
29
- """Voice assistant features"""
30
- VOICE_ASSISTANT = 1
31
- API_AUDIO = 2
32
- ANNOUNCE = 4
33
- START_CONVERSATION = 8
34
- TIMERS = 16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/reachy_mini_ha_voice/esphome/server.py DELETED
@@ -1,164 +0,0 @@
1
- """
2
- ESPHome server for Reachy Mini Voice Assistant
3
- """
4
-
5
- import asyncio
6
- import logging
7
- from typing import Optional, Callable, List
8
- from .protocol import VoiceAssistantEventType, VoiceAssistantFeature
9
-
10
- logger = logging.getLogger(__name__)
11
-
12
-
13
- class ESPHomeServer:
14
- """ESPHome protocol server"""
15
-
16
- def __init__(self, host: str = "0.0.0.0", port: int = 6053):
17
- self.host = host
18
- self.port = port
19
- self._server: Optional[asyncio.Server] = None
20
- self._is_running = False
21
- self._clients: List = []
22
- self._audio_callback: Optional[Callable[[bytes], None]] = None
23
- self._event_callback: Optional[Callable[[VoiceAssistantEventType, dict], None]] = None
24
-
25
- async def start(self):
26
- """Start ESPHome server"""
27
- if self._is_running:
28
- logger.warning("ESPHome server already running")
29
- return
30
-
31
- try:
32
- self._server = await asyncio.start_server(
33
- self._handle_client,
34
- self.host,
35
- self.port
36
- )
37
- self._is_running = True
38
-
39
- logger.info(f"ESPHome server started on {self.host}:{self.port}")
40
- except Exception as e:
41
- logger.error(f"Failed to start ESPHome server: {e}")
42
- raise
43
-
44
- async def stop(self):
45
- """Stop ESPHome server"""
46
- if not self._is_running:
47
- return
48
-
49
- self._is_running = False
50
-
51
- # Close all clients
52
- for client in self._clients:
53
- client.close()
54
- self._clients.clear()
55
-
56
- # Close server
57
- if self._server:
58
- self._server.close()
59
- await self._server.wait_closed()
60
-
61
- logger.info("ESPHome server stopped")
62
-
63
- def set_audio_callback(self, callback: Callable[[bytes], None]):
64
- """Set audio callback"""
65
- self._audio_callback = callback
66
-
67
- def set_event_callback(self, callback: Callable[[VoiceAssistantEventType, dict], None]):
68
- """Set event callback"""
69
- self._event_callback = callback
70
-
71
- async def send_audio(self, audio_data: bytes):
72
- """Send audio data to all clients"""
73
- for client in self._clients:
74
- try:
75
- client.write(audio_data)
76
- await client.drain()
77
- except Exception as e:
78
- logger.error(f"Error sending audio to client: {e}")
79
-
80
- async def send_event(self, event_type: VoiceAssistantEventType, data: dict):
81
- """Send event to all clients"""
82
- if self._event_callback:
83
- try:
84
- self._event_callback(event_type, data)
85
- except Exception as e:
86
- logger.error(f"Error in event callback: {e}")
87
-
88
- async def _handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
89
- """Handle client connection"""
90
- client_addr = writer.get_extra_info('peername')
91
- logger.info(f"Client connected: {client_addr}")
92
-
93
- self._clients.append(writer)
94
-
95
- try:
96
- while self._is_running:
97
- # Read data from client
98
- data = await reader.read(4096)
99
- if not data:
100
- break
101
-
102
- # Process data (simplified ESPHome protocol)
103
- await self._process_data(data)
104
- except Exception as e:
105
- logger.error(f"Error handling client {client_addr}: {e}")
106
- finally:
107
- self._clients.remove(writer)
108
- writer.close()
109
- await writer.wait_closed()
110
- logger.info(f"Client disconnected: {client_addr}")
111
-
112
- async def _process_data(self, data: bytes):
113
- """Process incoming data"""
114
- # Simplified ESPHome protocol processing
115
- # In a real implementation, this would parse ESPHome frames
116
- logger.debug(f"Received {len(data)} bytes from client")
117
-
118
-
119
- class VoiceSatelliteProtocol:
120
- """Voice satellite protocol handler"""
121
-
122
- def __init__(self, state):
123
- self.state = state
124
- self._is_streaming = False
125
- self._refractory_period = 2.0 # seconds
126
- self._last_wake_word_time = 0.0
127
-
128
- async def handle_message(self, msg):
129
- """Handle ESPHome message"""
130
- # Simplified message handling
131
- logger.debug(f"Received message: {msg}")
132
-
133
- async def handle_audio(self, audio_chunk: bytes):
134
- """Handle audio chunk"""
135
- if self._is_streaming and self.state.esphome_server:
136
- await self.state.esphome_server.send_audio(audio_chunk)
137
-
138
- async def handle_wake_word(self):
139
- """Handle wake word detection"""
140
- current_time = asyncio.get_event_loop().time()
141
-
142
- # Check refractory period
143
- if current_time - self._last_wake_word_time < self._refractory_period:
144
- logger.debug("Wake word in refractory period, ignoring")
145
- return
146
-
147
- self._last_wake_word_time = current_time
148
-
149
- # Send wake word event
150
- if self.state.esphome_server:
151
- await self.state.esphome_server.send_event(
152
- VoiceAssistantEventType.VOICE_ASSISTANT_WAKE_WORD_END,
153
- {"wake_word": "detected"}
154
- )
155
-
156
- # Start streaming
157
- self._is_streaming = True
158
-
159
- logger.info("Wake word detected, started streaming")
160
-
161
- async def stop_streaming(self):
162
- """Stop audio streaming"""
163
- self._is_streaming = False
164
- logger.info("Stopped streaming")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/reachy_mini_ha_voice/main.py DELETED
@@ -1,197 +0,0 @@
1
- """
2
- Main entry point for Reachy Mini Home Assistant Voice Assistant
3
- """
4
-
5
- import argparse
6
- import asyncio
7
- import logging
8
- import signal
9
- import sys
10
- from pathlib import Path
11
-
12
- from .config.manager import ConfigManager
13
- from .audio.adapter import AudioAdapter
14
- from .voice.detector import WakeWordDetector
15
- from .motion.controller import MotionController
16
- from .esphome.server import ESPHomeServer
17
- from .app import ReachyMiniVoiceApp
18
-
19
- # Configure logging
20
- logging.basicConfig(
21
- level=logging.INFO,
22
- format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
23
- )
24
- logger = logging.getLogger(__name__)
25
-
26
-
27
- def parse_args():
28
- """Parse command line arguments"""
29
- parser = argparse.ArgumentParser(
30
- description="Reachy Mini Home Assistant Voice Assistant"
31
- )
32
-
33
- parser.add_argument(
34
- "--name",
35
- type=str,
36
- default="Reachy Mini",
37
- help="Name of the voice assistant (default: Reachy Mini)"
38
- )
39
-
40
- parser.add_argument(
41
- "--config",
42
- type=str,
43
- default="config.json",
44
- help="Path to configuration file (default: config.json)"
45
- )
46
-
47
- parser.add_argument(
48
- "--audio-input-device",
49
- type=str,
50
- default=None,
51
- help="Audio input device name (default: auto-detect)"
52
- )
53
-
54
- parser.add_argument(
55
- "--audio-output-device",
56
- type=str,
57
- default=None,
58
- help="Audio output device name (default: auto-detect)"
59
- )
60
-
61
- parser.add_argument(
62
- "--list-input-devices",
63
- action="store_true",
64
- help="List available audio input devices and exit"
65
- )
66
-
67
- parser.add_argument(
68
- "--list-output-devices",
69
- action="store_true",
70
- help="List available audio output devices and exit"
71
- )
72
-
73
- parser.add_argument(
74
- "--wake-model",
75
- type=str,
76
- default="okay_nabu",
77
- help="Wake word model name (default: okay_nabu)"
78
- )
79
-
80
- parser.add_argument(
81
- "--wake-word-dir",
82
- type=str,
83
- action="append",
84
- help="Additional wake word directory (can be used multiple times)"
85
- )
86
-
87
- parser.add_argument(
88
- "--host",
89
- type=str,
90
- default="0.0.0.0",
91
- help="ESPHome server host (default: 0.0.0.0)"
92
- )
93
-
94
- parser.add_argument(
95
- "--port",
96
- type=int,
97
- default=6053,
98
- help="ESPHome server port (default: 6053)"
99
- )
100
-
101
- parser.add_argument(
102
- "--robot-host",
103
- type=str,
104
- default="localhost",
105
- help="Reachy Mini robot host (default: localhost)"
106
- )
107
-
108
- parser.add_argument(
109
- "--wireless",
110
- action="store_true",
111
- help="Use wireless version of Reachy Mini"
112
- )
113
-
114
- parser.add_argument(
115
- "--gradio",
116
- action="store_true",
117
- help="Launch Gradio web UI"
118
- )
119
-
120
- parser.add_argument(
121
- "--debug",
122
- action="store_true",
123
- help="Enable debug logging"
124
- )
125
-
126
- return parser.parse_args()
127
-
128
-
129
- async def list_audio_devices():
130
- """List available audio devices"""
131
- import sounddevice as sd
132
-
133
- print("\n=== Audio Input Devices ===")
134
- devices = sd.query_devices()
135
- for i, device in enumerate(devices):
136
- if device['max_input_channels'] > 0:
137
- print(f"{i}: {device['name']}")
138
-
139
- print("\n=== Audio Output Devices ===")
140
- for i, device in enumerate(devices):
141
- if device['max_output_channels'] > 0:
142
- print(f"{i}: {device['name']}")
143
-
144
- print()
145
-
146
-
147
- async def main():
148
- """Main entry point"""
149
- args = parse_args()
150
-
151
- # Set debug logging if requested
152
- if args.debug:
153
- logging.getLogger().setLevel(logging.DEBUG)
154
- logger.debug("Debug logging enabled")
155
-
156
- # List audio devices if requested
157
- if args.list_input_devices or args.list_output_devices:
158
- await list_audio_devices()
159
- return
160
-
161
- # Load configuration
162
- config = ConfigManager(args.config)
163
- logger.info(f"Loaded configuration from {args.config}")
164
-
165
- # Create application
166
- app = ReachyMiniVoiceApp(
167
- name=args.name,
168
- config=config,
169
- audio_input_device=args.audio_input_device,
170
- audio_output_device=args.audio_output_device,
171
- wake_model=args.wake_model,
172
- wake_word_dirs=args.wake_word_dir,
173
- host=args.host,
174
- port=args.port,
175
- robot_host=args.robot_host,
176
- wireless=args.wireless,
177
- gradio=args.gradio
178
- )
179
-
180
- # Setup signal handlers
181
- def signal_handler(sig, frame):
182
- logger.info(f"Received signal {sig}, shutting down...")
183
- asyncio.create_task(app.stop())
184
-
185
- signal.signal(signal.SIGINT, signal_handler)
186
- signal.signal(signal.SIGTERM, signal_handler)
187
-
188
- # Start application
189
- try:
190
- await app.start()
191
- except Exception as e:
192
- logger.error(f"Error starting application: {e}", exc_info=True)
193
- sys.exit(1)
194
-
195
-
196
- if __name__ == "__main__":
197
- asyncio.run(main())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/reachy_mini_ha_voice/motion/__init__.py DELETED
@@ -1,8 +0,0 @@
1
- """
2
- Motion control module for Reachy Mini Voice Assistant
3
- """
4
-
5
- from .controller import MotionController, ReachyMiniMotionController
6
- from .queue import MotionQueue, Motion
7
-
8
- __all__ = ["MotionController", "ReachyMiniMotionController", "MotionQueue", "Motion"]
 
 
 
 
 
 
 
 
 
src/reachy_mini_ha_voice/motion/controller.py DELETED
@@ -1,261 +0,0 @@
1
- """
2
- Motion controller for Reachy Mini Voice Assistant
3
- """
4
-
5
- import asyncio
6
- import logging
7
- from abc import ABC, abstractmethod
8
- from typing import Optional
9
- import numpy as np
10
- from scipy.spatial.transform import Rotation as R
11
-
12
- logger = logging.getLogger(__name__)
13
-
14
-
15
- class MotionController(ABC):
16
- """Abstract base class for motion controller"""
17
-
18
- @abstractmethod
19
- async def connect(self, host: str = 'localhost'):
20
- """Connect to robot"""
21
- pass
22
-
23
- @abstractmethod
24
- async def disconnect(self):
25
- """Disconnect from robot"""
26
- pass
27
-
28
- @abstractmethod
29
- async def wake_up(self):
30
- """Wake up robot"""
31
- pass
32
-
33
- @abstractmethod
34
- async def turn_off(self):
35
- """Turn off robot"""
36
- pass
37
-
38
- @abstractmethod
39
- async def move_head(self, pose: np.ndarray, duration: float = 1.0):
40
- """Move head to pose"""
41
- pass
42
-
43
- @abstractmethod
44
- async def move_antennas(self, left: float, right: float, duration: float = 1.0):
45
- """Move antennas"""
46
- pass
47
-
48
- @abstractmethod
49
- async def is_connected(self) -> bool:
50
- """Check if connected"""
51
- pass
52
-
53
-
54
- class ReachyMiniMotionController(MotionController):
55
- """Reachy Mini motion controller"""
56
-
57
- def __init__(self):
58
- self.reachy_mini = None
59
- self._connected = False
60
- self._speech_reactive = False
61
- self._speech_task = None
62
-
63
- async def connect(self, host: str = 'localhost'):
64
- """Connect to Reachy Mini"""
65
- try:
66
- from reachy_mini import ReachyMini
67
-
68
- self.reachy_mini = ReachyMini(host=host)
69
- self._connected = True
70
-
71
- logger.info(f"Connected to Reachy Mini at {host}")
72
- except ImportError:
73
- logger.error("reachy-mini not installed. Install with: pip install reachy-mini")
74
- raise
75
- except Exception as e:
76
- logger.error(f"Failed to connect to Reachy Mini: {e}")
77
- raise
78
-
79
- async def disconnect(self):
80
- """Disconnect from Reachy Mini"""
81
- if self.reachy_mini:
82
- try:
83
- await self.turn_off()
84
- except Exception as e:
85
- logger.error(f"Error turning off robot: {e}")
86
- finally:
87
- self.reachy_mini = None
88
- self._connected = False
89
- logger.info("Disconnected from Reachy Mini")
90
-
91
- async def wake_up(self):
92
- """Wake up robot"""
93
- if not self._connected or self.reachy_mini is None:
94
- logger.warning("Not connected to robot")
95
- return
96
-
97
- try:
98
- self.reachy_mini.wake_up()
99
- logger.info("Robot woke up")
100
- except Exception as e:
101
- logger.error(f"Failed to wake up robot: {e}")
102
- raise
103
-
104
- async def turn_off(self):
105
- """Turn off robot"""
106
- if not self._connected or self.reachy_mini is None:
107
- logger.warning("Not connected to robot")
108
- return
109
-
110
- try:
111
- self.reachy_mini.turn_off()
112
- logger.info("Robot turned off")
113
- except Exception as e:
114
- logger.error(f"Failed to turn off robot: {e}")
115
- raise
116
-
117
- async def move_head(self, pose: np.ndarray, duration: float = 1.0):
118
- """Move head to pose"""
119
- if not self._connected or self.reachy_mini is None:
120
- logger.warning("Not connected to robot")
121
- return
122
-
123
- try:
124
- self.reachy_mini.goto_target(head=pose, duration=duration)
125
- logger.debug(f"Moved head (duration: {duration}s)")
126
- except Exception as e:
127
- logger.error(f"Failed to move head: {e}")
128
- raise
129
-
130
- async def move_antennas(self, left: float, right: float, duration: float = 1.0):
131
- """Move antennas"""
132
- if not self._connected or self.reachy_mini is None:
133
- logger.warning("Not connected to robot")
134
- return
135
-
136
- try:
137
- self.reachy_mini.goto_target(antennas=[left, right], duration=duration)
138
- logger.debug(f"Moved antennas (left: {left}, right: {right})")
139
- except Exception as e:
140
- logger.error(f"Failed to move antennas: {e}")
141
- raise
142
-
143
- async def nod(self, count: int = 1, duration: float = 0.5):
144
- """Nod head"""
145
- for _ in range(count):
146
- # Nod down
147
- pose_down = np.eye(4)
148
- pose_down[:3, :3] = R.from_euler('xyz', [15, 0, 0], degrees=True).as_matrix()
149
- await self.move_head(pose_down, duration=duration / 2)
150
-
151
- # Nod up
152
- pose_up = np.eye(4)
153
- pose_up[:3, :3] = R.from_euler('xyz', [-15, 0, 0], degrees=True).as_matrix()
154
- await self.move_head(pose_up, duration=duration / 2)
155
-
156
- async def shake(self, count: int = 1, duration: float = 0.5):
157
- """Shake head"""
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, -20], degrees=True).as_matrix()
162
- await self.move_head(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, 20], degrees=True).as_matrix()
167
- await self.move_head(pose_right, duration=duration / 2)
168
-
169
- async def look_at(self, x: float = 0.5, y: float = 0.0, z: float = 0.0, duration: float = 1.0):
170
- """Look at a point"""
171
- # Calculate yaw and pitch
172
- yaw = np.arctan2(x, z)
173
- pitch = np.arctan2(y, np.sqrt(x**2 + z**2))
174
-
175
- # Create pose
176
- pose = np.eye(4)
177
- pose[:3, :3] = R.from_euler('xyz', [pitch, 0, yaw], degrees=True).as_matrix()
178
-
179
- await self.move_head(pose, duration=duration)
180
-
181
- async def start_speech_reactive_motion(self):
182
- """Start speech reactive motion"""
183
- if self._speech_reactive:
184
- return
185
-
186
- self._speech_reactive = True
187
- self._speech_task = asyncio.create_task(self._speech_reactive_loop())
188
- logger.info("Started speech reactive motion")
189
-
190
- async def stop_speech_reactive_motion(self):
191
- """Stop speech reactive motion"""
192
- if not self._speech_reactive:
193
- return
194
-
195
- self._speech_reactive = False
196
- if self._speech_task:
197
- self._speech_task.cancel()
198
- try:
199
- await self._speech_task
200
- except asyncio.CancelledError:
201
- pass
202
- logger.info("Stopped speech reactive motion")
203
-
204
- async def _speech_reactive_loop(self):
205
- """Speech reactive motion loop"""
206
- try:
207
- while self._speech_reactive:
208
- # Generate subtle wobble
209
- roll = np.sin(asyncio.get_event_loop().time() * 2) * 3
210
- pose = np.eye(4)
211
- pose[:3, :3] = R.from_euler('xyz', [0, 0, roll], degrees=True).as_matrix()
212
-
213
- await self.move_head(pose, duration=0.1)
214
- await asyncio.sleep(0.1)
215
- except asyncio.CancelledError:
216
- logger.debug("Speech reactive loop cancelled")
217
- except Exception as e:
218
- logger.error(f"Error in speech reactive loop: {e}")
219
-
220
- async def is_connected(self) -> bool:
221
- """Check if connected"""
222
- return self._connected
223
-
224
-
225
- class MockMotionController(MotionController):
226
- """Mock motion controller for testing"""
227
-
228
- def __init__(self):
229
- self._connected = False
230
-
231
- async def connect(self, host: str = 'localhost'):
232
- """Connect to mock robot"""
233
- self._connected = True
234
- logger.info("Connected to mock robot")
235
-
236
- async def disconnect(self):
237
- """Disconnect from mock robot"""
238
- self._connected = False
239
- logger.info("Disconnected from mock robot")
240
-
241
- async def wake_up(self):
242
- """Wake up mock robot"""
243
- logger.info("Mock robot woke up")
244
-
245
- async def turn_off(self):
246
- """Turn off mock robot"""
247
- logger.info("Mock robot turned off")
248
-
249
- async def move_head(self, pose: np.ndarray, duration: float = 1.0):
250
- """Move mock head"""
251
- logger.debug(f"Mock head moved (duration: {duration}s)")
252
- await asyncio.sleep(duration)
253
-
254
- async def move_antennas(self, left: float, right: float, duration: float = 1.0):
255
- """Move mock antennas"""
256
- logger.debug(f"Mock antennas moved (left: {left}, right: {right})")
257
- await asyncio.sleep(duration)
258
-
259
- async def is_connected(self) -> bool:
260
- """Check if connected"""
261
- return self._connected
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/reachy_mini_ha_voice/motion/queue.py DELETED
@@ -1,162 +0,0 @@
1
- """
2
- Motion queue for Reachy Mini Voice Assistant
3
- """
4
-
5
- import asyncio
6
- import logging
7
- from dataclasses import dataclass
8
- from enum import Enum
9
- from typing import Optional
10
-
11
- logger = logging.getLogger(__name__)
12
-
13
-
14
- class MotionPriority(Enum):
15
- """Motion priority levels"""
16
- HIGH = 1
17
- MEDIUM = 2
18
- LOW = 3
19
-
20
-
21
- @dataclass
22
- class Motion:
23
- """Motion command"""
24
- name: str
25
- execute: callable
26
- priority: MotionPriority = MotionPriority.MEDIUM
27
- duration: float = 1.0
28
-
29
-
30
- class MotionQueue:
31
- """Motion queue manager"""
32
-
33
- def __init__(self):
34
- self.high_priority = asyncio.Queue()
35
- self.medium_priority = asyncio.Queue()
36
- self.low_priority = asyncio.Queue()
37
- self.is_running = False
38
- self._current_motion: Optional[Motion] = None
39
- self._task: Optional[asyncio.Task] = None
40
-
41
- async def add_motion(self, motion: Motion):
42
- """Add motion to queue based on priority"""
43
- if motion.priority == MotionPriority.HIGH:
44
- await self.high_priority.put(motion)
45
- elif motion.priority == MotionPriority.MEDIUM:
46
- await self.medium_priority.put(motion)
47
- elif motion.priority == MotionPriority.LOW:
48
- await self.low_priority.put(motion)
49
- else:
50
- logger.warning(f"Unknown priority: {motion.priority}")
51
- return
52
-
53
- logger.debug(f"Added motion '{motion.name}' to queue (priority: {motion.priority.name})")
54
-
55
- async def add_high_priority(self, name: str, execute: callable, duration: float = 1.0):
56
- """Add high priority motion"""
57
- motion = Motion(name, execute, MotionPriority.HIGH, duration)
58
- await self.add_motion(motion)
59
-
60
- async def add_medium_priority(self, name: str, execute: callable, duration: float = 1.0):
61
- """Add medium priority motion"""
62
- motion = Motion(name, execute, MotionPriority.MEDIUM, duration)
63
- await self.add_motion(motion)
64
-
65
- async def add_low_priority(self, name: str, execute: callable, duration: float = 1.0):
66
- """Add low priority motion"""
67
- motion = Motion(name, execute, MotionPriority.LOW, duration)
68
- await self.add_motion(motion)
69
-
70
- async def start(self):
71
- """Start processing motion queue"""
72
- if self.is_running:
73
- logger.warning("Motion queue already running")
74
- return
75
-
76
- self.is_running = True
77
- self._task = asyncio.create_task(self._process_queue())
78
- logger.info("Started motion queue")
79
-
80
- async def stop(self):
81
- """Stop processing motion queue"""
82
- if not self.is_running:
83
- return
84
-
85
- self.is_running = False
86
-
87
- if self._task:
88
- self._task.cancel()
89
- try:
90
- await self._task
91
- except asyncio.CancelledError:
92
- pass
93
-
94
- logger.info("Stopped motion queue")
95
-
96
- async def clear(self):
97
- """Clear all queues"""
98
- while not self.high_priority.empty():
99
- await self.high_priority.get()
100
- while not self.medium_priority.empty():
101
- await self.medium_priority.get()
102
- while not self.low_priority.empty():
103
- await self.low_priority.get()
104
- logger.info("Cleared motion queues")
105
-
106
- async def _process_queue(self):
107
- """Process motion queue"""
108
- try:
109
- while self.is_running:
110
- # Get next motion based on priority
111
- motion = await self._get_next_motion()
112
-
113
- if motion is None:
114
- await asyncio.sleep(0.01)
115
- continue
116
-
117
- # Execute motion
118
- self._current_motion = motion
119
- logger.info(f"Executing motion: {motion.name}")
120
-
121
- try:
122
- await motion.execute()
123
- except Exception as e:
124
- logger.error(f"Error executing motion '{motion.name}': {e}")
125
- finally:
126
- self._current_motion = None
127
- except asyncio.CancelledError:
128
- logger.debug("Motion queue processing cancelled")
129
- except Exception as e:
130
- logger.error(f"Error in motion queue processing: {e}")
131
-
132
- async def _get_next_motion(self) -> Optional[Motion]:
133
- """Get next motion based on priority"""
134
- # Priority: HIGH > MEDIUM > LOW
135
- if not self.high_priority.empty():
136
- return await self.high_priority.get()
137
- elif not self.medium_priority.empty():
138
- return await self.medium_priority.get()
139
- elif not self.low_priority.empty():
140
- return await self.low_priority.get()
141
- else:
142
- return None
143
-
144
- def is_empty(self) -> bool:
145
- """Check if all queues are empty"""
146
- return (
147
- self.high_priority.empty() and
148
- self.medium_priority.empty() and
149
- self.low_priority.empty()
150
- )
151
-
152
- def get_queue_size(self) -> dict:
153
- """Get size of each queue"""
154
- return {
155
- "high": self.high_priority.qsize(),
156
- "medium": self.medium_priority.qsize(),
157
- "low": self.low_priority.qsize()
158
- }
159
-
160
- def get_current_motion(self) -> Optional[Motion]:
161
- """Get currently executing motion"""
162
- return self._current_motion
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/reachy_mini_ha_voice/state.py DELETED
@@ -1,83 +0,0 @@
1
- """
2
- State management for Reachy Mini Voice Assistant
3
- """
4
-
5
- import asyncio
6
- import logging
7
- from dataclasses import dataclass, field
8
- from typing import Optional, Dict, Any
9
- from queue import Queue
10
-
11
- logger = logging.getLogger(__name__)
12
-
13
-
14
- @dataclass
15
- class ServerState:
16
- """Global server state"""
17
- name: str
18
-
19
- # Configuration
20
- config: Optional[Any] = None
21
-
22
- # Audio
23
- microphone: Optional[Any] = None
24
- speaker: Optional[Any] = None
25
- audio_queue: Queue = field(default_factory=Queue)
26
-
27
- # Voice
28
- wake_word_detector: Optional[Any] = None
29
- stop_word_detector: Optional[Any] = None
30
- active_wake_words: list = field(default_factory=list)
31
-
32
- # Motion
33
- motion_controller: Optional[Any] = None
34
- motion_queue: Optional[Any] = None
35
-
36
- # ESPHome
37
- esphome_server: Optional[Any] = None
38
- voice_satellite: Optional[Any] = None
39
-
40
- # Status
41
- is_running: bool = False
42
- is_streaming: bool = False
43
-
44
- # Callbacks
45
- on_wake_word: Optional[callable] = None
46
- on_stt_result: Optional[callable] = None
47
- on_tts_audio: Optional[callable] = None
48
-
49
- def __post_init__(self):
50
- """Post-initialization"""
51
- logger.debug(f"ServerState initialized for {self.name}")
52
-
53
- async def cleanup(self):
54
- """Cleanup resources"""
55
- logger.info("Cleaning up ServerState")
56
-
57
- if self.microphone:
58
- try:
59
- await self.microphone.stop_recording()
60
- except Exception as e:
61
- logger.error(f"Error stopping microphone: {e}")
62
-
63
- if self.motion_controller:
64
- try:
65
- await self.motion_controller.stop_speech_reactive_motion()
66
- await self.motion_controller.turn_off()
67
- await self.motion_controller.disconnect()
68
- except Exception as e:
69
- logger.error(f"Error disconnecting motion controller: {e}")
70
-
71
- if self.motion_queue:
72
- try:
73
- await self.motion_queue.stop()
74
- except Exception as e:
75
- logger.error(f"Error stopping motion queue: {e}")
76
-
77
- if self.esphome_server:
78
- try:
79
- await self.esphome_server.stop()
80
- except Exception as e:
81
- logger.error(f"Error stopping ESPHome server: {e}")
82
-
83
- logger.info("ServerState cleanup complete")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/reachy_mini_ha_voice/voice/__init__.py DELETED
@@ -1,14 +0,0 @@
1
- """
2
- Voice processing module for Reachy Mini Voice Assistant
3
-
4
- Note: STT and TTS are handled by Home Assistant via ESPHome protocol.
5
- This module only contains offline wake word detection.
6
- """
7
-
8
- from .detector import WakeWordDetector, MicroWakeWordDetector, OpenWakeWordDetector
9
-
10
- __all__ = [
11
- "WakeWordDetector",
12
- "MicroWakeWordDetector",
13
- "OpenWakeWordDetector",
14
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/reachy_mini_ha_voice/voice/detector.py DELETED
@@ -1,178 +0,0 @@
1
- """
2
- Wake word detector for Reachy Mini Voice Assistant
3
- """
4
-
5
- import asyncio
6
- import logging
7
- from abc import ABC, abstractmethod
8
- from pathlib import Path
9
- from typing import Optional
10
-
11
- logger = logging.getLogger(__name__)
12
-
13
-
14
- class WakeWordDetector(ABC):
15
- """Abstract base class for wake word detector"""
16
-
17
- @abstractmethod
18
- async def load_model(self, model_path: str):
19
- """Load wake word model"""
20
- pass
21
-
22
- @abstractmethod
23
- async def process_audio(self, audio_chunk: bytes) -> bool:
24
- """Process audio chunk, return True if wake word detected"""
25
- pass
26
-
27
- @abstractmethod
28
- async def get_confidence(self) -> float:
29
- """Get detection confidence"""
30
- pass
31
-
32
-
33
- class MicroWakeWordDetector(WakeWordDetector):
34
- """microWakeWord detector"""
35
-
36
- def __init__(self, model_path: str):
37
- self.model = None
38
- self.features = None
39
- self.model_path = Path(model_path)
40
- self._confidence = 0.0
41
- self._loaded = False
42
-
43
- async def load_model(self, model_path: str):
44
- """Load microWakeWord model"""
45
- try:
46
- from pymicro_wakeword import MicroWakeWord, MicroWakeWordFeatures
47
-
48
- self.model_path = Path(model_path)
49
-
50
- # Load features
51
- self.features = MicroWakeWordFeatures()
52
-
53
- # Load model
54
- self.model = MicroWakeWord.from_config(str(self.model_path))
55
- self._loaded = True
56
-
57
- logger.info(f"Loaded microWakeWord model from {model_path}")
58
- except ImportError:
59
- logger.error("pymicro_wakeword not installed. Install with: pip install pymicro-wakeword")
60
- raise
61
- except Exception as e:
62
- logger.error(f"Failed to load microWakeWord model: {e}")
63
- raise
64
-
65
- async def process_audio(self, audio_chunk: bytes) -> bool:
66
- """Process audio chunk"""
67
- if not self._loaded or self.model is None:
68
- logger.warning("Model not loaded")
69
- return False
70
-
71
- try:
72
- import numpy as np
73
-
74
- # Convert audio to numpy array
75
- audio_array = np.frombuffer(audio_chunk, dtype=np.int16).astype(np.float32) / 32768.0
76
-
77
- # Extract features
78
- features = self.features.process_streaming(audio_array)
79
-
80
- # Process with model
81
- for feature in features:
82
- score = self.model.process_streaming(feature)
83
- if score is not None:
84
- self._confidence = score
85
- if score >= 0.5: # Threshold
86
- logger.info(f"Wake word detected with confidence: {score:.2f}")
87
- return True
88
-
89
- return False
90
- except Exception as e:
91
- logger.error(f"Error processing audio: {e}")
92
- return False
93
-
94
- async def get_confidence(self) -> float:
95
- """Get detection confidence"""
96
- return self._confidence
97
-
98
-
99
- class OpenWakeWordDetector(WakeWordDetector):
100
- """openWakeWord detector"""
101
-
102
- def __init__(self, model_path: str):
103
- self.model = None
104
- self.features = None
105
- self.model_path = Path(model_path)
106
- self._confidence = 0.0
107
- self._loaded = False
108
-
109
- async def load_model(self, model_path: str):
110
- """Load openWakeWord model"""
111
- try:
112
- from pyopen_wakeword import OpenWakeWord, OpenWakeWordFeatures
113
-
114
- self.model_path = Path(model_path)
115
-
116
- # Load features
117
- self.features = OpenWakeWordFeatures.from_builtin()
118
-
119
- # Load model
120
- self.model = OpenWakeWord(str(self.model_path))
121
- self._loaded = True
122
-
123
- logger.info(f"Loaded openWakeWord model from {model_path}")
124
- except ImportError:
125
- logger.error("pyopen_wakeword not installed. Install with: pip install pyopen-wakeword")
126
- raise
127
- except Exception as e:
128
- logger.error(f"Failed to load openWakeWord model: {e}")
129
- raise
130
-
131
- async def process_audio(self, audio_chunk: bytes) -> bool:
132
- """Process audio chunk"""
133
- if not self._loaded or self.model is None:
134
- logger.warning("Model not loaded")
135
- return False
136
-
137
- try:
138
- import numpy as np
139
-
140
- # Convert audio to numpy array
141
- audio_array = np.frombuffer(audio_chunk, dtype=np.int16).astype(np.float32) / 32768.0
142
-
143
- # Extract features
144
- features = self.features.process_streaming(audio_array)
145
-
146
- # Process with model
147
- for feature in features:
148
- scores = self.model.process_streaming(feature)
149
- for score in scores:
150
- self._confidence = score
151
- if score >= 0.5: # Threshold
152
- logger.info(f"Wake word detected with confidence: {score:.2f}")
153
- return True
154
-
155
- return False
156
- except Exception as e:
157
- logger.error(f"Error processing audio: {e}")
158
- return False
159
-
160
- async def get_confidence(self) -> float:
161
- """Get detection confidence"""
162
- return self._confidence
163
-
164
-
165
- async def load_wake_word_detector(
166
- model_path: str,
167
- detector_type: str = "micro"
168
- ) -> WakeWordDetector:
169
- """Load wake word detector based on type"""
170
- if detector_type == "micro":
171
- detector = MicroWakeWordDetector(model_path)
172
- elif detector_type == "open":
173
- detector = OpenWakeWordDetector(model_path)
174
- else:
175
- raise ValueError(f"Unknown detector type: {detector_type}")
176
-
177
- await detector.load_model(model_path)
178
- return detector