A fully local conversation stack for Reachy Mini — your robot talks to a local Ollama LLM, hears you through a local STT model, speaks with a local TTS engine, and animates itself with a state-driven emotion gesture system. No cloud, no API keys, no per-token costs.
// Stack
| Component | Default | Alternatives |
|---|---|---|
| STT | NVIDIA Parakeet TDT 0.6B v3 (multilingual, ~600 MB) | faster-whisper (CTranslate2); Parakeet v2 (EN-only); Parakeet v3-SmoothQuant (int8, long-audio); NVIDIA Canary-1B-v2 (translation) |
| LLM | Anything on your local Ollama server | OpenAI-compatible HTTP endpoints (vLLM, LM Studio, llama.cpp --server) |
| TTS | edge-tts (Microsoft neural voices, ~300 voices, online) | Silero v3_en (offline, CPU, 119 speakers); Qwen3-TTS (opt-in extra) |
| VAD | smolvad (silero-vad ONNX, <1 MB) with adaptive silence window | silero (PyPI; pulls torch) |
| Motion | pollen-robotics/reachy-mini-emotions-library (85 moves) — state-driven |
Procedural breathing + head-wobble when disabled |
// Install
▸ From the Reachy Mini desktop app
Open the app store, search for Reachy Conv,
install. The first run pulls Parakeet v3 (~600 MB) and
Silero TTS (~55 MB) into ~/.cache/huggingface.
▸ For local development
Laptop + sim daemon. Two terminals: one runs the daemon,
one runs the app. Dashboard opens on
http://localhost:7860/.
git clone https://huggingface.co/spaces/ArtFix0/reachy_conv_app
cd reachy_conv_app
pip install -e .
# In another terminal:
reachy-mini-daemon --sim
# Back in the first:
python -m reachy_conv_app
→ dashboard at http://localhost:7860/
The default TTS engine (edge-tts) returns MP3
audio and the pydub decoder shells out to
ffmpeg to convert it to PCM. As of v0.4.1 the
imageio-ffmpeg package is a required
dependency — it bundles a self-contained ffmpeg binary for
Linux, macOS, and Windows, and
tts/edge_backend.py automatically points
pydub at it. No system-level ffmpeg install is
required on a fresh install.
If you want to use a different ffmpeg (e.g. a system install
with hardware acceleration), install it on PATH and the
bundled one will be shadowed:
sudo apt install ffmpeg ·
brew install ffmpeg ·
winget install Gyan.FFmpeg.
Qwen3-TTS is not installed by default. The
qwen-tts PyPI package hard-pins
transformers==4.57.3, which transitively
requires huggingface-hub>=0.34.0,<1.0.
Every released reachy-mini (1.5.0 through
1.9.0) is incompatible with that constraint, so the Reachy
Mini Control desktop app's shared
apps_venv hard-fails the dependency
resolution the moment qwen-tts is required.
Install the extra in a separate venv:
python -m venv .venv-qwen
source .venv-qwen/bin/activate
pip install -e .[qwen_tts]
When qwen-tts is not importable, the
dashboard's STATUS / TTS line and the
#tts-status paragraph under the engine
dropdown show the fallback reason
(e.g. "qwen-tts not importable — check your
install") and the TTS manager falls back through
edge to silero. Selecting
silero in the TTS section always works.
// Configuration
All runtime settings live in config.yaml at the
project root. The dashboard edits this file in place and the
conversation loop hot-reloads it on every save — no restart
needed. A documented config.example.yaml is
checked in for reference.
# llm
llm:
base_url: http://localhost:11434
model: llama3.1:8b-instruct-q4_K_M
api_format: ollama # or "openai" for OpenAI-compat endpoints
system_prompt: "You are Reachy Mini, a small expressive desk robot..."
temperature: 0.7
max_tokens: 600 # reasoning models need room for thought + answer
think: false # set true to allow chain-of-thought on qwen3.5/gpt-oss
num_ctx: 4096 # context window; raise for longer conversations
keep_alive: 5m # how long Ollama keeps the model in VRAM ("-1" = never)
timeout_s: 300.0 # HTTP timeout per chat call (cold loads can take 60-120s)
# stt
stt:
engine: parakeet # or "whisper"
parakeet:
variant: v3 # v2 (EN-only) / v3 (multilingual) / v3sq / canary
whisper:
model: small # tiny / base / small / medium / large-v3
device: cpu # or "cuda"
# tts
tts:
engine: edge # edge (default) / silero / qwen (opt-in)
voice: en-US-AriaNeural # ~300 edge voices; silero en_0…en_118
rate: "+0%" # -50%…+100% (edge-tts only)
volume: "+0%" # -50%…+50%
pitch: "+0Hz" # -20Hz…+20Hz
strip_emoji: true # strip pictographs from LLM reply before TTS
# vad
vad:
engine: smolvad # "smolvad" (silero-vad ONNX) or "silero"
threshold: 0.5
min_speech_ms: 250
min_silence_ms: 300 # max silence window for short utterances
min_silence_floor_ms: 300 # floor for long utterances (adaptive mode)
silence_curve_knee_ms: 1500 # speech length (ms) at which we reach the floor
silence_curve: adaptive # "adaptive" (window shrinks) or "off" (fixed)
# conversation
conversation:
mode: ptt # "ptt" (antenna) or "vad" (always-on)
ptt_antenna: right # "right" or "left"
max_listen_seconds: 30.0
interrupt_on_touch: true
wake_word: '' # reserved for future use
# motion
motion:
enabled: true # false = breathing + head-wobble only
reply_overrides_enabled: true # false = no post-TTS punctuation gestures
emotion_map: # state → (move_name, loop?)
wake: laughing2
listening: breathing
thinking: laughing2
question: inquiring1
affirm: yes1
deny: no1
sad: sad1
cheerful: cheerful1
sleep: tired1
# dashboard
dashboard:
host: 0.0.0.0
port: 7860
autostart_loop: false
// Dashboard
http://<robot>:7860/
Single FastAPI server in a daemon thread on the robot.
Static assets are version-stamped
(/static/app.js?v=<version>) so the
browser is forced to fetch the new bundle after every
reinstall.
Backend pips (robot/llm/tts/vad/stt), armed/loop state, CPU/RAM meters, ARM/DISARM, live transcript.
Model picker (queries your Ollama), system prompt, temperature, max_tokens, num_ctx, keep_alive, timeout. "TEST → Say hi in 5 words" button.
Engine + per-engine knobs; upload a WAV to transcribe. Auto-populates the model dropdown on init.
Engine + voice picker (~300 edge voices grouped by language); "SPEAK" button to play a test phrase; rate/volume/pitch sliders (edge-tts only).
threshold, min-silence, adaptive curve, floor + knee; upload a WAV to see segment boundaries.
mode (PTT/VAD), antenna, max listen, ARM button, live transcript.
Library status (loads on first click), per-state move dropdowns with Test/Set buttons, master "Use emotion library" toggle, Retry library load, Post-TTS punctuation gestures.
Live tail of the last 200 log lines, color-coded by level, with a since watermark so reconnects don't duplicate.
Version (read from importlib.metadata.version()), config path.
// Internals
main.py reads 16 kHz PCM frames from the Reachy daemon's media manager and feeds them to LocalConversationHandler.receive().LocalConversationHandler (local_conversation.py) implements the fastrtc.AsyncStreamHandler interface, but the local loop bypasses fastrtc.Stream — mic samples go straight in.vad/backends.py consumes each frame, accumulates 512-sample chunks, and runs the silero-vad ONNX model. Its state machine implements an adaptive silence window: short utterances get the full min_silence_ms, long utterances get the min_silence_floor_ms. The transition is linear over silence_curve_knee_ms.utterance_ended=True, the handler flushes the buffered audio to the STT backend, gets the transcript, sends it to the LLM, gets the reply, sends it to the TTS backend, and pushes the resulting audio to the Reachy daemon's speaker._speaking_until in local_conversation.py) to prevent the robot from transcribing its own TTS output and replying to itself.MovementManager at 100 Hz. On each conversation state transition (start, arm, VAD start, LLM call, TTS start, TTS end), GestureDispatcher looks up the move name in motion.emotion_map and asks the MovementManager to play it from the pollen-robotics/reachy-mini-emotions-library dataset. State-driven mapping is hot-reloaded on every settings save.BreathingMove (5 mm z-sway at 0.1 Hz + ±15° antenna sway at 0.5 Hz) rather than a recorded move — it feels natural without committing to a heavy gesture. THINKING uses a short one-shot (default laughing2, loop forced off).// Troubleshooting
Click any item to expand the fix.
The silero VAD ONNX model isn't loadable. Reinstall the
package: pip install --force-reinstall reachy_conv_app
(the .onnx file ships in the wheel).
Start your local Ollama server (ollama serve)
or set llm.base_url to a remote one.
Check the Reachy daemon is running and the speaker is enabled. The dashboard's "SPEAK" test button uses the same audio path as real conversation, so if that plays something the audio chain is working.
edge-tts or one of its decoders isn't working. The
dashboard's STATUS / TTS line and the
#tts-status paragraph under the engine
dropdown show the install hint. Two common paths:
edge-tts not importable — the wheel didn't land.
Reinstall with
pip install --force-reinstall edge-tts pydub.
ffmpeg not on PATH — pydub shells out
to ffmpeg to decode the MP3 that edge-tts returns.
Install ffmpeg
(apt install ffmpeg /
brew install ffmpeg /
winget install Gyan.FFmpeg).
qwen-tts is an optional extra, not a default dep. The
dashboard's STATUS / TTS line and the
#tts-status paragraph show the install hint.
You want qwen in this install — qwen-tts
0.1.1 is upstream-incompatible with every released
reachy-mini version because it hard-pins
transformers==4.57.3, which requires
huggingface-hub<1.0. The shared
apps_venv resolution fails. There is no
workaround on the desktop app's shared venv; install
pip install .[qwen_tts] in a separate venv
where reachy-mini isn't pinned, or wait for
qwen-tts upstream to relax the
transformers pin.
You don't have qwen-tts installed at all — the
engine drops to silero with a yellow pip and a clear
fallback reason; select silero in the TTS
section to make the warning go away.
The HF dataset download failed (offline, rate-limited, disk full). Click the ↻ Retry library load button. The dashboard's fallback is procedural breathing + head-wobble; the robot still works.
The toast shows the real error string (not "[object
Object]"). Common causes: library not loaded yet (wait or
hit Retry), or the move name isn't in the dataset (the
dropdown should prevent that but a hand-edited
config.yaml could break it).
Your silence_curve floor is too aggressive.
Raise min_silence_floor_ms to 300+ (the
default).
The model checkpoint is downloading. Watch the logs; subsequent calls are fast.
Shouldn't happen any more (v0.4.16 added
?v=<version> cache-buster on
app.js and cyberpunk.css). If
it does, hard refresh (Ctrl+Shift+R)
and report the version shown in the top bar.
// Changelog
Motion tab play endpoint switched to path-parameter shape (POST /api/motion/play_emotion/{move_name}?loop=…) to match the official Reachy Mini Desktop App's pollen-robotics/reachy-mini-desktop-app Expression picker pattern (the daemon's own endpoint is POST /api/move/play/recorded-move-dataset/{ds}/{move}). No Pydantic body model — the v0.4.16 "Field required" 422 is now structurally impossible.
Cache-bust static assets (?v=<version> injected by index() into the <script>/<link> tags) so the browser is forced to fetch the new bundle on every reinstall. Diagnostic console.warn in playEmotionPreview logs the request body.
Dynamic /api/app/version from importlib.metadata.version(). The top-bar brand and About card now show the actually-installed version (was hardcoded "v0.1.0").
Defensive error-message coercion in api() and playEmotionPreview. Pydantic 422 detail lists are joined by ; instead of becoming "[object Object]".
Motion tab per-row Set button (focused PUT, hot-reloads the dispatcher's mapping on save, no restart) + real error feedback on Test (the r.ok === false toast that surfaced the silent "object = object" failure).
Auto-populate Ollama model dropdown on dashboard init; auto-save model on change. max_tokens raised to 600 for reasoning models.
Re-fetch Motion tab on every navigation (so the library list picks up the background download without a hard refresh); ↻ Retry library load button for failed downloads.
Emoji-strip toggle for TTS. Head-wobble fix on subsequent responses (was clipping when LLM replies were short).
LISTENING/IDLE = procedural BreathingMove; THINKING = one-shot. Smooth blend-in for every recorded move (so the head doesn't snap to the move's first frame). Motion tab dropdowns always editable even when the library isn't loaded.
State-driven emotion moves (GestureDispatcher + per-state map).
max_tokens raised to 200 (from 100) for reasoning models.
Bundle ffmpeg via imageio-ffmpeg (no system install required).
Wire edge-tts knobs (rate/volume/pitch) through settings, dashboard, and synth call.
Surface qwen→silero fallback reason; ship onnx-only silero VAD (dropped the silero-vad + torchaudio dep tree, which crashed on CPU-only robots without CUDA 13).