Spaces:
Running
Running
feat: Implement voice-controlled movement generation for Reachy Mini with real-time audio processing, new tests, and documentation.
Browse files- .gitignore +3 -1
- README.md +62 -11
- docs/ARCHITECTURE.md +133 -0
- docs/CHOREOGRAPHY_GUIDE.md +64 -0
- docs/SDK_DOCUMENTATION.md +485 -0
- docs/plans/voice-controlled-movement.md +692 -0
- pyproject.toml +6 -1
- reachy_mini_danceml/audio_capture.py +97 -0
- reachy_mini_danceml/dataset_loader.py +189 -0
- reachy_mini_danceml/main.py +304 -50
- reachy_mini_danceml/movement_generator.py +375 -0
- reachy_mini_danceml/movement_tools.py +220 -0
- reachy_mini_danceml/procedural_motion.py +370 -0
- reachy_mini_danceml/realtime_handler.py +413 -0
- tests/__init__.py +1 -0
- tests/check_device_details.py +13 -0
- tests/check_keyframes.py +22 -0
- tests/test_mic.py +43 -0
- tests/test_movement_generator.py +164 -0
- tests/test_startup.py +44 -0
- tests/verify_fix.py +71 -0
- tests/verify_generative_mode.py +41 -0
- tests/verify_smart_tools.py +72 -0
.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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 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 |
-
|
| 6 |
-
from
|
|
|
|
| 7 |
|
| 8 |
|
| 9 |
class ReachyMiniDanceml(ReachyMiniApp):
|
| 10 |
-
|
| 11 |
-
|
|
|
|
| 12 |
custom_app_url: str | None = "http://0.0.0.0:8042"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
-
def
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
def request_sound_play():
|
| 33 |
-
nonlocal sound_play_requested
|
| 34 |
-
sound_play_requested = True
|
| 35 |
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
else:
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
)
|
|
|
|
|
|
|
| 63 |
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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())
|