Boopster commited on
Commit
c8edd3d
·
1 Parent(s): 56783cb

feat: Implement voice-controlled movement generation for Reachy Mini with real-time audio processing, new tests, and documentation.

Browse files
.gitignore CHANGED
@@ -3,4 +3,6 @@ __pycache__/
3
 
4
  venv
5
  .env
6
- .DS_Store
 
 
 
3
 
4
  venv
5
  .env
6
+ .DS_Store
7
+
8
+ .pytest_cache
README.md CHANGED
@@ -1,11 +1,62 @@
1
- ---
2
- title: Reachy Mini Danceml
3
- emoji: 👋
4
- colorFrom: red
5
- colorTo: blue
6
- sdk: static
7
- pinned: false
8
- short_description: Write your description here
9
- tags:
10
- - reachy_mini
11
- ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Reachy Mini DanceML
2
+
3
+ 👋 Voice-controlled movement SDK for Reachy Mini robot.
4
+
5
+ ## Features
6
+
7
+ - 🎤 **Voice Control**: Natural language commands via OpenAI Realtime API
8
+ - 🧠 **Hybrid Architecture**: Intelligently switches between library retrieval and generative AI
9
+ - 📚 **Move Library**: Zero-latency access to 100+ dances and emotional expressions
10
+ - 🎯 **Keyframe Animations**: Smooth movements with cubic spline interpolation
11
+ - 🤖 **AI Agent Integration**: Function-calling schemas for LLM agents
12
+
13
+ ## Quick Start
14
+
15
+ ```bash
16
+ # Install dependencies
17
+ pip install -e .
18
+
19
+ # Set your OpenAI API key
20
+ export OPENAI_API_KEY="your-key-here"
21
+
22
+ # Run the app
23
+ python -m reachy_mini_danceml.main
24
+ ```
25
+
26
+ ## Documentation
27
+
28
+ 📖 See [SDK Documentation](docs/SDK_DOCUMENTATION.md) for:
29
+ - Dataset format (HuggingFace dance library)
30
+ - Core classes (KeyFrame, GeneratedMove, MovementGenerator)
31
+ - Movement tool schemas for AI agents
32
+ - Usage examples
33
+
34
+ ## HuggingFace Dataset
35
+
36
+ Load pre-recorded dance moves:
37
+
38
+ ```python
39
+ from datasets import load_dataset
40
+
41
+ ds = load_dataset("pollen-robotics/reachy-mini-dances-library")
42
+ print(ds['train'][0]['description']) # "A sharp, forward, chicken-like pecking motion."
43
+ ```
44
+
45
+ ## Example: Create a Wave Animation
46
+
47
+ ```python
48
+ from reachy_mini_danceml.movement_tools import KeyFrame
49
+ from reachy_mini_danceml.movement_generator import GeneratedMove
50
+
51
+ keyframes = [
52
+ KeyFrame(t=0.0, antennas=(0, 0)),
53
+ KeyFrame(t=0.3, antennas=(30, -30)),
54
+ KeyFrame(t=0.6, antennas=(-30, 30)),
55
+ KeyFrame(t=1.0, antennas=(0, 0)),
56
+ ]
57
+ move = GeneratedMove(keyframes)
58
+ ```
59
+
60
+ ## Tags
61
+
62
+ - reachy_mini
docs/ARCHITECTURE.md ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Reachy Mini DanceML Architecture
2
+
3
+ ## System Architecture
4
+
5
+ ```mermaid
6
+ flowchart TB
7
+ subgraph Input["🎤 Input Layer"]
8
+ USER["User Voice"]
9
+ MIC["Browser Microphone<br/>(Laptop/Mobile)"]
10
+ end
11
+
12
+ subgraph Streaming["⚡ Streaming Layer"]
13
+ GRADIO["Gradio UI<br/>:8042"]
14
+ FASTRTC["FastRTC<br/>Audio Stream"]
15
+ end
16
+
17
+ subgraph AI["🧠 AI Layer (OpenAI Realtime)"]
18
+ ASR["Speech-to-Text"]
19
+ REASON["gpt-realtime<br/>Reasoning"]
20
+ TTS["Text-to-Speech"]
21
+ end
22
+
23
+ subgraph ToolRouter["🔧 Tool Router"]
24
+ DISPATCH["Tool Dispatcher"]
25
+ end
26
+
27
+ subgraph CoreTools["Core Tools"]
28
+ GOTO["goto_pose"]
29
+ STOP["stop_movement"]
30
+ end
31
+
32
+ subgraph RetrievalTools["Retrieval Tools"]
33
+ SEARCH["search_moves"]
34
+ PLAY["play_move"]
35
+ end
36
+
37
+ subgraph GenerativeTools["Generative Tools"]
38
+ GUIDE["get_choreography_guide"]
39
+ CREATE["create_sequence"]
40
+ end
41
+
42
+ subgraph Backend["📦 Backend"]
43
+ LIBRARY["MoveLibrary<br/>(101 moves)"]
44
+ GENERATOR["MovementGenerator"]
45
+ DOCS["CHOREOGRAPHY_GUIDE.md"]
46
+ end
47
+
48
+ subgraph Robot["🤖 Reachy Mini"]
49
+ HEAD["Head<br/>roll/pitch/yaw"]
50
+ ANTENNAS["Antennas<br/>left/right"]
51
+ end
52
+
53
+ subgraph Output["🔊 Output Layer"]
54
+ SPEAKER["Speaker"]
55
+ end
56
+
57
+ %% Input flow
58
+ USER --> MIC --> GRADIO --> FASTRTC --> ASR
59
+
60
+ %% AI reasoning
61
+ ASR --> REASON
62
+ REASON --> TTS --> FASTRTC --> GRADIO --> SPEAKER
63
+
64
+ %% Tool calls
65
+ REASON -->|"function_call"| DISPATCH
66
+ DISPATCH --> CoreTools
67
+ DISPATCH --> RetrievalTools
68
+ DISPATCH --> GenerativeTools
69
+
70
+ %% Tool to backend
71
+ GOTO --> GENERATOR
72
+ STOP --> GENERATOR
73
+ SEARCH --> LIBRARY
74
+ PLAY --> LIBRARY
75
+ PLAY --> GENERATOR
76
+ GUIDE --> DOCS
77
+ CREATE --> GENERATOR
78
+
79
+ %% Backend to robot
80
+ GENERATOR --> HEAD
81
+ GENERATOR --> ANTENNAS
82
+
83
+ %% Results back
84
+ CoreTools -.->|"result"| DISPATCH
85
+ RetrievalTools -.->|"result"| DISPATCH
86
+ GenerativeTools -.->|"result"| DISPATCH
87
+ DISPATCH -.-> REASON
88
+ ```
89
+
90
+ ---
91
+
92
+ ## Tool Selection Flow
93
+
94
+ ```mermaid
95
+ flowchart TD
96
+ START(("🎤 User<br/>Request")) --> INTENT{"Classify<br/>Intent"}
97
+
98
+ INTENT -->|"look left<br/>tilt head"| SIMPLE["🎯 SIMPLE"]
99
+ INTENT -->|"stop<br/>freeze"| EMERGENCY["🛑 STOP"]
100
+ INTENT -->|"show happy<br/>do a dance"| EMOTION["🎭 EMOTION"]
101
+ INTENT -->|"act like...<br/>create new"| CREATIVE["✨ CREATIVE"]
102
+
103
+ SIMPLE --> GOTO_POSE["goto_pose()"]
104
+ EMERGENCY --> STOP_MOVE["stop_movement()"]
105
+
106
+ EMOTION --> SEARCH_LIB["search_moves()"]
107
+ SEARCH_LIB --> FOUND{"Results?"}
108
+ FOUND -->|"Yes"| PLAY_MOVE["play_move()"]
109
+ FOUND -->|"No"| LOAD_GUIDE
110
+
111
+ CREATIVE --> LOAD_GUIDE["get_choreography_guide()"]
112
+ LOAD_GUIDE --> CREATE_SEQ["create_sequence()"]
113
+
114
+ GOTO_POSE --> EXECUTE["⚡ Execute"]
115
+ STOP_MOVE --> EXECUTE
116
+ PLAY_MOVE --> EXECUTE
117
+ CREATE_SEQ --> EXECUTE
118
+
119
+ EXECUTE --> ROBOT(("🤖 Robot<br/>Moves"))
120
+ ```
121
+
122
+ ---
123
+
124
+ ## Component Summary
125
+
126
+ | Layer | Component | Purpose |
127
+ |-------|-----------|---------|
128
+ | **Input** | Gradio + FastRTC | Audio streaming |
129
+ | **AI** | OpenAI Realtime | Speech, reasoning, TTS |
130
+ | **Tools** | 6 functions | Intent execution |
131
+ | **Backend** | MoveLibrary | 101 pre-built moves |
132
+ | **Backend** | MovementGenerator | Keyframe interpolation |
133
+ | **Output** | Reachy Mini SDK | Motor control |
docs/CHOREOGRAPHY_GUIDE.md ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Reachy Mini Choreography Guide
2
+
3
+ This guide provides the physics rules and artistic principles for creating custom movements for Reachy Mini. Use these rules when `create_sequence` is required.
4
+
5
+ ## 1. Physics Constraints
6
+
7
+ ### Head Movement
8
+ * **Roll** (Tilt L/R): -30° to +30°
9
+ * *Positive* = Tilt Right
10
+ * *Expressive*: Use roll for "curiosity" (tilt head) or "cuteness".
11
+ * **Pitch** (Up/Down): -30° to +30°
12
+ * *Positive* = Look Up
13
+ * *Expressive*: Pitch down for sadness/shame, up for pride/joy.
14
+ * **Yaw** (Turn L/R): -45° to +45°
15
+ * *Positive* = Look Left
16
+ * *Expressive*: Shake head for "no", scan room for "searching".
17
+
18
+ ### Antennas
19
+ * **Range**: -60° (Back) to +60° (Front)
20
+ * *0°* = Vertical/Neutral
21
+ * **Expressiveness**:
22
+ * *Forward (+)*: Alert, interested, angry (if rigid).
23
+ * *Backward (-)*: Sad, scared, aerodynamic.
24
+ * *Asymmetric*: Confused, playful (one up, one down).
25
+
26
+ ## 2. Timing & Smoothness
27
+
28
+ * **Minimum Duration**: 0.5s between keyframes for large moves (>20°).
29
+ * *Fast*: 0.2s-0.3s (Small twitches, excitement).
30
+ * *Normal*: 0.5s-1.0s (Looking around).
31
+ * *Slow*: 1.5s+ (Breathing, sad movements).
32
+ * **Interpolation**: The system uses Cubic Spline.
33
+ * *Avoid*: Two identical keyframes too close together (creates pauses).
34
+ * *Do*: Use evenly spaced keyframes for smooth arcs.
35
+
36
+ ## 3. Choreography Patterns
37
+
38
+ ### "The Breath" (Idle/Calm)
39
+ Gentle, slow pitch movement accompanied by slight antenna swaying.
40
+ ```python
41
+ {"t": 0.0, "head": {"pitch": 0}, "antennas": [0, 0]}
42
+ {"t": 2.0, "head": {"pitch": 5}, "antennas": [-10, -10]} # Inhale/Up
43
+ {"t": 4.0, "head": {"pitch": 0}, "antennas": [0, 0]} # Exhale
44
+ ```
45
+
46
+ ### "The Scan" (Searching)
47
+ Head stays level (pitch 0), yaw sweeps, antennas alert (forward).
48
+ ```python
49
+ {"t": 0.0, "head": {"yaw": -30}, "antennas": [30, 30]}
50
+ {"t": 1.0, "head": {"yaw": 30}, "antennas": [30, 30]}
51
+ ```
52
+
53
+ ### "The Jiggle" (Excitement/Laugh)
54
+ Rapid, small alternating rolls or antenna movements.
55
+ ```python
56
+ {"t": 0.0, "head": {"roll": 0}, "antennas": [0, 0]}
57
+ {"t": 0.1, "head": {"roll": 5}, "antennas": [20, -20]}
58
+ {"t": 0.2, "head": {"roll": -5}, "antennas": [-20, 20]}
59
+ {"t": 0.3, "head": {"roll": 0}, "antennas": [0, 0]}
60
+ ```
61
+
62
+ ## 4. Safety
63
+ * **Collision**: Antennas can hit the head if pitched back too far while antennas are forward.
64
+ * **Rule**: If Pitch < -20 (looking down), keep Antennas < 20.
docs/SDK_DOCUMENTATION.md ADDED
@@ -0,0 +1,485 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Reachy Mini DanceML SDK Documentation
2
+
3
+ This documentation covers the SDK methods for controlling Reachy Mini movements and the data format for dance sequences.
4
+
5
+ ## Table of Contents
6
+ - [Overview](#overview)
7
+ - [Dataset Format](#dataset-format)
8
+ - [Core Classes](#core-classes)
9
+ - [Movement Tools](#movement-tools)
10
+ - [Usage Examples](#usage-examples)
11
+
12
+ ---
13
+
14
+ ## Overview
15
+
16
+ The Reachy Mini DanceML SDK enables:
17
+ - **Voice-controlled movements** via OpenAI Realtime API
18
+ - **Keyframe-based animations** with cubic spline interpolation
19
+ - **Simple pose commands** for direct head positioning
20
+
21
+ ---
22
+
23
+ ## Available Datasets
24
+
25
+ Pollen Robotics provides two HuggingFace datasets with pre-recorded movements:
26
+
27
+ | Dataset | Records | Description |
28
+ |---------|---------|-------------|
29
+ | `pollen-robotics/reachy-mini-dances-library` | 20 | Dance moves (pecking, bobbing, swaying) |
30
+ | `pollen-robotics/reachy-mini-emotions-library` | 81 | Emotional expressions (wonder, fear, joy, etc.) |
31
+
32
+ Both datasets share the same schema and can be used interchangeably with this SDK.
33
+
34
+ ### Dataset Schema
35
+
36
+ | Field | Type | Description |
37
+ |-------|------|-------------|
38
+ | `description` | `string` | Human-readable description of the movement |
39
+ | `time` | `List[float]` | Timestamps in seconds from animation start |
40
+ | `set_target_data` | `List[TargetData]` | Array of pose targets at each timestamp |
41
+
42
+ ### TargetData Structure
43
+
44
+ Each element in `set_target_data` contains:
45
+
46
+ ```python
47
+ {
48
+ "head": [[4x4 homogeneous transformation matrix]],
49
+ "antennas": [left_angle, right_angle], # in radians
50
+ "body_yaw": 0.0, # body rotation (typically 0)
51
+ "check_collision": false # collision check flag
52
+ }
53
+ ```
54
+
55
+ ### Head Pose Matrix
56
+
57
+ The `head` field is a 4x4 homogeneous transformation matrix representing the head orientation:
58
+
59
+ ```
60
+ [[r11, r12, r13, tx],
61
+ [r21, r22, r23, ty],
62
+ [r31, r32, r33, tz],
63
+ [0, 0, 0, 1 ]]
64
+ ```
65
+
66
+ Where:
67
+ - The 3x3 upper-left submatrix encodes rotation (roll, pitch, yaw)
68
+ - The last column `[tx, ty, tz, 1]` encodes translation
69
+
70
+ ### Loading the Datasets
71
+
72
+ ```python
73
+ from datasets import load_dataset
74
+
75
+ # Dance moves (requires HuggingFace login)
76
+ dances = load_dataset("pollen-robotics/reachy-mini-dances-library")
77
+
78
+ # Emotions library (requires HuggingFace login)
79
+ emotions = load_dataset("pollen-robotics/reachy-mini-emotions-library")
80
+
81
+ # Access a dance move
82
+ dance = dances['train'][0]
83
+ print(f"Description: {dance['description']}")
84
+ # Output: "A sharp, forward, chicken-like pecking motion."
85
+
86
+ # Access an emotion
87
+ emotion = emotions['train'][0]
88
+ print(f"Description: {emotion['description']}")
89
+ # Output: "When you discover something extraordinary..."
90
+
91
+ # Both have the same structure
92
+ print(f"Duration: {emotion['time'][-1]} seconds")
93
+ print(f"Frames: {len(emotion['time'])}")
94
+ ```
95
+
96
+ ### Example Emotion Descriptions
97
+
98
+ The emotions library includes expressive movements such as:
99
+ - **Wonder**: "When you discover something extraordinary"
100
+ - **Fear**: "You look around without really knowing where to look"
101
+ - **Joy**: Celebratory movements
102
+ - **Surprise**: Reactive startle responses
103
+ - **Curiosity**: Investigative head tilts
104
+
105
+ ---
106
+
107
+ ## Core Classes
108
+
109
+ ### KeyFrame
110
+
111
+ A single keyframe in an animation sequence.
112
+
113
+ ```python
114
+ from reachy_mini_danceml.movement_tools import KeyFrame
115
+
116
+ @dataclass
117
+ class KeyFrame:
118
+ t: float # Time in seconds from animation start
119
+ head: dict # {"roll": 0, "pitch": 0, "yaw": 0} in degrees
120
+ antennas: Tuple[float, float] # (left, right) antenna angles in degrees
121
+ ```
122
+
123
+ #### Methods
124
+
125
+ | Method | Description |
126
+ |--------|-------------|
127
+ | `KeyFrame.from_dict(data)` | Create KeyFrame from a dictionary |
128
+
129
+ #### Example
130
+
131
+ ```python
132
+ # Create keyframes for a nodding animation
133
+ keyframes = [
134
+ KeyFrame(t=0.0, head={"roll": 0, "pitch": 0, "yaw": 0}, antennas=(0, 0)),
135
+ KeyFrame(t=0.3, head={"roll": 0, "pitch": -15, "yaw": 0}, antennas=(10, 10)),
136
+ KeyFrame(t=0.6, head={"roll": 0, "pitch": 10, "yaw": 0}, antennas=(-5, -5)),
137
+ KeyFrame(t=1.0, head={"roll": 0, "pitch": 0, "yaw": 0}, antennas=(0, 0)),
138
+ ]
139
+ ```
140
+
141
+ ---
142
+
143
+ ### GeneratedMove
144
+
145
+ A Move generated from keyframes with cubic spline interpolation.
146
+
147
+ ```python
148
+ from reachy_mini_danceml.movement_generator import GeneratedMove
149
+
150
+ class GeneratedMove(Move):
151
+ def __init__(self, keyframes: List[KeyFrame])
152
+
153
+ @property
154
+ def duration(self) -> float
155
+
156
+ def evaluate(self, t: float) -> Tuple[np.ndarray, np.ndarray, float]
157
+ ```
158
+
159
+ #### Properties
160
+
161
+ | Property | Type | Description |
162
+ |----------|------|-------------|
163
+ | `duration` | `float` | Total animation duration in seconds |
164
+
165
+ #### Methods
166
+
167
+ | Method | Parameters | Returns | Description |
168
+ |--------|------------|---------|-------------|
169
+ | `evaluate(t)` | `t: float` (time in seconds) | `(head_pose, antennas, body_yaw)` | Interpolate pose at time t |
170
+
171
+ #### Return Values from `evaluate()`
172
+
173
+ - `head_pose`: 4x4 numpy array (homogeneous transformation matrix)
174
+ - `antennas`: numpy array `[left, right]` in radians
175
+ - `body_yaw`: float (always 0.0)
176
+
177
+ #### Example
178
+
179
+ ```python
180
+ from reachy_mini_danceml.movement_generator import GeneratedMove
181
+ from reachy_mini_danceml.movement_tools import KeyFrame
182
+
183
+ keyframes = [
184
+ KeyFrame(t=0.0, head={"yaw": 0}),
185
+ KeyFrame(t=1.0, head={"yaw": 30}),
186
+ KeyFrame(t=2.0, head={"yaw": 0}),
187
+ ]
188
+
189
+ move = GeneratedMove(keyframes)
190
+ print(f"Duration: {move.duration} seconds")
191
+
192
+ # Get pose at 0.5 seconds
193
+ head, antennas, body_yaw = move.evaluate(0.5)
194
+ ```
195
+
196
+ ---
197
+
198
+ ### MoveLibrary
199
+
200
+ Manages loading and indexing of dance and emotion datasets.
201
+
202
+ ```python
203
+ from reachy_mini_danceml.dataset_loader import MoveLibrary
204
+
205
+ library = MoveLibrary()
206
+ library.load()
207
+
208
+ # Search
209
+ results = library.search_moves("happy")
210
+
211
+ # Get Record
212
+ record = library.get_move("joy_jump")
213
+ ```
214
+
215
+ ### MovementGenerator
216
+
217
+ Generates and executes movements on Reachy Mini.
218
+
219
+ ```python
220
+ from reachy_mini_danceml.movement_generator import MovementGenerator
221
+
222
+ class MovementGenerator:
223
+ def __init__(self, reachy: ReachyMini)
224
+
225
+ def create_from_keyframes(self, keyframes) -> GeneratedMove
226
+
227
+ async def goto_pose(self, roll=0, pitch=0, yaw=0, duration=0.5) -> None
228
+
229
+ async def play_move(self, move: Move) -> None
230
+
231
+ async def stop(self) -> None
232
+ ```
233
+
234
+ #### Methods
235
+
236
+ | Method | Parameters | Description |
237
+ |--------|------------|-------------|
238
+ | `create_from_keyframes(keyframes)` | `List[KeyFrame]` or `List[dict]` | Create a GeneratedMove from keyframes |
239
+ | `goto_pose(roll, pitch, yaw, duration)` | Angles in degrees, duration in seconds | Move head to specific pose |
240
+ | `play_move(move)` | `Move` object | Play an animation asynchronously |
241
+ | `stop()` | None | Stop current movement, return to neutral |
242
+
243
+ #### Angle Limits
244
+
245
+ | Parameter | Range | Direction |
246
+ |-----------|-------|-----------|
247
+ | `roll` | -30° to 30° | Positive = tilt right |
248
+ | `pitch` | -30° to 30° | Positive = look up |
249
+ | `yaw` | -45° to 45° | Positive = look left |
250
+ | `antennas` | -60° to 60° | Each antenna independently |
251
+
252
+ #### Example
253
+
254
+ ```python
255
+ from reachy_mini import ReachyMini
256
+ from reachy_mini_danceml.movement_generator import MovementGenerator
257
+
258
+ async def demo(reachy: ReachyMini):
259
+ generator = MovementGenerator(reachy)
260
+
261
+ # Simple pose
262
+ await generator.goto_pose(roll=0, pitch=10, yaw=-20, duration=0.5)
263
+
264
+ # Keyframe animation
265
+ keyframes = [
266
+ {"t": 0.0, "head": {"yaw": 0}, "antennas": [0, 0]},
267
+ {"t": 0.5, "head": {"yaw": 30}, "antennas": [20, -20]},
268
+ {"t": 1.0, "head": {"yaw": 0}, "antennas": [0, 0]},
269
+ ]
270
+ move = generator.create_from_keyframes(keyframes)
271
+ await generator.play_move(move)
272
+ ```
273
+
274
+ ---
275
+
276
+ ## Movement Tools
277
+
278
+ These are OpenAI function-calling tool schemas for voice control integration.
279
+
280
+ ### PLAY_MOVE_TOOL
281
+
282
+ Play a pre-defined movement from the library by its name/ID.
283
+
284
+ ```python
285
+ {
286
+ "type": "function",
287
+ "name": "play_move",
288
+ "description": "Play a pre-defined movement from the library by its name (e.g., 'joy', 'fear', 'chicken_dance'). Prefer this over creating sequences manually.",
289
+ "parameters": {
290
+ "properties": {
291
+ "name": {"type": "string", "description": "Name or ID of the movement"}
292
+ },
293
+ "required": ["name"]
294
+ }
295
+ }
296
+ ```
297
+
298
+ ### SEARCH_MOVES_TOOL
299
+
300
+ Search the library for available movements.
301
+
302
+ ```python
303
+ {
304
+ "type": "function",
305
+ "name": "search_moves",
306
+ "description": "Search the movement library for available expressions or dances.",
307
+ "parameters": {
308
+ "properties": {
309
+ "query": {"type": "string", "description": "Keywords to search for"}
310
+ },
311
+ "required": ["query"]
312
+ }
313
+ }
314
+ ```
315
+
316
+ ### GET_CHOREOGRAPHY_GUIDE_TOOL
317
+
318
+ Retrieve physics rules and examples for custom generation.
319
+
320
+ ```python
321
+ {
322
+ "type": "function",
323
+ "name": "get_choreography_guide",
324
+ "description": "Read the choreography guide to learn how to create safe and expressive custom movements. Call this BEFORE using create_sequence for new moves."
325
+ }
326
+ ```
327
+
328
+ ### GOTO_POSE_TOOL
329
+
330
+ Move the robot's head to a specific pose.
331
+
332
+ ```python
333
+ {
334
+ "type": "function",
335
+ "name": "goto_pose",
336
+ "parameters": {
337
+ "properties": {
338
+ "roll": {"type": "number", "description": "Roll angle (-30 to 30°)"},
339
+ "pitch": {"type": "number", "description": "Pitch angle (-30 to 30°)"},
340
+ "yaw": {"type": "number", "description": "Yaw angle (-45 to 45°)"},
341
+ "duration": {"type": "number", "description": "Duration in seconds"}
342
+ }
343
+ }
344
+ }
345
+ ```
346
+
347
+ ### CREATE_SEQUENCE_TOOL
348
+
349
+ Create and play an animated movement sequence from keyframes.
350
+
351
+ ```python
352
+ {
353
+ "type": "function",
354
+ "name": "create_sequence",
355
+ "parameters": {
356
+ "properties": {
357
+ "keyframes": {
358
+ "type": "array",
359
+ "items": {
360
+ "type": "object",
361
+ "properties": {
362
+ "t": {"type": "number", "description": "Time in seconds"},
363
+ "head": {
364
+ "properties": {
365
+ "roll": {"type": "number"},
366
+ "pitch": {"type": "number"},
367
+ "yaw": {"type": "number"}
368
+ }
369
+ },
370
+ "antennas": {
371
+ "type": "array",
372
+ "items": {"type": "number"},
373
+ "description": "[left, right] in degrees (-60 to 60)"
374
+ }
375
+ },
376
+ "required": ["t"]
377
+ }
378
+ }
379
+ },
380
+ "required": ["keyframes"]
381
+ }
382
+ }
383
+ ```
384
+
385
+ ### STOP_MOVEMENT_TOOL
386
+
387
+ Stop any currently playing movement and return to neutral position.
388
+
389
+ ```python
390
+ {
391
+ "type": "function",
392
+ "name": "stop_movement",
393
+ "description": "Stop current movement and return to neutral"
394
+ }
395
+ ```
396
+
397
+ ---
398
+
399
+ ## Hybrid AI Workflow
400
+
401
+ The SDK is designed for a **Hybrid Generative/Retrieval** architecture to optimize context usage.
402
+
403
+ ### Recommended Agent Logic
404
+
405
+ 1. **Retrieval First**: Always try `search_moves(query)` first.
406
+ 2. **Play by Name**: If a match is found, use `play_move(name)`. This uses 0 tokens for movement data.
407
+ 3. **On-Demand Learning**: If no match is found, call `get_choreography_guide()` to load physics rules.
408
+ 4. **Safe Generation**: Finally, use `create_sequence(keyframes)` to generate a custom move using the loaded rules.
409
+
410
+ ---
411
+
412
+ ## Usage Examples
413
+
414
+ ### Example 1: Wave Animation
415
+
416
+ ```python
417
+ wave_keyframes = [
418
+ {"t": 0.0, "head": {"roll": 0, "yaw": 0}, "antennas": [0, 0]},
419
+ {"t": 0.3, "head": {"roll": 0, "yaw": 0}, "antennas": [30, -30]},
420
+ {"t": 0.6, "head": {"roll": 0, "yaw": 0}, "antennas": [-30, 30]},
421
+ {"t": 0.9, "head": {"roll": 0, "yaw": 0}, "antennas": [30, -30]},
422
+ {"t": 1.2, "head": {"roll": 0, "yaw": 0}, "antennas": [0, 0]},
423
+ ]
424
+ ```
425
+
426
+ ### Example 2: Curious Head Tilt
427
+
428
+ ```python
429
+ curious_keyframes = [
430
+ {"t": 0.0, "head": {"roll": 0, "pitch": 0, "yaw": 0}},
431
+ {"t": 0.4, "head": {"roll": 15, "pitch": 5, "yaw": 10}},
432
+ {"t": 1.5, "head": {"roll": 15, "pitch": 5, "yaw": 10}},
433
+ {"t": 2.0, "head": {"roll": 0, "pitch": 0, "yaw": 0}},
434
+ ]
435
+ ```
436
+
437
+ ### Example 3: Excited Celebration
438
+
439
+ ```python
440
+ excited_keyframes = [
441
+ {"t": 0.0, "head": {"pitch": 0}, "antennas": [0, 0]},
442
+ {"t": 0.2, "head": {"pitch": -10}, "antennas": [40, 40]},
443
+ {"t": 0.4, "head": {"pitch": 5}, "antennas": [-20, -20]},
444
+ {"t": 0.6, "head": {"pitch": -10}, "antennas": [40, 40]},
445
+ {"t": 0.8, "head": {"pitch": 5}, "antennas": [-20, -20]},
446
+ {"t": 1.0, "head": {"pitch": 0}, "antennas": [0, 0]},
447
+ ]
448
+ ```
449
+
450
+ ---
451
+
452
+ ## AI Agent Output Format
453
+
454
+ When building an AI agent to generate movements for Reachy Mini, the output should match this format:
455
+
456
+ ### For Simple Poses
457
+
458
+ ```json
459
+ {
460
+ "function": "goto_pose",
461
+ "arguments": {
462
+ "roll": 0,
463
+ "pitch": 10,
464
+ "yaw": -20,
465
+ "duration": 0.5
466
+ }
467
+ }
468
+ ```
469
+
470
+ ### For Animated Sequences
471
+
472
+ ```json
473
+ {
474
+ "function": "create_sequence",
475
+ "arguments": {
476
+ "keyframes": [
477
+ {"t": 0.0, "head": {"roll": 0, "pitch": 0, "yaw": 0}, "antennas": [0, 0]},
478
+ {"t": 0.5, "head": {"roll": 10, "pitch": -5, "yaw": 20}, "antennas": [15, -15]},
479
+ {"t": 1.0, "head": {"roll": 0, "pitch": 0, "yaw": 0}, "antennas": [0, 0]}
480
+ ]
481
+ }
482
+ }
483
+ ```
484
+
485
+ This format allows seamless integration with the OpenAI Realtime API for voice-controlled robot movements.
docs/plans/voice-controlled-movement.md ADDED
@@ -0,0 +1,692 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Voice-Controlled Movement App for Reachy Mini
2
+
3
+ > [!IMPORTANT]
4
+ > This plan has been implemented. For the latest architecture and tool definitions, please refer to [SDK_DOCUMENTATION.md](../SDK_DOCUMENTATION.md).
5
+
6
+
7
+
8
+ A standalone app using **OpenAI Realtime API** and **fastrtc** for voice conversations with Reachy Mini. Speak naturally and Reachy responds with voice while executing movements.
9
+
10
+ ## Design Decisions (Approved)
11
+
12
+ - **UI**: Web-based (Gradio) following project's existing CSS style
13
+ - **Movement Complexity**: Advanced (full procedural generation with timing/interpolation)
14
+ - **Voice API**: OpenAI Realtime API (bidirectional voice)
15
+ - **Streaming**: fastrtc for low-latency audio
16
+ - **Agent Framework**: LangGraph for stateful movement tool calling
17
+ - **Integration**: Independent, can borrow patterns from conversation app
18
+
19
+ ---
20
+
21
+ ## Architecture
22
+
23
+ ```mermaid
24
+ flowchart TB
25
+ subgraph User["User"]
26
+ MIC[🎤 Microphone]
27
+ SPK[🔊 Speaker]
28
+ end
29
+
30
+ subgraph UI["Gradio Web UI"]
31
+ AUDIO[Audio Stream]
32
+ TRANS[Transcript Display]
33
+ STATUS[Status Display]
34
+ end
35
+
36
+ subgraph FRTC["FastRTC Layer"]
37
+ STREAM[Stream Handler]
38
+ VAD[Voice Activity Detection]
39
+ end
40
+
41
+ subgraph OPENAI["OpenAI Realtime API"]
42
+ WS[WebSocket Connection]
43
+ ASR[Speech Recognition]
44
+ LLM[GPT-4.1 Realtime]
45
+ TTS[Text-to-Speech]
46
+ TOOLS[Function Calling]
47
+ end
48
+
49
+ subgraph Movement["Movement System"]
50
+ MG[Movement Generator]
51
+ SDK[Reachy Mini SDK]
52
+ end
53
+
54
+ MIC --> AUDIO
55
+ AUDIO --> STREAM
56
+ STREAM --> VAD
57
+ VAD --> WS
58
+ WS --> ASR
59
+ ASR --> LLM
60
+ LLM --> TOOLS
61
+ TOOLS --> MG
62
+ MG --> SDK
63
+ LLM --> TTS
64
+ TTS --> WS
65
+ WS --> STREAM
66
+ STREAM --> AUDIO
67
+ AUDIO --> SPK
68
+
69
+ SDK --> ROBOT[🤖 Reachy Mini]
70
+ ```
71
+
72
+ ---
73
+
74
+ ## OpenAI Realtime API Overview
75
+
76
+ The Realtime API provides:
77
+ - **WebSocket connection** for bidirectional streaming
78
+ - **Built-in speech recognition** (input audio → text)
79
+ - **Built-in TTS** (text → output audio)
80
+ - **Function calling** during conversation
81
+ - **~200ms latency** end-to-end
82
+
83
+ ### Event Flow
84
+
85
+ ```mermaid
86
+ sequenceDiagram
87
+ participant User
88
+ participant FastRTC
89
+ participant OpenAI
90
+ participant Tools
91
+ participant Reachy
92
+
93
+ User->>FastRTC: Speaks "Wave hello"
94
+ FastRTC->>OpenAI: Audio chunks
95
+ OpenAI->>OpenAI: Transcribe
96
+ OpenAI->>OpenAI: Process with LLM
97
+ OpenAI->>Tools: function_call: create_sequence
98
+ Tools->>Reachy: Execute wave animation
99
+ Tools-->>OpenAI: function_result: success
100
+ OpenAI->>OpenAI: Generate response
101
+ OpenAI->>FastRTC: Audio response
102
+ FastRTC->>User: "Okay, waving hello!"
103
+ ```
104
+
105
+ ---
106
+
107
+ ## Proposed Changes
108
+
109
+ ### Component Structure
110
+
111
+ ```
112
+ reachy_mini_danceml/
113
+ ├── reachy_mini_danceml/
114
+ │ ├── main.py # [MODIFY] Add Gradio voice UI
115
+ │ ├── realtime_handler.py # [NEW] OpenAI Realtime API + fastrtc
116
+ │ ├── movement_tools.py # [NEW] Function calling tools
117
+ │ ├── movement_generator.py # [NEW] Keyframe interpolation
118
+ │ └── static/ # (existing static files)
119
+ ├── tests/
120
+ │ └── test_movement_generator.py # [NEW] Unit tests
121
+ └── pyproject.toml # [MODIFY] Add dependencies
122
+ ```
123
+
124
+ ---
125
+
126
+ ### [NEW] `reachy_mini_danceml/realtime_handler.py`
127
+
128
+ OpenAI Realtime API integration with fastrtc.
129
+
130
+ ```python
131
+ import asyncio
132
+ import json
133
+ from typing import Optional, Callable
134
+ from fastrtc import Stream, ReplyOnPause
135
+ from openai import AsyncOpenAI
136
+ import numpy as np
137
+
138
+ # Tool definitions for movement control
139
+ MOVEMENT_TOOLS = [
140
+ {
141
+ "type": "function",
142
+ "name": "goto_pose",
143
+ "description": "Move the robot's head to a specific pose",
144
+ "parameters": {
145
+ "type": "object",
146
+ "properties": {
147
+ "roll": {"type": "number", "description": "Roll angle (-30 to 30 degrees)"},
148
+ "pitch": {"type": "number", "description": "Pitch angle (-30 to 30 degrees)"},
149
+ "yaw": {"type": "number", "description": "Yaw angle (-45 to 45 degrees)"},
150
+ "duration": {"type": "number", "description": "Duration in seconds", "default": 0.5}
151
+ }
152
+ }
153
+ },
154
+ {
155
+ "type": "function",
156
+ "name": "create_sequence",
157
+ "description": "Create and play an animated movement sequence",
158
+ "parameters": {
159
+ "type": "object",
160
+ "properties": {
161
+ "keyframes": {
162
+ "type": "array",
163
+ "items": {
164
+ "type": "object",
165
+ "properties": {
166
+ "t": {"type": "number", "description": "Time in seconds"},
167
+ "head": {"type": "object", "properties": {
168
+ "roll": {"type": "number"},
169
+ "pitch": {"type": "number"},
170
+ "yaw": {"type": "number"}
171
+ }},
172
+ "antennas": {"type": "array", "items": {"type": "number"}}
173
+ }
174
+ }
175
+ }
176
+ },
177
+ "required": ["keyframes"]
178
+ }
179
+ },
180
+ {
181
+ "type": "function",
182
+ "name": "stop_movement",
183
+ "description": "Stop any currently playing movement"
184
+ }
185
+ ]
186
+
187
+ SYSTEM_INSTRUCTIONS = """You are Reachy, a friendly robot companion. You control a robot with a head and antennas.
188
+
189
+ Available movements:
190
+ - Head: roll (-30 to 30°), pitch (-30 to 30°), yaw (-45 to 45°)
191
+ - Antennas: left/right (-60 to 60°)
192
+
193
+ Use goto_pose for simple movements ("look left", "look up").
194
+ Use create_sequence for animations ("wave hello", "nod excitedly", "dance").
195
+
196
+ Be conversational and friendly. Confirm what you're doing.
197
+ Examples:
198
+ - "Look left" → goto_pose with yaw=-30
199
+ - "Wave hello" → create_sequence with antenna keyframes
200
+ - "Nod yes" → create_sequence with pitch keyframes"""
201
+
202
+ class RealtimeHandler:
203
+ def __init__(self, openai_key: str, movement_generator):
204
+ self.client = AsyncOpenAI(api_key=openai_key)
205
+ self.generator = movement_generator
206
+ self.connection = None
207
+
208
+ async def handle_tool_call(self, name: str, arguments: dict) -> str:
209
+ """Execute a tool call and return the result."""
210
+ if name == "goto_pose":
211
+ await self.generator.goto_pose(**arguments)
212
+ return f"Moved to pose: roll={arguments.get('roll', 0)}°, pitch={arguments.get('pitch', 0)}°, yaw={arguments.get('yaw', 0)}°"
213
+ elif name == "create_sequence":
214
+ keyframes = arguments.get("keyframes", [])
215
+ move = self.generator.create_from_keyframes(keyframes)
216
+ await self.generator.play_move(move)
217
+ return f"Played sequence with {len(keyframes)} keyframes"
218
+ elif name == "stop_movement":
219
+ await self.generator.stop()
220
+ return "Movement stopped"
221
+ return "Unknown tool"
222
+
223
+ def create_stream(self):
224
+ """Create a fastrtc Stream for audio processing."""
225
+ async def audio_handler(audio_input):
226
+ """Process incoming audio through OpenAI Realtime API."""
227
+ async with self.client.beta.realtime.connect(
228
+ model="gpt-realtime"
229
+ ) as conn:
230
+ # Configure session with tools
231
+ await conn.session.update(session={
232
+ "modalities": ["text", "audio"],
233
+ "instructions": SYSTEM_INSTRUCTIONS,
234
+ "tools": MOVEMENT_TOOLS,
235
+ "input_audio_format": "pcm16",
236
+ "output_audio_format": "pcm16",
237
+ })
238
+
239
+ # Send audio input
240
+ await conn.input_audio_buffer.append(audio=audio_input)
241
+ await conn.input_audio_buffer.commit()
242
+ await conn.response.create()
243
+
244
+ # Process response events
245
+ async for event in conn:
246
+ if event.type == "response.audio.delta":
247
+ yield event.delta # Audio output
248
+ elif event.type == "response.function_call_arguments.done":
249
+ # Execute tool call
250
+ result = await self.handle_tool_call(
251
+ event.name,
252
+ json.loads(event.arguments)
253
+ )
254
+ # Send result back
255
+ await conn.conversation.item.create(item={
256
+ "type": "function_call_output",
257
+ "call_id": event.call_id,
258
+ "output": result
259
+ })
260
+ await conn.response.create()
261
+
262
+ return Stream(
263
+ handler=ReplyOnPause(audio_handler),
264
+ modality="audio",
265
+ mode="send-receive"
266
+ )
267
+ ```
268
+
269
+ ---
270
+
271
+ ### [NEW] `reachy_mini_danceml/movement_tools.py`
272
+
273
+ Movement tool definitions (shared between Realtime API and any non-realtime fallback).
274
+
275
+ ```python
276
+ from dataclasses import dataclass
277
+ from typing import List, Tuple, Optional
278
+
279
+ @dataclass
280
+ class KeyFrame:
281
+ t: float
282
+ head: dict # {"roll": 0, "pitch": 0, "yaw": 0}
283
+ antennas: Tuple[float, float] = (0.0, 0.0)
284
+
285
+ @classmethod
286
+ def from_dict(cls, data: dict) -> "KeyFrame":
287
+ return cls(
288
+ t=data.get("t", 0),
289
+ head=data.get("head", {}),
290
+ antennas=tuple(data.get("antennas", [0, 0]))
291
+ )
292
+
293
+ # Tool schemas for OpenAI function calling
294
+ GOTO_POSE_SCHEMA = {
295
+ "type": "function",
296
+ "name": "goto_pose",
297
+ "description": "Move the robot's head to a specific pose",
298
+ "parameters": {
299
+ "type": "object",
300
+ "properties": {
301
+ "roll": {"type": "number", "description": "Roll angle (-30 to 30 degrees)", "default": 0},
302
+ "pitch": {"type": "number", "description": "Pitch angle (-30 to 30 degrees)", "default": 0},
303
+ "yaw": {"type": "number", "description": "Yaw angle (-45 to 45 degrees)", "default": 0},
304
+ "duration": {"type": "number", "description": "Duration in seconds", "default": 0.5}
305
+ }
306
+ }
307
+ }
308
+
309
+ CREATE_SEQUENCE_SCHEMA = {
310
+ "type": "function",
311
+ "name": "create_sequence",
312
+ "description": "Create and play an animated movement sequence from keyframes",
313
+ "parameters": {
314
+ "type": "object",
315
+ "properties": {
316
+ "keyframes": {
317
+ "type": "array",
318
+ "description": "List of keyframes defining the animation",
319
+ "items": {
320
+ "type": "object",
321
+ "properties": {
322
+ "t": {"type": "number", "description": "Time in seconds"},
323
+ "head": {
324
+ "type": "object",
325
+ "properties": {
326
+ "roll": {"type": "number"},
327
+ "pitch": {"type": "number"},
328
+ "yaw": {"type": "number"}
329
+ }
330
+ },
331
+ "antennas": {
332
+ "type": "array",
333
+ "items": {"type": "number"},
334
+ "description": "[left, right] degrees"
335
+ }
336
+ }
337
+ }
338
+ }
339
+ },
340
+ "required": ["keyframes"]
341
+ }
342
+ }
343
+
344
+ STOP_MOVEMENT_SCHEMA = {
345
+ "type": "function",
346
+ "name": "stop_movement",
347
+ "description": "Stop any currently playing movement"
348
+ }
349
+
350
+ ALL_TOOLS = [GOTO_POSE_SCHEMA, CREATE_SEQUENCE_SCHEMA, STOP_MOVEMENT_SCHEMA]
351
+ ```
352
+
353
+ ---
354
+
355
+ ### [NEW] `reachy_mini_danceml/movement_generator.py`
356
+
357
+ Keyframe-to-Move conversion with interpolation (same as before).
358
+
359
+ ```python
360
+ from dataclasses import dataclass
361
+ from typing import List, Tuple, Optional, Union
362
+ import numpy as np
363
+ from scipy.interpolate import CubicSpline
364
+ from reachy_mini import ReachyMini
365
+ from reachy_mini.motion.move import Move
366
+ from reachy_mini.utils import create_head_pose
367
+ from .movement_tools import KeyFrame
368
+
369
+ class GeneratedMove(Move):
370
+ """A Move generated from keyframes with cubic spline interpolation."""
371
+
372
+ def __init__(self, keyframes: List[KeyFrame]):
373
+ if len(keyframes) < 2:
374
+ raise ValueError("Need at least 2 keyframes")
375
+
376
+ times = [kf.t for kf in keyframes]
377
+ self.roll_spline = CubicSpline(times, [kf.head.get("roll", 0) for kf in keyframes])
378
+ self.pitch_spline = CubicSpline(times, [kf.head.get("pitch", 0) for kf in keyframes])
379
+ self.yaw_spline = CubicSpline(times, [kf.head.get("yaw", 0) for kf in keyframes])
380
+ self.left_ant_spline = CubicSpline(times, [kf.antennas[0] for kf in keyframes])
381
+ self.right_ant_spline = CubicSpline(times, [kf.antennas[1] for kf in keyframes])
382
+ self._duration = max(times)
383
+ self._keyframes = keyframes
384
+
385
+ @property
386
+ def duration(self) -> float:
387
+ return self._duration
388
+
389
+ def evaluate(self, t: float):
390
+ """Evaluate the move at time t. Returns (head_pose, antennas, body_yaw)."""
391
+ t = min(t, self._duration) # Clamp to duration
392
+
393
+ roll = float(self.roll_spline(t))
394
+ pitch = float(self.pitch_spline(t))
395
+ yaw = float(self.yaw_spline(t))
396
+ head = create_head_pose(roll=roll, pitch=pitch, yaw=yaw, degrees=True)
397
+
398
+ left_ant = np.deg2rad(float(self.left_ant_spline(t)))
399
+ right_ant = np.deg2rad(float(self.right_ant_spline(t)))
400
+ antennas = np.array([left_ant, right_ant])
401
+
402
+ return head, antennas, 0.0
403
+
404
+ class MovementGenerator:
405
+ def __init__(self, reachy: ReachyMini):
406
+ self.reachy = reachy
407
+ self.last_movement: Optional[GeneratedMove] = None
408
+
409
+ def create_from_keyframes(self, keyframes: Union[List[KeyFrame], List[dict]]) -> GeneratedMove:
410
+ """Create a Move from keyframes."""
411
+ # Convert dicts to KeyFrame objects if needed
412
+ if keyframes and isinstance(keyframes[0], dict):
413
+ keyframes = [KeyFrame.from_dict(kf) for kf in keyframes]
414
+ move = GeneratedMove(keyframes)
415
+ self.last_movement = move
416
+ return move
417
+
418
+ async def goto_pose(self, roll: float = 0, pitch: float = 0, yaw: float = 0, duration: float = 0.5):
419
+ """Go to a specific head pose."""
420
+ head = create_head_pose(roll=roll, pitch=pitch, yaw=yaw, degrees=True)
421
+ self.reachy.goto_target(head=head, duration=duration)
422
+
423
+ async def play_move(self, move: Move):
424
+ """Play a move asynchronously."""
425
+ await self.reachy.async_play_move(move)
426
+
427
+ async def stop(self):
428
+ """Stop current movement (return to neutral)."""
429
+ head = create_head_pose(roll=0, pitch=0, yaw=0, degrees=True)
430
+ self.reachy.goto_target(head=head, duration=0.3)
431
+ ```
432
+
433
+ ---
434
+
435
+ ### [MODIFY] `reachy_mini_danceml/main.py`
436
+
437
+ Add Gradio UI with fastrtc audio stream.
438
+
439
+ ```python
440
+ import threading
441
+ import os
442
+ from reachy_mini import ReachyMini, ReachyMiniApp
443
+ import gradio as gr
444
+
445
+ from .realtime_handler import RealtimeHandler
446
+ from .movement_generator import MovementGenerator
447
+
448
+
449
+ class ReachyMiniDanceml(ReachyMiniApp):
450
+ custom_app_url: str | None = "http://0.0.0.0:8042"
451
+
452
+ def run(self, reachy_mini: ReachyMini, stop_event: threading.Event):
453
+ # Initialize movement generator
454
+ generator = MovementGenerator(reachy_mini)
455
+
456
+ # Check for OpenAI API key
457
+ openai_key = os.environ.get("OPENAI_API_KEY")
458
+ if not openai_key:
459
+ print("WARNING: OPENAI_API_KEY not set. Voice control disabled.")
460
+ return self._run_basic_mode(reachy_mini, stop_event)
461
+
462
+ # Initialize realtime handler
463
+ handler = RealtimeHandler(openai_key, generator)
464
+ stream = handler.create_stream()
465
+
466
+ # Create Gradio UI
467
+ with gr.Blocks(
468
+ title="Reachy Voice Control",
469
+ css=self._get_custom_css()
470
+ ) as demo:
471
+ gr.Markdown("# 🎤 Reachy Voice Control")
472
+ gr.Markdown("Speak to control Reachy's movements!")
473
+
474
+ with gr.Row():
475
+ audio = gr.Audio(
476
+ sources=["microphone"],
477
+ streaming=True,
478
+ label="Speak to Reachy"
479
+ )
480
+
481
+ with gr.Row():
482
+ status = gr.Textbox(
483
+ label="Status",
484
+ value="Ready to listen...",
485
+ interactive=False
486
+ )
487
+
488
+ # Connect audio stream
489
+ stream.ui.render()
490
+
491
+ # Launch Gradio
492
+ demo.launch(
493
+ server_name="0.0.0.0",
494
+ server_port=8042,
495
+ share=False,
496
+ prevent_thread_lock=True
497
+ )
498
+
499
+ # Keep running until stop event
500
+ while not stop_event.is_set():
501
+ stop_event.wait(1)
502
+
503
+ def _get_custom_css(self):
504
+ """Custom CSS matching project theme."""
505
+ return """
506
+ .gradio-container {
507
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
508
+ min-height: 100vh;
509
+ }
510
+ .main {
511
+ background: white;
512
+ border-radius: 20px;
513
+ box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
514
+ padding: 2rem;
515
+ margin: 2rem auto;
516
+ max-width: 800px;
517
+ }
518
+ h1 {
519
+ color: #1e293b;
520
+ text-align: center;
521
+ }
522
+ .audio-container {
523
+ background: #f0f9ff;
524
+ border-radius: 12px;
525
+ padding: 1.5rem;
526
+ margin: 1rem 0;
527
+ }
528
+ button.primary {
529
+ background: linear-gradient(135deg, #667eea, #764ba2) !important;
530
+ border: none !important;
531
+ border-radius: 50px !important;
532
+ }
533
+ """
534
+
535
+ def _run_basic_mode(self, reachy_mini, stop_event):
536
+ """Fallback mode without voice control."""
537
+ while not stop_event.is_set():
538
+ stop_event.wait(1)
539
+
540
+
541
+ if __name__ == "__main__":
542
+ app = ReachyMiniDanceml()
543
+ app.wrapped_run()
544
+ ```
545
+
546
+ ---
547
+
548
+ ### [MODIFY] `pyproject.toml`
549
+
550
+ ```diff
551
+ dependencies = [
552
+ "reachy-mini",
553
+ + "fastrtc[vad]>=0.0.16",
554
+ + "openai>=1.0.0",
555
+ + "gradio>=4.0.0",
556
+ + "scipy>=1.10.0",
557
+ ]
558
+ ```
559
+
560
+ ---
561
+
562
+ ## Verification Plan
563
+
564
+ ### Unit Tests
565
+
566
+ Since the movement generator is the only pure-logic component, we focus tests there.
567
+
568
+ #### [NEW] `tests/test_movement_generator.py`
569
+
570
+ ```python
571
+ import pytest
572
+ import numpy as np
573
+ from reachy_mini_danceml.movement_generator import GeneratedMove
574
+ from reachy_mini_danceml.movement_tools import KeyFrame
575
+
576
+ def test_generate_move_from_keyframes():
577
+ """Test that keyframes produce valid Move object."""
578
+ keyframes = [
579
+ KeyFrame(t=0.0, head={"yaw": 0}, antennas=(0, 0)),
580
+ KeyFrame(t=1.0, head={"yaw": 30}, antennas=(20, -20)),
581
+ ]
582
+ move = GeneratedMove(keyframes)
583
+ assert move.duration == 1.0
584
+
585
+ def test_evaluate_returns_correct_shape():
586
+ """Test that evaluate returns proper head pose and antennas."""
587
+ keyframes = [
588
+ KeyFrame(t=0.0, head={"roll": 0, "pitch": 0, "yaw": 0}, antennas=(0, 0)),
589
+ KeyFrame(t=1.0, head={"roll": 10, "pitch": 10, "yaw": 30}, antennas=(20, -20)),
590
+ ]
591
+ move = GeneratedMove(keyframes)
592
+ head, antennas, body_yaw = move.evaluate(0.5)
593
+
594
+ assert head.shape == (4, 4) # 4x4 homogeneous matrix
595
+ assert len(antennas) == 2
596
+ assert body_yaw == 0.0
597
+
598
+ def test_requires_minimum_keyframes():
599
+ """Test that we need at least 2 keyframes."""
600
+ with pytest.raises(ValueError):
601
+ GeneratedMove([KeyFrame(t=0, head={})])
602
+
603
+ def test_keyframe_from_dict():
604
+ """Test KeyFrame.from_dict parsing."""
605
+ data = {"t": 1.5, "head": {"yaw": 30}, "antennas": [10, -10]}
606
+ kf = KeyFrame.from_dict(data)
607
+ assert kf.t == 1.5
608
+ assert kf.head["yaw"] == 30
609
+ assert kf.antennas == (10, -10)
610
+ ```
611
+
612
+ **Run tests:**
613
+ ```bash
614
+ source venv/bin/activate
615
+ pip install pytest
616
+ python -m pytest tests/test_movement_generator.py -v
617
+ ```
618
+
619
+ ### Manual Testing (requires robot + microphone)
620
+
621
+ Since Realtime API is primarily interactive, manual testing is essential.
622
+
623
+ **Prerequisites:**
624
+ 1. Set `OPENAI_API_KEY=sk-...` in environment or `.env` file
625
+ 2. Have Reachy Mini connected (or simulator)
626
+ 3. Have microphone connected to computer
627
+
628
+ **Test 1: Basic Startup**
629
+ 1. Run: `source venv/bin/activate && python -m reachy_mini_danceml.main`
630
+ 2. Open browser to `http://localhost:8042`
631
+ 3. ✅ Verify: Gradio UI loads with purple gradient theme
632
+
633
+ **Test 2: Voice Recognition**
634
+ 1. Click microphone button in Gradio UI
635
+ 2. Say: "Hello Reachy"
636
+ 3. ✅ Verify: You hear Reachy respond with voice
637
+
638
+ **Test 3: Simple Movement**
639
+ 1. Say: "Look to the left"
640
+ 2. ✅ Verify: Reachy's head rotates left
641
+ 3. ✅ Verify: Reachy says something like "Looking left!"
642
+
643
+ **Test 4: Animated Movement**
644
+ 1. Say: "Wave your antennas excitedly"
645
+ 2. ✅ Verify: Antennas move in an animated pattern
646
+ 3. ✅ Verify: Reachy confirms verbally
647
+
648
+ **Test 5: Complex Request**
649
+ 1. Say: "Nod your head up and down twice, then look at me"
650
+ 2. ✅ Verify: Reachy performs multi-part animation
651
+
652
+ ---
653
+
654
+ ## File Summary
655
+
656
+ | File | Action | Description |
657
+ |------|--------|-------------|
658
+ | `realtime_handler.py` | NEW | OpenAI Realtime API + fastrtc integration |
659
+ | `movement_tools.py` | NEW | Tool schemas and KeyFrame dataclass |
660
+ | `movement_generator.py` | NEW | Keyframe interpolation and movement execution |
661
+ | `main.py` | MODIFY | Add Gradio UI with audio stream |
662
+ | `pyproject.toml` | MODIFY | Add fastrtc, openai, gradio, scipy |
663
+ | `tests/test_movement_generator.py` | NEW | Unit tests |
664
+
665
+ ---
666
+
667
+ ## Comparison: Old vs New Architecture
668
+
669
+ | Aspect | Previous (LangGraph + Web Speech) | New (Realtime API + fastrtc) |
670
+ |--------|-----------------------------------|------------------------------|
671
+ | Speech-to-text | Browser Web Speech API | OpenAI Realtime (built-in) |
672
+ | LLM | Separate GPT-4.1 calls | Realtime API (streaming) |
673
+ | Text-to-speech | None | OpenAI Realtime (built-in) |
674
+ | Latency | ~500-800ms | ~200ms |
675
+ | Reachy speaks? | No (text only) | Yes (voice responses) |
676
+ | State management | LangGraph | OpenAI maintains conversation |
677
+ | UI Framework | Custom HTML/JS | Gradio |
678
+ | Complexity | More code | Less code (fastrtc handles details) |
679
+
680
+ ---
681
+
682
+ ## Next Steps (After Approval)
683
+
684
+ 1. ✅ Plan approved (waiting)
685
+ 2. [ ] Update `pyproject.toml` with dependencies
686
+ 3. [ ] Install dependencies
687
+ 4. [ ] Create `movement_tools.py`
688
+ 5. [ ] Create `movement_generator.py`
689
+ 6. [ ] Create `realtime_handler.py`
690
+ 7. [ ] Update `main.py` with Gradio UI
691
+ 8. [ ] Create unit tests
692
+ 9. [ ] Manual testing with robot
pyproject.toml CHANGED
@@ -10,7 +10,12 @@ description = "Add your description here"
10
  readme = "README.md"
11
  requires-python = ">=3.10"
12
  dependencies = [
13
- "reachy-mini"
 
 
 
 
 
14
  ]
15
  keywords = ["reachy-mini-app"]
16
 
 
10
  readme = "README.md"
11
  requires-python = ">=3.10"
12
  dependencies = [
13
+ "reachy-mini",
14
+ "fastrtc[vad]>=0.0.16",
15
+ "openai>=1.0.0",
16
+ "gradio>=4.0.0",
17
+ "scipy>=1.10.0",
18
+ "sounddevice>=0.4.6",
19
  ]
20
  keywords = ["reachy-mini-app"]
21
 
reachy_mini_danceml/audio_capture.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import numpy as np
3
+ import sounddevice as sd
4
+ import threading
5
+ import queue
6
+ from typing import Optional, Callable, AsyncGenerator
7
+
8
+ class LocalAudioCapture:
9
+ """Captures audio from the default local microphone using sounddevice."""
10
+
11
+ def __init__(self, sample_rate: int = 24000, channels: int = 1, device_name: Optional[str] = None):
12
+ self.sample_rate = sample_rate
13
+ self.channels = channels
14
+ self.device_name = device_name
15
+ self._input_stream: Optional[sd.InputStream] = None
16
+ self._audio_queue = queue.Queue() # Thread-safe queue
17
+ self._running = False
18
+ self._device_index: Optional[int] = None
19
+
20
+ if self.device_name:
21
+ self._device_index = self._find_device_index(self.device_name)
22
+ if self._device_index is not None:
23
+ print(f"Selected audio device '{self.device_name}' (Index: {self._device_index})")
24
+ else:
25
+ print(f"Warning: Audio device '{self.device_name}' not found. Using default.")
26
+
27
+ def _find_device_index(self, name_substring: str) -> Optional[int]:
28
+ """Find device index by name substring."""
29
+ try:
30
+ devices = sd.query_devices()
31
+ for i, dev in enumerate(devices):
32
+ if name_substring in dev['name'] and dev['max_input_channels'] > 0:
33
+ return i
34
+ except Exception as e:
35
+ print(f"Error querying devices: {e}")
36
+ return None
37
+
38
+ def start(self):
39
+ """Start capturing audio."""
40
+ if self._running:
41
+ return
42
+
43
+ self._running = True
44
+
45
+ # Start sounddevice stream (uses callback in separate thread)
46
+ self._input_stream = sd.InputStream(
47
+ samplerate=self.sample_rate,
48
+ channels=self.channels,
49
+ dtype=np.int16,
50
+ callback=self._audio_callback,
51
+ device=self._device_index
52
+ )
53
+ self._input_stream.start()
54
+ print(f"Local audio capture started at {self.sample_rate}Hz using device {self._device_index if self._device_index is not None else 'Default'}")
55
+
56
+ def stop(self):
57
+ """Stop capturing audio."""
58
+ self._running = False
59
+ if self._input_stream:
60
+ self._input_stream.stop()
61
+ self._input_stream.close()
62
+ self._input_stream = None
63
+ print("Local audio capture stopped")
64
+
65
+ def _audio_callback(self, indata, frames, time, status):
66
+ """Callback from sounddevice (runs in a separate thread)."""
67
+ if status:
68
+ print(f"Audio status: {status}")
69
+ if self._running:
70
+ # Copy data effectively
71
+ audio_bytes = indata.tobytes()
72
+ # Thread-safe queue put
73
+ self._audio_queue.put_nowait(audio_bytes)
74
+
75
+ async def stream_audio(self) -> AsyncGenerator[bytes, None]:
76
+ """Yields audio chunks from the queue."""
77
+ while self._running:
78
+ try:
79
+ # Non-blocking poll of sync queue
80
+ try:
81
+ chunk = self._audio_queue.get_nowait()
82
+ yield chunk
83
+ except queue.Empty:
84
+ # No data yet, sleep briefly
85
+ await asyncio.sleep(0.01)
86
+ except asyncio.CancelledError:
87
+ break
88
+ except Exception as e:
89
+ print(f"Error in audio stream: {e}")
90
+ break
91
+
92
+ def get_chunk(self, timeout: float = 0.1) -> Optional[bytes]:
93
+ """Get next audio chunk synchronously (with timeout)."""
94
+ try:
95
+ return self._audio_queue.get(timeout=timeout)
96
+ except queue.Empty:
97
+ return None
reachy_mini_danceml/dataset_loader.py ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from dataclasses import dataclass
3
+ from typing import List, Dict, Optional, Tuple, Any
4
+ import numpy as np
5
+ from datasets import load_dataset, concatenate_datasets
6
+ from scipy.spatial.transform import Rotation
7
+
8
+ from reachy_mini.motion.recorded_move import RecordedMove
9
+
10
+ from .movement_tools import KeyFrame
11
+
12
+ @dataclass
13
+ class MoveRecord:
14
+ name: str
15
+ description: str
16
+ dataset_source: str
17
+ raw_move_data: Dict[str, Any] # Store raw data for RecordedMove
18
+ keyframes: List[KeyFrame] # Keep for backward compatibility
19
+
20
+ def to_recorded_move(self) -> RecordedMove:
21
+ """Create a RecordedMove from the raw data for smooth playback."""
22
+ return RecordedMove(self.raw_move_data)
23
+
24
+ class MoveLibrary:
25
+ """
26
+ Manages loading, caching, and serving movement data from HuggingFace datasets.
27
+ """
28
+ DANCE_DATASET = "pollen-robotics/reachy-mini-dances-library"
29
+ EMOTION_DATASET = "pollen-robotics/reachy-mini-emotions-library"
30
+
31
+ def __init__(self):
32
+ self._moves: Dict[str, MoveRecord] = {}
33
+ self._is_loaded = False
34
+
35
+ def load(self):
36
+ """Loads and indexes datasets from HuggingFace."""
37
+ if self._is_loaded:
38
+ return
39
+
40
+ print("Loading movement library...")
41
+ try:
42
+ # Load datasets
43
+ dances = load_dataset(self.DANCE_DATASET, split="train")
44
+ emotions = load_dataset(self.EMOTION_DATASET, split="train")
45
+
46
+ self._process_dataset(dances, "dance")
47
+ self._process_dataset(emotions, "emotion")
48
+
49
+ self._is_loaded = True
50
+ print(f"Library loaded with {len(self._moves)} moves.")
51
+
52
+ except Exception as e:
53
+ print(f"Error loading datasets: {e}")
54
+ # Fallback or re-raise depending on strictness requirements
55
+ raise e
56
+
57
+ def _process_dataset(self, dataset, source_type: str):
58
+ """Converts raw dataset rows into MoveRecord objects."""
59
+ for row in dataset:
60
+ try:
61
+ name = self._generate_id(row['description'])
62
+ description = row['description']
63
+ times = row['time']
64
+ targets = row['set_target_data']
65
+
66
+ # Store raw data for RecordedMove (smooth playback)
67
+ raw_move_data = {
68
+ 'description': description,
69
+ 'time': times,
70
+ 'set_target_data': targets
71
+ }
72
+
73
+ # Also create KeyFrames for backward compatibility
74
+ keyframes = []
75
+ for t, target in zip(times, targets):
76
+ kf = self._convert_target_to_keyframe(t, target)
77
+ keyframes.append(kf)
78
+
79
+ # Deduplicate names if necessary (simple increment for now)
80
+ original_name = name
81
+ counter = 1
82
+ while name in self._moves:
83
+ name = f"{original_name}_{counter}"
84
+ counter += 1
85
+
86
+ self._moves[name] = MoveRecord(
87
+ name=name,
88
+ description=description,
89
+ dataset_source=source_type,
90
+ raw_move_data=raw_move_data,
91
+ keyframes=keyframes
92
+ )
93
+ except Exception as e:
94
+ print(f"Skipping malformed row: {e}")
95
+
96
+ def _convert_target_to_keyframe(self, t: float, target: Dict[str, Any]) -> KeyFrame:
97
+ """
98
+ Converts the dataset's target structure (matrix, radians)
99
+ to our SDK's KeyFrame structure (euler degrees).
100
+ """
101
+ # 1. Extract Head Pose (4x4 Matrix)
102
+ # Note: Dataset provides list of lists, need numpy
103
+ matrix = np.array(target['head'])
104
+
105
+ # Extract rotation (top-left 3x3)
106
+ rot_matrix = matrix[:3, :3]
107
+ r = Rotation.from_matrix(rot_matrix)
108
+
109
+ # Convert to Euler angles (degrees) - standard order for Reachy/SDK usually zyx or xyz?
110
+ # SDK create_head_pose uses: R.from_euler('xyz', [roll, pitch, yaw], degrees=True)
111
+ # So we should extract 'xyz'
112
+ roll, pitch, yaw = r.as_euler('xyz', degrees=True)
113
+
114
+ # 2. Extract Antennas (Radians -> Degrees)
115
+ # Dataset: [left, right] in radians
116
+ ant_rad = target['antennas']
117
+ ant_deg = [np.rad2deg(a) for a in ant_rad]
118
+
119
+ return KeyFrame(
120
+ t=t,
121
+ head={
122
+ "roll": float(roll),
123
+ "pitch": float(pitch),
124
+ "yaw": float(yaw)
125
+ },
126
+ antennas=tuple(ant_deg)
127
+ )
128
+
129
+ def _generate_id(self, description: str) -> str:
130
+ """Generates a slug-like ID from the description."""
131
+ # Simple heuristic: take first few words or key concept
132
+ # Actual descriptions are like "A sharp, forward..."
133
+ # We might want to use specific names if the dataset had them, but it doesn't really.
134
+ # Let's clean up the string to make a valid python-ish name
135
+ slug = description.lower()
136
+ slug = "".join(c if c.isalnum() else "_" for c in slug)
137
+ words = list(filter(None, slug.split("_")))
138
+ return "_".join(words[:4]) # Limit length
139
+
140
+ def get_move(self, name: str) -> Optional[MoveRecord]:
141
+ """Retrieves a move by its generated ID/name."""
142
+ if not self._is_loaded:
143
+ self.load()
144
+ return self._moves.get(name)
145
+
146
+ def search_moves(self, query: str) -> List[Dict[str, str]]:
147
+ """
148
+ Searches move descriptions using OR matching with relevance scoring.
149
+ Returns list of {"name": name, "description": description, "source": source}.
150
+ """
151
+ if not self._is_loaded:
152
+ self.load()
153
+
154
+ params = query.lower().split()
155
+ scored_results = []
156
+
157
+ for name, record in self._moves.items():
158
+ desc_lower = record.description.lower()
159
+ name_lower = name.lower()
160
+
161
+ # Score based on how many keywords match (OR logic)
162
+ score = 0
163
+ for p in params:
164
+ if p in desc_lower:
165
+ score += 2 # Description match worth more
166
+ if p in name_lower:
167
+ score += 1 # Name match
168
+
169
+ if score > 0:
170
+ scored_results.append({
171
+ "name": name,
172
+ "description": record.description,
173
+ "source": record.dataset_source,
174
+ "_score": score
175
+ })
176
+
177
+ # Sort by score (highest first)
178
+ scored_results.sort(key=lambda x: x["_score"], reverse=True)
179
+
180
+ # Remove internal score field
181
+ for r in scored_results:
182
+ del r["_score"]
183
+
184
+ return scored_results[:15] # Return more results for better options
185
+
186
+ def list_moves(self) -> List[str]:
187
+ if not self._is_loaded:
188
+ self.load()
189
+ return list(self._moves.keys())
reachy_mini_danceml/main.py CHANGED
@@ -1,67 +1,321 @@
 
 
 
 
 
 
 
1
  import threading
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  from reachy_mini import ReachyMini, ReachyMiniApp
3
  from reachy_mini.utils import create_head_pose
4
  import numpy as np
5
- import time
6
- from pydantic import BaseModel
 
7
 
8
 
9
  class ReachyMiniDanceml(ReachyMiniApp):
10
- # Optional: URL to a custom configuration page for the app
11
- # eg. "http://localhost:8042"
 
12
  custom_app_url: str | None = "http://0.0.0.0:8042"
 
 
 
 
13
 
14
- def run(self, reachy_mini: ReachyMini, stop_event: threading.Event):
15
- t0 = time.time()
16
-
17
- antennas_enabled = True
18
- sound_play_requested = False
19
-
20
- # You can ignore this part if you don't want to add settings to your app. If you set custom_app_url to None, you have to remove this part as well.
21
- # === vvv ===
22
- class AntennaState(BaseModel):
23
- enabled: bool
24
-
25
- @self.settings_app.post("/antennas")
26
- def update_antennas_state(state: AntennaState):
27
- nonlocal antennas_enabled
28
- antennas_enabled = state.enabled
29
- return {"antennas_enabled": antennas_enabled}
30
-
31
- @self.settings_app.post("/play_sound")
32
- def request_sound_play():
33
- nonlocal sound_play_requested
34
- sound_play_requested = True
35
 
36
- # === ^^^ ===
37
-
38
- # Main control loop
39
- while not stop_event.is_set():
40
- t = time.time() - t0
41
-
42
- yaw_deg = 30.0 * np.sin(2.0 * np.pi * 0.2 * t)
43
- head_pose = create_head_pose(yaw=yaw_deg, degrees=True)
44
-
45
- if antennas_enabled:
46
- amp_deg = 25.0
47
- a = amp_deg * np.sin(2.0 * np.pi * 0.5 * t)
48
- antennas_deg = np.array([a, -a])
 
 
 
 
 
 
 
 
49
  else:
50
- antennas_deg = np.array([0.0, 0.0])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
 
52
- if sound_play_requested:
53
- print("Playing sound...")
54
- reachy_mini.media.play_sound("wake_up.wav")
55
- sound_play_requested = False
56
-
57
- antennas_rad = np.deg2rad(antennas_deg)
58
-
59
- reachy_mini.set_target(
60
- head=head_pose,
61
- antennas=antennas_rad,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  )
 
 
63
 
64
- time.sleep(0.02)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
 
66
 
67
  if __name__ == "__main__":
 
1
+ """Voice-controlled Reachy Mini app using OpenAI Realtime API.
2
+
3
+ This app allows users to speak to Reachy Mini and control its movements
4
+ through natural language commands. Uses OpenAI Realtime API for low-latency
5
+ bidirectional voice conversations.
6
+ """
7
+
8
  import threading
9
+ import os
10
+ import asyncio
11
+ from pathlib import Path
12
+
13
+ # Load .env file from the project directory
14
+ from dotenv import load_dotenv
15
+ # Try loading from multiple possible locations
16
+ env_paths = [
17
+ Path(__file__).parent.parent / ".env", # Project root
18
+ Path.cwd() / ".env", # Current working directory
19
+ ]
20
+ for env_path in env_paths:
21
+ if env_path.exists():
22
+ load_dotenv(env_path)
23
+ print(f"Loaded environment from: {env_path}")
24
+ break
25
+
26
  from reachy_mini import ReachyMini, ReachyMiniApp
27
  from reachy_mini.utils import create_head_pose
28
  import numpy as np
29
+
30
+ from .movement_generator import MovementGenerator
31
+ from .realtime_handler import RealtimeHandler
32
 
33
 
34
  class ReachyMiniDanceml(ReachyMiniApp):
35
+ """Voice-controlled Reachy Mini app with Gradio UI."""
36
+
37
+ # URL for the custom app page (Gradio will serve here)
38
  custom_app_url: str | None = "http://0.0.0.0:8042"
39
+
40
+ def __init__(self):
41
+ super().__init__()
42
+ self._stop_event = None
43
 
44
+ def stop(self):
45
+ """Stop the application."""
46
+ print("Stopping app...")
47
+ if self._stop_event:
48
+ self._stop_event.set()
49
+
50
+ def wrapped_run(self, *args, **kwargs):
51
+ """Override to connect without requiring camera.
52
+
53
+ Our app only uses voice control, so we don't need the camera.
54
+ This prevents the SDK from failing if camera access is denied.
55
+ """
56
+ import threading
57
+
58
+ # Try to connect without camera first
59
+ try:
60
+ # Set environment variable to skip camera auth dialog
61
+ os.environ['OPENCV_AVFOUNDATION_SKIP_AUTH'] = '1'
 
 
 
62
 
63
+ with ReachyMini(*args, **kwargs) as reachy_mini:
64
+ self._stop_event = threading.Event()
65
+ stop_event = self._stop_event
66
+
67
+ # Run the app
68
+ try:
69
+ self.run(reachy_mini, stop_event)
70
+ except KeyboardInterrupt:
71
+ stop_event.set()
72
+
73
+ except RuntimeError as e:
74
+ if "Camera not found" in str(e):
75
+ print("=" * 60)
76
+ print("Note: Camera not available, but that's OK!")
77
+ print("Voice control doesn't require a camera.")
78
+ print("Proceeding without camera...")
79
+ print("=" * 60)
80
+
81
+ # Create a minimal connection without media manager
82
+ # by catching and ignoring the camera error
83
+ self._run_without_camera(*args, **kwargs)
84
  else:
85
+ raise
86
+
87
+ def _run_without_camera(self, *args, **kwargs):
88
+ """Run the app with a manual ReachyMini connection that tolerates camera failure."""
89
+ import threading
90
+ from reachy_mini.io.zenoh_client import ZenohClient
91
+
92
+ # Create a simple client connection without full media manager
93
+ client = ZenohClient(localhost_only=True)
94
+ client.wait_for_connection(timeout=5.0)
95
+
96
+ # Create a minimal ReachyMini-like object
97
+ class MinimalReachy:
98
+ def __init__(self, client):
99
+ self._client = client
100
+
101
+ def set_target(self, head=None, antennas=None, body_yaw=None):
102
+ # Send target via client
103
+ pass
104
+
105
+ def goto_target(self, head=None, antennas=None, body_yaw=None, duration=0.5):
106
+ # Send goto via client
107
+ pass
108
+
109
+ async def async_play_move(self, move):
110
+ # Play move - simplified
111
+ pass
112
+
113
+ # For now, just inform user this mode isn't fully implemented
114
+ print("=" * 60)
115
+ print("Camera-less mode not fully implemented yet.")
116
+ print("Please grant camera access to Terminal in System Preferences")
117
+ print("or contact support for help.")
118
+ print("=" * 60)
119
 
120
+ def run(self, reachy_mini: ReachyMini, stop_event: threading.Event):
121
+ """Run the voice control app.
122
+
123
+ Args:
124
+ reachy_mini: Connected ReachyMini robot instance.
125
+ stop_event: Event to signal when to stop.
126
+ """
127
+ # Check for OpenAI API key
128
+ openai_key = os.environ.get("OPENAI_API_KEY")
129
+ if not openai_key:
130
+ print("=" * 60)
131
+ print("WARNING: OPENAI_API_KEY not set!")
132
+ print("Voice control is disabled.")
133
+ print("Set OPENAI_API_KEY environment variable to enable.")
134
+ print("=" * 60)
135
+ self._run_fallback_mode(reachy_mini, stop_event)
136
+ return
137
+
138
+ # Initialize components
139
+ generator = MovementGenerator(reachy_mini)
140
+ handler = RealtimeHandler(openai_key, generator, audio_device_name="Reachy Mini Audio")
141
+
142
+ # Create and launch Gradio UI
143
+ self._launch_gradio(handler, stop_event)
144
+
145
+ def _launch_gradio(self, handler: RealtimeHandler, stop_event: threading.Event):
146
+ """Launch the Gradio web UI.
147
+
148
+ Args:
149
+ handler: Configured RealtimeHandler.
150
+ stop_event: Event to signal when to stop.
151
+ """
152
+ import gradio as gr
153
+
154
+ # Custom CSS matching project theme
155
+ custom_css = """
156
+ .gradio-container {
157
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
158
+ min-height: 100vh;
159
+ }
160
+ .main {
161
+ background: white;
162
+ border-radius: 20px;
163
+ box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
164
+ padding: 2rem;
165
+ margin: 2rem auto;
166
+ max-width: 800px;
167
+ }
168
+ h1, h2 {
169
+ color: #1e293b;
170
+ text-align: center;
171
+ }
172
+ .voice-status {
173
+ background: #f0f9ff;
174
+ border: 2px solid #e0f2fe;
175
+ border-radius: 12px;
176
+ padding: 1rem;
177
+ text-align: center;
178
+ color: #0369a1;
179
+ font-weight: 500;
180
+ }
181
+ .instruction-box {
182
+ background: #fef3c7;
183
+ border: 2px solid #fde047;
184
+ border-radius: 12px;
185
+ padding: 1rem;
186
+ margin: 1rem 0;
187
+ }
188
+ button.primary {
189
+ background: linear-gradient(135deg, #667eea, #764ba2) !important;
190
+ border: none !important;
191
+ border-radius: 50px !important;
192
+ padding: 1rem 2rem !important;
193
+ }
194
+ """
195
+
196
+ # Build Gradio interface
197
+ with gr.Blocks(
198
+ title="Reachy Voice Control",
199
+ css=custom_css,
200
+ theme=gr.themes.Soft()
201
+ ) as demo:
202
+ gr.Markdown("# 🎤 Reachy Voice Control")
203
+ gr.Markdown("Speak to control Reachy's movements!")
204
+
205
+ with gr.Row():
206
+ with gr.Column():
207
+ # Instructions
208
+ gr.Markdown("""
209
+ ### How to use:
210
+ 1. Click the microphone button to start
211
+ 2. Speak your command (e.g., "Look to the left" or "Wave hello")
212
+ 3. Reachy will respond and move!
213
+
214
+ ### Example commands:
215
+ - "Look up" / "Look down" / "Look left" / "Look right"
216
+ - "Nod your head yes"
217
+ - "Wave your antennas excitedly"
218
+ - "Do a happy dance"
219
+ - "Tilt your head curiously"
220
+ """)
221
+
222
+ # Microphone toggle button
223
+ with gr.Row():
224
+ mic_button = gr.Button(
225
+ "🎤 Start Listening",
226
+ variant="primary",
227
+ size="lg",
228
+ elem_id="mic-toggle"
229
+ )
230
+
231
+ with gr.Row():
232
+ status = gr.Textbox(
233
+ label="Status",
234
+ value="🤖 Ready - Click the button to start listening",
235
+ interactive=False,
236
+ elem_classes=["voice-status"]
237
+ )
238
+
239
+ # Button click handler
240
+ def toggle_mic():
241
+ handler.toggle_listening()
242
+ if handler.is_listening:
243
+ return gr.update(value="🔴 Stop Listening", variant="stop"), "🎤 Listening... Speak now!"
244
+ else:
245
+ return gr.update(value="🎤 Start Listening", variant="primary"), "🤖 Ready - Click the button to start listening"
246
+
247
+ mic_button.click(
248
+ fn=toggle_mic,
249
+ outputs=[mic_button, status]
250
  )
251
+
252
+ gr.Markdown("### 💡 Tip: Click the button above to enable voice control")
253
 
254
+ # Launch Gradio server
255
+ print("=" * 60)
256
+ print("🎤 Reachy Voice Control Starting!")
257
+ print("Open http://localhost:8042 in your browser")
258
+ print("=" * 60)
259
+
260
+ demo.launch(
261
+ server_name="0.0.0.0",
262
+ server_port=8042,
263
+ share=False,
264
+ prevent_thread_lock=True,
265
+ show_error=True
266
+ )
267
+
268
+ # Start robot mic capture
269
+ from .audio_capture import LocalAudioCapture
270
+
271
+ print("Starting robot microphone capture...")
272
+ local_capture = LocalAudioCapture(device_name="Reachy Mini Audio")
273
+ local_capture.start()
274
+
275
+ # Run the OpenAI session in the background
276
+ async def run_session():
277
+ try:
278
+ await handler.run_local_session(local_capture._audio_queue, stop_event)
279
+ except Exception as e:
280
+ print(f"Session error: {e}")
281
+
282
+ # Start the async session
283
+ import threading
284
+ def run_async_session():
285
+ loop = asyncio.new_event_loop()
286
+ asyncio.set_event_loop(loop)
287
+ loop.run_until_complete(run_session())
288
+
289
+ session_thread = threading.Thread(target=run_async_session, daemon=True)
290
+ session_thread.start()
291
+
292
+ # Keep running until stop event
293
+ while not stop_event.is_set():
294
+ stop_event.wait(1)
295
+
296
+ print("Shutting down...")
297
+ local_capture.stop()
298
+ demo.close()
299
+
300
+ def _run_fallback_mode(self, reachy_mini: ReachyMini, stop_event: threading.Event):
301
+ """Run a simple fallback mode when API key is not available.
302
+
303
+ Just keeps robot alive without voice control.
304
+
305
+ Args:
306
+ reachy_mini: Connected ReachyMini robot instance.
307
+ stop_event: Event to signal when to stop.
308
+ """
309
+ print("Running in fallback mode (no voice control)...")
310
+
311
+ # Return to neutral position
312
+ head_pose = create_head_pose(roll=0, pitch=0, yaw=0, degrees=True)
313
+ antennas = np.array([0.0, 0.0])
314
+ reachy_mini.set_target(head=head_pose, antennas=antennas)
315
+
316
+ # Wait for stop
317
+ while not stop_event.is_set():
318
+ stop_event.wait(1)
319
 
320
 
321
  if __name__ == "__main__":
reachy_mini_danceml/movement_generator.py ADDED
@@ -0,0 +1,375 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Movement generator for voice-controlled Reachy Mini.
2
+
3
+ Creates smooth movements from keyframes using cubic spline interpolation.
4
+ """
5
+
6
+ import asyncio
7
+ from dataclasses import dataclass
8
+ from typing import List, Optional, Union
9
+ import numpy as np
10
+ from scipy.interpolate import CubicSpline
11
+ from reachy_mini import ReachyMini
12
+ from reachy_mini.motion.move import Move
13
+ from reachy_mini.utils import create_head_pose
14
+
15
+ from .movement_tools import KeyFrame
16
+ from .dataset_loader import MoveLibrary
17
+
18
+
19
+ def lerp(v0: float, v1: float, alpha: float) -> float:
20
+ """Linear interpolation between two values."""
21
+ return v0 + alpha * (v1 - v0)
22
+
23
+
24
+ class GeneratedMove(Move):
25
+ """A Move generated from keyframes using linear pose interpolation.
26
+
27
+ Uses the same interpolation approach as SDK's RecordedMove for
28
+ smooth, consistent motion.
29
+ """
30
+
31
+ def __init__(self, keyframes: List[KeyFrame]):
32
+ """Initialize from a list of keyframes.
33
+
34
+ Args:
35
+ keyframes: At least 2 keyframes defining the animation.
36
+
37
+ Raises:
38
+ ValueError: If fewer than 2 keyframes provided.
39
+ """
40
+ if len(keyframes) < 2:
41
+ raise ValueError("Need at least 2 keyframes for interpolation")
42
+
43
+ self._keyframes = keyframes
44
+ self._times = [kf.t for kf in keyframes]
45
+
46
+ # Pre-compute head poses as 4x4 matrices
47
+ self._head_poses = []
48
+ for kf in keyframes:
49
+ roll = kf.head.get("roll", 0)
50
+ pitch = kf.head.get("pitch", 0)
51
+ yaw = kf.head.get("yaw", 0)
52
+ head = create_head_pose(roll=roll, pitch=pitch, yaw=yaw, degrees=True)
53
+ self._head_poses.append(head)
54
+
55
+ # Pre-compute antenna positions in radians
56
+ self._antennas = []
57
+ for kf in keyframes:
58
+ left = np.deg2rad(kf.antennas[0])
59
+ right = np.deg2rad(kf.antennas[1])
60
+ self._antennas.append([left, right])
61
+
62
+ self._duration = max(self._times)
63
+
64
+ @property
65
+ def duration(self) -> float:
66
+ """Get the total duration of this move in seconds."""
67
+ return self._duration
68
+
69
+ def evaluate(self, t: float):
70
+ """Evaluate the move at time t using linear pose interpolation.
71
+
72
+ Args:
73
+ t: Time in seconds from start of animation.
74
+
75
+ Returns:
76
+ Tuple of (head_pose, antennas, body_yaw):
77
+ - head_pose: 4x4 homogeneous transformation matrix
78
+ - antennas: numpy array of [left, right] in radians
79
+ - body_yaw: body rotation in radians (always 0)
80
+ """
81
+ from reachy_mini.utils.interpolation import linear_pose_interpolation
82
+ import bisect
83
+
84
+ # Clamp time to valid range
85
+ t = max(0, min(t, self._duration - 1e-3))
86
+
87
+ # Find the right interval using binary search (like RecordedMove)
88
+ index = bisect.bisect_right(self._times, t)
89
+ idx_prev = index - 1 if index > 0 else 0
90
+ idx_next = index if index < len(self._times) else idx_prev
91
+
92
+ t_prev = self._times[idx_prev]
93
+ t_next = self._times[idx_next]
94
+
95
+ # Calculate interpolation alpha
96
+ if t_next == t_prev:
97
+ alpha = 0.0
98
+ else:
99
+ alpha = (t - t_prev) / (t_next - t_prev)
100
+
101
+ # Linear pose interpolation for head (like RecordedMove)
102
+ head_prev = self._head_poses[idx_prev]
103
+ head_next = self._head_poses[idx_next]
104
+ head_pose = linear_pose_interpolation(head_prev, head_next, alpha)
105
+
106
+ # Linear interpolation for antennas
107
+ ant_prev = self._antennas[idx_prev]
108
+ ant_next = self._antennas[idx_next]
109
+ antennas = np.array([
110
+ lerp(ant_prev[0], ant_next[0], alpha),
111
+ lerp(ant_prev[1], ant_next[1], alpha)
112
+ ])
113
+
114
+ return head_pose, antennas, 0.0
115
+
116
+
117
+ class MovementGenerator:
118
+ """Generates and executes movements on Reachy Mini.
119
+
120
+ Handles:
121
+ - Simple goto poses
122
+ - Complex keyframe animations
123
+ - Movement state tracking
124
+ """
125
+
126
+ def __init__(self, reachy: ReachyMini):
127
+ """Initialize with a ReachyMini instance.
128
+
129
+ Args:
130
+ reachy: Connected ReachyMini robot instance.
131
+ """
132
+ self.reachy = reachy
133
+ self.last_movement: Optional[GeneratedMove] = None
134
+ self._is_playing = False
135
+ self._move_library = MoveLibrary()
136
+ self._idle_enabled = True
137
+
138
+ # Pre-load the move library to avoid first-command latency
139
+ print("📚 Pre-loading movement library...")
140
+ self._move_library.load()
141
+ print("✅ Movement library ready!")
142
+
143
+ # Motor control thread components
144
+ self._motor_thread = None
145
+ self._motor_stop_event = None
146
+ self._current_move = None # Move to play (thread-safe via _is_playing flag)
147
+ self._move_done_event = None # Signal when move completes
148
+
149
+ def start_motor_thread(self, stop_event):
150
+ """Start the dedicated motor control thread.
151
+
152
+ This runs synchronously like the default app for guaranteed 50 Hz.
153
+ """
154
+ import threading
155
+ self._motor_stop_event = stop_event
156
+ self._move_done_event = threading.Event()
157
+ self._motor_thread = threading.Thread(target=self._motor_loop, daemon=True)
158
+ self._motor_thread.start()
159
+ print("🌀 Motor control thread started (100 Hz moves, 50 Hz idle)")
160
+
161
+ def _motor_loop(self):
162
+ """Synchronous motor control loop - runs in dedicated thread."""
163
+ import time
164
+ t0 = time.time()
165
+
166
+ while not self._motor_stop_event.is_set():
167
+ loop_start = time.time()
168
+
169
+ # Check if a move should be playing
170
+ if self._is_playing and self._current_move is not None:
171
+ # Play the move
172
+ move = self._current_move
173
+ move_start = time.time()
174
+
175
+ while time.time() - move_start < move.duration:
176
+ if self._motor_stop_event.is_set():
177
+ break
178
+
179
+ t = time.time() - move_start
180
+ try:
181
+ head, antennas, body_yaw = move.evaluate(t)
182
+ self.reachy.set_target(head=head, antennas=antennas, body_yaw=body_yaw)
183
+ except Exception as e:
184
+ print(f"Move playback error: {e}")
185
+ break
186
+
187
+ time.sleep(0.01) # 100 Hz for move playback
188
+
189
+ # Move finished
190
+ self._current_move = None
191
+ self._is_playing = False
192
+ self._move_done_event.set()
193
+
194
+ elif self._idle_enabled:
195
+ # Idle animation
196
+ t = time.time() - t0
197
+
198
+ # Gentle yaw oscillation
199
+ yaw_deg = 15.0 * np.sin(2.0 * np.pi * 0.1 * t)
200
+ head_pose = create_head_pose(yaw=yaw_deg, degrees=True)
201
+
202
+ # Gentle antenna wave
203
+ amp_deg = 15.0
204
+ a = amp_deg * np.sin(2.0 * np.pi * 0.3 * t)
205
+ antennas_rad = np.deg2rad(np.array([a, -a]))
206
+
207
+ try:
208
+ self.reachy.set_target(head=head_pose, antennas=antennas_rad, body_yaw=0.0)
209
+ except Exception as e:
210
+ pass # Ignore idle errors
211
+
212
+ time.sleep(0.02) # 50 Hz for idle
213
+ else:
214
+ time.sleep(0.02)
215
+
216
+ print("🌀 Motor control thread stopped")
217
+
218
+ def queue_move(self, move) -> None:
219
+ """Queue a move to be played by the motor thread."""
220
+ import threading
221
+ self._move_done_event = threading.Event()
222
+ self._current_move = move
223
+ self._is_playing = True
224
+
225
+ def wait_for_move(self, timeout=30.0) -> bool:
226
+ """Wait for the current move to complete. Returns True if completed."""
227
+ if self._move_done_event:
228
+ return self._move_done_event.wait(timeout=timeout)
229
+ return True
230
+
231
+ def play_move_by_name(self, name: str) -> str:
232
+ """
233
+ Plays a move from the library by its ID/name.
234
+ Returns a status string.
235
+ """
236
+ record = self._move_library.get_move(name)
237
+
238
+ # If exact match fails, try search
239
+ if not record:
240
+ results = self._move_library.search_moves(name)
241
+ if results:
242
+ # Pick the first result's ID (best guess)
243
+ best_match = results[0]["name"]
244
+ record = self._move_library.get_move(best_match)
245
+ print(f"Exact match not found for '{name}', playing '{best_match}' instead.")
246
+
247
+ if not record:
248
+ return f"Move '{name}' not found."
249
+
250
+ try:
251
+ move = self.create_from_keyframes(record.keyframes)
252
+ import asyncio
253
+ # We need to run this on the event loop, but play_move is async.
254
+ # Usually this is called from async context.
255
+ # If called from async handle_tool_call, we should return the Move or waitable.
256
+ # Wait, handle_tool_call awaits this. This method should be async.
257
+ return record # Let caller handle playing, or better: make this async.
258
+ except Exception as e:
259
+ return f"Error creating move: {e}"
260
+
261
+ async def play_library_move(self, name: str) -> str:
262
+ """Play a library move using the dedicated motor thread for smooth playback."""
263
+ record_or_error = self.play_move_by_name(name)
264
+ if isinstance(record_or_error, str):
265
+ return record_or_error # Error message
266
+
267
+ record = record_or_error
268
+
269
+ # Use RecordedMove for smooth playback (like dashboard)
270
+ move = record.to_recorded_move()
271
+
272
+ print(f"\n ⏱️ PROFILING MOVE: duration={move.duration:.2f}s")
273
+ import time
274
+ start_time = time.time()
275
+
276
+ # Queue move to motor thread and wait
277
+ self.queue_move(move)
278
+ self.wait_for_move(timeout=move.duration + 5.0)
279
+
280
+ elapsed = time.time() - start_time
281
+ drift = elapsed - move.duration
282
+ print(f" ⏱️ PROFILE RESULTS:")
283
+ print(f" Expected: {move.duration:.3f}s")
284
+ print(f" Actual: {elapsed:.3f}s")
285
+ print(f" Drift: {drift:+.3f}s ({abs(drift/move.duration)*100:.1f}%)")
286
+
287
+ return f"Playing movement: {record.description}"
288
+
289
+ def create_from_keyframes(
290
+ self,
291
+ keyframes: Union[List[KeyFrame], List[dict]]
292
+ ) -> GeneratedMove:
293
+ """Create a Move from keyframes.
294
+
295
+ Args:
296
+ keyframes: List of KeyFrame objects or dicts to convert.
297
+
298
+ Returns:
299
+ GeneratedMove ready to play.
300
+ """
301
+ # Convert dicts to KeyFrame objects if needed
302
+ if keyframes and isinstance(keyframes[0], dict):
303
+ keyframes = [KeyFrame.from_dict(kf) for kf in keyframes]
304
+
305
+ move = GeneratedMove(keyframes)
306
+ self.last_movement = move
307
+ return move
308
+
309
+ async def goto_pose(
310
+ self,
311
+ roll: float = 0,
312
+ pitch: float = 0,
313
+ yaw: float = 0,
314
+ body_yaw: float = 0,
315
+ duration: float = 0.5
316
+ ) -> None:
317
+ """Go to a specific head and body pose smoothly.
318
+
319
+ Args:
320
+ roll: Roll angle in degrees (-30 to 30).
321
+ pitch: Pitch angle in degrees (-30 to 30).
322
+ yaw: Yaw angle in degrees (-45 to 45).
323
+ body_yaw: Body rotation angle in degrees (-45 to 45).
324
+ duration: Time to reach pose in seconds.
325
+ """
326
+ # Clamp values to safe ranges
327
+ roll = max(-30, min(30, roll))
328
+ pitch = max(-30, min(30, pitch))
329
+ yaw = max(-45, min(45, yaw))
330
+ body_yaw_rad = np.deg2rad(max(-45, min(45, body_yaw)))
331
+
332
+ head = create_head_pose(roll=roll, pitch=pitch, yaw=yaw, degrees=True)
333
+ antennas = np.array([0.0, 0.0]) # Keep antennas at current position
334
+ self.reachy.goto_target(head=head, antennas=antennas, body_yaw=body_yaw_rad, duration=duration)
335
+
336
+ async def play_move(self, move: Move, profile: bool = True) -> None:
337
+ """Play a move asynchronously with optional profiling.
338
+
339
+ Args:
340
+ move: Move object to play.
341
+ profile: If True, logs timing statistics.
342
+ """
343
+ import time
344
+
345
+ self._is_playing = True
346
+
347
+ if profile:
348
+ print(f"\n ⏱️ PROFILING MOVE: duration={move.duration:.2f}s")
349
+ start_time = time.time()
350
+
351
+ try:
352
+ await self.reachy.async_play_move(move)
353
+ finally:
354
+ self._is_playing = False
355
+
356
+ if profile:
357
+ elapsed = time.time() - start_time
358
+ expected = move.duration
359
+ drift = elapsed - expected
360
+
361
+ print(f" ⏱️ PROFILE RESULTS:")
362
+ print(f" Expected: {expected:.3f}s")
363
+ print(f" Actual: {elapsed:.3f}s")
364
+ print(f" Drift: {drift:+.3f}s ({abs(drift/expected)*100:.1f}%)")
365
+
366
+ if abs(drift) > 0.1:
367
+ print(f" ⚠️ Significant timing drift detected!")
368
+
369
+ async def stop(self) -> None:
370
+ """Stop current movement and return to neutral position."""
371
+ self._is_playing = False
372
+ # Return to neutral
373
+ head = create_head_pose(roll=0, pitch=0, yaw=0, degrees=True)
374
+ antennas = np.array([0.0, 0.0])
375
+ self.reachy.goto_target(head=head, antennas=antennas, body_yaw=0.0, duration=0.3)
reachy_mini_danceml/movement_tools.py ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Movement tools for voice-controlled Reachy Mini.
2
+
3
+ Defines KeyFrame dataclass and tool schemas for OpenAI function calling.
4
+ """
5
+
6
+ from dataclasses import dataclass
7
+ from typing import Tuple
8
+
9
+
10
+ @dataclass
11
+ class KeyFrame:
12
+ """A single keyframe in an animation sequence.
13
+
14
+ Attributes:
15
+ t: Time in seconds from start of animation
16
+ head: Dict with roll, pitch, yaw in degrees
17
+ antennas: Tuple of (left, right) antenna angles in degrees
18
+ """
19
+ t: float
20
+ head: dict # {"roll": 0, "pitch": 0, "yaw": 0}
21
+ antennas: Tuple[float, float] = (0.0, 0.0)
22
+
23
+ @classmethod
24
+ def from_dict(cls, data: dict) -> "KeyFrame":
25
+ """Create KeyFrame from dictionary (for JSON deserialization)."""
26
+ return cls(
27
+ t=data.get("t", 0),
28
+ head=data.get("head", {}),
29
+ antennas=tuple(data.get("antennas", [0, 0]))
30
+ )
31
+
32
+
33
+ # Tool schemas for OpenAI Realtime API function calling
34
+ GOTO_POSE_TOOL = {
35
+ "type": "function",
36
+ "name": "goto_pose",
37
+ "description": "Move the robot's head and/or body to a specific pose smoothly",
38
+ "parameters": {
39
+ "type": "object",
40
+ "properties": {
41
+ "roll": {
42
+ "type": "number",
43
+ "description": "Head roll angle in degrees (-30 to 30). Positive tilts head right.",
44
+ "default": 0
45
+ },
46
+ "pitch": {
47
+ "type": "number",
48
+ "description": "Head pitch angle in degrees (-30 to 30). NEGATIVE = look UP, POSITIVE = look DOWN.",
49
+ "default": 0
50
+ },
51
+ "yaw": {
52
+ "type": "number",
53
+ "description": "Head yaw angle in degrees (-45 to 45). Positive looks left.",
54
+ "default": 0
55
+ },
56
+ "body_yaw": {
57
+ "type": "number",
58
+ "description": "Body rotation angle in degrees (-45 to 45). Positive turns body left. Use for 'turn around', 'face left/right'.",
59
+ "default": 0
60
+ },
61
+ "duration": {
62
+ "type": "number",
63
+ "description": "Duration of movement in seconds",
64
+ "default": 0.5
65
+ }
66
+ }
67
+ }
68
+ }
69
+
70
+ GENERATE_MOTION_TOOL = {
71
+ "type": "function",
72
+ "name": "generate_motion",
73
+ "description": "Generate smooth procedural motion. Use for expressive animations. Supports position offsets, waveforms, and transients!",
74
+ "parameters": {
75
+ "type": "object",
76
+ "properties": {
77
+ "motion_type": {
78
+ "type": "string",
79
+ "enum": ["oscillate", "wave", "bounce", "spiral", "sway", "nod", "shake", "figure8", "peek", "recoil", "chicken", "dizzy", "yeah", "groove"],
80
+ "description": "Motion pattern: nod (yes), shake (no), wave (flowing), bounce (energetic), spiral (circular), sway (gentle), peek (peekaboo), recoil (surprise), chicken (peck), dizzy (circles), yeah (emphatic), groove (funky)"
81
+ },
82
+ "duration": {
83
+ "type": "number",
84
+ "description": "Duration in seconds (1-10)",
85
+ "default": 3.0
86
+ },
87
+ "pitch_amplitude": {
88
+ "type": "number",
89
+ "description": "Up/down motion in degrees (0-30)",
90
+ "default": 15
91
+ },
92
+ "yaw_amplitude": {
93
+ "type": "number",
94
+ "description": "Left/right turn in degrees (0-45)",
95
+ "default": 20
96
+ },
97
+ "roll_amplitude": {
98
+ "type": "number",
99
+ "description": "Head tilt in degrees (0-30)",
100
+ "default": 5
101
+ },
102
+ "antenna_amplitude": {
103
+ "type": "number",
104
+ "description": "Antenna movement in degrees (0-60)",
105
+ "default": 30
106
+ },
107
+ "body_yaw_amplitude": {
108
+ "type": "number",
109
+ "description": "Body rotation/swivel amplitude in degrees (0-45). Use for dancing, turning!",
110
+ "default": 0
111
+ },
112
+ "x_offset_amplitude": {
113
+ "type": "number",
114
+ "description": "Forward/back oscillation in meters (0-0.02). Keep small to avoid IK errors.",
115
+ "default": 0
116
+ },
117
+ "y_offset_amplitude": {
118
+ "type": "number",
119
+ "description": "Side-to-side oscillation in meters (0-0.02). Keep small to avoid IK errors.",
120
+ "default": 0
121
+ },
122
+ "z_offset_amplitude": {
123
+ "type": "number",
124
+ "description": "Up/down oscillation in meters (0-0.02). Keep small to avoid IK errors.",
125
+ "default": 0
126
+ },
127
+ "x_drift": {
128
+ "type": "number",
129
+ "description": "Gradual forward/back movement in meters (-0.02 to 0.02). Keep small!",
130
+ "default": 0
131
+ },
132
+ "y_drift": {
133
+ "type": "number",
134
+ "description": "Gradual left/right movement in meters (-0.02 to 0.02).",
135
+ "default": 0
136
+ },
137
+ "z_drift": {
138
+ "type": "number",
139
+ "description": "Gradual rise (+) or sink (-) in meters (-0.02 to 0.02). Use for snake charmer effect.",
140
+ "default": 0
141
+ },
142
+ "tempo": {
143
+ "type": "number",
144
+ "description": "Speed: 0.3=slow, 1.0=normal, 2.0=fast",
145
+ "default": 1.0
146
+ },
147
+ "intensity": {
148
+ "type": "number",
149
+ "description": "Overall scale 0.0-1.0",
150
+ "default": 1.0
151
+ },
152
+ "waveform": {
153
+ "type": "string",
154
+ "enum": ["sin", "triangle", "square", "sawtooth"],
155
+ "description": "Wave shape: sin (smooth), triangle (linear ramps), square (snappy), sawtooth (ramp)",
156
+ "default": "sin"
157
+ },
158
+ "transient_enabled": {
159
+ "type": "boolean",
160
+ "description": "Enable impulse/transient modifier for sharper movements",
161
+ "default": False
162
+ }
163
+ },
164
+ "required": ["motion_type"]
165
+ }
166
+ }
167
+
168
+ STOP_MOVEMENT_TOOL = {
169
+ "type": "function",
170
+ "name": "stop_movement",
171
+ "description": "Stop any currently playing movement and return to neutral position"
172
+ }
173
+
174
+ PLAY_MOVE_TOOL = {
175
+ "type": "function",
176
+ "name": "play_move",
177
+ "description": "Play a pre-defined movement from the library by its name (e.g., 'joy', 'fear', 'chicken_dance'). Prefer this over creating sequences manually.",
178
+ "parameters": {
179
+ "type": "object",
180
+ "properties": {
181
+ "name": {
182
+ "type": "string",
183
+ "description": "Name or ID of the movement to play"
184
+ }
185
+ },
186
+ "required": ["name"]
187
+ }
188
+ }
189
+
190
+ SEARCH_MOVES_TOOL = {
191
+ "type": "function",
192
+ "name": "search_moves",
193
+ "description": "Search the movement library for available expressions or dances.",
194
+ "parameters": {
195
+ "type": "object",
196
+ "properties": {
197
+ "query": {
198
+ "type": "string",
199
+ "description": "Keywords to search for (e.g., 'happy', 'dance', 'scared')"
200
+ }
201
+ },
202
+ "required": ["query"]
203
+ }
204
+ }
205
+
206
+ GET_CHOREOGRAPHY_GUIDE_TOOL = {
207
+ "type": "function",
208
+ "name": "get_choreography_guide",
209
+ "description": "Read the choreography guide to learn how to create safe and expressive custom movements."
210
+ }
211
+
212
+ # All tools for easy import
213
+ ALL_TOOLS = [
214
+ GOTO_POSE_TOOL,
215
+ GENERATE_MOTION_TOOL,
216
+ STOP_MOVEMENT_TOOL,
217
+ PLAY_MOVE_TOOL,
218
+ SEARCH_MOVES_TOOL,
219
+ GET_CHOREOGRAPHY_GUIDE_TOOL
220
+ ]
reachy_mini_danceml/procedural_motion.py ADDED
@@ -0,0 +1,370 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Enhanced procedural motion generation with position offsets, waveforms, transients, and phrases.
2
+
3
+ Generates continuous motion from parameters. Inspired by the dance.py module.
4
+ """
5
+
6
+ import numpy as np
7
+ from typing import Optional, Tuple, List
8
+ from reachy_mini.motion.move import Move
9
+ from reachy_mini.utils import create_head_pose
10
+
11
+
12
+ # ──────────────────────────── WAVEFORM FUNCTIONS ────────────────────────────
13
+
14
+ def waveform_sin(phase: float) -> float:
15
+ """Standard sine wave."""
16
+ return np.sin(phase)
17
+
18
+ def waveform_triangle(phase: float) -> float:
19
+ """Triangle wave - linear ramps."""
20
+ return 2.0 * np.abs(2.0 * (phase / (2*np.pi) - np.floor(phase / (2*np.pi) + 0.5))) - 1.0
21
+
22
+ def waveform_square(phase: float) -> float:
23
+ """Square wave - hard on/off."""
24
+ return 1.0 if np.sin(phase) >= 0 else -1.0
25
+
26
+ def waveform_sawtooth(phase: float) -> float:
27
+ """Sawtooth wave - linear ramp up, instant drop."""
28
+ return 2.0 * (phase / (2*np.pi) - np.floor(phase / (2*np.pi) + 0.5))
29
+
30
+ WAVEFORMS = {
31
+ "sin": waveform_sin,
32
+ "triangle": waveform_triangle,
33
+ "square": waveform_square,
34
+ "sawtooth": waveform_sawtooth,
35
+ }
36
+
37
+
38
+ # ──────────────────────────── EASING FUNCTIONS ────────────────────────────
39
+
40
+ def ease_smooth(t: float) -> float:
41
+ """Smooth step easing (cubic hermite)."""
42
+ t = np.clip(t, 0.0, 1.0)
43
+ return t * t * (3 - 2 * t)
44
+
45
+ def ease_bounce(t: float) -> float:
46
+ """Bounce at the end."""
47
+ t = np.clip(t, 0.0, 1.0)
48
+ if t < 0.5:
49
+ return 8 * t * t * t * t
50
+ else:
51
+ t = t - 1
52
+ return 1 - 8 * t * t * t * t
53
+
54
+
55
+ # ──────────────────────────── TRANSIENT MOTION ────────────────────────────
56
+
57
+ def transient_impulse(t: float, duration: float, delay: float = 0.0, repeat: float = 0.0) -> float:
58
+ """Generate a transient impulse (quick hit and decay).
59
+
60
+ Args:
61
+ t: Current time
62
+ duration: How long the impulse lasts
63
+ delay: Delay before impulse starts
64
+ repeat: If > 0, repeat every this many seconds
65
+
66
+ Returns:
67
+ Value from 0 to 1 representing the impulse
68
+ """
69
+ if repeat > 0:
70
+ t = t % repeat
71
+
72
+ t_adjusted = t - delay
73
+ if t_adjusted < 0 or t_adjusted > duration:
74
+ return 0.0
75
+
76
+ # Quick attack, slow decay
77
+ progress = t_adjusted / duration
78
+ return (1.0 - progress) * (1.0 - progress)
79
+
80
+
81
+ # ──────────────────────────── PROCEDURAL MOVE ────────────────────────────
82
+
83
+ class ProceduralMove(Move):
84
+ """Enhanced procedural motion with position offsets, waveforms, transients, and phrases."""
85
+
86
+ MOTION_TYPES = [
87
+ "oscillate", "wave", "bounce", "spiral", "sway", "nod", "shake", "figure8",
88
+ # New types
89
+ "peek", "recoil", "chicken", "dizzy", "yeah", "groove"
90
+ ]
91
+
92
+ def __init__(
93
+ self,
94
+ motion_type: str = "wave",
95
+ duration: float = 3.0,
96
+ # Rotation amplitudes (degrees)
97
+ pitch_amplitude: float = 15.0,
98
+ yaw_amplitude: float = 20.0,
99
+ roll_amplitude: float = 5.0,
100
+ antenna_amplitude: float = 30.0,
101
+ body_yaw_amplitude: float = 0.0,
102
+ # Position offsets (meters) - oscillating
103
+ x_offset_amplitude: float = 0.0, # forward/back oscillation
104
+ y_offset_amplitude: float = 0.0, # left/right oscillation
105
+ z_offset_amplitude: float = 0.0, # up/down oscillation
106
+ # Position drift (meters) - gradual movement over duration
107
+ x_drift: float = 0.0, # total forward/back movement
108
+ y_drift: float = 0.0, # total left/right movement
109
+ z_drift: float = 0.0, # total up/down movement (positive = rise)
110
+ # Timing
111
+ tempo: float = 1.0,
112
+ # Style
113
+ intensity: float = 1.0,
114
+ waveform: str = "sin", # NEW: sin, triangle, square, sawtooth
115
+ phase_offset: float = 0.0,
116
+ # Transient (impulse) settings - NEW!
117
+ transient_enabled: bool = False,
118
+ transient_repeat: float = 0.5, # seconds between impulses
119
+ ):
120
+ self.motion_type = motion_type
121
+ self._duration = duration
122
+
123
+ # Apply intensity scaling
124
+ self.pitch_amp = np.deg2rad(pitch_amplitude * intensity)
125
+ self.yaw_amp = np.deg2rad(yaw_amplitude * intensity)
126
+ self.roll_amp = np.deg2rad(roll_amplitude * intensity)
127
+ self.antenna_amp = np.deg2rad(antenna_amplitude * intensity)
128
+ self.body_yaw_amp = np.deg2rad(body_yaw_amplitude * intensity)
129
+
130
+ # Position amplitudes (meters) - oscillation
131
+ self.x_amp = x_offset_amplitude * intensity
132
+ self.y_amp = y_offset_amplitude * intensity
133
+ self.z_amp = z_offset_amplitude * intensity
134
+
135
+ # Position drift (gradual movement over duration)
136
+ self.x_drift = x_drift
137
+ self.y_drift = y_drift
138
+ self.z_drift = z_drift
139
+
140
+ self.tempo = tempo
141
+ self.phase_offset = phase_offset
142
+ self.waveform_fn = WAVEFORMS.get(waveform, waveform_sin)
143
+
144
+ # Transient settings
145
+ self.transient_enabled = transient_enabled
146
+ self.transient_repeat = transient_repeat
147
+
148
+ @property
149
+ def duration(self) -> float:
150
+ return self._duration
151
+
152
+ def evaluate(self, t: float) -> Tuple[np.ndarray, np.ndarray, float]:
153
+ """Generate pose at time t."""
154
+ # Base phase
155
+ phase = 2.0 * np.pi * self.tempo * t + self.phase_offset
156
+
157
+ # Get motion values
158
+ motion_fn = getattr(self, f"_motion_{self.motion_type}", self._motion_wave)
159
+ pitch, yaw, roll, ant_l, ant_r, body, x_off, y_off, z_off = motion_fn(t, phase)
160
+
161
+ # Apply transient modifier if enabled
162
+ if self.transient_enabled:
163
+ impulse = transient_impulse(t, 0.2, 0.0, self.transient_repeat)
164
+ pitch *= (1 + impulse * 0.5)
165
+ yaw *= (1 + impulse * 0.5)
166
+
167
+ # Create head pose with position offset
168
+ # Note: create_head_pose builds a 4x4 matrix, we add translation
169
+ head = create_head_pose(
170
+ roll=np.rad2deg(roll),
171
+ pitch=np.rad2deg(pitch),
172
+ yaw=np.rad2deg(yaw),
173
+ degrees=True
174
+ )
175
+
176
+ # Add position offset (oscillation) to the transformation matrix
177
+ head[0, 3] += x_off
178
+ head[1, 3] += y_off
179
+ head[2, 3] += z_off
180
+
181
+ # Add position drift (linear interpolation over duration)
182
+ progress = t / self._duration if self._duration > 0 else 0
183
+ progress = min(1.0, max(0.0, progress)) # clamp to 0-1
184
+ head[0, 3] += self.x_drift * progress
185
+ head[1, 3] += self.y_drift * progress
186
+ head[2, 3] += self.z_drift * progress
187
+
188
+ antennas = np.array([ant_l, ant_r])
189
+ return head, antennas, body
190
+
191
+ # ──────────────────────── MOTION PATTERNS ────────────────────────
192
+
193
+ def _motion_oscillate(self, t: float, phase: float):
194
+ """Synchronized oscillation."""
195
+ s = self.waveform_fn(phase)
196
+ return (
197
+ self.pitch_amp * s, self.yaw_amp * s, self.roll_amp * s,
198
+ self.antenna_amp * s, -self.antenna_amp * s, self.body_yaw_amp * s,
199
+ self.x_amp * s, self.y_amp * s, self.z_amp * s
200
+ )
201
+
202
+ def _motion_wave(self, t: float, phase: float):
203
+ """Wave with phase offsets."""
204
+ return (
205
+ self.pitch_amp * self.waveform_fn(phase),
206
+ self.yaw_amp * self.waveform_fn(phase + np.pi/4),
207
+ self.roll_amp * self.waveform_fn(phase + np.pi/2),
208
+ self.antenna_amp * self.waveform_fn(phase + np.pi/3),
209
+ self.antenna_amp * self.waveform_fn(phase - np.pi/3),
210
+ self.body_yaw_amp * self.waveform_fn(phase + np.pi/6),
211
+ self.x_amp * self.waveform_fn(phase),
212
+ self.y_amp * self.waveform_fn(phase + np.pi/4),
213
+ self.z_amp * self.waveform_fn(phase + np.pi/2)
214
+ )
215
+
216
+ def _motion_bounce(self, t: float, phase: float):
217
+ """Bouncy up-down."""
218
+ bounce = abs(self.waveform_fn(phase))
219
+ side = self.waveform_fn(phase * 2)
220
+ return (
221
+ -self.pitch_amp * bounce, self.yaw_amp * side * 0.3, self.roll_amp * side * 0.2,
222
+ self.antenna_amp * bounce, self.antenna_amp * bounce, 0.0,
223
+ 0.0, 0.0, self.z_amp * bounce
224
+ )
225
+
226
+ def _motion_spiral(self, t: float, phase: float):
227
+ """Circular pattern."""
228
+ return (
229
+ self.pitch_amp * np.sin(phase), self.yaw_amp * np.cos(phase),
230
+ self.roll_amp * np.sin(phase * 2) * 0.5,
231
+ self.antenna_amp * np.sin(phase + np.pi/2), self.antenna_amp * np.sin(phase - np.pi/2),
232
+ self.body_yaw_amp * np.cos(phase),
233
+ self.x_amp * np.cos(phase), self.y_amp * np.sin(phase), 0.0
234
+ )
235
+
236
+ def _motion_sway(self, t: float, phase: float):
237
+ """Gentle swaying."""
238
+ slow = self.waveform_fn(phase * 0.5)
239
+ fast = self.waveform_fn(phase * 1.5)
240
+ return (
241
+ self.pitch_amp * 0.3 * slow, self.yaw_amp * slow, self.roll_amp * 0.5 * fast,
242
+ self.antenna_amp * fast * 0.5, -self.antenna_amp * fast * 0.5,
243
+ self.body_yaw_amp * slow * 0.3,
244
+ 0.0, self.y_amp * slow, 0.0
245
+ )
246
+
247
+ def _motion_nod(self, t: float, phase: float):
248
+ """Nodding (yes)."""
249
+ nod = self.waveform_fn(phase)
250
+ return (
251
+ self.pitch_amp * nod, self.yaw_amp * 0.1 * nod, 0.0,
252
+ self.antenna_amp * 0.5 * abs(nod), self.antenna_amp * 0.5 * abs(nod), 0.0,
253
+ 0.0, 0.0, self.z_amp * 0.5 * abs(nod)
254
+ )
255
+
256
+ def _motion_shake(self, t: float, phase: float):
257
+ """Head shake (no)."""
258
+ shake = self.waveform_fn(phase)
259
+ return (
260
+ self.pitch_amp * 0.1 * shake, self.yaw_amp * shake, self.roll_amp * 0.3 * shake,
261
+ self.antenna_amp * shake, -self.antenna_amp * shake, 0.0,
262
+ 0.0, 0.0, 0.0
263
+ )
264
+
265
+ def _motion_figure8(self, t: float, phase: float):
266
+ """Figure-8 pattern."""
267
+ return (
268
+ self.pitch_amp * np.sin(phase * 2), self.yaw_amp * np.sin(phase),
269
+ self.roll_amp * np.cos(phase * 2) * 0.5,
270
+ self.antenna_amp * np.sin(phase + np.pi/4), self.antenna_amp * np.sin(phase - np.pi/4),
271
+ self.body_yaw_amp * np.sin(phase) * 0.5,
272
+ self.x_amp * np.sin(phase), self.y_amp * np.sin(phase * 2), 0.0
273
+ )
274
+
275
+ # ──────────────────────── NEW MOTION TYPES ────────────────────────
276
+
277
+ def _motion_peek(self, t: float, phase: float):
278
+ """Peekaboo - duck down then peek side to side."""
279
+ period = self._duration
280
+ t_norm = (t % period) / period
281
+
282
+ # 5 phases: duck, peek-left, duck, peek-right, rise
283
+ if t_norm < 0.2:
284
+ # Ducking down
285
+ prog = ease_smooth(t_norm / 0.2)
286
+ return (0, 0, 0, 0, 0, 0, 0, 0, -self.z_amp * prog)
287
+ elif t_norm < 0.4:
288
+ # Peek left
289
+ prog = ease_smooth((t_norm - 0.2) / 0.2)
290
+ return (
291
+ -self.pitch_amp * 0.3, self.yaw_amp * prog, 0,
292
+ self.antenna_amp * prog, 0, 0,
293
+ 0, self.y_amp * prog, -self.z_amp * (1 - prog * 0.5)
294
+ )
295
+ elif t_norm < 0.6:
296
+ # Back to center
297
+ prog = ease_smooth((t_norm - 0.4) / 0.2)
298
+ return (
299
+ -self.pitch_amp * 0.3 * (1-prog), self.yaw_amp * (1-prog), 0,
300
+ self.antenna_amp * (1-prog), 0, 0,
301
+ 0, self.y_amp * (1-prog), -self.z_amp * 0.5
302
+ )
303
+ elif t_norm < 0.8:
304
+ # Peek right
305
+ prog = ease_smooth((t_norm - 0.6) / 0.2)
306
+ return (
307
+ -self.pitch_amp * 0.3, -self.yaw_amp * prog, 0,
308
+ 0, self.antenna_amp * prog, 0,
309
+ 0, -self.y_amp * prog, -self.z_amp * 0.5
310
+ )
311
+ else:
312
+ # Rise back up
313
+ prog = ease_smooth((t_norm - 0.8) / 0.2)
314
+ return (0, -self.yaw_amp * (1-prog), 0, 0, 0, 0, 0, -self.y_amp * (1-prog), -self.z_amp * 0.5 * (1-prog))
315
+
316
+ def _motion_recoil(self, t: float, phase: float):
317
+ """Quick backward recoil - surprise reaction."""
318
+ impulse = transient_impulse(t, 0.3, 0.0, self.transient_repeat or 1.0)
319
+ return (
320
+ -self.pitch_amp * impulse, 0, 0,
321
+ self.antenna_amp * impulse, self.antenna_amp * impulse, 0,
322
+ -self.x_amp * impulse, 0, 0
323
+ )
324
+
325
+ def _motion_chicken(self, t: float, phase: float):
326
+ """Chicken peck - forward thrust."""
327
+ impulse = transient_impulse(t, 0.3, 0.0, 1.0 / self.tempo if self.tempo > 0 else 1.0)
328
+ return (
329
+ self.pitch_amp * impulse, 0, 0,
330
+ self.antenna_amp * impulse, self.antenna_amp * impulse, 0,
331
+ self.x_amp * impulse, 0, 0
332
+ )
333
+
334
+ def _motion_dizzy(self, t: float, phase: float):
335
+ """Dizzy circular motion."""
336
+ return (
337
+ self.pitch_amp * np.sin(phase),
338
+ self.yaw_amp * np.cos(phase),
339
+ self.roll_amp * np.sin(phase),
340
+ self.antenna_amp * np.sin(phase * 2),
341
+ -self.antenna_amp * np.sin(phase * 2),
342
+ 0,
343
+ self.x_amp * np.cos(phase), self.y_amp * np.sin(phase), 0
344
+ )
345
+
346
+ def _motion_yeah(self, t: float, phase: float):
347
+ """Emphatic 'yeah' double-nod."""
348
+ period = 1.0 / self.tempo if self.tempo > 0 else 1.0
349
+ t_in_period = t % period
350
+
351
+ # Two quick nods
352
+ nod1 = transient_impulse(t_in_period, period * 0.3, 0, 0)
353
+ nod2 = transient_impulse(t_in_period, period * 0.2, period * 0.4, 0)
354
+ nod = nod1 + nod2 * 0.7
355
+
356
+ return (
357
+ self.pitch_amp * nod, 0, 0,
358
+ self.antenna_amp * nod, self.antenna_amp * nod, 0,
359
+ 0, 0, 0
360
+ )
361
+
362
+ def _motion_groove(self, t: float, phase: float):
363
+ """Groovy sway with roll."""
364
+ sway = self.waveform_fn(phase)
365
+ roll = self.waveform_fn(phase + np.pi/4)
366
+ return (
367
+ self.pitch_amp * 0.3 * sway, 0, self.roll_amp * roll,
368
+ self.antenna_amp * sway, -self.antenna_amp * sway, self.body_yaw_amp * sway,
369
+ 0, self.y_amp * sway, 0
370
+ )
reachy_mini_danceml/realtime_handler.py ADDED
@@ -0,0 +1,413 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """OpenAI Realtime API handler for voice-controlled Reachy Mini.
2
+
3
+ Integrates OpenAI Realtime API with fastrtc for bidirectional voice
4
+ conversations with Reachy Mini.
5
+ """
6
+
7
+ import asyncio
8
+ import threading
9
+ import json
10
+ import base64
11
+ from typing import Optional, AsyncGenerator
12
+ import numpy as np
13
+
14
+ from .movement_tools import ALL_TOOLS, KeyFrame
15
+ from .movement_generator import MovementGenerator
16
+
17
+
18
+ # System instructions for the AI
19
+ SYSTEM_INSTRUCTIONS = """You are Reachy, a friendly and expressive robot companion with a movable head and antennas.
20
+
21
+ ## Tool Selection Guide
22
+
23
+ ### 🎯 SIMPLE POSITIONING (use `goto_pose`)
24
+ - "Look left/right/up/down"
25
+ - "Turn your body"
26
+ → Call `goto_pose(pitch, yaw, roll, body_yaw, duration)`
27
+
28
+ ### 🎭 LIBRARY MOVES (use `search_moves` → `play_move`) **PREFERRED!**
29
+ - "Show me happy/sad/scared"
30
+ - "Do a dance"
31
+ → Call `search_moves(query)` first, then `play_move(name)`
32
+
33
+ ### 🌊 PROCEDURAL MOTION (use `generate_motion`) **SMOOTH & EXPRESSIVE!**
34
+ When library has nothing, use procedural motion instead of keyframes!
35
+
36
+ **Basic Motions:**
37
+ - `nod` - Yes motion (pitch dominant)
38
+ - `shake` - No motion (yaw dominant)
39
+ - `wave` - Flowing, natural (phase offsets)
40
+ - `bounce` - Energetic, playful
41
+ - `spiral` - Circular, hypnotic
42
+ - `sway` - Gentle, idle-like
43
+ - `figure8` - Playful loops
44
+ - `oscillate` - Simple back and forth
45
+
46
+ **Expressive Motions:**
47
+ - `peek` - Peekaboo, duck down and peek side to side
48
+ - `recoil` - Surprise/shock reaction, quick pullback
49
+ - `chicken` - Pecking motion, head bobs forward
50
+ - `dizzy` - Wobbly circles like confused/dizzy
51
+ - `yeah` - Emphatic nodding, excited agreement
52
+ - `groove` - Funky dance groove with attitude
53
+
54
+ **Body Rotation (swivel the base):**
55
+ - `body_yaw_amplitude` (degrees, 0-45) - Oscillate body left/right. USE THIS FOR DANCING!
56
+
57
+ **Position Offsets (oscillating head movement):**
58
+ - `x_offset_amplitude` (meters, max 0.02) - Oscillate head FORWARD/BACK
59
+ - `y_offset_amplitude` (meters, max 0.02) - Oscillate head LEFT/RIGHT
60
+ - `z_offset_amplitude` (meters, max 0.02) - Oscillate head UP/DOWN
61
+
62
+ **Position Drift (gradual movement over duration):**
63
+ - `z_drift` (meters, max ±0.02) - Rise UP (+) or sink DOWN (-) over duration. Use for snake charmer rising!
64
+ - `x_drift` (meters, max ±0.02) - Move FORWARD (+) or BACK (-) over duration
65
+ - `y_drift` (meters, max ±0.02) - Move LEFT or RIGHT over duration
66
+
67
+ Example snake charmer rising: `generate_motion(motion_type="spiral", z_drift=0.02, duration=5)`
68
+ Example sinking sad: `generate_motion(motion_type="sway", z_drift=-0.02, duration=4)`
69
+
70
+ ### 🛑 STOP (use `stop_movement`)
71
+ - "Stop", "Freeze"
72
+
73
+ ## Physical Conventions
74
+ - **Pitch**: NEGATIVE = look UP, POSITIVE = look DOWN
75
+ - Head: roll ±30°, pitch ±30°, yaw ±45°
76
+ - Body: yaw ±45°
77
+ - Antennas: ±60° each
78
+
79
+ ## Personality
80
+ Be friendly, brief. ALWAYS SPEAK IN ENGLISH.
81
+ """
82
+
83
+
84
+ class RealtimeHandler:
85
+ """Handles OpenAI Realtime API connections for voice control.
86
+
87
+ This class manages:
88
+ - WebSocket connections to OpenAI Realtime API
89
+ - Audio streaming (input and output)
90
+ - Tool calling for movement execution
91
+ """
92
+
93
+ def __init__(self, openai_key: str, movement_generator: MovementGenerator, audio_device_name: Optional[str] = None):
94
+ """Initialize the realtime handler.
95
+
96
+ Args:
97
+ openai_key: OpenAI API key with Realtime API access.
98
+ movement_generator: MovementGenerator instance for executing moves.
99
+ audio_device_name: Optional name of the audio device to use for output.
100
+ """
101
+ self.openai_key = openai_key
102
+ self.generator = movement_generator
103
+ self.audio_device_name = audio_device_name
104
+ self._connection = None
105
+
106
+ # Listening state - controlled by UI button
107
+ self.is_listening = False
108
+ self._on_listening_change = None # Callback for UI updates
109
+
110
+ def set_listening(self, listening: bool):
111
+ """Set listening state and notify callback."""
112
+ self.is_listening = listening
113
+ print(f"🎤 Listening: {'ON' if listening else 'OFF'}")
114
+ if self._on_listening_change:
115
+ self._on_listening_change(listening)
116
+
117
+ def toggle_listening(self):
118
+ """Toggle listening state."""
119
+ self.set_listening(not self.is_listening)
120
+
121
+ async def handle_tool_call(self, name: str, arguments: dict) -> str:
122
+ """Execute a tool call and return the result.
123
+
124
+ Args:
125
+ name: Name of the tool to call.
126
+ arguments: Arguments for the tool.
127
+
128
+ Returns:
129
+ String result describing what happened.
130
+ """
131
+ print(f"\n{'='*60}")
132
+ print(f"🔧 TOOL CALL: {name}")
133
+ print(f" Arguments: {json.dumps(arguments, indent=2)}")
134
+ print(f"{'='*60}")
135
+
136
+ try:
137
+ if name == "goto_pose":
138
+ roll = arguments.get("roll", 0)
139
+ pitch = arguments.get("pitch", 0)
140
+ yaw = arguments.get("yaw", 0)
141
+ body_yaw = arguments.get("body_yaw", 0)
142
+ duration = arguments.get("duration", 0.5)
143
+
144
+ print(f" 🎯 Moving to: head(roll={roll}°, pitch={pitch}°, yaw={yaw}°), body_yaw={body_yaw}° over {duration}s")
145
+ await self.generator.goto_pose(
146
+ roll=roll, pitch=pitch, yaw=yaw, body_yaw=body_yaw, duration=duration
147
+ )
148
+ result = f"Moved head to roll={roll}°, pitch={pitch}°, yaw={yaw}°, body_yaw={body_yaw}°"
149
+ print(f" ✅ {result}")
150
+ return result
151
+
152
+ elif name == "generate_motion":
153
+ from .procedural_motion import ProceduralMove
154
+
155
+ motion_type = arguments.get("motion_type", "wave")
156
+ duration = arguments.get("duration", 3.0)
157
+ pitch_amp = arguments.get("pitch_amplitude", 15)
158
+ yaw_amp = arguments.get("yaw_amplitude", 20)
159
+ roll_amp = arguments.get("roll_amplitude", 5)
160
+ antenna_amp = arguments.get("antenna_amplitude", 30)
161
+ body_yaw_amp = arguments.get("body_yaw_amplitude", 0)
162
+ x_offset = arguments.get("x_offset_amplitude", 0)
163
+ y_offset = arguments.get("y_offset_amplitude", 0)
164
+ z_offset = arguments.get("z_offset_amplitude", 0)
165
+ tempo = arguments.get("tempo", 1.0)
166
+ intensity = arguments.get("intensity", 1.0)
167
+ waveform = arguments.get("waveform", "sin")
168
+ transient = arguments.get("transient_enabled", False)
169
+
170
+ print(f" 🌊 Generating {motion_type} motion: {duration}s, tempo={tempo}, waveform={waveform}")
171
+ print(f" Rotation: pitch={pitch_amp}°, yaw={yaw_amp}°, roll={roll_amp}°")
172
+ if body_yaw_amp:
173
+ print(f" Body swivel: ±{body_yaw_amp}°")
174
+ if x_offset or y_offset or z_offset:
175
+ print(f" Oscillation: x={x_offset}m, y={y_offset}m, z={z_offset}m")
176
+
177
+ # Drift parameters (gradual movement)
178
+ x_drift = arguments.get("x_drift", 0)
179
+ y_drift = arguments.get("y_drift", 0)
180
+ z_drift = arguments.get("z_drift", 0)
181
+ if x_drift or y_drift or z_drift:
182
+ print(f" Drift: x={x_drift}m, y={y_drift}m, z={z_drift}m")
183
+
184
+ move = ProceduralMove(
185
+ motion_type=motion_type,
186
+ duration=duration,
187
+ pitch_amplitude=pitch_amp,
188
+ yaw_amplitude=yaw_amp,
189
+ roll_amplitude=roll_amp,
190
+ antenna_amplitude=antenna_amp,
191
+ body_yaw_amplitude=body_yaw_amp,
192
+ x_offset_amplitude=x_offset,
193
+ y_offset_amplitude=y_offset,
194
+ z_offset_amplitude=z_offset,
195
+ x_drift=x_drift,
196
+ y_drift=y_drift,
197
+ z_drift=z_drift,
198
+ tempo=tempo,
199
+ intensity=intensity,
200
+ waveform=waveform,
201
+ transient_enabled=transient
202
+ )
203
+
204
+ # Queue to motor thread
205
+ self.generator.queue_move(move)
206
+ self.generator.wait_for_move(timeout=duration + 2.0)
207
+
208
+ result = f"Played {motion_type} motion for {duration}s"
209
+ print(f" ✅ {result}")
210
+ return result
211
+
212
+ elif name == "stop_movement":
213
+ print(f" 🛑 Stopping all movement")
214
+ await self.generator.stop()
215
+ result = "Stopped movement, returned to neutral"
216
+ print(f" ✅ {result}")
217
+ return result
218
+
219
+ elif name == "play_move":
220
+ move_name = arguments.get("name")
221
+ print(f" 🎬 Playing library move: '{move_name}'")
222
+ if not move_name:
223
+ return "Error: Name required"
224
+ result = await self.generator.play_library_move(move_name)
225
+ print(f" ✅ {result}")
226
+ return result
227
+
228
+ elif name == "search_moves":
229
+ query = arguments.get("query", "")
230
+ print(f" 🔍 Searching library for: '{query}'")
231
+ if not query:
232
+ return "Error: Query required"
233
+
234
+ # Access the library directly from the generator
235
+ if hasattr(self.generator, "_move_library"):
236
+ results = self.generator._move_library.search_moves(query)
237
+ if not results:
238
+ print(f" ❌ No moves found")
239
+ return f"No moves found for '{query}'"
240
+
241
+ # Format results for the agent
242
+ print(f" 📚 Found {len(results)} moves:")
243
+ response = "Found movements:\n"
244
+ for r in results:
245
+ print(f" - {r['name']} ({r['source']})")
246
+ response += f"- {r['name']} ({r['source']}): {r['description'][:100]}...\n"
247
+ return response
248
+ else:
249
+ print(f" ⚠️ Move library not available")
250
+ return "Error: Move library not available"
251
+
252
+ elif name == "get_choreography_guide":
253
+ print(f" 📖 Loading choreography guide")
254
+ try:
255
+ with open("docs/CHOREOGRAPHY_GUIDE.md", "r") as f:
256
+ content = f.read()
257
+ print(f" ✅ Loaded guide ({len(content)} bytes)")
258
+ return content
259
+ except FileNotFoundError:
260
+ print(f" ❌ Guide not found")
261
+ return "Error: Choreography guide not found at docs/CHOREOGRAPHY_GUIDE.md"
262
+
263
+ else:
264
+ print(f" ❓ Unknown tool: {name}")
265
+ return f"Unknown tool: {name}"
266
+
267
+ except Exception as e:
268
+ print(f" ❌ Error: {str(e)}")
269
+ return f"Error executing {name}: {str(e)}"
270
+
271
+ async def run_local_session(self, audio_queue: asyncio.Queue, stop_event: Optional[threading.Event] = None):
272
+ """Run a persistent session with local audio input/output.
273
+
274
+ Uses OpenAI's server-side VAD.
275
+
276
+ Args:
277
+ audio_queue: Queue receiving raw PCM16 audio bytes from microphone.
278
+ stop_event: Optional event to signal cancellation.
279
+ """
280
+ from openai import AsyncOpenAI
281
+ import sounddevice as sd
282
+
283
+ client = AsyncOpenAI(api_key=self.openai_key)
284
+
285
+ print("Connecting to OpenAI Realtime API (Local Mode)...")
286
+
287
+ # Find output device index if specified
288
+ output_device_index = None
289
+ if self.audio_device_name:
290
+ try:
291
+ devices = sd.query_devices()
292
+ for i, dev in enumerate(devices):
293
+ if self.audio_device_name in dev['name'] and dev['max_output_channels'] > 0:
294
+ output_device_index = i
295
+ print(f"Selected output device '{self.audio_device_name}' (Index: {i})")
296
+ break
297
+ if output_device_index is None:
298
+ print(f"Warning: Audio output device '{self.audio_device_name}' not found. Using default.")
299
+ except Exception as e:
300
+ print(f"Error querying devices for output: {e}")
301
+
302
+ async with client.beta.realtime.connect(
303
+ model="gpt-realtime"
304
+ ) as conn:
305
+ print("Connected!")
306
+
307
+ # Configure session with VAD
308
+ await conn.session.update(session={
309
+ "modalities": ["text", "audio"],
310
+ "instructions": SYSTEM_INSTRUCTIONS,
311
+ "tools": ALL_TOOLS,
312
+ "input_audio_format": "pcm16",
313
+ "output_audio_format": "pcm16",
314
+ "turn_detection": {
315
+ "type": "server_vad",
316
+ "threshold": 0.5,
317
+ "prefix_padding_ms": 300,
318
+ "silence_duration_ms": 500
319
+ }
320
+ })
321
+
322
+ # Output stream setup (PyAudio/SoundDevice)
323
+ # We use a simple callback to play audio as it arrives
324
+ output_stream = sd.OutputStream(
325
+ samplerate=24000,
326
+ channels=1,
327
+ dtype=np.int16,
328
+ device=output_device_index
329
+ )
330
+ output_stream.start()
331
+
332
+
333
+ # Task to stream mic input to OpenAI
334
+ async def send_mic_audio():
335
+ import queue as sync_queue
336
+ chunk_count = 0
337
+ try:
338
+ while True:
339
+ if stop_event and stop_event.is_set():
340
+ break
341
+
342
+ try:
343
+ chunk = audio_queue.get(timeout=0.05)
344
+
345
+ # Only send audio when listening is enabled
346
+ if not self.is_listening:
347
+ continue
348
+
349
+ # Check for silence (all zeros)
350
+ if chunk_count % 50 == 0:
351
+ max_amp = np.frombuffer(chunk, dtype=np.int16).max() if len(chunk) > 0 else 0
352
+ print(f"Audio chunks sent: {chunk_count} | Max Amp: {max_amp}")
353
+
354
+ chunk_count += 1
355
+
356
+ # Base64 encode
357
+ b64_chunk = base64.b64encode(chunk).decode()
358
+ await conn.input_audio_buffer.append(audio=b64_chunk)
359
+ except sync_queue.Empty:
360
+ await asyncio.sleep(0.01)
361
+ except asyncio.CancelledError:
362
+ print(f"Audio send cancelled after {chunk_count} chunks")
363
+ except Exception as e:
364
+ print(f"Error sending audio: {e}")
365
+
366
+ send_task = asyncio.create_task(send_mic_audio())
367
+
368
+ # Start motor control thread (runs synchronously for smooth 50Hz)
369
+ self.generator.start_motor_thread(stop_event)
370
+
371
+ try:
372
+ # Process events from OpenAI
373
+ async for event in conn:
374
+ # Only log important events (skip noisy deltas)
375
+
376
+ if event.type == "response.audio.delta":
377
+ if event.delta:
378
+ audio_bytes = base64.b64decode(event.delta)
379
+ # Write to output stream (blocking call, but short)
380
+ output_stream.write(np.frombuffer(audio_bytes, dtype=np.int16))
381
+
382
+ elif event.type == "input_audio_buffer.speech_started":
383
+ print(">>> User started speaking!")
384
+
385
+ elif event.type == "input_audio_buffer.speech_stopped":
386
+ print(">>> User stopped speaking!")
387
+
388
+ elif event.type == "error":
389
+ print(f">>> OpenAI Error: {event}")
390
+
391
+ elif event.type == "response.function_call_arguments.done":
392
+ print(f"Tool call: {event.name}")
393
+ try:
394
+ args = json.loads(event.arguments)
395
+ except:
396
+ args = {}
397
+
398
+ result = await self.handle_tool_call(event.name, args)
399
+
400
+ await conn.conversation.item.create(item={
401
+ "type": "function_call_output",
402
+ "call_id": event.call_id,
403
+ "output": result
404
+ })
405
+ await conn.response.create()
406
+
407
+ except Exception as e:
408
+ print(f"Session error: {e}")
409
+ finally:
410
+ send_task.cancel()
411
+ # Motor thread will stop via stop_event
412
+ output_stream.stop()
413
+ output_stream.close()
tests/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Test package
tests/check_device_details.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import sounddevice as sd
3
+
4
+ print(f"Default input device: {sd.default.device[0]}")
5
+ print("Searching for 'Reachy Mini Audio'...")
6
+ devices = sd.query_devices()
7
+ for i, dev in enumerate(devices):
8
+ if "Reachy Mini Audio" in dev['name']:
9
+ print(f"\nFOUND DEVICE {i}: {dev['name']}")
10
+ print(f" Max Input Channels: {dev['max_input_channels']}")
11
+ print(f" Default Sample Rate: {dev['default_samplerate']}")
12
+ print(f" Host API: {dev['hostapi']}")
13
+ print(f" Full Info: {dev}")
tests/check_keyframes.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ """Check keyframe counts in library moves."""
3
+ import sys
4
+ sys.path.append(".")
5
+
6
+ from reachy_mini_danceml.dataset_loader import MoveLibrary
7
+
8
+ lib = MoveLibrary()
9
+ lib.load()
10
+
11
+ # Check a few moves
12
+ sample_moves = ["a_firm_categorical_no", "a_robotic_grid_snapping", "you_look_around_use"]
13
+
14
+ for name in sample_moves:
15
+ record = lib.get_move(name)
16
+ if record:
17
+ print(f"\n{name}:")
18
+ print(f" Keyframes: {len(record.keyframes)}")
19
+ print(f" Duration: {record.keyframes[-1].t:.2f}s")
20
+ print(f" Rate: {len(record.keyframes) / record.keyframes[-1].t:.1f} keyframes/sec")
21
+ else:
22
+ print(f"\n{name}: NOT FOUND")
tests/test_mic.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sounddevice as sd
2
+ import numpy as np
3
+ import time
4
+ import queue
5
+
6
+ def test_microphone(duration=3):
7
+ devices = sd.query_devices()
8
+ print("\nScanning all input devices for signal...\n")
9
+
10
+ for i, dev in enumerate(devices):
11
+ if dev['max_input_channels'] > 0:
12
+ print(f"Testing Device {i}: {dev['name']}")
13
+ try:
14
+ q = queue.Queue()
15
+ def callback(indata, frames, time, status):
16
+ q.put(indata.copy())
17
+
18
+ # Use device specific sample rate to avoid mismatches
19
+ sr = int(dev['default_samplerate'])
20
+
21
+ with sd.InputStream(device=i, samplerate=sr, channels=1, dtype=np.int16, callback=callback):
22
+ start = time.time()
23
+ max_amp = 0
24
+ while time.time() - start < 1.0: # Test for 1 second
25
+ try:
26
+ data = q.get(timeout=0.1)
27
+ current_max = np.max(np.abs(data))
28
+ max_amp = max(max_amp, current_max)
29
+ except queue.Empty:
30
+ pass
31
+
32
+ print(f" -> Max Amplitude: {max_amp}")
33
+ if max_amp > 100:
34
+ print(" ✅ SIGNAL DETECTED!")
35
+ else:
36
+ print(" ❌ No Signal (Silence)")
37
+
38
+ except Exception as e:
39
+ print(f" -> Error: {e}")
40
+ print("-" * 30)
41
+
42
+ if __name__ == "__main__":
43
+ test_microphone()
tests/test_movement_generator.py ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for movement generator."""
2
+
3
+ import pytest
4
+ import numpy as np
5
+ from reachy_mini_danceml.movement_tools import KeyFrame
6
+ from reachy_mini_danceml.movement_generator import GeneratedMove
7
+
8
+
9
+ class TestKeyFrame:
10
+ """Tests for KeyFrame dataclass."""
11
+
12
+ def test_from_dict_full(self):
13
+ """Test KeyFrame.from_dict with all fields."""
14
+ data = {
15
+ "t": 1.5,
16
+ "head": {"roll": 10, "pitch": -5, "yaw": 30},
17
+ "antennas": [15, -15]
18
+ }
19
+ kf = KeyFrame.from_dict(data)
20
+
21
+ assert kf.t == 1.5
22
+ assert kf.head["roll"] == 10
23
+ assert kf.head["pitch"] == -5
24
+ assert kf.head["yaw"] == 30
25
+ assert kf.antennas == (15, -15)
26
+
27
+ def test_from_dict_minimal(self):
28
+ """Test KeyFrame.from_dict with minimal fields."""
29
+ data = {"t": 0.5}
30
+ kf = KeyFrame.from_dict(data)
31
+
32
+ assert kf.t == 0.5
33
+ assert kf.head == {}
34
+ assert kf.antennas == (0, 0)
35
+
36
+ def test_from_dict_defaults(self):
37
+ """Test KeyFrame.from_dict uses defaults for missing fields."""
38
+ data = {}
39
+ kf = KeyFrame.from_dict(data)
40
+
41
+ assert kf.t == 0
42
+ assert kf.head == {}
43
+ assert kf.antennas == (0, 0)
44
+
45
+
46
+ class TestGeneratedMove:
47
+ """Tests for GeneratedMove class."""
48
+
49
+ def test_requires_minimum_keyframes(self):
50
+ """Test that at least 2 keyframes are required."""
51
+ with pytest.raises(ValueError, match="at least 2 keyframes"):
52
+ GeneratedMove([KeyFrame(t=0, head={})])
53
+
54
+ def test_duration(self):
55
+ """Test that duration equals max keyframe time."""
56
+ keyframes = [
57
+ KeyFrame(t=0.0, head={"yaw": 0}),
58
+ KeyFrame(t=1.5, head={"yaw": 30}),
59
+ KeyFrame(t=3.0, head={"yaw": 0}),
60
+ ]
61
+ move = GeneratedMove(keyframes)
62
+ assert move.duration == 3.0
63
+
64
+ def test_evaluate_returns_correct_types(self):
65
+ """Test that evaluate returns correct data types."""
66
+ keyframes = [
67
+ KeyFrame(t=0.0, head={"roll": 0, "pitch": 0, "yaw": 0}, antennas=(0, 0)),
68
+ KeyFrame(t=1.0, head={"roll": 10, "pitch": 10, "yaw": 30}, antennas=(20, -20)),
69
+ ]
70
+ move = GeneratedMove(keyframes)
71
+ head, antennas, body_yaw = move.evaluate(0.5)
72
+
73
+ # Head should be 4x4 matrix
74
+ assert head.shape == (4, 4)
75
+ # Antennas should be array of 2
76
+ assert len(antennas) == 2
77
+ assert isinstance(antennas, np.ndarray)
78
+ # Body yaw should be 0
79
+ assert body_yaw == 0.0
80
+
81
+ def test_evaluate_at_boundaries(self):
82
+ """Test evaluation at start and end times."""
83
+ keyframes = [
84
+ KeyFrame(t=0.0, head={"yaw": 0}),
85
+ KeyFrame(t=1.0, head={"yaw": 30}),
86
+ ]
87
+ move = GeneratedMove(keyframes)
88
+
89
+ # Should not raise at boundaries
90
+ head_start, _, _ = move.evaluate(0.0)
91
+ head_end, _, _ = move.evaluate(1.0)
92
+
93
+ assert head_start is not None
94
+ assert head_end is not None
95
+
96
+ def test_evaluate_clamps_time(self):
97
+ """Test that evaluation clamps time to valid range."""
98
+ keyframes = [
99
+ KeyFrame(t=0.0, head={"yaw": 0}),
100
+ KeyFrame(t=1.0, head={"yaw": 30}),
101
+ ]
102
+ move = GeneratedMove(keyframes)
103
+
104
+ # Should not raise for out-of-range times
105
+ head_before, _, _ = move.evaluate(-1.0)
106
+ head_after, _, _ = move.evaluate(5.0)
107
+
108
+ assert head_before is not None
109
+ assert head_after is not None
110
+
111
+ def test_interpolation_midpoint(self):
112
+ """Test that interpolation produces reasonable midpoint values."""
113
+ keyframes = [
114
+ KeyFrame(t=0.0, head={"yaw": 0}, antennas=(0, 0)),
115
+ KeyFrame(t=1.0, head={"yaw": 30}, antennas=(30, -30)),
116
+ ]
117
+ move = GeneratedMove(keyframes)
118
+
119
+ # At midpoint, values should be roughly halfway
120
+ # (Cubic spline may vary slightly from linear midpoint)
121
+ _, antennas, _ = move.evaluate(0.5)
122
+
123
+ # Convert back to degrees for comparison
124
+ left_deg = np.rad2deg(antennas[0])
125
+ right_deg = np.rad2deg(antennas[1])
126
+
127
+ # Should be roughly 15 and -15 (within tolerance for cubic spline)
128
+ assert 10 < left_deg < 20
129
+ assert -20 < right_deg < -10
130
+
131
+
132
+ class TestMovementToolSchemas:
133
+ """Tests for movement tool schemas."""
134
+
135
+ def test_all_tools_have_required_fields(self):
136
+ """Test that all tool schemas have required fields."""
137
+ from reachy_mini_danceml.movement_tools import ALL_TOOLS
138
+
139
+ for tool in ALL_TOOLS:
140
+ assert "type" in tool
141
+ assert tool["type"] == "function"
142
+ assert "name" in tool
143
+
144
+ def test_goto_pose_schema(self):
145
+ """Test goto_pose tool schema structure."""
146
+ from reachy_mini_danceml.movement_tools import GOTO_POSE_TOOL
147
+
148
+ assert GOTO_POSE_TOOL["name"] == "goto_pose"
149
+ params = GOTO_POSE_TOOL["parameters"]["properties"]
150
+
151
+ assert "roll" in params
152
+ assert "pitch" in params
153
+ assert "yaw" in params
154
+ assert "duration" in params
155
+
156
+ def test_create_sequence_schema(self):
157
+ """Test create_sequence tool schema structure."""
158
+ from reachy_mini_danceml.movement_tools import CREATE_SEQUENCE_TOOL
159
+
160
+ assert CREATE_SEQUENCE_TOOL["name"] == "create_sequence"
161
+ params = CREATE_SEQUENCE_TOOL["parameters"]["properties"]
162
+
163
+ assert "keyframes" in params
164
+ assert params["keyframes"]["type"] == "array"
tests/test_startup.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import asyncio
3
+ import os
4
+ import sys
5
+
6
+ # Mock Reachy libraries to avoid full robot connection
7
+ from unittest.mock import MagicMock
8
+ sys.modules["reachy_mini"] = MagicMock()
9
+ sys.modules["reachy_mini.utils"] = MagicMock()
10
+ sys.modules["reachy_mini.motion"] = MagicMock()
11
+ sys.modules["reachy_mini.motion.move"] = MagicMock()
12
+ sys.modules["reachy_mini.io"] = MagicMock()
13
+ sys.modules["reachy_mini.io.zenoh_client"] = MagicMock()
14
+
15
+ from reachy_mini_danceml.realtime_handler import RealtimeHandler
16
+ from reachy_mini_danceml.movement_generator import MovementGenerator
17
+
18
+ # Mock Generator
19
+ gen = MagicMock(spec=MovementGenerator)
20
+
21
+ async def test_startup():
22
+ print("Testing RealtimeHandler initialization...")
23
+ try:
24
+ handler = RealtimeHandler("fake-key", gen)
25
+ print("Handler created.")
26
+
27
+ print("Creating stream...")
28
+ # This calls run_until_complete internally
29
+ stream = handler.create_stream()
30
+ print("Stream created successfully.")
31
+
32
+ except Exception as e:
33
+ print(f"CRASH: {e}")
34
+ import traceback
35
+ traceback.print_exc()
36
+
37
+ if __name__ == "__main__":
38
+ try:
39
+ # Check if we can run the internal logic
40
+ handler = RealtimeHandler("fake-key", gen)
41
+ stream = handler.create_stream()
42
+ print("Sync execution success.")
43
+ except Exception as e:
44
+ print(f"Sync execution failed: {e}")
tests/verify_fix.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import sys
3
+ import os
4
+ import time
5
+ import numpy as np
6
+
7
+ # Ensure we can import the module
8
+ sys.path.append(os.getcwd())
9
+
10
+ from reachy_mini_danceml.audio_capture import LocalAudioCapture
11
+
12
+ def verify_fix():
13
+ print("Testing Audio Capture with 'Reachy Mini Audio'...")
14
+
15
+ # Initialize with the specific device name
16
+ capture = LocalAudioCapture(device_name="Reachy Mini Audio")
17
+
18
+ # Check if the device index resulted in a valid index (not None)
19
+ # The class prints "Selected audio device..." or "Warning..."
20
+ # We can inspect the internal property
21
+ if capture._device_index is None:
22
+ print("❌ Verification Failed: Device 'Reachy Mini Audio' not found.")
23
+ return
24
+
25
+ print(f"✅ Device found! Index: {capture._device_index}")
26
+
27
+ capture.start()
28
+ time.sleep(1) # Let it warm up
29
+
30
+ print("Capturing 2 seconds of audio...")
31
+ chunks = []
32
+ start_time = time.time()
33
+ while time.time() - start_time < 2:
34
+ chunk = capture.get_chunk()
35
+ if chunk:
36
+ chunks.append(chunk)
37
+
38
+ capture.stop()
39
+
40
+ total_chunks = len(chunks)
41
+ print(f"Total chunks captured: {total_chunks}")
42
+
43
+ if total_chunks == 0:
44
+ print("❌ Verification Failed: No audio chunks captured.")
45
+ return
46
+
47
+ # Concatenate all chunks
48
+ all_audio = b"".join(chunks)
49
+ audio_array = np.frombuffer(all_audio, dtype=np.int16)
50
+
51
+ max_amp = np.max(np.abs(audio_array))
52
+ print(f"Max Amplitude: {max_amp}")
53
+
54
+ if max_amp < 100:
55
+ print("⚠️ Warning: Amplitude is very low (Silence?). Check mic mute.")
56
+ else:
57
+ print("✅ Signal Detected! Amplitude looks good.")
58
+
59
+ # Check sample rate consistency
60
+ # 2 seconds * 24000 samples/sec = 48000 samples
61
+ expected_samples = 48000
62
+ actual_samples = len(audio_array)
63
+ print(f"Samples Captured: {actual_samples} (Expected ~{expected_samples})")
64
+
65
+ if abs(actual_samples - expected_samples) > 10000:
66
+ print("⚠️ Warning: Sample count deviation is high. Check specific timing/buffer issues.")
67
+ else:
68
+ print("✅ Sample rate looks consistent.")
69
+
70
+ if __name__ == "__main__":
71
+ verify_fix()
tests/verify_generative_mode.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import os
3
+ from reachy_mini_danceml.movement_generator import MovementGenerator
4
+ from reachy_mini_danceml.realtime_handler import RealtimeHandler, SYSTEM_INSTRUCTIONS
5
+
6
+ # Mock Generator
7
+ class MockGenerator(MovementGenerator):
8
+ def __init__(self):
9
+ pass
10
+ async def stop(self):
11
+ pass
12
+
13
+ async def test_generative_tools():
14
+ print("--- Testing Hybrid Generative Tools ---")
15
+
16
+ # Init Handler
17
+ handler = RealtimeHandler(openai_key="fake-key", movement_generator=MockGenerator())
18
+
19
+ # 1. Test get_choreography_guide
20
+ print("\n[Test] Calling 'get_choreography_guide'...")
21
+ # Emulate tool call
22
+ result = await handler.handle_tool_call("get_choreography_guide", {})
23
+
24
+ if "Error" in result:
25
+ print(f"FAIL: {result}")
26
+ # Hint: check if we are running from root
27
+ print(f"CWD: {os.getcwd()}")
28
+ else:
29
+ print(f"SUCCESS: Retrieved guide ({len(result)} chars)")
30
+ print(f"Snippet: {result[:100]}...")
31
+ assert "Reachy Mini Choreography Guide" in result
32
+
33
+ # 2. Check System Instructions for Router Logic
34
+ print("\n[Test] Checking System Instructions...")
35
+ if "get_choreography_guide" in SYSTEM_INSTRUCTIONS:
36
+ print("SUCCESS: System instructions mention the guide tool.")
37
+ else:
38
+ print("FAIL: System instructions missing router logic reference.")
39
+
40
+ if __name__ == "__main__":
41
+ asyncio.run(test_generative_tools())
tests/verify_smart_tools.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ from reachy_mini_danceml.movement_generator import MovementGenerator
3
+ from reachy_mini_danceml.dataset_loader import MoveLibrary
4
+
5
+ # Mock ReachyMini since we verify logic, not physical hardware
6
+ class MockReachyMini:
7
+ async def async_play_move(self, move):
8
+ print(f"Mock: Playing move (duration={move.duration:.2f}s)")
9
+
10
+ def goto_target(self, head=None, antennas=None, duration=0.0):
11
+ print("Mock: goto_target")
12
+
13
+ async def test_smart_architecture():
14
+ print("--- 1. Testing MoveLibrary ---")
15
+ library = MoveLibrary()
16
+ library.load()
17
+
18
+ moves = library.list_moves()
19
+ print(f"Loaded {len(moves)} moves.")
20
+ if not moves:
21
+ print("FAIL: No moves loaded!")
22
+ return
23
+
24
+ print(f"Sample move: {moves[0]}")
25
+
26
+ print("\n--- 2. Testing Search ---")
27
+ results = library.search_moves("happy")
28
+ print(f"Search 'happy' found {len(results)} results:")
29
+ for r in results[:3]:
30
+ print(f" - {r['name']}: {r['description'][:50]}...")
31
+
32
+ if not results:
33
+ print("WARN: Search 'happy' returned nothing.")
34
+
35
+ print("\n--- 3. Testing MovementGenerator Integration ---")
36
+ # Using a real name from the library or the first loaded one
37
+ test_move_name = results[0]['name'] if results else moves[0]
38
+
39
+ reachy = MockReachyMini()
40
+ generator = MovementGenerator(reachy)
41
+
42
+ print(f"Attempting to generate move: '{test_move_name}'")
43
+ # We call the synchronous version to verify retrieval and generation logic
44
+ # Note: The generator will reload the library internally if not shared,
45
+ # but that's fine for testing.
46
+ # In real app, we might want to share the instance or optimize loading.
47
+
48
+ # We access the internal library to verify same state or just let it load
49
+ # For speed in test, let's inject our loaded library if possible,
50
+ # but simpler to just let it load again (caching should handle it).
51
+
52
+ # Actually, let's call play_move_by_name which returns the record
53
+ record = generator.play_move_by_name(test_move_name)
54
+
55
+ if isinstance(record, str):
56
+ print(f"FAIL: Generator returned error: {record}")
57
+ else:
58
+ print(f"SUCCESS: Retrieved record for '{record.name}'")
59
+ print(f" - Source: {record.dataset_source}")
60
+ print(f" - Keyframes: {len(record.keyframes)}")
61
+
62
+ # Test converting to Move
63
+ move = generator.create_from_keyframes(record.keyframes)
64
+ print(f" - Generated Move duration: {move.duration:.2f}s")
65
+
66
+ # Test async play wrapper (simulating tool call)
67
+ print("\n--- 4. Testing Async Tool Wrapper ---")
68
+ result = await generator.play_library_move(test_move_name)
69
+ print(f"Tool Result: {result}")
70
+
71
+ if __name__ == "__main__":
72
+ asyncio.run(test_smart_architecture())