Commit ·
1e8f606
1
Parent(s): c99f503
"feat-add-emotion-buttons-and-microphone-volume-control"
Browse files
reachy_mini_ha_voice/reachy_controller.py
CHANGED
|
@@ -119,6 +119,52 @@ class ReachyController:
|
|
| 119 |
except Exception as e:
|
| 120 |
logger.error(f"Error setting speaker volume: {e}")
|
| 121 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
# ========== Phase 2: Motor Control ==========
|
| 123 |
|
| 124 |
def get_motors_enabled(self) -> bool:
|
|
|
|
| 119 |
except Exception as e:
|
| 120 |
logger.error(f"Error setting speaker volume: {e}")
|
| 121 |
|
| 122 |
+
def get_microphone_volume(self) -> float:
|
| 123 |
+
"""Get microphone volume (0-100) using local SDK audio interface."""
|
| 124 |
+
if not self.is_available:
|
| 125 |
+
return 50.0 # Default if not available
|
| 126 |
+
|
| 127 |
+
try:
|
| 128 |
+
# Use Reachy Mini's local audio interface (like get_DoA)
|
| 129 |
+
audio = self.reachy.media.audio
|
| 130 |
+
if audio is not None and audio._respeaker is not None:
|
| 131 |
+
# AUDIO_MGR_MIC_GAIN returns gain in dB (typically 0-15 dB)
|
| 132 |
+
gain_db = audio._respeaker.read("AUDIO_MGR_MIC_GAIN")
|
| 133 |
+
if gain_db is not None:
|
| 134 |
+
# Convert dB gain to 0-100 percentage
|
| 135 |
+
# Assuming 0 dB = 0%, 15 dB = 100%
|
| 136 |
+
volume = min(100.0, max(0.0, (gain_db[0] / 15.0) * 100.0))
|
| 137 |
+
logger.debug(f"Microphone gain: {gain_db[0]:.2f} dB -> {volume:.0f}%")
|
| 138 |
+
return volume
|
| 139 |
+
except Exception as e:
|
| 140 |
+
logger.debug(f"Could not get microphone volume from SDK: {e}")
|
| 141 |
+
|
| 142 |
+
return 50.0 # Default fallback
|
| 143 |
+
|
| 144 |
+
def set_microphone_volume(self, volume: float) -> None:
|
| 145 |
+
"""
|
| 146 |
+
Set microphone volume (0-100) using local SDK audio interface.
|
| 147 |
+
|
| 148 |
+
Args:
|
| 149 |
+
volume: Volume level 0-100
|
| 150 |
+
"""
|
| 151 |
+
volume = max(0.0, min(100.0, volume))
|
| 152 |
+
|
| 153 |
+
if self.is_available:
|
| 154 |
+
try:
|
| 155 |
+
# Use Reachy Mini's local audio interface (like get_DoA)
|
| 156 |
+
audio = self.reachy.media.audio
|
| 157 |
+
if audio is not None and audio._respeaker is not None:
|
| 158 |
+
# Convert 0-100% to dB gain (0-15 dB range)
|
| 159 |
+
gain_db = (volume / 100.0) * 15.0
|
| 160 |
+
audio._respeaker.write("AUDIO_MGR_MIC_GAIN", [gain_db])
|
| 161 |
+
logger.info(f"Microphone volume set to {volume}% (gain: {gain_db:.2f} dB)")
|
| 162 |
+
return
|
| 163 |
+
except Exception as e:
|
| 164 |
+
logger.error(f"Failed to set microphone volume: {e}")
|
| 165 |
+
else:
|
| 166 |
+
logger.warning("Cannot set microphone volume: robot not available")
|
| 167 |
+
|
| 168 |
# ========== Phase 2: Motor Control ==========
|
| 169 |
|
| 170 |
def get_motors_enabled(self) -> bool:
|
reachy_mini_ha_voice/satellite.py
CHANGED
|
@@ -88,6 +88,8 @@ class VoiceSatelliteProtocol(APIServer):
|
|
| 88 |
self._setup_phase5_entities()
|
| 89 |
self._setup_phase6_entities()
|
| 90 |
self._setup_phase7_entities()
|
|
|
|
|
|
|
| 91 |
|
| 92 |
self._is_streaming_audio = False
|
| 93 |
self._tts_url: Optional[str] = None
|
|
@@ -518,6 +520,37 @@ class VoiceSatelliteProtocol(APIServer):
|
|
| 518 |
except Exception as e:
|
| 519 |
_LOGGER.error("Reachy Mini motion error: %s", e)
|
| 520 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 521 |
# -------------------------------------------------------------------------
|
| 522 |
# Entity Setup Methods
|
| 523 |
# -------------------------------------------------------------------------
|
|
@@ -1047,3 +1080,102 @@ class VoiceSatelliteProtocol(APIServer):
|
|
| 1047 |
self.state.entities.append(imu_temperature)
|
| 1048 |
|
| 1049 |
_LOGGER.info("Phase 7 entities registered: IMU accelerometer, gyroscope, temperature")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
self._setup_phase5_entities()
|
| 89 |
self._setup_phase6_entities()
|
| 90 |
self._setup_phase7_entities()
|
| 91 |
+
self._setup_phase8_entities()
|
| 92 |
+
self._setup_phase9_entities()
|
| 93 |
|
| 94 |
self._is_streaming_audio = False
|
| 95 |
self._tts_url: Optional[str] = None
|
|
|
|
| 520 |
except Exception as e:
|
| 521 |
_LOGGER.error("Reachy Mini motion error: %s", e)
|
| 522 |
|
| 523 |
+
def _play_emotion(self, emotion_name: str) -> None:
|
| 524 |
+
"""Play an emotion/expression from the emotions library.
|
| 525 |
+
|
| 526 |
+
Args:
|
| 527 |
+
emotion_name: Name of the emotion (e.g., "happy1", "sad1", etc.)
|
| 528 |
+
"""
|
| 529 |
+
try:
|
| 530 |
+
import requests
|
| 531 |
+
|
| 532 |
+
# Get WLAN IP from daemon status
|
| 533 |
+
wlan_ip = "localhost"
|
| 534 |
+
if self.state.reachy_mini is not None:
|
| 535 |
+
try:
|
| 536 |
+
status = self.state.reachy_mini.client.get_status(wait=False)
|
| 537 |
+
wlan_ip = status.get('wlan_ip', 'localhost')
|
| 538 |
+
except Exception:
|
| 539 |
+
wlan_ip = "localhost"
|
| 540 |
+
|
| 541 |
+
# Call the emotion playback API
|
| 542 |
+
# Dataset: pollen-robotics/reachy-mini-emotions-library
|
| 543 |
+
url = f"http://{wlan_ip}:8000/api/move/play/recorded-move-dataset/pollen-robotics/reachy-mini-emotions-library/{emotion_name}"
|
| 544 |
+
|
| 545 |
+
response = requests.post(url, timeout=5)
|
| 546 |
+
if response.status_code == 200:
|
| 547 |
+
_LOGGER.info(f"Playing emotion: {emotion_name}")
|
| 548 |
+
else:
|
| 549 |
+
_LOGGER.warning(f"Failed to play emotion {emotion_name}: HTTP {response.status_code}")
|
| 550 |
+
|
| 551 |
+
except Exception as e:
|
| 552 |
+
_LOGGER.error(f"Error playing emotion {emotion_name}: {e}")
|
| 553 |
+
|
| 554 |
# -------------------------------------------------------------------------
|
| 555 |
# Entity Setup Methods
|
| 556 |
# -------------------------------------------------------------------------
|
|
|
|
| 1080 |
self.state.entities.append(imu_temperature)
|
| 1081 |
|
| 1082 |
_LOGGER.info("Phase 7 entities registered: IMU accelerometer, gyroscope, temperature")
|
| 1083 |
+
|
| 1084 |
+
def _setup_phase8_entities(self) -> None:
|
| 1085 |
+
"""Setup Phase 8 entities: Emotion and expression buttons."""
|
| 1086 |
+
|
| 1087 |
+
# Happy emotion
|
| 1088 |
+
happy_button = ButtonEntity(
|
| 1089 |
+
server=self,
|
| 1090 |
+
key=len(self.state.entities),
|
| 1091 |
+
name="Happy",
|
| 1092 |
+
object_id="emotion_happy",
|
| 1093 |
+
icon="mdi:emoticon-happy",
|
| 1094 |
+
device_class="restart",
|
| 1095 |
+
on_press=lambda: self._play_emotion("happy1"),
|
| 1096 |
+
)
|
| 1097 |
+
self.state.entities.append(happy_button)
|
| 1098 |
+
|
| 1099 |
+
# Sad emotion
|
| 1100 |
+
sad_button = ButtonEntity(
|
| 1101 |
+
server=self,
|
| 1102 |
+
key=len(self.state.entities),
|
| 1103 |
+
name="Sad",
|
| 1104 |
+
object_id="emotion_sad",
|
| 1105 |
+
icon="mdi:emoticon-sad",
|
| 1106 |
+
device_class="restart",
|
| 1107 |
+
on_press=lambda: self._play_emotion("sad1"),
|
| 1108 |
+
)
|
| 1109 |
+
self.state.entities.append(sad_button)
|
| 1110 |
+
|
| 1111 |
+
# Angry emotion
|
| 1112 |
+
angry_button = ButtonEntity(
|
| 1113 |
+
server=self,
|
| 1114 |
+
key=len(self.state.entities),
|
| 1115 |
+
name="Angry",
|
| 1116 |
+
object_id="emotion_angry",
|
| 1117 |
+
icon="mdi:emoticon-angry",
|
| 1118 |
+
device_class="restart",
|
| 1119 |
+
on_press=lambda: self._play_emotion("angry1"),
|
| 1120 |
+
)
|
| 1121 |
+
self.state.entities.append(angry_button)
|
| 1122 |
+
|
| 1123 |
+
# Fear emotion
|
| 1124 |
+
fear_button = ButtonEntity(
|
| 1125 |
+
server=self,
|
| 1126 |
+
key=len(self.state.entities),
|
| 1127 |
+
name="Fear",
|
| 1128 |
+
object_id="emotion_fear",
|
| 1129 |
+
icon="mdi:emoticon-frown",
|
| 1130 |
+
device_class="restart",
|
| 1131 |
+
on_press=lambda: self._play_emotion("fear1"),
|
| 1132 |
+
)
|
| 1133 |
+
self.state.entities.append(fear_button)
|
| 1134 |
+
|
| 1135 |
+
# Surprise emotion
|
| 1136 |
+
surprise_button = ButtonEntity(
|
| 1137 |
+
server=self,
|
| 1138 |
+
key=len(self.state.entities),
|
| 1139 |
+
name="Surprise",
|
| 1140 |
+
object_id="emotion_surprise",
|
| 1141 |
+
icon="mdi:emoticon-surprised",
|
| 1142 |
+
device_class="restart",
|
| 1143 |
+
on_press=lambda: self._play_emotion("surprise1"),
|
| 1144 |
+
)
|
| 1145 |
+
self.state.entities.append(surprise_button)
|
| 1146 |
+
|
| 1147 |
+
# Disgust emotion
|
| 1148 |
+
disgust_button = ButtonEntity(
|
| 1149 |
+
server=self,
|
| 1150 |
+
key=len(self.state.entities),
|
| 1151 |
+
name="Disgust",
|
| 1152 |
+
object_id="emotion_disgust",
|
| 1153 |
+
icon="mdi:emoticon-poop",
|
| 1154 |
+
device_class="restart",
|
| 1155 |
+
on_press=lambda: self._play_emotion("disgust1"),
|
| 1156 |
+
)
|
| 1157 |
+
self.state.entities.append(disgust_button)
|
| 1158 |
+
|
| 1159 |
+
_LOGGER.info("Phase 8 entities registered: emotions (happy, sad, angry, fear, surprise, disgust)")
|
| 1160 |
+
|
| 1161 |
+
def _setup_phase9_entities(self) -> None:
|
| 1162 |
+
"""Setup Phase 9 entities: Audio controls."""
|
| 1163 |
+
|
| 1164 |
+
# Microphone volume control
|
| 1165 |
+
microphone_volume = NumberEntity(
|
| 1166 |
+
server=self,
|
| 1167 |
+
key=len(self.state.entities),
|
| 1168 |
+
name="Microphone Volume",
|
| 1169 |
+
object_id="microphone_volume",
|
| 1170 |
+
min_value=0.0,
|
| 1171 |
+
max_value=100.0,
|
| 1172 |
+
step=1.0,
|
| 1173 |
+
icon="mdi:microphone",
|
| 1174 |
+
unit_of_measurement="%",
|
| 1175 |
+
mode=2, # Slider mode
|
| 1176 |
+
value_getter=self.reachy_controller.get_microphone_volume,
|
| 1177 |
+
value_setter=self.reachy_controller.set_microphone_volume,
|
| 1178 |
+
)
|
| 1179 |
+
self.state.entities.append(microphone_volume)
|
| 1180 |
+
|
| 1181 |
+
_LOGGER.info("Phase 9 entities registered: microphone_volume")
|