|
| 1 | +# Reachy Mini playground |
| 2 | + |
| 3 | +Code for driving a Reachy Mini Lite (USB) — a small expressive desktop robot. |
| 4 | +Two interfaces live here: |
| 5 | + |
| 6 | +- **`behaviors.py`** — a handful of canned emote behaviors with a one-shot CLI. |
| 7 | +- **`reachy_agent/`** — a real-time voice agent: the model becomes Reachy, hears |
| 8 | + through Reachy's mic, sees through its camera, talks through its speaker, and |
| 9 | + calls motion tools to physically react. |
| 10 | + |
| 11 | +The voice agent runs on **[OpenAI's realtime API (`gpt-realtime-2`)](https://platform.openai.com/docs/guides/realtime)**, |
| 12 | +proxied through **[Opper](https://opper.ai)**. Opper handles auth (OAuth device |
| 13 | +flow, no client-side OpenAI key needed), mints short-lived WebSocket tickets, |
| 14 | +and you only need an Opper credential (`OPPER_API_KEY` or `--opper-login`). |
| 15 | + |
| 16 | +## Layout |
| 17 | + |
| 18 | +``` |
| 19 | +. |
| 20 | +├── behaviors.py Standalone emotes: greet, yes, no, excitement, … |
| 21 | +├── reachy_agent/ Voice-agent package (python -m reachy_agent) |
| 22 | +│ ├── main.py Orchestrator + CLI |
| 23 | +│ ├── realtime.py Opper realtime WebSocket client |
| 24 | +│ ├── audio.py Mic capture + speaker output (direct USB audio) |
| 25 | +│ ├── vision.py Camera → image.input |
| 26 | +│ ├── tools.py 19 motion/perception tools the model can call |
| 27 | +│ ├── prompt.py "You are Reachy" system prompt |
| 28 | +│ ├── server.py Tiny FastAPI sidecar (SSE + frame stream) |
| 29 | +│ └── static/index.html Observability UI |
| 30 | +└── reachy_mini_env/ uv venv (Python 3.12) — not checked in |
| 31 | +``` |
| 32 | + |
| 33 | +## One-time setup |
| 34 | + |
| 35 | +The venv, Reachy Mini SDK, and agent dependencies are already in place. If you |
| 36 | +ever need to recreate it: |
| 37 | + |
| 38 | +```bash |
| 39 | +uv venv reachy_mini_env --python 3.12 |
| 40 | +source reachy_mini_env/bin/activate |
| 41 | +uv pip install reachy-mini |
| 42 | +uv pip install websockets httpx fastapi 'uvicorn[standard]' sounddevice Pillow python-dotenv |
| 43 | +``` |
| 44 | + |
| 45 | +Activate the venv before running anything: |
| 46 | + |
| 47 | +```bash |
| 48 | +source reachy_mini_env/bin/activate |
| 49 | +``` |
| 50 | + |
| 51 | +## Run the daemon |
| 52 | + |
| 53 | +The `reachy-mini-daemon` owns the USB connection to the robot. It must be |
| 54 | +running before the SDK or the agent can do anything. Use port 1111 to keep |
| 55 | +out of the way of other local services. |
| 56 | + |
| 57 | +### Start (foreground) |
| 58 | + |
| 59 | +```bash |
| 60 | +reachy-mini-daemon --fastapi-port 1111 |
| 61 | +``` |
| 62 | + |
| 63 | +Leave it in its own terminal. The API is at `http://localhost:1111/docs`. |
| 64 | +**Ctrl-C** stops it. |
| 65 | + |
| 66 | +### Start (background) |
| 67 | + |
| 68 | +```bash |
| 69 | +nohup reachy-mini-daemon --fastapi-port 1111 > /tmp/reachy-daemon.log 2>&1 & |
| 70 | +disown |
| 71 | +``` |
| 72 | + |
| 73 | +### Check status |
| 74 | + |
| 75 | +```bash |
| 76 | +curl -s http://localhost:1111/api/daemon/status | jq . # robot state + control loop stats |
| 77 | +lsof -nP -iTCP:1111 -sTCP:LISTEN # who's holding the port (PID) |
| 78 | +ps -ax | grep reachy-mini-daemon # process list |
| 79 | +tail -f /tmp/reachy-daemon.log # live log |
| 80 | +``` |
| 81 | + |
| 82 | +### Pause / resume the robot (motors only, daemon stays up) |
| 83 | + |
| 84 | +```bash |
| 85 | +curl -X POST http://localhost:1111/api/daemon/stop # disengage motors, keep API alive |
| 86 | +curl -X POST http://localhost:1111/api/daemon/start # re-engage |
| 87 | +curl -X POST http://localhost:1111/api/daemon/restart # both, atomically |
| 88 | +``` |
| 89 | + |
| 90 | +Use this when you want to manually pose the robot or just calm it down without |
| 91 | +killing the process. |
| 92 | + |
| 93 | +### Stop the process entirely |
| 94 | + |
| 95 | +```bash |
| 96 | +# Find the PID by port and kill it cleanly. |
| 97 | +kill "$(lsof -nP -iTCP:1111 -sTCP:LISTEN -t)" |
| 98 | + |
| 99 | +# Or by name. |
| 100 | +pkill -f reachy-mini-daemon |
| 101 | +``` |
| 102 | + |
| 103 | +> **Heads up:** only one client can hold the USB serial port. If the Reachy |
| 104 | +> Mini Control desktop app is open, it will fight the daemon — quit one or the |
| 105 | +> other. |
| 106 | +
|
| 107 | +## `behaviors.py` — quick emotes |
| 108 | + |
| 109 | +Single CLI, one behavior per invocation: |
| 110 | + |
| 111 | +```bash |
| 112 | +python behaviors.py greet # head sweep + antenna wiggle + "Hi!" |
| 113 | +python behaviors.py yes # 3 quick nods |
| 114 | +python behaviors.py no # head shake |
| 115 | +python behaviors.py excitement # rapid antenna wiggle |
| 116 | +python behaviors.py questionable # head tilt + confused sound |
| 117 | +python behaviors.py dance # body sway + head bob + antennas + music |
| 118 | +python behaviors.py wave # antennas wag + small body sway |
| 119 | +python behaviors.py lean # head/body lean (defaults to left) |
| 120 | +python behaviors.py look_around # head sweep -45° → 45° → 0° |
| 121 | +``` |
| 122 | + |
| 123 | +Both default to the daemon on `localhost:1111`. Override with `REACHY_PORT`: |
| 124 | + |
| 125 | +```bash |
| 126 | +REACHY_PORT=8000 python behaviors.py dance |
| 127 | +``` |
| 128 | + |
| 129 | +Or import and call directly: |
| 130 | + |
| 131 | +```python |
| 132 | +from behaviors import greet, dance |
| 133 | +from reachy_mini import ReachyMini |
| 134 | + |
| 135 | +with ReachyMini(port=1111) as mini: |
| 136 | + greet(mini) |
| 137 | + dance(mini) |
| 138 | +``` |
| 139 | + |
| 140 | +## `reachy_agent` — voice agent |
| 141 | + |
| 142 | +The model hears via Reachy's mic, sees via Reachy's camera, replies through |
| 143 | +Reachy's speaker, and calls motion tools to move while it talks. |
| 144 | + |
| 145 | +```bash |
| 146 | +export OPPER_API_KEY=op-... # one option |
| 147 | +python -m reachy_agent |
| 148 | + |
| 149 | +# or, no key at all — sign in via OAuth device flow (one-time): |
| 150 | +python -m reachy_agent --opper-login |
| 151 | +``` |
| 152 | + |
| 153 | +Then open <http://localhost:1080> and talk to Reachy. |
| 154 | + |
| 155 | +### Auth |
| 156 | + |
| 157 | +Two ways to authenticate, in priority order: |
| 158 | + |
| 159 | +1. **`OPPER_API_KEY`** — env var, or in a `.env` next to where you run from. |
| 160 | + Wins over everything else; right for CI, scripts, or pinning a project to a |
| 161 | + specific key. |
| 162 | +2. **`~/.opper/config.json`** — shared with the [Opper CLI](https://github.com/opper-ai/cli). |
| 163 | + If you've already run `opper login`, reachy uses that credential. Otherwise |
| 164 | + `python -m reachy_agent --opper-login` runs the device flow itself |
| 165 | + (opens your browser, you confirm a short code, the key gets stored). |
| 166 | + |
| 167 | +No client-side secret is involved — the device flow exchanges a user |
| 168 | +confirmation for an API key the server mints for you. |
| 169 | + |
| 170 | +### Common flags |
| 171 | + |
| 172 | +```bash |
| 173 | +python -m reachy_agent --opper-login # sign in via OAuth (no API key) |
| 174 | +python -m reachy_agent --voice cedar # 10 voices available (see below) |
| 175 | +python -m reachy_agent --reasoning-effort medium # better tool sequencing |
| 176 | +python -m reachy_agent --reasoning-effort low # snappier, simpler choices |
| 177 | +python -m reachy_agent --reasoning-effort minimal # absolute fastest, less tool use |
| 178 | +python -m reachy_agent --vad-silence-ms 1800 # be more patient before responding |
| 179 | +python -m reachy_agent --vad-threshold 0.7 # less sensitive to ambient noise |
| 180 | +python -m reachy_agent --auto-orient # Reachy physically faces whoever speaks |
| 181 | +python -m reachy_agent --mac-mic # debug: use Mac mic instead of Reachy |
| 182 | +python -m reachy_agent --no-ambient-vision # disable the 10s periodic frame |
| 183 | +python -m reachy_agent --ambient-vision-seconds 30 # slower periodic frame |
| 184 | +python -m reachy_agent --no-ui # skip the FastAPI sidecar |
| 185 | +python -m reachy_agent --ui-port 8080 # different UI port |
| 186 | +python -m reachy_agent --daemon-port 1111 # if you change the daemon port |
| 187 | +``` |
| 188 | + |
| 189 | +Stop with **Ctrl-C** in the terminal, or click **Disconnect** in the UI. |
| 190 | + |
| 191 | +### What the model can do |
| 192 | + |
| 193 | +19 tools, dispatched mid-conversation: |
| 194 | + |
| 195 | +| Category | Tools | |
| 196 | +|---|---| |
| 197 | +| Emotes (canned) | `greet`, `nod`, `shake_head`, `wiggle_antennas`, `dance`, `look_confused`, `wave`, `lean`, `look_around`, `sleep`, `wake_up` | |
| 198 | +| Motor primitives | `set_head`, `set_antennas`, `set_body_yaw`, `look_at` (image-space) | |
| 199 | +| Composing | `perform_sequence` — list of keyframes for ad-hoc gestures (used to mirror your wave / nod / lean) | |
| 200 | +| Perception | `look` (fresh camera frame as image.input), `get_sound_direction` (DoA), `turn_toward_sound` (orient body) | |
| 201 | + |
| 202 | +The prompt tells the model to mimic the human: see you wave → wave back; see |
| 203 | +you nod → nod back; see you tilt your head → tilt its head; hear sound off to |
| 204 | +the side → turn toward it. |
| 205 | + |
| 206 | +### Voices |
| 207 | + |
| 208 | +`gpt-realtime-2` ships with 10 voices. Pick with `--voice <name>`. Default is |
| 209 | +`marin`. All are English-native, OpenAI's standard realtime cast: |
| 210 | + |
| 211 | +| Voice | Feel | |
| 212 | +|---|---| |
| 213 | +| `alloy` | Neutral, balanced | |
| 214 | +| `ash` | Warm, conversational | |
| 215 | +| `ballad` | Softer, slower, narrative | |
| 216 | +| `coral` | Bright, friendly | |
| 217 | +| `echo` | Calm, deeper | |
| 218 | +| `sage` | Measured, soft-spoken | |
| 219 | +| `shimmer` | Bright, warm | |
| 220 | +| `verse` | Expressive, narrative | |
| 221 | +| `marin` | Friendly, expressive (default) | |
| 222 | +| `cedar` | Deeper, warm | |
| 223 | + |
| 224 | +### Reasoning effort |
| 225 | + |
| 226 | +`--reasoning-effort {minimal,low,medium,high,xhigh}`. `low` is the default and |
| 227 | +right for most chat. Bump to `medium` when you want better tool sequencing |
| 228 | +(e.g. "look first, then react" chains). `minimal` is snappiest but uses tools |
| 229 | +less. `xhigh` is slow — only for puzzles you've explicitly told it to think |
| 230 | +through. |
| 231 | + |
| 232 | +### Sound localisation |
| 233 | + |
| 234 | +A background thread polls Reachy's direction-of-arrival mic array (`get_DoA`) |
| 235 | +4× a second and keeps the most recent **voice-positive** angle in a buffer. |
| 236 | + |
| 237 | +- The `turn_toward_sound` tool reads from the buffer (DoA captured *while* you |
| 238 | + were talking, not the silent moment after). |
| 239 | +- `--auto-orient` runs an ambient loop that smoothly nudges body + head |
| 240 | + together toward the buffered direction whenever Reachy isn't speaking — so |
| 241 | + the robot physically faces whoever just spoke, without the model having to |
| 242 | + decide to. Head leads, body follows, looks like a real turn. |
| 243 | + |
| 244 | +### UI |
| 245 | + |
| 246 | +`http://localhost:1080`: |
| 247 | + |
| 248 | +- Live camera frame (1 Hz refresh) |
| 249 | +- Scrolling transcript (you + Reachy) |
| 250 | +- Tool-call log (name + args + result) |
| 251 | +- State badge (idle / listening / speaking / disconnecting) |
| 252 | +- Disconnect button (triggers a graceful shutdown — same as Ctrl-C) |
| 253 | + |
| 254 | +## Troubleshooting |
| 255 | + |
| 256 | +**`command not found: reachy-mini-daemon`** or **`No module named 'reachy_mini'`** — |
| 257 | +venv isn't active in this shell. Run `source reachy_mini_env/bin/activate` |
| 258 | +(the prompt will show `(reachy_mini_env)`). Or invoke the binary by absolute |
| 259 | +path: `./reachy_mini_env/bin/reachy-mini-daemon --fastapi-port 1111`. |
| 260 | + |
| 261 | +**`PermissionError [Errno 13]` on a port** — macOS reserves ports below 1024. |
| 262 | +Pick a higher one with `--ui-port`. |
| 263 | + |
| 264 | +**Audio comes from MacBook speakers, not Reachy** — should not happen anymore; |
| 265 | +the agent writes directly to the `Reachy Mini Audio` USB device via |
| 266 | +`sounddevice`. If it returns, check that the device shows up in |
| 267 | +`python -c "import sounddevice; print(sounddevice.query_devices())"`. |
| 268 | + |
| 269 | +**Daemon dies after a while / port already in use** — only one daemon can hold |
| 270 | +the USB serial port. Find the holder with |
| 271 | +`lsof -nP -iTCP:1111 -sTCP:LISTEN`, kill it, restart. |
| 272 | + |
| 273 | +**`conversation_already_has_active_response`** — racing the model. The `look` |
| 274 | +tool no longer triggers a second response.create; if you add new tools that |
| 275 | +attach data to the conversation, do *not* send `response.create` from within a |
| 276 | +tool that runs mid-turn. |
| 277 | + |
| 278 | +**Pyright/IDE complains about missing imports** — your editor isn't using the |
| 279 | +venv interpreter. Point it at `reachy_mini_env/bin/python`. The code runs fine. |
0 commit comments