Server-side puppeteer for Reachy Mini × PersonaPlex. The robot installs nothing; the brain lives next to the model.
Scope. kuroko does one thing: PersonaPlex, one Reachy Mini, done well — realtime audio, conversation lifecycle, embodiment. Knowledge back-ends, model arbitration and multi-model scheduling deliberately live in its larger sibling, kōken, which drives kuroko through the small control surface in
kuroko/control.pyrather than replacing it. If you just want a robot that talks, you want this repo. (kōken lives here — design stage.)
In kabuki, the kuroko are the black-clad stagehands who operate props and puppets in
full view of the audience — and are, by convention, invisible. Here, the kuroko is a
sidecar process on the inference box (an NVIDIA GB10 / DGX Spark class machine) that
connects to a Reachy Mini Wireless over the network via the reachy_mini SDK's
remote-daemon path, bridges the robot's mic/speaker to a realtime PersonaPlex-7B
websocket on localhost, and drives the robot's body with lookahead the model itself
provides.
Prior art (e.g. TwinPeaksTownie/reachy_personaplex, which this design started from and
owes its existence to) runs everything on the Pi inside the robot: websocket client,
opus codec, motion heuristics, web UI. That shape fights the hardware at every layer:
| app-on-Pi | kuroko (app-on-GB10) | |
|---|---|---|
| deps on the Pi | opus/sphn native builds (fragile) | zero — stock daemon only |
| codec CPU | shares 4GB Pi with daemon + GIL | idle Grace cores |
| motion source | reactive to played audio (always late) | authored from frames before playout |
| echo handling | none | server holds a perfect far-end reference |
| clock drift | unbounded buffer creep | frame-time servo, hard latency cap |
| wifi drop | robot freezes mid-gesture | Pi brainstem degrades to "sleepy robot" |
| deploy/update | app-store install per robot | docker compose up on the server |
| server preempted | crash | scripted goodbye scene, auto-resume |
The asymmetry is the whole design: the GB10 serving PersonaPlex at 46.5 ms per 80 ms frame has ~40% headroom, ~100 GB free unified RAM, and mostly-idle CPU cores, while the robot is a 4 GB Pi on 2.4 GHz wifi. Every byte of work you move off the Pi is a failure class deleted, not just latency saved.
┌────────────────────────── GB10 / DGX Spark ──────────────────────────┐
│ │
│ ┌─────────────────┐ ws (localhost) ┌────────────────────────────┐ │
│ │ PersonaPlex-7B │◄─────────────────►│ kuroko sidecar │ │
│ │ (fork, --fast │ \x01 opus │ audio bridge (sphn/gst) │ │
│ │ w8a16 serving) │ \x02 text │ puppet-track compiler │ │
│ │ │──────────────────►│ gaze arbiter │ │
│ │ frame tap ─────┤ sidecar UDP: │ scene director (banto) │ │
│ │ (energy, text, │ frame_id, rms, │ flight recorder │ │
│ │ turn state) │ turn logits └──────────┬─────────────────┘ │
│ └─────────────────┘ │ zenoh + media │
└───────────────────────────────────────────────────┼───────────────────┘
LAN / tailnet
│
┌────────────────────────── Reachy Mini ────────────┼───────────────────┐
│ stock reachy_mini daemon (UNMODIFIED) ◄─────────┘ │
│ mic array + DoA ──► up speaker ◄── down pose/antennas │
│ kuroko-reflex (~100 lines, OPTIONAL): heartbeat dead-man's switch │
│ → on brain loss: cancel gestures, hand back to daemon idle │
└───────────────────────────────────────────────────────────────────────┘
Three load-bearing ideas:
-
Puppeteer topology. The kuroko connects to the robot daemon remotely (zenoh scouting on LAN, explicit endpoint over tailnet). Mic/speaker flow over the SDK's remote media path; PersonaPlex is a localhost websocket. The Pi runs stock firmware.
-
Lookahead embodiment. The serve loop knows each 80 ms Mimi frame ~100–500 ms before the robot's speaker plays it, and samples the text token stream ahead of the audio. A small tap exports
{frame_id, rms, voiced, text_token, turn_state}to the kuroko, which compiles a 10–20 Hz puppet track — wobble amplitude, beat-nods, antenna state, gaze bias — scheduled in frame time against a playhead heartbeat, so gestures land on (or a breath before) the stressed syllable instead of trailing it. Gaze is arbitrated: DoA nominates a glance; daemon face-tracking confirms a committed head-turn; no confirmation decays back to the track's bias. -
Resilience as theater. Failures collapse into rehearsed scenes:
- GB10 preempted with warning (banto lease): persona says goodbye, head parks, lease released, auto-resume + wake scene when the box frees.
- Anything else (wifi drop, OOM, kill -9): the Pi-side reflex loses the 100 ms heartbeat and hands the body back to daemon-native idle — sleepy robot, never frozen robot.
- Sessions are keyed to robot identity, not connection: reconnects open a fresh PersonaPlex session warm-started from a condensed transcript ("as I was saying…").
Running. Wake it with a phrase, talk to it, dismiss it.
docker build -t kuroko . && ./voice VARF2 100 # on the inference box
- P0 — probe: remote full-duplex loopback verified at ~19 ms p50, zero stalls over 2.4 GHz wifi. DoA is not exposed over the remote media path (open issue).
- P1 — voice loop:
kuroko/bridge.py. Conversational, and clock-disciplined (see "The clock problem" below — this was the whole ballgame). - P2 — body: puppet track →
kuroko/body.pyat 30 Hz through a single writer; gaze arbiter with face-tracking confirmation. - lifecycle: wake/sleep phrases, lookback pre-roll, idle end, catatonia recovery.
- P3 — theater: Pi reflex supervisor, banto lease scenes, flight recorder.
- P4 — server tap: fork-side sidecar channel (see
server/TAP.md) for true lookahead: text tokens, turn logits, pre-playout energy.
PersonaPlex is a Moshi-architecture model: frame arrival is its clock. It consumes whatever shows up, as fast as it shows up. The Reachy's webrtc capture delivers 0.52x–1.86x realtime second to second (std 0.328) plus a ~2x backlog dump on connect. Forwarding that as-received jerks the model's sense of time around and destroys its turn-taking — it monologues, talks over you, and reads as broken, while every log and byte counter looks perfectly healthy.
So the bridge is a clock master: capture_loop fills a bounded jitter buffer and
pace_loop emits exactly one 80 ms frame per 80 ms of wall clock (silence on underrun,
drop-oldest on overflow, gentle 1.15x catch-up when deep). Watch tx_fps in the io log
— it must sit at 12.5. If it drifts, conversation quality dies and nothing else will
tell you why.
Two more hard-won things:
- The robot's audio hardware is excellent — do not "fix" it. The mic array's hardware AEC strips the robot's own voice ~28 dB while leaving yours intact (barge-in measured at +3.1 dB). Adding echo cancellation makes things worse.
- Probe with speech, never tones. The mic DSP suppresses non-speech by design, so a sine-wave test reports a dead microphone that is in fact perfectly healthy.
A PersonaPlex session is a bounded conversation, not a permanent state. Left open, it takes the floor whenever it hears silence and eventually goes catatonic — emitting well-formed opus silence forever while byte counters keep climbing (observed at ~9 min).
dormant ──"hey mini"──► conversation ──"go to sleep" / 45 s idle──► dormant
└── stale / aged ──► fresh session
The ears (kuroko/listener.py, vosk with a grammar constrained to just these phrases)
run continuously, so phrases are configurable at runtime with no keyword-model
training. A ring buffer records the whole time, so a woken session is seeded with what
you already said instead of making you repeat yourself while it connects.
Phrases must be spelled with words the recognizer knows or they can never fire — and fail silently.
reachyis not in the small English model, hence the phonetic stand-ins in the defaults. Check new phrases withpython -m probe.vocab.
kuroko/control.py is the whole public surface — four operations, none of which
require knowing how the audio path works:
bridge.suspend("looking something up") # model freezes, keeps full context
bridge.inject(answer_pcm_24k) # heard by the MODEL only
bridge.play(hold_music, sample_rate=16000) # heard by the HUMAN only
bridge.resume() # model never knew it stopped
bridge.on_text(lambda piece: ...) # model's text, before its audio playsThe two audio paths are genuinely independent, which is what makes a "hold" possible: music can cover a pause for the human while an answer is fed to the model that the human never hears. And suspension is free — the serve loop only advances the model when frames arrive, so a suspended session experiences zero elapsed time and resumes with full context and no reconstruction.
kuroko/ the sidecar (runs on the inference box)
pi/ optional ~100-line reflex supervisor (the only thing that may touch the Pi)
server/ notes + patch sketch for the PersonaPlex fork's frame tap
probe/ P0 de-risk instruments
- Server box: the PersonaPlex fork serving realtime (see NVIDIA/personaplex PRs #102 /
#103 for GB10-class devices), python 3.10+,
reachy-mini,sphn,websockets. - Robot: Reachy Mini (wireless) with stock daemon, reachable over LAN or tailnet.
MIT