Commit ·
52e44ae
1
Parent(s): f3f955c
Add conversation_app architecture documentation and analysis
Browse files- .claude/settings.local.json +36 -9
- PROJECT_PLAN.md +19 -16
- reachy_mini_ha_voice/animations/conversation_animations.json +139 -51
- reachy_mini_ha_voice/animations/emotion_keywords.json +67 -0
- reachy_mini_ha_voice/audio_player.py +13 -2
- reachy_mini_ha_voice/movement_manager.py +101 -13
- reachy_mini_ha_voice/satellite.py +71 -0
.claude/settings.local.json
CHANGED
|
@@ -3,24 +3,51 @@
|
|
| 3 |
"includeCoAuthoredBy": false,
|
| 4 |
"permissions": {
|
| 5 |
"allow": [
|
| 6 |
-
"
|
|
|
|
| 7 |
"Edit",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
"Bash(cd:*)",
|
| 9 |
-
"
|
| 10 |
-
"
|
| 11 |
-
"
|
| 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 |
-
"
|
| 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.
|
| 854 |
```python
|
| 855 |
-
#
|
| 856 |
-
|
|
|
|
| 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.
|
| 941 |
```python
|
| 942 |
-
#
|
| 943 |
-
#
|
| 944 |
-
CONTROL_LOOP_FREQUENCY_HZ =
|
| 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 |
-
|
| 969 |
-
|
| 970 |
-
|
|
| 971 |
-
|
|
| 972 |
-
|
|
| 973 |
-
|
|
| 974 |
-
|
|
| 975 |
-
|
|
|
|
|
|
|
|
| 976 |
|
| 977 |
### Key Finding
|
| 978 |
|
| 979 |
-
Reference `reachy_mini_conversation_app` uses 100Hz control loop
|
| 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": "
|
| 5 |
-
"z_amplitude_m": 0.
|
| 6 |
-
"
|
| 7 |
-
"
|
|
|
|
|
|
|
| 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 |
-
|
| 30 |
-
|
| 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/
|
| 49 |
-
"
|
| 50 |
-
"
|
| 51 |
-
"
|
| 52 |
-
"
|
| 53 |
-
"
|
|
|
|
| 54 |
},
|
| 55 |
"sad": {
|
| 56 |
-
"description": "Sad/
|
| 57 |
-
"
|
| 58 |
-
"
|
| 59 |
-
"
|
| 60 |
-
"
|
| 61 |
-
"
|
| 62 |
-
"
|
| 63 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
},
|
| 65 |
"confused": {
|
| 66 |
-
"description": "Confused/
|
| 67 |
-
"
|
| 68 |
-
"
|
| 69 |
-
"
|
| 70 |
-
"
|
| 71 |
-
"
|
| 72 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
},
|
| 74 |
"alert": {
|
| 75 |
-
"description": "Alert/
|
| 76 |
-
"
|
| 77 |
-
"
|
| 78 |
-
"
|
| 79 |
-
"
|
| 80 |
-
"
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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": "
|
| 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
|
| 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 +
|
| 637 |
-
|
|
|
|
|
|
|
| 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
|
| 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.
|
|
|
|
|
|
|
|
|
|
| 787 |
head_pose, antennas, body_yaw = self._compose_final_pose()
|
| 788 |
|
| 789 |
-
#
|
| 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 |
|