Skip to content

Commit e463ee3

Browse files
committed
Initial commit
Reachy Mini playground: - `behaviors.py` — canned emotes (greet, nod, shake, dance, …) with a CLI. - `reachy_agent/` — real-time voice agent on OpenAI gpt-realtime-2 via Opper. Hears via Reachy's mic, sees via its camera, replies via its speaker, calls 19 motion/perception tools to react. Sound localisation (DoA buffer + optional --auto-orient) so Reachy faces whoever spoke. Auth via OPPER_API_KEY or `--opper-login` (OAuth device flow, shared with the Opper CLI's ~/.opper/config.json).
0 parents  commit e463ee3

15 files changed

Lines changed: 2319 additions & 0 deletions

File tree

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
reachy_mini_env/
2+
__pycache__/
3+
*.pyc
4+
.env
5+
.DS_Store
6+
/tmp/

README.md

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
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

Comments
 (0)