Commit ·
bc98bd5
1
Parent(s): 6b00c9d
"cleanup-old-modules"
Browse files- src/reachy_mini_ha_voice/app.py +0 -282
- src/reachy_mini_ha_voice/audio/__init__.py +0 -8
- src/reachy_mini_ha_voice/audio/adapter.py +0 -260
- src/reachy_mini_ha_voice/audio/processor.py +0 -94
- src/reachy_mini_ha_voice/config/__init__.py +0 -7
- src/reachy_mini_ha_voice/config/manager.py +0 -159
- src/reachy_mini_ha_voice/esphome/__init__.py +0 -8
- src/reachy_mini_ha_voice/esphome/protocol.py +0 -34
- src/reachy_mini_ha_voice/esphome/server.py +0 -164
- src/reachy_mini_ha_voice/main.py +0 -197
- src/reachy_mini_ha_voice/motion/__init__.py +0 -8
- src/reachy_mini_ha_voice/motion/controller.py +0 -261
- src/reachy_mini_ha_voice/motion/queue.py +0 -162
- src/reachy_mini_ha_voice/state.py +0 -83
- src/reachy_mini_ha_voice/voice/__init__.py +0 -14
- src/reachy_mini_ha_voice/voice/detector.py +0 -178
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|