Desmond-Dong commited on
Commit
52e44ae
·
1 Parent(s): f3f955c

Add conversation_app architecture documentation and analysis

Browse files
.claude/settings.local.json CHANGED
@@ -3,24 +3,51 @@
3
  "includeCoAuthoredBy": false,
4
  "permissions": {
5
  "allow": [
6
- "SlashCommand(/zcf:git-commit)",
 
7
  "Edit",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  "Bash(cd:*)",
9
- "SlashCommand(/zcf:git-commit --emoji)",
10
- "SlashCommand(/zcf:git-commit:*)",
11
- "Bash(git:*)",
12
- "Bash(ls:*)"
13
  ],
14
  "deny": [],
15
  "ask": []
16
  },
 
17
  "hooks": {},
18
- "alwaysThinkingEnabled": true,
19
- "outputStyle": "default",
20
  "statusLine": {
21
  "type": "command",
22
  "command": "%USERPROFILE%\\.claude\\ccline\\ccline.exe",
23
  "padding": 0
24
  },
25
- "model": "opus"
26
- }
 
 
 
 
 
 
3
  "includeCoAuthoredBy": false,
4
  "permissions": {
5
  "allow": [
6
+ "Bash",
7
+ "BashOutput",
8
  "Edit",
9
+ "Glob",
10
+ "Grep",
11
+ "KillShell",
12
+ "NotebookEdit",
13
+ "Read",
14
+ "SlashCommand",
15
+ "Task",
16
+ "TodoWrite",
17
+ "WebFetch",
18
+ "WebSearch",
19
+ "Write",
20
+ "mcp__ide",
21
+ "mcp__exa",
22
+ "mcp__context7",
23
+ "mcp__mcp-deepwiki",
24
+ "mcp__Playwright",
25
+ "mcp__spec-workflow",
26
+ "mcp__open-websearch",
27
+ "mcp__serena",
28
+ "All",
29
+ "Bash(copy:*)",
30
+ "mcp__zread__search_doc",
31
+ "mcp__zread__read_file",
32
  "Bash(cd:*)",
33
+ "Bash(ls:*)",
34
+ "Bash(find:*)",
35
+ "mcp__acp__Bash"
 
36
  ],
37
  "deny": [],
38
  "ask": []
39
  },
40
+ "model": "opus",
41
  "hooks": {},
 
 
42
  "statusLine": {
43
  "type": "command",
44
  "command": "%USERPROFILE%\\.claude\\ccline\\ccline.exe",
45
  "padding": 0
46
  },
47
+ "enabledPlugins": {
48
+ "glm-plan-usage@zai-coding-plugins": true,
49
+ "glm-plan-bug@zai-coding-plugins": true
50
+ },
51
+ "outputStyle": "Explanatory",
52
+ "alwaysThinkingEnabled": true
53
+ }
PROJECT_PLAN.md CHANGED
@@ -850,10 +850,11 @@ During long-term operation, `reachy_mini daemon` would crash, causing robot to b
850
 
851
  ### Fix Solution
852
 
853
- #### 1. Reduce control loop frequency (movement_manager.py)
854
  ```python
855
- # Reduced from 100Hz to 20Hz
856
- CONTROL_LOOP_FREQUENCY_HZ = 20 # 80% reduction in messages
 
857
  ```
858
 
859
  #### 2. Add pose change detection (movement_manager.py)
@@ -937,11 +938,11 @@ Through deep analysis of SDK source code:
937
 
938
  ### Fix Solution
939
 
940
- #### 1. Further reduce control loop frequency (movement_manager.py)
941
  ```python
942
- # Reduced from 20Hz to 10Hz
943
- # 10Hz × 3 messages = 30 messages/second, safely below daemon's 50Hz capacity
944
- CONTROL_LOOP_FREQUENCY_HZ = 10
945
  ```
946
 
947
  #### 2. Increase pose change threshold (movement_manager.py)
@@ -965,18 +966,20 @@ self._cache_ttl = 2.0
965
 
966
  ### Fix Results
967
 
968
- | Metric | Before (20Hz) | After (10Hz) | Improvement |
969
- |--------|---------------|--------------|-------------|
970
- | Control loop frequency | 20 Hz | 10 Hz | 50% |
971
- | Max Zenoh messages | 60 msg/s | 30 msg/s | ↓ 50% |
972
- | Actual messages (with change detection) | ~40 msg/s | ~15 msg/s | 62% |
973
- | Face tracking frequency | 15 Hz | 10 Hz | 33% |
974
- | State cache TTL | 1 second | 2 seconds | 100% |
975
- | Expected stability | Crash within hours | Stable operation | Major improvement |
 
 
976
 
977
  ### Key Finding
978
 
979
- Reference `reachy_mini_conversation_app` uses 100Hz control loop, but it's an official app that may have special optimizations or runs on more powerful hardware. Our app needs more conservative settings.
980
 
981
  ### Related Files
982
  - `movement_manager.py` - Control loop frequency and pose threshold
 
850
 
851
  ### Fix Solution
852
 
853
+ #### 1. Control loop frequency (movement_manager.py)
854
  ```python
855
+ # Initially reduced from 100Hz to 20Hz, then later restored to 100Hz
856
+ # See "Update (2026-01-12)" below for current status
857
+ CONTROL_LOOP_FREQUENCY_HZ = 100 # Now restored to 100Hz
858
  ```
859
 
860
  #### 2. Add pose change detection (movement_manager.py)
 
938
 
939
  ### Fix Solution
940
 
941
+ #### 1. Control loop frequency history (movement_manager.py)
942
  ```python
943
+ # Evolution: 100Hz -> 20Hz -> 10Hz -> 100Hz (restored)
944
+ # After daemon updates, 100Hz is now stable
945
+ CONTROL_LOOP_FREQUENCY_HZ = 100 # Restored to 100Hz (2026-01-12)
946
  ```
947
 
948
  #### 2. Increase pose change threshold (movement_manager.py)
 
966
 
967
  ### Fix Results
968
 
969
+ > **Note**: Control loop has been restored to 100Hz as of 2026-01-12. The table below shows historical values before restoration.
970
+
971
+ | Metric | Before (20Hz) | After (10Hz) | Current (100Hz) |
972
+ |--------|---------------|--------------|-----------------|
973
+ | Control loop frequency | 20 Hz | 10 Hz | 100 Hz (restored) |
974
+ | Max Zenoh messages | 60 msg/s | 30 msg/s | ~100 msg/s (optimized) |
975
+ | Actual messages (with change detection) | ~40 msg/s | ~15 msg/s | ~30 msg/s |
976
+ | Face tracking frequency | 15 Hz | 10 Hz | Adaptive (2-15 Hz) |
977
+ | State cache TTL | 1 second | 2 seconds | 2 seconds |
978
+ | Expected stability | Crash within hours | Stable operation | Stable (daemon updated) |
979
 
980
  ### Key Finding
981
 
982
+ Reference `reachy_mini_conversation_app` uses 100Hz control loop. After daemon updates and optimizations (pose change threshold 0.005, state cache TTL 2s), our app now also runs stably at 100Hz.
983
 
984
  ### Related Files
985
  - `movement_manager.py` - Control loop frequency and pose threshold
reachy_mini_ha_voice/animations/conversation_animations.json CHANGED
@@ -1,10 +1,12 @@
1
  {
2
  "animations": {
3
  "idle": {
4
- "description": "No movement when idle - robot stays at neutral position",
5
- "z_amplitude_m": 0.0,
6
- "antenna_amplitude_rad": 0.0,
7
- "frequency_hz": 0.0
 
 
8
  },
9
  "listening": {
10
  "description": "Attentive pose while listening to user - slight forward lean",
@@ -25,63 +27,149 @@
25
  "antenna_amplitude_rad": 0.25,
26
  "antenna_move_name": "wiggle",
27
  "frequency_hz": 0.4
28
- },
29
- "speaking": {
30
- "description": "Speaking animation - multi-frequency natural head sway",
31
- "pitch_amplitude_rad": 0.08,
32
- "pitch_frequency_hz": 2.2,
33
- "yaw_amplitude_rad": 0.13,
34
- "yaw_frequency_hz": 0.6,
35
- "roll_amplitude_rad": 0.04,
36
- "roll_frequency_hz": 1.3,
37
- "x_amplitude_m": 0.0045,
38
- "x_frequency_hz": 0.35,
39
- "y_amplitude_m": 0.00375,
40
- "y_frequency_hz": 0.45,
41
- "z_amplitude_m": 0.00225,
42
- "z_frequency_hz": 0.25,
43
- "antenna_amplitude_rad": 0.5,
44
- "antenna_move_name": "wiggle",
45
- "frequency_hz": 1.0
46
- },
47
  "happy": {
48
- "description": "Happy/positive response",
49
- "pitch_amplitude_rad": 0.08,
50
- "z_amplitude_m": 0.01,
51
- "antenna_amplitude_rad": 0.5,
52
- "antenna_move_name": "both",
53
- "frequency_hz": 1.2
 
54
  },
55
  "sad": {
56
- "description": "Sad/negative response - head droops",
57
- "pitch_offset_rad": 0.1,
58
- "pitch_amplitude_rad": 0.04,
59
- "z_offset_m": -0.01,
60
- "z_amplitude_m": 0.002,
61
- "antenna_amplitude_rad": 0.1,
62
- "antenna_move_name": "both",
63
- "frequency_hz": 0.3
 
 
 
 
 
 
 
 
 
64
  },
65
  "confused": {
66
- "description": "Confused/error state - head tilts",
67
- "roll_amplitude_rad": 0.1,
68
- "yaw_amplitude_rad": 0.12,
69
- "pitch_amplitude_rad": 0.05,
70
- "antenna_amplitude_rad": 0.4,
71
- "antenna_move_name": "wiggle",
72
- "frequency_hz": 0.7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  },
74
  "alert": {
75
- "description": "Alert/timer finished - quick movements",
76
- "pitch_amplitude_rad": 0.1,
77
- "z_amplitude_m": 0.012,
78
- "antenna_amplitude_rad": 0.6,
79
- "antenna_move_name": "both",
80
- "frequency_hz": 1.5
 
81
  }
82
  },
83
  "settings": {
84
  "amplitude_scale": 1.0,
85
- "transition_duration_s": 0.3
 
 
86
  }
87
  }
 
1
  {
2
  "animations": {
3
  "idle": {
4
+ "description": "Breathing animation when idle - gentle z-axis movement and antenna sway (same as conversation_app BreathingMove)",
5
+ "z_amplitude_m": 0.005,
6
+ "z_frequency_hz": 0.1,
7
+ "antenna_amplitude_rad": 0.262,
8
+ "antenna_move_name": "wiggle",
9
+ "frequency_hz": 0.5
10
  },
11
  "listening": {
12
  "description": "Attentive pose while listening to user - slight forward lean",
 
27
  "antenna_amplitude_rad": 0.25,
28
  "antenna_move_name": "wiggle",
29
  "frequency_hz": 0.4
30
+ }
31
+ },
32
+ "emotions": {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  "happy": {
34
+ "description": "Happy/joyful expression - bouncy head movement with excited antennas",
35
+ "duration": 2.0,
36
+ "pitch_amplitude": 0.15,
37
+ "z_amplitude": 0.015,
38
+ "antenna_left": 0.5,
39
+ "antenna_right": 0.5,
40
+ "frequency": 1.5
41
  },
42
  "sad": {
43
+ "description": "Sad/disappointed expression - drooping head and antennas",
44
+ "duration": 2.5,
45
+ "pitch": 0.15,
46
+ "z": -0.01,
47
+ "pitch_amplitude": 0.05,
48
+ "antenna_left": -0.2,
49
+ "antenna_right": -0.2,
50
+ "frequency": 0.3
51
+ },
52
+ "surprised": {
53
+ "description": "Surprised/shocked expression - head pulls back with alert antennas",
54
+ "duration": 1.5,
55
+ "pitch": -0.1,
56
+ "z": 0.01,
57
+ "antenna_left": 0.7,
58
+ "antenna_right": 0.7,
59
+ "frequency": 2.0
60
  },
61
  "confused": {
62
+ "description": "Confused/puzzled expression - head tilts with asymmetric antennas",
63
+ "duration": 2.0,
64
+ "roll": 0.12,
65
+ "yaw_amplitude": 0.15,
66
+ "antenna_left": 0.3,
67
+ "antenna_right": -0.3,
68
+ "frequency": 0.8
69
+ },
70
+ "excited": {
71
+ "description": "Excited/enthusiastic expression - energetic multi-axis movement",
72
+ "duration": 2.0,
73
+ "pitch_amplitude": 0.12,
74
+ "yaw_amplitude": 0.1,
75
+ "z_amplitude": 0.02,
76
+ "antenna_left": 0.6,
77
+ "antenna_right": 0.6,
78
+ "frequency": 2.0
79
+ },
80
+ "thinking_emotion": {
81
+ "description": "Thinking/pondering expression - slight tilt with slow movement",
82
+ "duration": 2.0,
83
+ "roll": 0.08,
84
+ "pitch": -0.05,
85
+ "yaw_amplitude": 0.08,
86
+ "antenna_left": 0.4,
87
+ "antenna_right": -0.2,
88
+ "frequency": 0.4
89
+ },
90
+ "nod": {
91
+ "description": "Nodding gesture - quick up-down head movement",
92
+ "duration": 0.8,
93
+ "pitch_amplitude": 0.2,
94
+ "frequency": 2.5
95
+ },
96
+ "shake": {
97
+ "description": "Shaking gesture - quick left-right head movement",
98
+ "duration": 0.8,
99
+ "yaw_amplitude": 0.25,
100
+ "frequency": 3.0
101
+ },
102
+ "curious": {
103
+ "description": "Curious/interested expression - head tilts forward with alert antennas",
104
+ "duration": 1.5,
105
+ "pitch": -0.08,
106
+ "roll": 0.1,
107
+ "antenna_left": 0.5,
108
+ "antenna_right": 0.3,
109
+ "frequency": 0.6
110
+ },
111
+ "sleepy": {
112
+ "description": "Sleepy/tired expression - slow drooping movement",
113
+ "duration": 3.0,
114
+ "pitch": 0.12,
115
+ "z": -0.015,
116
+ "pitch_amplitude": 0.03,
117
+ "antenna_left": -0.4,
118
+ "antenna_right": -0.4,
119
+ "frequency": 0.15
120
+ },
121
+ "angry": {
122
+ "description": "Angry/frustrated expression - intense forward lean with tense antennas",
123
+ "duration": 1.5,
124
+ "pitch": -0.12,
125
+ "roll_amplitude": 0.08,
126
+ "antenna_left": 0.8,
127
+ "antenna_right": 0.8,
128
+ "frequency": 1.8
129
+ },
130
+ "shy": {
131
+ "description": "Shy/embarrassed expression - head turns away slightly",
132
+ "duration": 2.0,
133
+ "yaw": 0.15,
134
+ "pitch": 0.08,
135
+ "roll": 0.05,
136
+ "antenna_left": -0.1,
137
+ "antenna_right": -0.1,
138
+ "frequency": 0.3
139
+ },
140
+ "love": {
141
+ "description": "Loving/affectionate expression - gentle swaying with happy antennas",
142
+ "duration": 2.5,
143
+ "yaw_amplitude": 0.08,
144
+ "pitch_amplitude": 0.06,
145
+ "z_amplitude": 0.008,
146
+ "antenna_left": 0.4,
147
+ "antenna_right": 0.4,
148
+ "frequency": 0.8
149
+ },
150
+ "bored": {
151
+ "description": "Bored/uninterested expression - slow side-to-side with droopy antennas",
152
+ "duration": 3.0,
153
+ "yaw_amplitude": 0.1,
154
+ "pitch": 0.05,
155
+ "antenna_left": -0.15,
156
+ "antenna_right": -0.15,
157
+ "frequency": 0.2
158
  },
159
  "alert": {
160
+ "description": "Alert/attentive expression - quick upward movement with perky antennas",
161
+ "duration": 1.0,
162
+ "pitch": -0.15,
163
+ "z": 0.015,
164
+ "antenna_left": 0.7,
165
+ "antenna_right": 0.7,
166
+ "frequency": 2.5
167
  }
168
  },
169
  "settings": {
170
  "amplitude_scale": 1.0,
171
+ "transition_duration_s": 0.3,
172
+ "default_emotion_duration": 2.0,
173
+ "default_emotion_frequency": 1.0
174
  }
175
  }
reachy_mini_ha_voice/animations/emotion_keywords.json ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "description": "Emotion keyword detection for automatic emotion triggering. Maps text patterns to robot emotion names from pollen-robotics/reachy-mini-emotions-library.",
3
+ "keywords": {
4
+ "haha": "laughing1",
5
+ "hehe": "laughing1",
6
+ "lol": "laughing1",
7
+ "太棒了": "cheerful1",
8
+ "太好了": "cheerful1",
9
+ "好开心": "cheerful1",
10
+ "真高兴": "cheerful1",
11
+ "恭喜": "cheerful1",
12
+ "congratulations": "cheerful1",
13
+ "awesome": "cheerful1",
14
+ "amazing": "amazed1",
15
+ "wonderful": "cheerful1",
16
+ "fantastic": "enthusiastic1",
17
+ "love": "loving1",
18
+ "喜欢": "loving1",
19
+ "爱": "loving1",
20
+ "thank": "grateful1",
21
+ "谢谢": "grateful1",
22
+ "感谢": "grateful1",
23
+ "welcome": "welcoming1",
24
+ "欢迎": "welcoming1",
25
+ "sure": "helpful1",
26
+ "of course": "helpful1",
27
+ "当然": "helpful1",
28
+ "好的": "helpful1",
29
+ "没问题": "helpful1",
30
+ "interesting": "curious1",
31
+ "有意思": "curious1",
32
+ "curious": "curious1",
33
+ "好奇": "curious1",
34
+ "hmm": "thoughtful1",
35
+ "嗯": "thoughtful1",
36
+ "让我想想": "thoughtful1",
37
+ "let me think": "thoughtful1",
38
+ "sorry": "sad1",
39
+ "抱歉": "sad1",
40
+ "对不起": "sad1",
41
+ "unfortunately": "sad1",
42
+ "不幸": "sad1",
43
+ "oops": "oops1",
44
+ "糟糕": "oops1",
45
+ "哎呀": "oops1",
46
+ "don't know": "uncertain1",
47
+ "不知道": "uncertain1",
48
+ "不确定": "uncertain1",
49
+ "confused": "confused1",
50
+ "困惑": "confused1",
51
+ "wow": "surprised1",
52
+ "哇": "surprised1",
53
+ "真的吗": "surprised1",
54
+ "really": "surprised1",
55
+ "yes": "yes1",
56
+ "是的": "yes1",
57
+ "对": "yes1",
58
+ "no": "no1",
59
+ "不是": "no1",
60
+ "不行": "no1"
61
+ },
62
+ "settings": {
63
+ "enabled": true,
64
+ "case_sensitive": false,
65
+ "max_emotions_per_response": 1
66
+ }
67
+ }
reachy_mini_ha_voice/audio_player.py CHANGED
@@ -21,6 +21,11 @@ if TYPE_CHECKING:
21
 
22
  _LOGGER = logging.getLogger(__name__)
23
 
 
 
 
 
 
24
  # Check if aiosendspin is available
25
  try:
26
  from aiosendspin.client import SendspinClient, PCMFormat
@@ -463,6 +468,8 @@ class AudioPlayer:
463
  self.reachy_mini.media.play_sound(file_path)
464
 
465
  # Playback loop with sway animation
 
 
466
  start_time = time.time()
467
  frame_duration = 0.05 # 50ms per sway frame (HOP_MS)
468
  frame_idx = 0
@@ -472,10 +479,14 @@ class AudioPlayer:
472
  self.reachy_mini.media.stop_playing()
473
  break
474
 
475
- # Apply sway frame if available
476
  if self._sway_callback and frame_idx < len(sway_frames):
477
  elapsed = time.time() - start_time
478
- target_frame = int(elapsed / frame_duration)
 
 
 
 
479
  while frame_idx <= target_frame and frame_idx < len(sway_frames):
480
  self._sway_callback(sway_frames[frame_idx])
481
  frame_idx += 1
 
21
 
22
  _LOGGER = logging.getLogger(__name__)
23
 
24
+ # Movement latency to sync head motion with audio playback
25
+ # Audio playback has hardware buffer latency, so we delay head motion to match
26
+ # Same as reachy_mini_conversation_app's HeadWobbler.MOVEMENT_LATENCY_S
27
+ MOVEMENT_LATENCY_S = 0.2 # 200ms latency between audio start and head movement
28
+
29
  # Check if aiosendspin is available
30
  try:
31
  from aiosendspin.client import SendspinClient, PCMFormat
 
468
  self.reachy_mini.media.play_sound(file_path)
469
 
470
  # Playback loop with sway animation
471
+ # Apply MOVEMENT_LATENCY_S delay to sync head motion with audio
472
+ # (audio playback has hardware buffer latency)
473
  start_time = time.time()
474
  frame_duration = 0.05 # 50ms per sway frame (HOP_MS)
475
  frame_idx = 0
 
479
  self.reachy_mini.media.stop_playing()
480
  break
481
 
482
+ # Apply sway frame if available, with 200ms delay
483
  if self._sway_callback and frame_idx < len(sway_frames):
484
  elapsed = time.time() - start_time
485
+ # Apply latency: head motion starts MOVEMENT_LATENCY_S after audio
486
+ effective_elapsed = max(0, elapsed - MOVEMENT_LATENCY_S)
487
+ target_frame = int(effective_elapsed / frame_duration)
488
+
489
+ # Skip frames if falling behind (lag compensation)
490
  while frame_idx <= target_frame and frame_idx < len(sway_frames):
491
  self._sway_callback(sway_frames[frame_idx])
492
  frame_idx += 1
reachy_mini_ha_voice/movement_manager.py CHANGED
@@ -5,7 +5,7 @@ This module provides a centralized control system for robot movements,
5
  inspired by the reachy_mini_conversation_app architecture.
6
 
7
  Key features:
8
- - Single 10Hz control loop (balanced between responsiveness and stability)
9
  - Command queue pattern (thread-safe external API)
10
  - Error throttling (prevents log explosion)
11
  - JSON-driven animation system (conversation state animations)
@@ -18,6 +18,7 @@ Key features:
18
 
19
  import logging
20
  import math
 
21
  import threading
22
  import time
23
  from dataclasses import dataclass, field
@@ -57,12 +58,21 @@ TARGET_PERIOD = 1.0 / CONTROL_LOOP_FREQUENCY_HZ
57
  # Antenna freeze parameters (listening mode)
58
  ANTENNA_BLEND_DURATION = 0.5 # Seconds to blend back from frozen state
59
 
 
 
 
 
 
 
 
 
60
  # State to animation mapping
 
61
  STATE_ANIMATION_MAP = {
62
  "idle": "idle",
63
  "listening": "listening",
64
  "thinking": "thinking",
65
- "speaking": "speaking",
66
  }
67
 
68
 
@@ -120,6 +130,10 @@ class MovementState:
120
  antenna_blend: float = 1.0 # 0=frozen, 1=normal
121
  antenna_blend_start_time: float = 0.0
122
 
 
 
 
 
123
 
124
  @dataclass
125
  class PendingAction:
@@ -137,13 +151,10 @@ class PendingAction:
137
 
138
  class MovementManager:
139
  """
140
- Unified movement manager with 10Hz control loop.
141
 
142
  All external interactions go through the command queue,
143
  ensuring thread safety and preventing race conditions.
144
-
145
- Note: Frequency reduced from 100Hz to 10Hz to prevent daemon crashes
146
- caused by excessive Zenoh message traffic.
147
  """
148
 
149
  def __init__(self, reachy_mini: Optional["ReachyMini"] = None):
@@ -279,7 +290,7 @@ class MovementManager:
279
 
280
  def set_camera_server(self, camera_server) -> None:
281
  """Set the camera server for face tracking offsets.
282
-
283
  Args:
284
  camera_server: MJPEGCameraServer instance with face tracking
285
  """
@@ -488,6 +499,9 @@ class MovementManager:
488
  self._pending_action.callback()
489
  except Exception as e:
490
  logger.error("Action callback error: %s", e)
 
 
 
491
  self._pending_action = None
492
 
493
  def _update_animation(self, dt: float) -> None:
@@ -563,6 +577,75 @@ class MovementManager:
563
  except Exception as e:
564
  logger.debug("Error getting face tracking offsets: %s", e)
565
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
566
  def _compose_final_pose(self) -> Tuple[np.ndarray, Tuple[float, float], float]:
567
  """Compose final pose from all sources using SDK's compose_world_offset.
568
 
@@ -633,8 +716,10 @@ class MovementManager:
633
  final_head[:3, 3] = primary_head[:3, 3] + secondary_head[:3, 3]
634
 
635
  # Antenna pose with freeze blending
636
- target_antenna_left = self.state.target_antenna_left + self.state.anim_antenna_left
637
- target_antenna_right = self.state.target_antenna_right + self.state.anim_antenna_right
 
 
638
 
639
  # Apply antenna freeze blending (listening mode)
640
  blend = self.state.antenna_blend
@@ -757,7 +842,7 @@ class MovementManager:
757
  # =========================================================================
758
 
759
  def _control_loop(self) -> None:
760
- """Main 10Hz control loop."""
761
  logger.info("Movement manager control loop started (%.0f Hz)", CONTROL_LOOP_FREQUENCY_HZ)
762
 
763
  last_time = self._now()
@@ -779,14 +864,17 @@ class MovementManager:
779
 
780
  # 4. Update antenna blend (listening mode freeze/unfreeze)
781
  self._update_antenna_blend(dt)
782
-
783
  # 5. Update face tracking offsets from camera server
784
  self._update_face_tracking()
785
 
786
- # 6. Compose final pose (returns head_pose matrix, antennas tuple, body_yaw)
 
 
 
787
  head_pose, antennas, body_yaw = self._compose_final_pose()
788
 
789
- # 7. Send to robot (single control point!)
790
  self._issue_control_command(head_pose, antennas, body_yaw)
791
 
792
  except Exception as e:
 
5
  inspired by the reachy_mini_conversation_app architecture.
6
 
7
  Key features:
8
+ - Single 100Hz control loop (same as reachy_mini_conversation_app)
9
  - Command queue pattern (thread-safe external API)
10
  - Error throttling (prevents log explosion)
11
  - JSON-driven animation system (conversation state animations)
 
18
 
19
  import logging
20
  import math
21
+ import random
22
  import threading
23
  import time
24
  from dataclasses import dataclass, field
 
58
  # Antenna freeze parameters (listening mode)
59
  ANTENNA_BLEND_DURATION = 0.5 # Seconds to blend back from frozen state
60
 
61
+ # Idle look-around behavior parameters
62
+ IDLE_LOOK_AROUND_MIN_INTERVAL = 8.0 # Minimum seconds between look-arounds
63
+ IDLE_LOOK_AROUND_MAX_INTERVAL = 20.0 # Maximum seconds between look-arounds
64
+ IDLE_LOOK_AROUND_YAW_RANGE = 25.0 # Maximum yaw angle in degrees
65
+ IDLE_LOOK_AROUND_PITCH_RANGE = 10.0 # Maximum pitch angle in degrees
66
+ IDLE_LOOK_AROUND_DURATION = 1.2 # Duration of look-around action in seconds
67
+ IDLE_INACTIVITY_THRESHOLD = 5.0 # Seconds of inactivity before look-around starts
68
+
69
  # State to animation mapping
70
+ # Note: SPEAKING uses idle animation as base, with speech_sway offsets layered on top
71
  STATE_ANIMATION_MAP = {
72
  "idle": "idle",
73
  "listening": "listening",
74
  "thinking": "thinking",
75
+ "speaking": "idle", # Base animation only; actual motion from speech_sway
76
  }
77
 
78
 
 
130
  antenna_blend: float = 1.0 # 0=frozen, 1=normal
131
  antenna_blend_start_time: float = 0.0
132
 
133
+ # Idle look-around behavior
134
+ next_look_around_time: float = 0.0
135
+ look_around_in_progress: bool = False
136
+
137
 
138
  @dataclass
139
  class PendingAction:
 
151
 
152
  class MovementManager:
153
  """
154
+ Unified movement manager with 100Hz control loop.
155
 
156
  All external interactions go through the command queue,
157
  ensuring thread safety and preventing race conditions.
 
 
 
158
  """
159
 
160
  def __init__(self, reachy_mini: Optional["ReachyMini"] = None):
 
290
 
291
  def set_camera_server(self, camera_server) -> None:
292
  """Set the camera server for face tracking offsets.
293
+
294
  Args:
295
  camera_server: MJPEGCameraServer instance with face tracking
296
  """
 
499
  self._pending_action.callback()
500
  except Exception as e:
501
  logger.error("Action callback error: %s", e)
502
+ # Reset look-around state if this was a look-around action
503
+ if self._pending_action.name == "look_around":
504
+ self.state.look_around_in_progress = False
505
  self._pending_action = None
506
 
507
  def _update_animation(self, dt: float) -> None:
 
577
  except Exception as e:
578
  logger.debug("Error getting face tracking offsets: %s", e)
579
 
580
+ def _update_idle_look_around(self) -> None:
581
+ """Trigger random look-around behavior when idle for a while.
582
+
583
+ This adds life-like behavior to the robot by occasionally looking around
584
+ when not engaged in conversation. Similar to conversation_app's idle behaviors.
585
+ """
586
+ # Only trigger when in IDLE state
587
+ if self.state.robot_state != RobotState.IDLE:
588
+ # Reset timing when not idle
589
+ self.state.next_look_around_time = 0.0
590
+ self.state.look_around_in_progress = False
591
+ return
592
+
593
+ # Check if we have an action in progress
594
+ if self._pending_action is not None:
595
+ return
596
+
597
+ now = self._now()
598
+ idle_duration = now - self.state.idle_start_time
599
+
600
+ # Only start look-around after sufficient inactivity
601
+ if idle_duration < IDLE_INACTIVITY_THRESHOLD:
602
+ return
603
+
604
+ # Schedule next look-around if not scheduled
605
+ if self.state.next_look_around_time == 0.0:
606
+ interval = random.uniform(
607
+ IDLE_LOOK_AROUND_MIN_INTERVAL,
608
+ IDLE_LOOK_AROUND_MAX_INTERVAL
609
+ )
610
+ self.state.next_look_around_time = now + interval
611
+ logger.debug("Scheduled next look-around in %.1fs", interval)
612
+ return
613
+
614
+ # Check if it's time for look-around
615
+ if now >= self.state.next_look_around_time and not self.state.look_around_in_progress:
616
+ # Generate random look direction
617
+ target_yaw = random.uniform(
618
+ -IDLE_LOOK_AROUND_YAW_RANGE,
619
+ IDLE_LOOK_AROUND_YAW_RANGE
620
+ )
621
+ target_pitch = random.uniform(
622
+ -IDLE_LOOK_AROUND_PITCH_RANGE,
623
+ IDLE_LOOK_AROUND_PITCH_RANGE
624
+ )
625
+
626
+ # Create look-around action
627
+ action = PendingAction(
628
+ name="look_around",
629
+ target_yaw=math.radians(target_yaw),
630
+ target_pitch=math.radians(target_pitch),
631
+ duration=IDLE_LOOK_AROUND_DURATION,
632
+ )
633
+
634
+ # Start the action
635
+ self._start_action(action)
636
+ self.state.look_around_in_progress = True
637
+
638
+ # Schedule return to center and next look-around
639
+ interval = random.uniform(
640
+ IDLE_LOOK_AROUND_MIN_INTERVAL,
641
+ IDLE_LOOK_AROUND_MAX_INTERVAL
642
+ )
643
+ self.state.next_look_around_time = now + IDLE_LOOK_AROUND_DURATION * 2 + interval
644
+
645
+ logger.debug("Starting look-around: yaw=%.1f°, pitch=%.1f°",
646
+ target_yaw, target_pitch)
647
+
648
+
649
  def _compose_final_pose(self) -> Tuple[np.ndarray, Tuple[float, float], float]:
650
  """Compose final pose from all sources using SDK's compose_world_offset.
651
 
 
716
  final_head[:3, 3] = primary_head[:3, 3] + secondary_head[:3, 3]
717
 
718
  # Antenna pose with freeze blending
719
+ target_antenna_left = (self.state.target_antenna_left +
720
+ self.state.anim_antenna_left)
721
+ target_antenna_right = (self.state.target_antenna_right +
722
+ self.state.anim_antenna_right)
723
 
724
  # Apply antenna freeze blending (listening mode)
725
  blend = self.state.antenna_blend
 
842
  # =========================================================================
843
 
844
  def _control_loop(self) -> None:
845
+ """Main 100Hz control loop."""
846
  logger.info("Movement manager control loop started (%.0f Hz)", CONTROL_LOOP_FREQUENCY_HZ)
847
 
848
  last_time = self._now()
 
864
 
865
  # 4. Update antenna blend (listening mode freeze/unfreeze)
866
  self._update_antenna_blend(dt)
867
+
868
  # 5. Update face tracking offsets from camera server
869
  self._update_face_tracking()
870
 
871
+ # 6. Update idle look-around behavior
872
+ self._update_idle_look_around()
873
+
874
+ # 7. Compose final pose (returns head_pose matrix, antennas tuple, body_yaw)
875
  head_pose, antennas, body_yaw = self._compose_final_pose()
876
 
877
+ # 8. Send to robot (single control point!)
878
  self._issue_control_command(head_pose, antennas, body_yaw)
879
 
880
  except Exception as e:
reachy_mini_ha_voice/satellite.py CHANGED
@@ -142,6 +142,11 @@ class VoiceSatelliteProtocol(APIServer):
142
  for entity in self.state.entities:
143
  entity.server = self
144
 
 
 
 
 
 
145
  def handle_voice_event(
146
  self, event_type: VoiceAssistantEventType, data: Dict[str, str]
147
  ) -> None:
@@ -179,6 +184,12 @@ class VoiceSatelliteProtocol(APIServer):
179
  _LOGGER.debug("TTS_START event received, triggering speaking animation")
180
  self._reachy_on_speaking()
181
 
 
 
 
 
 
 
182
  elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END:
183
  self._tts_url = data.get("url")
184
  self.play_tts()
@@ -748,6 +759,66 @@ class VoiceSatelliteProtocol(APIServer):
748
  except Exception as e:
749
  _LOGGER.error("Reachy Mini motion error: %s", e)
750
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
751
  def _play_emotion(self, emotion_name: str) -> None:
752
  """Play an emotion/expression from the emotions library.
753
 
 
142
  for entity in self.state.entities:
143
  entity.server = self
144
 
145
+ # Load emotion keywords from JSON file for auto-triggering
146
+ self._emotion_keywords: Dict[str, str] = {}
147
+ self._emotion_detection_enabled = True
148
+ self._load_emotion_keywords()
149
+
150
  def handle_voice_event(
151
  self, event_type: VoiceAssistantEventType, data: Dict[str, str]
152
  ) -> None:
 
184
  _LOGGER.debug("TTS_START event received, triggering speaking animation")
185
  self._reachy_on_speaking()
186
 
187
+ # Auto-trigger emotion based on response text
188
+ # TTS_START may contain the text to be spoken
189
+ tts_text = data.get("tts_output") or data.get("text") or ""
190
+ if tts_text:
191
+ self._detect_and_play_emotion(tts_text)
192
+
193
  elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END:
194
  self._tts_url = data.get("url")
195
  self.play_tts()
 
759
  except Exception as e:
760
  _LOGGER.error("Reachy Mini motion error: %s", e)
761
 
762
+ def _load_emotion_keywords(self) -> None:
763
+ """Load emotion keywords from JSON configuration file.
764
+
765
+ The file is located at animations/emotion_keywords.json and contains
766
+ keyword-to-emotion mappings for automatic emotion detection.
767
+ """
768
+ import json
769
+ from pathlib import Path
770
+
771
+ keywords_file = Path(__file__).parent / "animations" / "emotion_keywords.json"
772
+
773
+ if not keywords_file.exists():
774
+ _LOGGER.warning("Emotion keywords file not found: %s", keywords_file)
775
+ return
776
+
777
+ try:
778
+ with open(keywords_file, "r", encoding="utf-8") as f:
779
+ data = json.load(f)
780
+
781
+ self._emotion_keywords = data.get("keywords", {})
782
+ settings = data.get("settings", {})
783
+ self._emotion_detection_enabled = settings.get("enabled", True)
784
+
785
+ _LOGGER.info(
786
+ "Loaded %d emotion keywords (enabled=%s)",
787
+ len(self._emotion_keywords),
788
+ self._emotion_detection_enabled
789
+ )
790
+ except Exception as e:
791
+ _LOGGER.error("Failed to load emotion keywords: %s", e)
792
+
793
+ def _detect_and_play_emotion(self, text: str) -> None:
794
+ """Detect emotion from text and trigger corresponding robot animation.
795
+
796
+ This provides automatic emotion expression based on the LLM response content.
797
+ Keywords are matched case-insensitively against the text.
798
+
799
+ Args:
800
+ text: The text to analyze for emotional content
801
+ """
802
+ if not text or not self._emotion_detection_enabled:
803
+ return
804
+
805
+ if not self._emotion_keywords:
806
+ return
807
+
808
+ text_lower = text.lower()
809
+
810
+ # Check each keyword pattern
811
+ for keyword, emotion_name in self._emotion_keywords.items():
812
+ if keyword.lower() in text_lower:
813
+ _LOGGER.info(
814
+ "Auto-detected emotion '%s' from keyword '%s' in response",
815
+ emotion_name, keyword
816
+ )
817
+ self._play_emotion(emotion_name)
818
+ return # Only trigger one emotion per response
819
+
820
+ _LOGGER.debug("No emotion keywords detected in response text")
821
+
822
  def _play_emotion(self, emotion_name: str) -> None:
823
  """Play an emotion/expression from the emotions library.
824