Desmond-Dong commited on
Commit
634577b
·
1 Parent(s): f526ff1

"revert_to_version_0.2.0"

Browse files
reachy_mini_ha_voice/emotion_moves.py DELETED
@@ -1,124 +0,0 @@
1
- """Emotion moves for the movement queue system.
2
-
3
- This module implements emotions as Move objects that can be queued
4
- and executed by the MovementManager, similar to reachy_mini_conversation_app.
5
- """
6
-
7
- from __future__ import annotations
8
- import logging
9
- from typing import Tuple, Optional
10
-
11
- import numpy as np
12
- from numpy.typing import NDArray
13
-
14
- try:
15
- from reachy_mini.motion.move import Move
16
- from reachy_mini.motion.recorded_move import RecordedMoves
17
- REACHY_MINI_AVAILABLE = True
18
- except ImportError:
19
- REACHY_MINI_AVAILABLE = False
20
- # Create dummy base class
21
- class Move:
22
- pass
23
-
24
- logger = logging.getLogger(__name__)
25
-
26
-
27
- class EmotionQueueMove(Move): # type: ignore
28
- """Wrapper for emotion moves to work with the movement queue system."""
29
-
30
- def __init__(self, emotion_name: str, recorded_moves: "RecordedMoves"):
31
- """Initialize an EmotionQueueMove.
32
-
33
- Args:
34
- emotion_name: Name of the emotion (e.g., "happy1", "sad1")
35
- recorded_moves: RecordedMoves instance containing the emotion library
36
- """
37
- if not REACHY_MINI_AVAILABLE:
38
- raise ImportError("reachy_mini package is required for emotion moves")
39
-
40
- self.emotion_move = recorded_moves.get(emotion_name)
41
- self.emotion_name = emotion_name
42
-
43
- @property
44
- def duration(self) -> float:
45
- """Duration property required by official Move interface."""
46
- return float(self.emotion_move.duration)
47
-
48
- def evaluate(self, t: float) -> Tuple[Optional[NDArray[np.float64]], Optional[NDArray[np.float64]], Optional[float]]:
49
- """Evaluate emotion move at time t.
50
-
51
- Args:
52
- t: Time in seconds since move started
53
-
54
- Returns:
55
- Tuple of (head_pose, antennas, body_yaw)
56
- """
57
- try:
58
- # Get the pose from the emotion move
59
- head_pose, antennas, body_yaw = self.emotion_move.evaluate(t)
60
-
61
- # Convert to numpy array if antennas is tuple and return in official Move format
62
- if isinstance(antennas, tuple):
63
- antennas = np.array([antennas[0], antennas[1]], dtype=np.float64)
64
-
65
- return (head_pose, antennas, body_yaw)
66
-
67
- except Exception as e:
68
- logger.error(f"Error evaluating emotion '{self.emotion_name}' at t={t}: {e}")
69
- # Return neutral pose on error
70
- from reachy_mini.utils import create_head_pose
71
-
72
- neutral_head_pose = create_head_pose(0, 0, 0, 0, 0, 0, degrees=True)
73
- return (neutral_head_pose, np.array([0.0, 0.0], dtype=np.float64), 0.0)
74
-
75
-
76
- # Global emotion library instance (lazy loaded)
77
- _RECORDED_MOVES: Optional["RecordedMoves"] = None
78
- _EMOTION_LIBRARY_AVAILABLE = False
79
-
80
-
81
- def get_emotion_library() -> Optional["RecordedMoves"]:
82
- """Get or initialize the emotion library.
83
-
84
- Returns:
85
- RecordedMoves instance or None if not available
86
- """
87
- global _RECORDED_MOVES, _EMOTION_LIBRARY_AVAILABLE
88
-
89
- if not REACHY_MINI_AVAILABLE:
90
- return None
91
-
92
- if _RECORDED_MOVES is None and not _EMOTION_LIBRARY_AVAILABLE:
93
- try:
94
- from reachy_mini.motion.recorded_move import RecordedMoves
95
- # Note: huggingface_hub automatically reads HF_TOKEN from environment variables
96
- _RECORDED_MOVES = RecordedMoves("pollen-robotics/reachy-mini-emotions-library")
97
- _EMOTION_LIBRARY_AVAILABLE = True
98
- logger.info("Emotion library loaded successfully")
99
- except Exception as e:
100
- logger.warning(f"Failed to load emotion library: {e}")
101
- _EMOTION_LIBRARY_AVAILABLE = True # Mark as attempted
102
-
103
- return _RECORDED_MOVES
104
-
105
-
106
- def create_emotion_move(emotion_name: str) -> Optional[EmotionQueueMove]:
107
- """Create an emotion move for the given emotion name.
108
-
109
- Args:
110
- emotion_name: Name of the emotion (e.g., "happy1", "sad1")
111
-
112
- Returns:
113
- EmotionQueueMove instance or None if emotion library not available
114
- """
115
- recorded_moves = get_emotion_library()
116
- if recorded_moves is None:
117
- logger.warning(f"Cannot create emotion move '{emotion_name}': library not available")
118
- return None
119
-
120
- try:
121
- return EmotionQueueMove(emotion_name, recorded_moves)
122
- except Exception as e:
123
- logger.error(f"Failed to create emotion move '{emotion_name}': {e}")
124
- return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
reachy_mini_ha_voice/motion.py CHANGED
@@ -9,7 +9,6 @@ import math
9
  from typing import Optional
10
 
11
  from .movement_manager import MovementManager, RobotState, PendingAction
12
- from .emotion_moves import create_emotion_move
13
 
14
  _LOGGER = logging.getLogger(__name__)
15
 
@@ -60,7 +59,7 @@ class ReachyMiniMotion:
60
  # -------------------------------------------------------------------------
61
 
62
  def on_wakeup(self, doa_angle_deg: Optional[float] = None):
63
- """Called when wake word is detected - turn to sound source and nod.
64
 
65
  Non-blocking: command sent to MovementManager.
66
 
@@ -84,29 +83,9 @@ class ReachyMiniMotion:
84
  self._movement_manager.reset_to_neutral(duration=0.3)
85
  _LOGGER.debug("DOA angle is None, looking forward")
86
 
87
- # Add nod confirmation gesture after turning
88
- # Enhanced: More visible nod with longer duration
89
- nod_action = PendingAction(
90
- name="wakeup_nod",
91
- target_pitch=math.radians(18), # Nod down 18 degrees (increased from 10)
92
- duration=0.5, # Longer duration for visibility (increased from 0.3)
93
- )
94
- self._movement_manager.queue_action(nod_action)
95
-
96
- # Return to neutral pitch after nod
97
- return_action = PendingAction(
98
- name="wakeup_return",
99
- target_pitch=math.radians(0), # Return to neutral
100
- duration=0.3, # Slightly longer return (increased from 0.2)
101
- )
102
- self._movement_manager.queue_action(return_action)
103
-
104
  # Set listening state
105
  self._movement_manager.set_state(RobotState.LISTENING)
106
 
107
- _LOGGER.info("Reachy Mini: Wake word detected - turning to %.1f° and nodding",
108
- doa_angle_deg if doa_angle_deg is not None else 0.0)
109
-
110
  def on_listening(self):
111
  """Called when listening for speech - attentive pose.
112
 
@@ -116,18 +95,7 @@ class ReachyMiniMotion:
116
  return
117
 
118
  self._movement_manager.set_state(RobotState.LISTENING)
119
-
120
- # Use a subtle attentive gesture instead of full emotion move
121
- # Slight head tilt to show attention
122
- attention_action = PendingAction(
123
- name="listening_attention",
124
- target_pitch=math.radians(-5), # Slight look up
125
- target_roll=math.radians(3), # Slight tilt
126
- duration=0.4,
127
- )
128
- self._movement_manager.queue_action(attention_action)
129
-
130
- _LOGGER.debug("Reachy Mini: Listening pose - attentive gesture")
131
 
132
  def on_thinking(self):
133
  """Called when processing speech - thinking pose.
@@ -139,17 +107,15 @@ class ReachyMiniMotion:
139
 
140
  self._movement_manager.set_state(RobotState.THINKING)
141
 
142
- # Use a subtle thinking gesture instead of full emotion move
143
- # Look up slightly as if thinking
144
- thinking_action = PendingAction(
145
  name="thinking",
146
- target_pitch=math.radians(-8), # Look up
147
- target_yaw=math.radians(5), # Slight turn
148
  duration=0.4,
149
  )
150
- self._movement_manager.queue_action(thinking_action)
151
-
152
- _LOGGER.debug("Reachy Mini: Thinking pose - subtle gesture")
153
 
154
  def on_speaking_start(self):
155
  """Called when TTS starts - start speech-reactive motion.
@@ -162,9 +128,14 @@ class ReachyMiniMotion:
162
  self._is_speaking = True
163
  self._movement_manager.set_state(RobotState.SPEAKING)
164
 
165
- # Don't queue emotion moves during conversation - let SpeechSway handle natural motion
166
- # Emotion moves can still be triggered manually via ESPHome entity
167
- _LOGGER.debug("Reachy Mini: Speaking started with speech sway")
 
 
 
 
 
168
 
169
  def on_speaking_end(self):
170
  """Called when TTS ends - stop speech-reactive motion.
@@ -188,12 +159,8 @@ class ReachyMiniMotion:
188
 
189
  self._is_speaking = False
190
  self._movement_manager.set_state(RobotState.IDLE)
191
-
192
- # Return to neutral position smoothly
193
- # Don't queue emotion moves - let breathing animation take over naturally
194
- self._movement_manager.reset_to_neutral(duration=0.8)
195
-
196
- _LOGGER.debug("Reachy Mini: Returning to idle - neutral pose")
197
 
198
  def on_timer_finished(self):
199
  """Called when a timer finishes - alert animation.
 
9
  from typing import Optional
10
 
11
  from .movement_manager import MovementManager, RobotState, PendingAction
 
12
 
13
  _LOGGER = logging.getLogger(__name__)
14
 
 
59
  # -------------------------------------------------------------------------
60
 
61
  def on_wakeup(self, doa_angle_deg: Optional[float] = None):
62
+ """Called when wake word is detected - turn to sound source.
63
 
64
  Non-blocking: command sent to MovementManager.
65
 
 
83
  self._movement_manager.reset_to_neutral(duration=0.3)
84
  _LOGGER.debug("DOA angle is None, looking forward")
85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  # Set listening state
87
  self._movement_manager.set_state(RobotState.LISTENING)
88
 
 
 
 
89
  def on_listening(self):
90
  """Called when listening for speech - attentive pose.
91
 
 
95
  return
96
 
97
  self._movement_manager.set_state(RobotState.LISTENING)
98
+ _LOGGER.debug("Reachy Mini: Listening pose")
 
 
 
 
 
 
 
 
 
 
 
99
 
100
  def on_thinking(self):
101
  """Called when processing speech - thinking pose.
 
107
 
108
  self._movement_manager.set_state(RobotState.THINKING)
109
 
110
+ # Look up slightly (thinking gesture)
111
+ action = PendingAction(
 
112
  name="thinking",
113
+ target_pitch=math.radians(-10), # Look up
114
+ target_yaw=math.radians(5), # Slight turn
115
  duration=0.4,
116
  )
117
+ self._movement_manager.queue_action(action)
118
+ _LOGGER.debug("Reachy Mini: Thinking pose")
 
119
 
120
  def on_speaking_start(self):
121
  """Called when TTS starts - start speech-reactive motion.
 
128
  self._is_speaking = True
129
  self._movement_manager.set_state(RobotState.SPEAKING)
130
 
131
+ # Gentle nod to indicate speaking
132
+ action = PendingAction(
133
+ name="speaking_start",
134
+ target_pitch=math.radians(5), # Slight nod down
135
+ duration=0.3,
136
+ )
137
+ self._movement_manager.queue_action(action)
138
+ _LOGGER.debug("Reachy Mini: Speaking started")
139
 
140
  def on_speaking_end(self):
141
  """Called when TTS ends - stop speech-reactive motion.
 
159
 
160
  self._is_speaking = False
161
  self._movement_manager.set_state(RobotState.IDLE)
162
+ self._movement_manager.reset_to_neutral(duration=0.5)
163
+ _LOGGER.debug("Reachy Mini: Idle pose")
 
 
 
 
164
 
165
  def on_timer_finished(self):
166
  """Called when a timer finishes - alert animation.
reachy_mini_ha_voice/movement_manager.py CHANGED
@@ -17,7 +17,6 @@ import logging
17
  import math
18
  import threading
19
  import time
20
- from collections import deque
21
  from dataclasses import dataclass, field
22
  from enum import Enum
23
  from queue import Queue, Empty
@@ -28,7 +27,6 @@ from scipy.spatial.transform import Rotation as R
28
 
29
  if TYPE_CHECKING:
30
  from reachy_mini import ReachyMini
31
- from reachy_mini.motion.move import Move
32
 
33
  logger = logging.getLogger(__name__)
34
 
@@ -339,11 +337,6 @@ class MovementManager:
339
  self._action_start_time: float = 0.0
340
  self._action_start_pose: Dict[str, float] = {}
341
 
342
- # Move queue (for emotions and other Move objects)
343
- self._move_queue: deque = deque()
344
- self._current_move: Optional["Move"] = None
345
- self._move_start_time: Optional[float] = None
346
-
347
  # Audio loudness (updated externally)
348
  self._audio_loudness_db: float = -100.0
349
  self._audio_lock = threading.Lock()
@@ -380,18 +373,6 @@ class MovementManager:
380
  """Thread-safe: Queue a motion action."""
381
  self._command_queue.put(("action", action))
382
 
383
- def queue_move(self, move: "Move") -> None:
384
- """Thread-safe: Queue a Move object (e.g., emotion).
385
-
386
- Args:
387
- move: A Move object (e.g., EmotionQueueMove)
388
- """
389
- self._command_queue.put(("queue_move", move))
390
-
391
- def clear_move_queue(self) -> None:
392
- """Thread-safe: Clear the move queue and stop current move."""
393
- self._command_queue.put(("clear_queue", None))
394
-
395
  def turn_to_angle(self, yaw_deg: float, duration: float = 0.8) -> None:
396
  """Thread-safe: Turn head to face a direction."""
397
  action = PendingAction(
@@ -474,18 +455,6 @@ class MovementManager:
474
  elif cmd == "action":
475
  self._start_action(payload)
476
 
477
- elif cmd == "queue_move":
478
- if payload is not None:
479
- self._move_queue.append(payload)
480
- self.state.last_activity_time = self._now()
481
- logger.debug("Queued move, queue size: %d", len(self._move_queue))
482
-
483
- elif cmd == "clear_queue":
484
- self._move_queue.clear()
485
- self._current_move = None
486
- self._move_start_time = None
487
- logger.info("Cleared move queue and stopped current move")
488
-
489
  elif cmd == "nod":
490
  amplitude_deg, duration = payload
491
  self._do_nod(amplitude_deg, duration)
@@ -539,68 +508,8 @@ class MovementManager:
539
  # Internal: Motion updates (runs in control loop)
540
  # =========================================================================
541
 
542
- def _manage_move_queue(self) -> None:
543
- """Manage the move queue (emotions, etc.)."""
544
- current_time = self._now()
545
-
546
- # Check if current move is finished
547
- if self._current_move is None or (
548
- self._move_start_time is not None
549
- and current_time - self._move_start_time >= self._current_move.duration
550
- ):
551
- self._current_move = None
552
- self._move_start_time = None
553
-
554
- # Start next move in queue
555
- if self._move_queue:
556
- self._current_move = self._move_queue.popleft()
557
- self._move_start_time = current_time
558
- logger.debug(f"Starting move, duration: {self._current_move.duration}s")
559
-
560
- # Evaluate current move and update target pose
561
- # NOTE: Moves set the base target pose, but SpeechSway can still add on top
562
- if self._current_move is not None and self._move_start_time is not None:
563
- move_time = current_time - self._move_start_time
564
- try:
565
- head_pose, antennas, body_yaw = self._current_move.evaluate(move_time)
566
-
567
- if head_pose is not None:
568
- # Extract rotation from head pose matrix
569
- from scipy.spatial.transform import Rotation as R
570
- rotation = R.from_matrix(head_pose[:3, :3])
571
- euler = rotation.as_euler('xyz')
572
-
573
- self.state.target_pitch = float(euler[0])
574
- self.state.target_roll = float(euler[1])
575
- self.state.target_yaw = float(euler[2])
576
- self.state.target_x = float(head_pose[0, 3])
577
- self.state.target_y = float(head_pose[1, 3])
578
- self.state.target_z = float(head_pose[2, 3])
579
-
580
- if antennas is not None:
581
- self.state.target_antenna_right = float(antennas[0])
582
- self.state.target_antenna_left = float(antennas[1])
583
-
584
- if body_yaw is not None:
585
- self.state.target_body_yaw = float(body_yaw)
586
-
587
- # Cancel any pending action since move takes priority
588
- if self._pending_action is not None:
589
- self._pending_action = None
590
- logger.debug("Cancelled pending action due to active move")
591
-
592
- except Exception as e:
593
- logger.error(f"Error evaluating move: {e}")
594
- # Clear the problematic move
595
- self._current_move = None
596
- self._move_start_time = None
597
-
598
  def _update_action(self, dt: float) -> None:
599
  """Update pending action interpolation."""
600
- # Skip if a move is currently playing (moves take priority)
601
- if self._current_move is not None:
602
- return
603
-
604
  if self._pending_action is None:
605
  return
606
 
@@ -862,25 +771,22 @@ class MovementManager:
862
  # 1. Process commands from queue
863
  self._poll_commands()
864
 
865
- # 2. Manage move queue (emotions, etc.)
866
- self._manage_move_queue()
867
-
868
- # 3. Update action interpolation
869
  self._update_action(dt)
870
 
871
- # 4. Update speech sway
872
  self._update_speech_sway(dt)
873
 
874
- # 5. Update breathing animation
875
  self._update_breathing(dt)
876
 
877
- # 6. Update antenna blend (listening mode freeze/unfreeze)
878
  self._update_antenna_blend(dt)
879
 
880
- # 7. Compose final pose
881
  pose = self._compose_final_pose()
882
 
883
- # 8. Send to robot (single control point!)
884
  self._issue_control_command(pose)
885
 
886
  except Exception as e:
 
17
  import math
18
  import threading
19
  import time
 
20
  from dataclasses import dataclass, field
21
  from enum import Enum
22
  from queue import Queue, Empty
 
27
 
28
  if TYPE_CHECKING:
29
  from reachy_mini import ReachyMini
 
30
 
31
  logger = logging.getLogger(__name__)
32
 
 
337
  self._action_start_time: float = 0.0
338
  self._action_start_pose: Dict[str, float] = {}
339
 
 
 
 
 
 
340
  # Audio loudness (updated externally)
341
  self._audio_loudness_db: float = -100.0
342
  self._audio_lock = threading.Lock()
 
373
  """Thread-safe: Queue a motion action."""
374
  self._command_queue.put(("action", action))
375
 
 
 
 
 
 
 
 
 
 
 
 
 
376
  def turn_to_angle(self, yaw_deg: float, duration: float = 0.8) -> None:
377
  """Thread-safe: Turn head to face a direction."""
378
  action = PendingAction(
 
455
  elif cmd == "action":
456
  self._start_action(payload)
457
 
 
 
 
 
 
 
 
 
 
 
 
 
458
  elif cmd == "nod":
459
  amplitude_deg, duration = payload
460
  self._do_nod(amplitude_deg, duration)
 
508
  # Internal: Motion updates (runs in control loop)
509
  # =========================================================================
510
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
511
  def _update_action(self, dt: float) -> None:
512
  """Update pending action interpolation."""
 
 
 
 
513
  if self._pending_action is None:
514
  return
515
 
 
771
  # 1. Process commands from queue
772
  self._poll_commands()
773
 
774
+ # 2. Update action interpolation
 
 
 
775
  self._update_action(dt)
776
 
777
+ # 3. Update speech sway
778
  self._update_speech_sway(dt)
779
 
780
+ # 4. Update breathing animation
781
  self._update_breathing(dt)
782
 
783
+ # 5. Update antenna blend (listening mode freeze/unfreeze)
784
  self._update_antenna_blend(dt)
785
 
786
+ # 6. Compose final pose
787
  pose = self._compose_final_pose()
788
 
789
+ # 7. Send to robot (single control point!)
790
  self._issue_control_command(pose)
791
 
792
  except Exception as e:
reachy_mini_ha_voice/satellite.py CHANGED
@@ -540,24 +540,30 @@ class VoiceSatelliteProtocol(APIServer):
540
  def _play_emotion(self, emotion_name: str) -> None:
541
  """Play an emotion/expression from the emotions library.
542
 
543
- This method is kept for backward compatibility with entity_registry.
544
- It now uses the queue-based system instead of HTTP API.
545
-
546
  Args:
547
  emotion_name: Name of the emotion (e.g., "happy1", "sad1", etc.)
548
  """
549
  try:
550
- from .emotion_moves import create_emotion_move
551
-
552
- if self.state.motion and self.state.motion.movement_manager:
553
- emotion_move = create_emotion_move(emotion_name)
554
- if emotion_move:
555
- self.state.motion.movement_manager.queue_move(emotion_move)
556
- _LOGGER.info(f"Queued emotion: {emotion_name}")
557
- else:
558
- _LOGGER.warning(f"Failed to create emotion move: {emotion_name}")
 
 
 
 
 
 
 
 
 
559
  else:
560
- _LOGGER.warning("Motion system not available for emotion playback")
561
 
562
  except Exception as e:
563
- _LOGGER.error(f"Error queueing emotion {emotion_name}: {e}")
 
540
  def _play_emotion(self, emotion_name: str) -> None:
541
  """Play an emotion/expression from the emotions library.
542
 
 
 
 
543
  Args:
544
  emotion_name: Name of the emotion (e.g., "happy1", "sad1", etc.)
545
  """
546
  try:
547
+ import requests
548
+
549
+ # Get WLAN IP from daemon status
550
+ wlan_ip = "localhost"
551
+ if self.state.reachy_mini is not None:
552
+ try:
553
+ status = self.state.reachy_mini.client.get_status(wait=False)
554
+ wlan_ip = status.get('wlan_ip', 'localhost')
555
+ except Exception:
556
+ wlan_ip = "localhost"
557
+
558
+ # Call the emotion playback API
559
+ # Dataset: pollen-robotics/reachy-mini-emotions-library
560
+ url = f"http://{wlan_ip}:8000/api/move/play/recorded-move-dataset/pollen-robotics/reachy-mini-emotions-library/{emotion_name}"
561
+
562
+ response = requests.post(url, timeout=5)
563
+ if response.status_code == 200:
564
+ _LOGGER.info(f"Playing emotion: {emotion_name}")
565
  else:
566
+ _LOGGER.warning(f"Failed to play emotion {emotion_name}: HTTP {response.status_code}")
567
 
568
  except Exception as e:
569
+ _LOGGER.error(f"Error playing emotion {emotion_name}: {e}")