Spaces:
Running
Running
Upload 344 files
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .env.example +79 -0
- .gitattributes +61 -35
- .gitignore +15 -0
- APP_INTEGRATION_LAYER.md +57 -0
- ATTITUDE_PLAN.md +128 -0
- CODE_OF_CONDUCT.md +89 -0
- CONTRIBUTING.md +93 -0
- LICENSE +201 -0
- MANIFEST.in +1 -0
- README.md +253 -11
- app.py +239 -0
- external_content/external_profiles/starter_profile/instructions.txt +6 -0
- external_content/external_profiles/starter_profile/tools.txt +12 -0
- external_content/external_tools/starter_custom_tool.py +34 -0
- index.html +125 -19
- pyproject.toml +150 -0
- requirements.txt +1 -0
- setup.py +28 -0
- src/Reachy_OpenWebUI/.env.example +44 -0
- src/Reachy_OpenWebUI/__init__.py +1 -0
- src/Reachy_OpenWebUI/__pycache__/__init__.cpython-311.pyc +0 -0
- src/Reachy_OpenWebUI/__pycache__/app_profiles.cpython-311.pyc +0 -0
- src/Reachy_OpenWebUI/__pycache__/camera_frame_encoding.cpython-311.pyc +0 -0
- src/Reachy_OpenWebUI/__pycache__/camera_worker.cpython-311.pyc +0 -0
- src/Reachy_OpenWebUI/__pycache__/chat_history.cpython-311.pyc +0 -0
- src/Reachy_OpenWebUI/__pycache__/config.cpython-311.pyc +0 -0
- src/Reachy_OpenWebUI/__pycache__/console.cpython-311.pyc +3 -0
- src/Reachy_OpenWebUI/__pycache__/dance_emotion_moves.cpython-311.pyc +0 -0
- src/Reachy_OpenWebUI/__pycache__/main.cpython-311.pyc +0 -0
- src/Reachy_OpenWebUI/__pycache__/motion_patterns.cpython-311.pyc +0 -0
- src/Reachy_OpenWebUI/__pycache__/moves.cpython-311.pyc +0 -0
- src/Reachy_OpenWebUI/__pycache__/settings_keys.cpython-311.pyc +0 -0
- src/Reachy_OpenWebUI/__pycache__/utils.cpython-311.pyc +0 -0
- src/Reachy_OpenWebUI/app_profiles.py +62 -0
- src/Reachy_OpenWebUI/audio/__init__.py +1 -0
- src/Reachy_OpenWebUI/audio/__pycache__/__init__.cpython-311.pyc +0 -0
- src/Reachy_OpenWebUI/audio/__pycache__/attitude_wobbler.cpython-311.pyc +0 -0
- src/Reachy_OpenWebUI/audio/__pycache__/head_wobbler.cpython-311.pyc +0 -0
- src/Reachy_OpenWebUI/audio/__pycache__/speech_tapper.cpython-311.pyc +0 -0
- src/Reachy_OpenWebUI/audio/__pycache__/startup_config.cpython-311.pyc +0 -0
- src/Reachy_OpenWebUI/audio/attitude_wobbler.py +243 -0
- src/Reachy_OpenWebUI/audio/head_wobbler.py +205 -0
- src/Reachy_OpenWebUI/audio/speech_tapper.py +274 -0
- src/Reachy_OpenWebUI/audio/startup_config.py +68 -0
- src/Reachy_OpenWebUI/camera_frame_encoding.py +27 -0
- src/Reachy_OpenWebUI/camera_worker.py +78 -0
- src/Reachy_OpenWebUI/config.py +145 -0
- src/Reachy_OpenWebUI/console.py +0 -0
- src/Reachy_OpenWebUI/dance_emotion_moves.py +154 -0
- src/Reachy_OpenWebUI/images/astronaut.png +3 -0
.env.example
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# =============================================================================
|
| 2 |
+
# OpenWebUI connection
|
| 3 |
+
# =============================================================================
|
| 4 |
+
# OpenWebUI base URL. Values ending in /api are accepted.
|
| 5 |
+
OPENWEBUI_URL=http://localhost:3001/api
|
| 6 |
+
|
| 7 |
+
# Model/workspace id as known by OpenWebUI.
|
| 8 |
+
OPENWEB_LOADED_LLM=reachy-convo
|
| 9 |
+
|
| 10 |
+
# API key for OpenWebUI HTTP endpoints when API-key bearer auth is accepted.
|
| 11 |
+
OPENWEBUI_API_KEY=
|
| 12 |
+
|
| 13 |
+
# OpenWebUI browser/session JWT. Preferred over OPENWEBUI_API_KEY and required
|
| 14 |
+
# for authenticated Socket.IO user-join behavior.
|
| 15 |
+
OPENWEBUI_TOKEN=
|
| 16 |
+
|
| 17 |
+
# Optional browser User-Agent sent with OpenWebUI requests.
|
| 18 |
+
OPENWEBUI_USER_AGENT=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# =============================================================================
|
| 22 |
+
# OpenWebUI chat state
|
| 23 |
+
# =============================================================================
|
| 24 |
+
# Leave blank on first launch. The app saves the real OpenWebUI chat id after a
|
| 25 |
+
# successful turn. Callback and daily rollover clear this value on purpose.
|
| 26 |
+
OPENWEBUI_CHAT_ID=
|
| 27 |
+
OPENWEBUI_CHAT_WINDOW_START=
|
| 28 |
+
OPENWEBUI_CHAT_PENDING_REASON=
|
| 29 |
+
OPENWEBUI_CHAT_PENDING_EVENT_ID=
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
# =============================================================================
|
| 33 |
+
# OpenWebUI variables and user context
|
| 34 |
+
# =============================================================================
|
| 35 |
+
OPENWEBUI_USER_ID=
|
| 36 |
+
OPENWEBUI_USER_NAME=Reachy
|
| 37 |
+
OPENWEBUI_USER_EMAIL=reachy@example.local
|
| 38 |
+
OPENWEBUI_USER_LANGUAGE=en-US
|
| 39 |
+
OPENWEBUI_USER_LOCATION=Unknown
|
| 40 |
+
OPENWEBUI_USER_TIMEZONE=America/New_York
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
# =============================================================================
|
| 44 |
+
# Audio, speech, and VAD
|
| 45 |
+
# =============================================================================
|
| 46 |
+
# Digital gain applied to microphone input before VAD and transcription upload.
|
| 47 |
+
MIC_GAIN=1.0
|
| 48 |
+
|
| 49 |
+
# OpenWebUI speech read timeout in seconds.
|
| 50 |
+
OPENWEBUI_TTS_READ_TIMEOUT=15.0
|
| 51 |
+
|
| 52 |
+
# No-wake-word VAD tuning. ONNX Silero is preferred; audio-level fallback keeps
|
| 53 |
+
# startup alive if ONNX Runtime or the model is unavailable.
|
| 54 |
+
VAD_THRESHOLD=0.12
|
| 55 |
+
VAD_FALLBACK_THRESHOLD=0.010
|
| 56 |
+
VAD_SPEECH_ONSET_CHUNKS=2
|
| 57 |
+
VAD_SILENCE_END_CHUNKS=70
|
| 58 |
+
VAD_MIN_SPEECH_CHUNKS=8
|
| 59 |
+
|
| 60 |
+
# Optional override for the downloaded Silero ONNX model path. Blank uses
|
| 61 |
+
# ~/.cache/reachy-openwebui-vad/silero_vad.onnx.
|
| 62 |
+
SILERO_VAD_ONNX_PATH=
|
| 63 |
+
|
| 64 |
+
# Optional idle timeout. 0 disables idle-based conversation reset.
|
| 65 |
+
OPENWEBUI_CONVERSATION_IDLE_RESET_SECONDS=0
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
# =============================================================================
|
| 69 |
+
# Vision and Hugging Face cache
|
| 70 |
+
# =============================================================================
|
| 71 |
+
# Local device vision model used only when optional local vision code is present
|
| 72 |
+
# and the app is launched with --smolvlm.
|
| 73 |
+
LOCAL_DEV_VISION_MODEL=HuggingFaceTB/SmolVLM2-2.2B-Instruct
|
| 74 |
+
|
| 75 |
+
# Cache directory for downloaded Hugging Face assets.
|
| 76 |
+
HF_HOME=./cache
|
| 77 |
+
|
| 78 |
+
# Optional Hugging Face token for gated/private assets.
|
| 79 |
+
HF_TOKEN=
|
.gitattributes
CHANGED
|
@@ -1,35 +1,61 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
*.
|
| 6 |
-
*.
|
| 7 |
-
*.
|
| 8 |
-
*.
|
| 9 |
-
*.
|
| 10 |
-
*.
|
| 11 |
-
*.
|
| 12 |
-
*.
|
| 13 |
-
*.
|
| 14 |
-
*.
|
| 15 |
-
*.
|
| 16 |
-
*.
|
| 17 |
-
*.
|
| 18 |
-
*.
|
| 19 |
-
*.
|
| 20 |
-
*.
|
| 21 |
-
*.
|
| 22 |
-
*.
|
| 23 |
-
*.
|
| 24 |
-
*.
|
| 25 |
-
*.
|
| 26 |
-
|
| 27 |
-
*.
|
| 28 |
-
*.
|
| 29 |
-
*.
|
| 30 |
-
*.
|
| 31 |
-
|
| 32 |
-
*.
|
| 33 |
-
|
| 34 |
-
*.
|
| 35 |
-
*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Macro for all binary files that should use Git LFS.
|
| 2 |
+
[attr]lfs -text filter=lfs diff=lfs merge=lfs
|
| 3 |
+
|
| 4 |
+
# Image
|
| 5 |
+
*.jpg lfs
|
| 6 |
+
*.jpeg lfs
|
| 7 |
+
*.png lfs
|
| 8 |
+
*.apng lfs
|
| 9 |
+
*.atsc lfs
|
| 10 |
+
*.gif lfs
|
| 11 |
+
*.bmp lfs
|
| 12 |
+
*.exr lfs
|
| 13 |
+
*.tga lfs
|
| 14 |
+
*.tiff lfs
|
| 15 |
+
*.tif lfs
|
| 16 |
+
*.iff lfs
|
| 17 |
+
*.pict lfs
|
| 18 |
+
*.dds lfs
|
| 19 |
+
*.xcf lfs
|
| 20 |
+
*.leo lfs
|
| 21 |
+
*.kra lfs
|
| 22 |
+
*.kpp lfs
|
| 23 |
+
*.clip lfs
|
| 24 |
+
*.webm lfs
|
| 25 |
+
*.webp lfs
|
| 26 |
+
*.svg text eol=lf
|
| 27 |
+
*.svgz text eol=lf
|
| 28 |
+
*.psd lfs
|
| 29 |
+
*.afphoto lfs
|
| 30 |
+
*.afdesign lfs
|
| 31 |
+
# Models
|
| 32 |
+
*.pth lfs
|
| 33 |
+
# Binaries
|
| 34 |
+
*.bin lfs
|
| 35 |
+
*.pkl lfs
|
| 36 |
+
*.pckl lfs
|
| 37 |
+
# 3D
|
| 38 |
+
*.ply lfs
|
| 39 |
+
*.vis lfs
|
| 40 |
+
*.db lfs
|
| 41 |
+
*.ply lfs
|
| 42 |
+
docs/assets/reachy_mini_dance.gif filter=lfs diff=lfs merge=lfs -text
|
| 43 |
+
src/reachy_mini_conversation_app/images/reachymini_avatar.png filter=lfs diff=lfs merge=lfs -text
|
| 44 |
+
src/reachy_mini_conversation_app/images/user_avatar.png filter=lfs diff=lfs merge=lfs -text
|
| 45 |
+
src/Reachy_OpenWebUI/images/reachymini_avatar.png filter=lfs diff=lfs merge=lfs -text
|
| 46 |
+
src/Reachy_OpenWebUI/images/user_avatar.png filter=lfs diff=lfs merge=lfs -text
|
| 47 |
+
src/Reachy_OpenWebUI/static/assets/reachy-conversation-app.png filter=lfs diff=lfs merge=lfs -text
|
| 48 |
+
src/Reachy_OpenWebUI/__pycache__/console.cpython-311.pyc filter=lfs diff=lfs merge=lfs -text
|
| 49 |
+
src/Reachy_OpenWebUI/images/astronaut.png filter=lfs diff=lfs merge=lfs -text
|
| 50 |
+
src/Reachy_OpenWebUI/images/captain.png filter=lfs diff=lfs merge=lfs -text
|
| 51 |
+
src/Reachy_OpenWebUI/images/cowboy.png filter=lfs diff=lfs merge=lfs -text
|
| 52 |
+
src/Reachy_OpenWebUI/images/explorer.png filter=lfs diff=lfs merge=lfs -text
|
| 53 |
+
src/Reachy_OpenWebUI/images/farmer-2sejRPMv.png filter=lfs diff=lfs merge=lfs -text
|
| 54 |
+
src/Reachy_OpenWebUI/images/plumber.png filter=lfs diff=lfs merge=lfs -text
|
| 55 |
+
src/Reachy_OpenWebUI/images/reachy-conversation-app.jpg filter=lfs diff=lfs merge=lfs -text
|
| 56 |
+
src/Reachy_OpenWebUI/images/reachy-conversation-app.png filter=lfs diff=lfs merge=lfs -text
|
| 57 |
+
src/Reachy_OpenWebUI/images/reachy-how-to-create-app.png filter=lfs diff=lfs merge=lfs -text
|
| 58 |
+
src/Reachy_OpenWebUI/images/reachy-icon.png filter=lfs diff=lfs merge=lfs -text
|
| 59 |
+
src/Reachy_OpenWebUI/images/sleeping-reachy.png filter=lfs diff=lfs merge=lfs -text
|
| 60 |
+
src/Reachy_OpenWebUI/static/assets/pwa-icon-512.png filter=lfs diff=lfs merge=lfs -text
|
| 61 |
+
src/Reachy_OpenWebUI/static/assets/settings-icon.png filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.env
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
build/
|
| 5 |
+
*.egg-info/
|
| 6 |
+
.tmp_wheel_test/
|
| 7 |
+
dist/
|
| 8 |
+
|
| 9 |
+
# Local-only developer scratch files and private bot overlays.
|
| 10 |
+
dev/
|
| 11 |
+
turbo/
|
| 12 |
+
local_bot/
|
| 13 |
+
|
| 14 |
+
# Raw OpenWebUI model exports can include user ids, emails, and access grants.
|
| 15 |
+
profiles/*/*.json
|
APP_INTEGRATION_LAYER.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# App Integration Layer
|
| 2 |
+
|
| 3 |
+
Each mini app keeps its own files inside its own app folder.
|
| 4 |
+
|
| 5 |
+
For CookAIware, the app folder is:
|
| 6 |
+
|
| 7 |
+
`src/Reachy_OpenWebUI/sub_apps/cookAIware/`
|
| 8 |
+
|
| 9 |
+
That folder owns CookAIware-specific tools, prompts, profile files, data helpers, assets, and app behavior. If CookAIware needs to be removed, this folder should be safe to wipe after removing its integration-layer hooks.
|
| 10 |
+
|
| 11 |
+
For the base conversation app, the app folder is:
|
| 12 |
+
|
| 13 |
+
`src/Reachy_OpenWebUI/sub_apps/conversation_app/`
|
| 14 |
+
|
| 15 |
+
That folder owns conversation-mode support code such as chat history, VAD helpers, and the OpenWebUI local handler/LLM/TTS pipeline. Keep conversation-only state and helpers there instead of spreading them through the global app package.
|
| 16 |
+
|
| 17 |
+
## Integration Layer
|
| 18 |
+
|
| 19 |
+
The integration layer is the only place where app-specific code is allowed to touch the shared Reachy app.
|
| 20 |
+
|
| 21 |
+
Current CookAIware integration-layer files:
|
| 22 |
+
|
| 23 |
+
`src/Reachy_OpenWebUI/static/index.html`
|
| 24 |
+
|
| 25 |
+
`src/Reachy_OpenWebUI/static/main.js`
|
| 26 |
+
|
| 27 |
+
`src/Reachy_OpenWebUI/static/style.css`
|
| 28 |
+
|
| 29 |
+
`src/Reachy_OpenWebUI/console.py`
|
| 30 |
+
|
| 31 |
+
`src/Reachy_OpenWebUI/local/handler.py`
|
| 32 |
+
|
| 33 |
+
`src/Reachy_OpenWebUI/tools/core_tools.py`
|
| 34 |
+
|
| 35 |
+
`pyproject.toml`
|
| 36 |
+
|
| 37 |
+
## Rule
|
| 38 |
+
|
| 39 |
+
Do not scatter app-owned files through shared folders.
|
| 40 |
+
|
| 41 |
+
Shared files should only contain the wiring needed to:
|
| 42 |
+
|
| 43 |
+
- show or launch the app
|
| 44 |
+
- route app requests to the backend
|
| 45 |
+
- load the app profile/tools
|
| 46 |
+
- pass runtime context into the shared conversation spine
|
| 47 |
+
- package the app folder
|
| 48 |
+
|
| 49 |
+
Everything else belongs in the app folder.
|
| 50 |
+
|
| 51 |
+
## Removal Checklist
|
| 52 |
+
|
| 53 |
+
To remove an app:
|
| 54 |
+
|
| 55 |
+
1. Delete its app folder.
|
| 56 |
+
2. Remove its entries from the integration-layer files.
|
| 57 |
+
3. Restart and verify the shared app still starts.
|
ATTITUDE_PLAN.md
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Reachy Attitude Plan
|
| 2 |
+
|
| 3 |
+
## Goal
|
| 4 |
+
|
| 5 |
+
Make Reachy feel different per contact/model by selecting one simple user-facing `Attitude`.
|
| 6 |
+
|
| 7 |
+
The app should not expose separate controls for idle, speaking, and tool moves. The chosen attitude should control those internal pieces together.
|
| 8 |
+
|
| 9 |
+
## Core Idea
|
| 10 |
+
|
| 11 |
+
One setting:
|
| 12 |
+
|
| 13 |
+
```text
|
| 14 |
+
Attitude
|
| 15 |
+
```
|
| 16 |
+
|
| 17 |
+
That attitude controls:
|
| 18 |
+
|
| 19 |
+
```text
|
| 20 |
+
idle movement
|
| 21 |
+
speaking movement
|
| 22 |
+
available emotion/dance tool moves
|
| 23 |
+
```
|
| 24 |
+
|
| 25 |
+
All robot movement stays bot-side/local. OpenWebUI models can trigger tools, but Reachy decides what moves are available and how they execute.
|
| 26 |
+
|
| 27 |
+
## Movement Types
|
| 28 |
+
|
| 29 |
+
`Idle movement`
|
| 30 |
+
|
| 31 |
+
Runs when Reachy is waiting, listening, or between responses.
|
| 32 |
+
|
| 33 |
+
`Speaking movement`
|
| 34 |
+
|
| 35 |
+
Runs while TTS is playing. This should be procedural and loopable, not a big recorded emotion every sentence.
|
| 36 |
+
|
| 37 |
+
`Accent moves`
|
| 38 |
+
|
| 39 |
+
One-shot recorded emotion/dance moves, usually triggered by tool calls or selected from an attitude's allowed move pool.
|
| 40 |
+
|
| 41 |
+
## Attitude Examples
|
| 42 |
+
|
| 43 |
+
`flirty`
|
| 44 |
+
|
| 45 |
+
Soft, playful, attentive. Good candidate accents: `shy1`, `loving1`, `welcoming2`, `laughing2`, `thoughtful2`.
|
| 46 |
+
|
| 47 |
+
`serious`
|
| 48 |
+
|
| 49 |
+
Restrained, direct, minimal. Good candidate accents: `attentive1`, `understanding2`, `inquiring1`, `thoughtful1`.
|
| 50 |
+
|
| 51 |
+
`spaceshot`
|
| 52 |
+
|
| 53 |
+
Distracted, wandering, odd timing. Good candidate accents: `lost1`, `curious1`, `confused1`, `inquiring3`, `indifferent1`.
|
| 54 |
+
|
| 55 |
+
`depressed`
|
| 56 |
+
|
| 57 |
+
Low energy, slower, downward posture. Good candidate accents: `downcast1`, `sad2`, `tired1`, `resigned1`, `lonely1`.
|
| 58 |
+
|
| 59 |
+
`tough`
|
| 60 |
+
|
| 61 |
+
Harder, less friendly, guarded. Good candidate accents: `impatient1`, `impatient2`, `irritated1`, `displeased2`, `contempt1`.
|
| 62 |
+
|
| 63 |
+
`silly`
|
| 64 |
+
|
| 65 |
+
Goofy, bouncy, reactive. Good candidate accents: `curious1`, `indifferent1`, `proud2`, `laughing2`, `dance1`.
|
| 66 |
+
|
| 67 |
+
## Emotion Library Use
|
| 68 |
+
|
| 69 |
+
The emotion library source is:
|
| 70 |
+
|
| 71 |
+
```text
|
| 72 |
+
C:\Users\jsnmd\Documents\Reachy_OpenWebUI\reachy-mini-emotions-library
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
The `.json` files are the recorded motion data. The matching `.wav` files are audio and should not be used unless we intentionally want robot-side sound.
|
| 76 |
+
|
| 77 |
+
Most recorded moves are too theatrical for idle. Use them as occasional accent moves, not continuous behavior.
|
| 78 |
+
|
| 79 |
+
## Tool Move Gating
|
| 80 |
+
|
| 81 |
+
The app sends tool schemas with each model message turn.
|
| 82 |
+
|
| 83 |
+
That means allowed `play_emotion` and `dance` choices can be changed before each model response based on the currently selected attitude.
|
| 84 |
+
|
| 85 |
+
Example:
|
| 86 |
+
|
| 87 |
+
```text
|
| 88 |
+
Attitude = flirty
|
| 89 |
+
play_emotion enum = shy1, loving1, welcoming2, laughing2
|
| 90 |
+
|
| 91 |
+
Attitude = serious
|
| 92 |
+
play_emotion enum = attentive1, understanding2, inquiring1, thoughtful1
|
| 93 |
+
```
|
| 94 |
+
|
| 95 |
+
The model only sees the allowed list for that turn. If the attitude changes, the next message can send a different list.
|
| 96 |
+
|
| 97 |
+
## UI Plan
|
| 98 |
+
|
| 99 |
+
Keep the settings UI simple:
|
| 100 |
+
|
| 101 |
+
```text
|
| 102 |
+
Attitude
|
| 103 |
+
```
|
| 104 |
+
|
| 105 |
+
Do not expose separate idle/speaking/accent settings in the first pass.
|
| 106 |
+
|
| 107 |
+
Rename the current `Idle attitude` field to `Attitude`.
|
| 108 |
+
|
| 109 |
+
## Implementation Plan
|
| 110 |
+
|
| 111 |
+
1. Rename current user-facing `Idle attitude` wording to `Attitude`.
|
| 112 |
+
2. Keep the existing backend setting for now unless renaming it is worth the churn.
|
| 113 |
+
3. Add an attitude definition structure that includes:
|
| 114 |
+
- idle motion parameters
|
| 115 |
+
- speaking motion parameters
|
| 116 |
+
- allowed emotion moves
|
| 117 |
+
- allowed dance moves
|
| 118 |
+
4. Use selected attitude to filter `play_emotion` tool enum per message.
|
| 119 |
+
5. Use selected attitude to filter `dance` tool enum per message.
|
| 120 |
+
6. Keep full tool enable/disable separate from move gating.
|
| 121 |
+
7. Later, allow OpenWebUI model/contact metadata to select attitude automatically, with manual app setting as fallback or override.
|
| 122 |
+
|
| 123 |
+
## Do Not Do Yet
|
| 124 |
+
|
| 125 |
+
- Do not dump the full emotion library into the app repo.
|
| 126 |
+
- Do not make separate UI controls for idle and speaking movement.
|
| 127 |
+
- Do not use long theatrical moves as random idle loops.
|
| 128 |
+
- Do not depend on OpenWebUI to execute robot movement.
|
CODE_OF_CONDUCT.md
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Contributor Covenant Code of Conduct
|
| 2 |
+
|
| 3 |
+
## Our Pledge
|
| 4 |
+
|
| 5 |
+
We pledge to make our community welcoming, safe, and equitable for all.
|
| 6 |
+
|
| 7 |
+
We are committed to fostering an environment that respects and promotes the dignity, rights, and contributions of all individuals, regardless of characteristics including race, ethnicity, caste, color, age, physical characteristics, neurodiversity, disability, sex or gender, gender identity or expression, sexual orientation, language, philosophy or religion, national or social origin, socio-economic position, level of education, or other status. The same privileges of participation are extended to everyone who participates in good faith and in accordance with this Covenant.
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
## Encouraged Behaviors
|
| 11 |
+
|
| 12 |
+
While acknowledging differences in social norms, we all strive to meet our community's expectations for positive behavior. We also understand that our words and actions may be interpreted differently than we intend based on culture, background, or native language.
|
| 13 |
+
|
| 14 |
+
With these considerations in mind, we agree to behave mindfully toward each other and act in ways that center our shared values, including:
|
| 15 |
+
|
| 16 |
+
1. Respecting the **purpose of our community**, our activities, and our ways of gathering.
|
| 17 |
+
2. Engaging **kindly and honestly** with others.
|
| 18 |
+
3. Respecting **different viewpoints** and experiences.
|
| 19 |
+
4. **Taking responsibility** for our actions and contributions.
|
| 20 |
+
5. Gracefully giving and accepting **constructive feedback**.
|
| 21 |
+
6. Committing to **repairing harm** when it occurs.
|
| 22 |
+
7. Behaving in other ways that promote and sustain the **well-being of our community**.
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
## Restricted Behaviors
|
| 26 |
+
|
| 27 |
+
We agree to restrict the following behaviors in our community. Instances, threats, and promotion of these behaviors are violations of this Code of Conduct.
|
| 28 |
+
|
| 29 |
+
1. **Harassment.** Violating explicitly expressed boundaries or engaging in unnecessary personal attention after any clear request to stop.
|
| 30 |
+
2. **Character attacks.** Making insulting, demeaning, or pejorative comments directed at a community member or group of people.
|
| 31 |
+
3. **Stereotyping or discrimination.** Characterizing anyone’s personality or behavior on the basis of immutable identities or traits.
|
| 32 |
+
4. **Sexualization.** Behaving in a way that would generally be considered inappropriately intimate in the context or purpose of the community.
|
| 33 |
+
5. **Violating confidentiality**. Sharing or acting on someone's personal or private information without their permission.
|
| 34 |
+
6. **Endangerment.** Causing, encouraging, or threatening violence or other harm toward any person or group.
|
| 35 |
+
7. Behaving in other ways that **threaten the well-being** of our community.
|
| 36 |
+
|
| 37 |
+
### Other Restrictions
|
| 38 |
+
|
| 39 |
+
1. **Misleading identity.** Impersonating someone else for any reason, or pretending to be someone else to evade enforcement actions.
|
| 40 |
+
2. **Failing to credit sources.** Not properly crediting the sources of content you contribute.
|
| 41 |
+
3. **Promotional materials**. Sharing marketing or other commercial content in a way that is outside the norms of the community.
|
| 42 |
+
4. **Irresponsible communication.** Failing to responsibly present content which includes, links or describes any other restricted behaviors.
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
## Reporting an Issue
|
| 46 |
+
|
| 47 |
+
Tensions can occur between community members even when they are trying their best to collaborate. Not every conflict represents a code of conduct violation, and this Code of Conduct reinforces encouraged behaviors and norms that can help avoid conflicts and minimize harm.
|
| 48 |
+
|
| 49 |
+
When an incident does occur, it is important to report it promptly. To report a possible violation, please, send an email to contact@pollen-robotics.com.
|
| 50 |
+
|
| 51 |
+
Community Moderators take reports of violations seriously and will make every effort to respond in a timely manner. They will investigate all reports of code of conduct violations, reviewing messages, logs, and recordings, or interviewing witnesses and other participants. Community Moderators will keep investigation and enforcement actions as transparent as possible while prioritizing safety and confidentiality. In order to honor these values, enforcement actions are carried out in private with the involved parties, but communicating to the whole community may be part of a mutually agreed upon resolution.
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
## Addressing and Repairing Harm
|
| 55 |
+
|
| 56 |
+
If an investigation by the Community Moderators finds that this Code of Conduct has been violated, the following enforcement ladder may be used to determine how best to repair harm, based on the incident's impact on the individuals involved and the community as a whole. Depending on the severity of a violation, lower rungs on the ladder may be skipped.
|
| 57 |
+
|
| 58 |
+
1) **Warning**
|
| 59 |
+
1) Event: A violation involving a single incident or series of incidents.
|
| 60 |
+
2) Consequence: A private, written warning from the Community Moderators.
|
| 61 |
+
3) Repair: Examples of repair include a private written apology, acknowledgement of responsibility, and seeking clarification on expectations.
|
| 62 |
+
2) **Temporarily Limited Activities**
|
| 63 |
+
1) Event: A repeated incidence of a violation that previously resulted in a warning, or the first incidence of a more serious violation.
|
| 64 |
+
2) Consequence: A private, written warning with a time-limited cooldown period designed to underscore the seriousness of the situation and give the community members involved time to process the incident. The cooldown period may be limited to particular communication channels or interactions with particular community members.
|
| 65 |
+
3) Repair: Examples of repair may include making an apology, using the cooldown period to reflect on actions and impact, and being thoughtful about re-entering community spaces after the period is over.
|
| 66 |
+
3) **Temporary Suspension**
|
| 67 |
+
1) Event: A pattern of repeated violation which the Community Moderators have tried to address with warnings, or a single serious violation.
|
| 68 |
+
2) Consequence: A private written warning with conditions for return from suspension. In general, temporary suspensions give the person being suspended time to reflect upon their behavior and possible corrective actions.
|
| 69 |
+
3) Repair: Examples of repair include respecting the spirit of the suspension, meeting the specified conditions for return, and being thoughtful about how to reintegrate with the community when the suspension is lifted.
|
| 70 |
+
4) **Permanent Ban**
|
| 71 |
+
1) Event: A pattern of repeated code of conduct violations that other steps on the ladder have failed to resolve, or a violation so serious that the Community Moderators determine there is no way to keep the community safe with this person as a member.
|
| 72 |
+
2) Consequence: Access to all community spaces, tools, and communication channels is removed. In general, permanent bans should be rarely used, should have strong reasoning behind them, and should only be resorted to if working through other remedies has failed to change the behavior.
|
| 73 |
+
3) Repair: There is no possible repair in cases of this severity.
|
| 74 |
+
|
| 75 |
+
This enforcement ladder is intended as a guideline. It does not limit the ability of Community Managers to use their discretion and judgment, in keeping with the best interests of our community.
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
## Scope
|
| 79 |
+
|
| 80 |
+
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public or other spaces. Examples of representing our community include using an official email address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
## Attribution
|
| 84 |
+
|
| 85 |
+
This Code of Conduct is adapted from the Contributor Covenant, version 3.0, permanently available at [https://www.contributor-covenant.org/version/3/0/](https://www.contributor-covenant.org/version/3/0/).
|
| 86 |
+
|
| 87 |
+
Contributor Covenant is stewarded by the Organization for Ethical Source and licensed under CC BY-SA 4.0. To view a copy of this license, visit [https://creativecommons.org/licenses/by-sa/4.0/](https://creativecommons.org/licenses/by-sa/4.0/)
|
| 88 |
+
|
| 89 |
+
For answers to common questions about Contributor Covenant, see the FAQ at [https://www.contributor-covenant.org/faq](https://www.contributor-covenant.org/faq). Translations are provided at [https://www.contributor-covenant.org/translations](https://www.contributor-covenant.org/translations). Additional enforcement and community guideline resources can be found at [https://www.contributor-covenant.org/resources](https://www.contributor-covenant.org/resources). The enforcement ladder was inspired by the work of [Mozilla’s code of conduct team](https://github.com/mozilla/inclusion).
|
CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Contributing
|
| 2 |
+
|
| 3 |
+
Thank you for helping improve Reachy OpenWebUI.
|
| 4 |
+
|
| 5 |
+
This repository is an OpenWebUI-native Reachy Mini app. Keep changes aligned with the current shape of the code: OpenWebUI owns transcription, model/workspace behavior, variables, and speech; the Reachy app owns robot media, settings, tool dispatch, VAD, and motion.
|
| 6 |
+
|
| 7 |
+
## Quick Start
|
| 8 |
+
|
| 9 |
+
1. Fork and clone the repo.
|
| 10 |
+
|
| 11 |
+
```bash
|
| 12 |
+
git clone https://github.com/pollen-robotics/Reachy_OpenWebUI
|
| 13 |
+
cd Reachy_OpenWebUI
|
| 14 |
+
```
|
| 15 |
+
|
| 16 |
+
2. Create an environment.
|
| 17 |
+
|
| 18 |
+
```bash
|
| 19 |
+
uv venv --python python3.12 .venv
|
| 20 |
+
source .venv/bin/activate
|
| 21 |
+
uv sync --group dev
|
| 22 |
+
```
|
| 23 |
+
|
| 24 |
+
3. Copy `.env.example` only for local development. Do not commit real `.env` files or secrets.
|
| 25 |
+
|
| 26 |
+
## Current Project Shape
|
| 27 |
+
|
| 28 |
+
- Python package: `Reachy_OpenWebUI`
|
| 29 |
+
- Reachy Mini Apps entry point: `Reachy_OpenWebUI`
|
| 30 |
+
- Console script: `reachy-mini-conversation-app-openwebui`
|
| 31 |
+
- Main runtime code: `src/Reachy_OpenWebUI/`
|
| 32 |
+
- Settings/call UI: `src/Reachy_OpenWebUI/static/`
|
| 33 |
+
- Default tool allow-list: `src/Reachy_OpenWebUI/profiles/default/tools.txt`
|
| 34 |
+
- Hugging Face Space project page: `app.py`
|
| 35 |
+
|
| 36 |
+
`requirements.txt` is for the Space project page only. Use `pyproject.toml` for the robot app dependencies.
|
| 37 |
+
|
| 38 |
+
## Development Workflow
|
| 39 |
+
|
| 40 |
+
- Keep pull requests focused on one behavior or documentation update.
|
| 41 |
+
- Prefer small, reviewable changes over broad rewrites.
|
| 42 |
+
- Do not reintroduce legacy local STT, voice-provider, or old package-name docs unless the code actually supports them again.
|
| 43 |
+
- Keep OpenWebUI auth wording precise: API keys may work for HTTP endpoints, but the realtime socket path needs a JWT in `OPENWEBUI_TOKEN`.
|
| 44 |
+
- Update README and `.env.example` whenever settings, CLI flags, package names, or user-visible behavior change.
|
| 45 |
+
- Avoid committing generated files, real settings files, downloaded models, caches, or secrets.
|
| 46 |
+
|
| 47 |
+
## Checks
|
| 48 |
+
|
| 49 |
+
Run the checks that are available in this checkout:
|
| 50 |
+
|
| 51 |
+
```bash
|
| 52 |
+
uv run ruff check .
|
| 53 |
+
uv run ruff format . --check
|
| 54 |
+
uv run mypy --pretty --show-error-codes
|
| 55 |
+
uv run python -m compileall src app.py
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
There is no `tests/` directory in this checkout. Add focused tests when you add behavior that can be tested without robot hardware.
|
| 59 |
+
|
| 60 |
+
## Documentation Checklist
|
| 61 |
+
|
| 62 |
+
Before opening a PR, verify docs against the actual code:
|
| 63 |
+
|
| 64 |
+
- CLI flags come from `src/Reachy_OpenWebUI/utils.py`.
|
| 65 |
+
- Env keys come from `src/Reachy_OpenWebUI/settings_keys.py` and `src/Reachy_OpenWebUI/config.py`.
|
| 66 |
+
- Settings UI routes come from `src/Reachy_OpenWebUI/console.py`.
|
| 67 |
+
- Tool names come from `src/Reachy_OpenWebUI/profiles/default/tools.txt` and `src/Reachy_OpenWebUI/tools/`.
|
| 68 |
+
- Package names and entry points come from `pyproject.toml`.
|
| 69 |
+
|
| 70 |
+
## Release Notes
|
| 71 |
+
|
| 72 |
+
When preparing a release:
|
| 73 |
+
|
| 74 |
+
1. Update the version in `pyproject.toml`.
|
| 75 |
+
2. Confirm README and `.env.example` match the release behavior.
|
| 76 |
+
3. Run the checks above.
|
| 77 |
+
4. Commit the version bump.
|
| 78 |
+
5. Create and push a tag.
|
| 79 |
+
|
| 80 |
+
```bash
|
| 81 |
+
git tag vX.Y.Z
|
| 82 |
+
git push origin vX.Y.Z
|
| 83 |
+
```
|
| 84 |
+
|
| 85 |
+
## AI-Assisted Contributions
|
| 86 |
+
|
| 87 |
+
AI tools are fine for exploration and drafts, but the human author is responsible for the final patch. Keep AI-assisted changes readable, minimal, and verified against the code that is actually in the repo.
|
| 88 |
+
|
| 89 |
+
## Filing Issues
|
| 90 |
+
|
| 91 |
+
For bugs, include the Reachy Mini environment, Python version, OpenWebUI URL shape, auth mode, exact command or launcher path, and relevant logs. Use `--debug` when collecting local logs.
|
| 92 |
+
|
| 93 |
+
For feature requests, describe the user workflow, expected robot behavior, and whether the change belongs in OpenWebUI, the Reachy settings UI, or the robot-side tool/runtime layer.
|
LICENSE
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Apache License
|
| 2 |
+
Version 2.0, January 2004
|
| 3 |
+
http://www.apache.org/licenses/
|
| 4 |
+
|
| 5 |
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
| 6 |
+
|
| 7 |
+
1. Definitions.
|
| 8 |
+
|
| 9 |
+
"License" shall mean the terms and conditions for use, reproduction,
|
| 10 |
+
and distribution as defined by Sections 1 through 9 of this document.
|
| 11 |
+
|
| 12 |
+
"Licensor" shall mean the copyright owner or entity authorized by
|
| 13 |
+
the copyright owner that is granting the License.
|
| 14 |
+
|
| 15 |
+
"Legal Entity" shall mean the union of the acting entity and all
|
| 16 |
+
other entities that control, are controlled by, or are under common
|
| 17 |
+
control with that entity. For the purposes of this definition,
|
| 18 |
+
"control" means (i) the power, direct or indirect, to cause the
|
| 19 |
+
direction or management of such entity, whether by contract or
|
| 20 |
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
| 21 |
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
| 22 |
+
|
| 23 |
+
"You" (or "Your") shall mean an individual or Legal Entity
|
| 24 |
+
exercising permissions granted by this License.
|
| 25 |
+
|
| 26 |
+
"Source" form shall mean the preferred form for making modifications,
|
| 27 |
+
including but not limited to software source code, documentation
|
| 28 |
+
source, and configuration files.
|
| 29 |
+
|
| 30 |
+
"Object" form shall mean any form resulting from mechanical
|
| 31 |
+
transformation or translation of a Source form, including but
|
| 32 |
+
not limited to compiled object code, generated documentation,
|
| 33 |
+
and conversions to other media types.
|
| 34 |
+
|
| 35 |
+
"Work" shall mean the work of authorship, whether in Source or
|
| 36 |
+
Object form, made available under the License, as indicated by a
|
| 37 |
+
copyright notice that is included in or attached to the work
|
| 38 |
+
(an example is provided in the Appendix below).
|
| 39 |
+
|
| 40 |
+
"Derivative Works" shall mean any work, whether in Source or Object
|
| 41 |
+
form, that is based on (or derived from) the Work and for which the
|
| 42 |
+
editorial revisions, annotations, elaborations, or other modifications
|
| 43 |
+
represent, as a whole, an original work of authorship. For the purposes
|
| 44 |
+
of this License, Derivative Works shall not include works that remain
|
| 45 |
+
separable from, or merely link (or bind by name) to the interfaces of,
|
| 46 |
+
the Work and Derivative Works thereof.
|
| 47 |
+
|
| 48 |
+
"Contribution" shall mean any work of authorship, including
|
| 49 |
+
the original version of the Work and any modifications or additions
|
| 50 |
+
to that Work or Derivative Works thereof, that is intentionally
|
| 51 |
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
| 52 |
+
or by an individual or Legal Entity authorized to submit on behalf of
|
| 53 |
+
the copyright owner. For the purposes of this definition, "submitted"
|
| 54 |
+
means any form of electronic, verbal, or written communication sent
|
| 55 |
+
to the Licensor or its representatives, including but not limited to
|
| 56 |
+
communication on electronic mailing lists, source code control systems,
|
| 57 |
+
and issue tracking systems that are managed by, or on behalf of, the
|
| 58 |
+
Licensor for the purpose of discussing and improving the Work, but
|
| 59 |
+
excluding communication that is conspicuously marked or otherwise
|
| 60 |
+
designated in writing by the copyright owner as "Not a Contribution."
|
| 61 |
+
|
| 62 |
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
| 63 |
+
on behalf of whom a Contribution has been received by Licensor and
|
| 64 |
+
subsequently incorporated within the Work.
|
| 65 |
+
|
| 66 |
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
| 67 |
+
this License, each Contributor hereby grants to You a perpetual,
|
| 68 |
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
| 69 |
+
copyright license to reproduce, prepare Derivative Works of,
|
| 70 |
+
publicly display, publicly perform, sublicense, and distribute the
|
| 71 |
+
Work and such Derivative Works in Source or Object form.
|
| 72 |
+
|
| 73 |
+
3. Grant of Patent License. Subject to the terms and conditions of
|
| 74 |
+
this License, each Contributor hereby grants to You a perpetual,
|
| 75 |
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
| 76 |
+
(except as stated in this section) patent license to make, have made,
|
| 77 |
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
| 78 |
+
where such license applies only to those patent claims licensable
|
| 79 |
+
by such Contributor that are necessarily infringed by their
|
| 80 |
+
Contribution(s) alone or by combination of their Contribution(s)
|
| 81 |
+
with the Work to which such Contribution(s) was submitted. If You
|
| 82 |
+
institute patent litigation against any entity (including a
|
| 83 |
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
| 84 |
+
or a Contribution incorporated within the Work constitutes direct
|
| 85 |
+
or contributory patent infringement, then any patent licenses
|
| 86 |
+
granted to You under this License for that Work shall terminate
|
| 87 |
+
as of the date such litigation is filed.
|
| 88 |
+
|
| 89 |
+
4. Redistribution. You may reproduce and distribute copies of the
|
| 90 |
+
Work or Derivative Works thereof in any medium, with or without
|
| 91 |
+
modifications, and in Source or Object form, provided that You
|
| 92 |
+
meet the following conditions:
|
| 93 |
+
|
| 94 |
+
(a) You must give any other recipients of the Work or
|
| 95 |
+
Derivative Works a copy of this License; and
|
| 96 |
+
|
| 97 |
+
(b) You must cause any modified files to carry prominent notices
|
| 98 |
+
stating that You changed the files; and
|
| 99 |
+
|
| 100 |
+
(c) You must retain, in the Source form of any Derivative Works
|
| 101 |
+
that You distribute, all copyright, patent, trademark, and
|
| 102 |
+
attribution notices from the Source form of the Work,
|
| 103 |
+
excluding those notices that do not pertain to any part of
|
| 104 |
+
the Derivative Works; and
|
| 105 |
+
|
| 106 |
+
(d) If the Work includes a "NOTICE" text file as part of its
|
| 107 |
+
distribution, then any Derivative Works that You distribute must
|
| 108 |
+
include a readable copy of the attribution notices contained
|
| 109 |
+
within such NOTICE file, excluding those notices that do not
|
| 110 |
+
pertain to any part of the Derivative Works, in at least one
|
| 111 |
+
of the following places: within a NOTICE text file distributed
|
| 112 |
+
as part of the Derivative Works; within the Source form or
|
| 113 |
+
documentation, if provided along with the Derivative Works; or,
|
| 114 |
+
within a display generated by the Derivative Works, if and
|
| 115 |
+
wherever such third-party notices normally appear. The contents
|
| 116 |
+
of the NOTICE file are for informational purposes only and
|
| 117 |
+
do not modify the License. You may add Your own attribution
|
| 118 |
+
notices within Derivative Works that You distribute, alongside
|
| 119 |
+
or as an addendum to the NOTICE text from the Work, provided
|
| 120 |
+
that such additional attribution notices cannot be construed
|
| 121 |
+
as modifying the License.
|
| 122 |
+
|
| 123 |
+
You may add Your own copyright statement to Your modifications and
|
| 124 |
+
may provide additional or different license terms and conditions
|
| 125 |
+
for use, reproduction, or distribution of Your modifications, or
|
| 126 |
+
for any such Derivative Works as a whole, provided Your use,
|
| 127 |
+
reproduction, and distribution of the Work otherwise complies with
|
| 128 |
+
the conditions stated in this License.
|
| 129 |
+
|
| 130 |
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
| 131 |
+
any Contribution intentionally submitted for inclusion in the Work
|
| 132 |
+
by You to the Licensor shall be under the terms and conditions of
|
| 133 |
+
this License, without any additional terms or conditions.
|
| 134 |
+
Notwithstanding the above, nothing herein shall supersede or modify
|
| 135 |
+
the terms of any separate license agreement you may have executed
|
| 136 |
+
with Licensor regarding such Contributions.
|
| 137 |
+
|
| 138 |
+
6. Trademarks. This License does not grant permission to use the trade
|
| 139 |
+
names, trademarks, service marks, or product names of the Licensor,
|
| 140 |
+
except as required for reasonable and customary use in describing the
|
| 141 |
+
origin of the Work and reproducing the content of the NOTICE file.
|
| 142 |
+
|
| 143 |
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
| 144 |
+
agreed to in writing, Licensor provides the Work (and each
|
| 145 |
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
| 146 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
| 147 |
+
implied, including, without limitation, any warranties or conditions
|
| 148 |
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
| 149 |
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
| 150 |
+
appropriateness of using or redistributing the Work and assume any
|
| 151 |
+
risks associated with Your exercise of permissions under this License.
|
| 152 |
+
|
| 153 |
+
8. Limitation of Liability. In no event and under no legal theory,
|
| 154 |
+
whether in tort (including negligence), contract, or otherwise,
|
| 155 |
+
unless required by applicable law (such as deliberate and grossly
|
| 156 |
+
negligent acts) or agreed to in writing, shall any Contributor be
|
| 157 |
+
liable to You for damages, including any direct, indirect, special,
|
| 158 |
+
incidental, or consequential damages of any character arising as a
|
| 159 |
+
result of this License or out of the use or inability to use the
|
| 160 |
+
Work (including but not limited to damages for loss of goodwill,
|
| 161 |
+
work stoppage, computer failure or malfunction, or any and all
|
| 162 |
+
other commercial damages or losses), even if such Contributor
|
| 163 |
+
has been advised of the possibility of such damages.
|
| 164 |
+
|
| 165 |
+
9. Accepting Warranty or Additional Liability. While redistributing
|
| 166 |
+
the Work or Derivative Works thereof, You may choose to offer,
|
| 167 |
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
| 168 |
+
or other liability obligations and/or rights consistent with this
|
| 169 |
+
License. However, in accepting such obligations, You may act only
|
| 170 |
+
on Your own behalf and on Your sole responsibility, not on behalf
|
| 171 |
+
of any other Contributor, and only if You agree to indemnify,
|
| 172 |
+
defend, and hold each Contributor harmless for any liability
|
| 173 |
+
incurred by, or claims asserted against, such Contributor by reason
|
| 174 |
+
of your accepting any such warranty or additional liability.
|
| 175 |
+
|
| 176 |
+
END OF TERMS AND CONDITIONS
|
| 177 |
+
|
| 178 |
+
APPENDIX: How to apply the Apache License to your work.
|
| 179 |
+
|
| 180 |
+
To apply the Apache License to your work, attach the following
|
| 181 |
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
| 182 |
+
replaced with your own identifying information. (Don't include
|
| 183 |
+
the brackets!) The text should be enclosed in the appropriate
|
| 184 |
+
comment syntax for the file format. We also recommend that a
|
| 185 |
+
file or class name and description of purpose be included on the
|
| 186 |
+
same "printed page" as the copyright notice for easier
|
| 187 |
+
identification within third-party archives.
|
| 188 |
+
|
| 189 |
+
Copyright [yyyy] [name of copyright owner]
|
| 190 |
+
|
| 191 |
+
Licensed under the Apache License, Version 2.0 (the "License");
|
| 192 |
+
you may not use this file except in compliance with the License.
|
| 193 |
+
You may obtain a copy of the License at
|
| 194 |
+
|
| 195 |
+
http://www.apache.org/licenses/LICENSE-2.0
|
| 196 |
+
|
| 197 |
+
Unless required by applicable law or agreed to in writing, software
|
| 198 |
+
distributed under the License is distributed on an "AS IS" BASIS,
|
| 199 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 200 |
+
See the License for the specific language governing permissions and
|
| 201 |
+
limitations under the License.
|
MANIFEST.in
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
recursive-include src/Reachy_OpenWebUI/profiles *.txt *.py
|
README.md
CHANGED
|
@@ -1,11 +1,253 @@
|
|
| 1 |
-
---
|
| 2 |
-
title: Reachy OpenWebUI
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom: gray
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk:
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
-
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Reachy OpenWebUI
|
| 3 |
+
emoji: 🐞
|
| 4 |
+
colorFrom: gray
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: gradio
|
| 7 |
+
app_file: app.py
|
| 8 |
+
pinned: false
|
| 9 |
+
short_description: Phone-style OpenWebUI conversations for Reachy Mini.
|
| 10 |
+
tags:
|
| 11 |
+
- reachy_mini
|
| 12 |
+
- reachy_mini_python_app
|
| 13 |
+
sdk_version: 6.18.0
|
| 14 |
+
license: apache-2.0
|
| 15 |
+
---
|
| 16 |
+
|
| 17 |
+
<p align="center">
|
| 18 |
+
<img src="src/Reachy_OpenWebUI/static/assets/reachy-conversation-app.png" width="112" alt="Reachy OpenWebUI icon">
|
| 19 |
+
</p>
|
| 20 |
+
|
| 21 |
+
# Reachy OpenWebUI
|
| 22 |
+
|
| 23 |
+
Reachy OpenWebUI is a phone-style conversation app for Reachy Mini. OpenWebUI owns the model, transcription, chat behavior, user context, and TTS. This app owns the robot-side runtime: settings, no-wake-word listening, camera buffering, local Reachy tools, avatars, chat state, and motion.
|
| 24 |
+
|
| 25 |
+
This Hugging Face Space is a project page. The real app runs inside the Reachy Mini app environment where it can access the robot daemon, microphone, speaker, camera, and movement APIs.
|
| 26 |
+
|
| 27 |
+
## Highlights
|
| 28 |
+
|
| 29 |
+
- Phone-inspired settings and conversation UI served by the Reachy Mini app webserver.
|
| 30 |
+
- OpenWebUI model/workspace picker shown as contacts.
|
| 31 |
+
- OpenWebUI conversation list support for returning to saved chats.
|
| 32 |
+
- Model and user avatar proxying from OpenWebUI profile image endpoints.
|
| 33 |
+
- No-wake-word voice loop using Reachy audio, Silero VAD, OpenWebUI STT, OpenWebUI chat, and OpenWebUI TTS.
|
| 34 |
+
- Sentence-by-sentence speech streaming so audio starts before the full model answer is finished.
|
| 35 |
+
- Reachy-side tools for dance, recorded emotions, camera, head tracking, head movement, and speech.
|
| 36 |
+
- Speech-reactive head motion blended with queued robot moves.
|
| 37 |
+
- Robot attitudes that can shape idle motion, speaking motion, and which dance/emotion moves the model can choose.
|
| 38 |
+
|
| 39 |
+
## Runtime Flow
|
| 40 |
+
|
| 41 |
+
```text
|
| 42 |
+
Reachy microphone
|
| 43 |
+
-> audio startup tuning and SDK record loop
|
| 44 |
+
-> Silero VAD and level fallback
|
| 45 |
+
-> OpenWebUI audio transcription
|
| 46 |
+
-> OpenWebUI chat stream with optional Reachy tool specs
|
| 47 |
+
-> optional Reachy-side tool execution
|
| 48 |
+
-> OpenWebUI speech
|
| 49 |
+
-> Reachy speaker output plus speech-reactive motion
|
| 50 |
+
```
|
| 51 |
+
|
| 52 |
+
OpenWebUI may accept requests at `/api/chat/completions` while returning either
|
| 53 |
+
classic chat-completion chunks (`chat.completion.chunk`) or Responses-style
|
| 54 |
+
events (`response.output_text.delta`, `response.output_text.done`,
|
| 55 |
+
`response.completed`). The runtime reader must support both formats because the
|
| 56 |
+
selected OpenWebUI model/contact can route through different providers even when
|
| 57 |
+
the model id shown in the UI is unchanged.
|
| 58 |
+
|
| 59 |
+
## Install
|
| 60 |
+
|
| 61 |
+
This repo currently has no `uv.lock`, so do not use `uv sync --frozen`.
|
| 62 |
+
|
| 63 |
+
```bash
|
| 64 |
+
uv venv --python python3.12 .venv
|
| 65 |
+
source .venv/bin/activate
|
| 66 |
+
uv sync
|
| 67 |
+
```
|
| 68 |
+
|
| 69 |
+
Editable pip install:
|
| 70 |
+
|
| 71 |
+
```bash
|
| 72 |
+
python -m venv .venv
|
| 73 |
+
source .venv/bin/activate
|
| 74 |
+
pip install -e .
|
| 75 |
+
```
|
| 76 |
+
|
| 77 |
+
Optional local vision extra:
|
| 78 |
+
|
| 79 |
+
```bash
|
| 80 |
+
uv sync --extra local_vision
|
| 81 |
+
pip install -e .[local_vision]
|
| 82 |
+
```
|
| 83 |
+
|
| 84 |
+
Developer tools:
|
| 85 |
+
|
| 86 |
+
```bash
|
| 87 |
+
uv sync --group dev
|
| 88 |
+
```
|
| 89 |
+
|
| 90 |
+
`requirements.txt` is only for this Hugging Face Space project page. The robot app dependencies live in `pyproject.toml`.
|
| 91 |
+
|
| 92 |
+
## Configuration
|
| 93 |
+
|
| 94 |
+
The app reads `~/.config/settings/.env` first, then a local `.env` if present. The settings UI writes the stable file at `~/.config/settings/.env`.
|
| 95 |
+
|
| 96 |
+
Core OpenWebUI settings:
|
| 97 |
+
|
| 98 |
+
| Variable | Purpose |
|
| 99 |
+
| --- | --- |
|
| 100 |
+
| `OPENWEBUI_URL` | OpenWebUI base URL. Values ending in `/api` are accepted. |
|
| 101 |
+
| `OPENWEB_LOADED_LLM` | Selected OpenWebUI model, workspace, or contact id. |
|
| 102 |
+
| `OPENWEBUI_API_KEY` | API key for HTTP requests when your OpenWebUI server accepts API-key bearer auth. |
|
| 103 |
+
| `OPENWEBUI_TOKEN` | Browser/session JWT. Preferred for chat, Socket.IO, transcription, and speech. |
|
| 104 |
+
| `OPENWEBUI_CHAT_ID` | Saved OpenWebUI chat id. Leave blank on first launch. |
|
| 105 |
+
| `MIC_GAIN` | Digital gain applied before VAD and transcription upload. |
|
| 106 |
+
| `HF_HOME` | Hugging Face cache directory. |
|
| 107 |
+
| `HF_TOKEN` | Optional token for gated or private assets. |
|
| 108 |
+
|
| 109 |
+
User/context variables sent to OpenWebUI:
|
| 110 |
+
|
| 111 |
+
| Variable | Purpose |
|
| 112 |
+
| --- | --- |
|
| 113 |
+
| `OPENWEBUI_USER_ID` | Optional user id. If blank, the app tries to derive it from the JWT. |
|
| 114 |
+
| `OPENWEBUI_USER_NAME` | User name supplied to OpenWebUI variables and socket context. |
|
| 115 |
+
| `OPENWEBUI_USER_EMAIL` | User email supplied to OpenWebUI variables and socket context. |
|
| 116 |
+
| `OPENWEBUI_USER_LANGUAGE` | User language variable. |
|
| 117 |
+
| `OPENWEBUI_USER_LOCATION` | User location variable. |
|
| 118 |
+
| `OPENWEBUI_USER_TIMEZONE` | User timezone variable. |
|
| 119 |
+
|
| 120 |
+
Audio and timing settings:
|
| 121 |
+
|
| 122 |
+
| Variable | Purpose |
|
| 123 |
+
| --- | --- |
|
| 124 |
+
| `OPENWEBUI_TTS_READ_TIMEOUT` | Read timeout for OpenWebUI speech responses. |
|
| 125 |
+
| `OPENWEBUI_CONVERSATION_IDLE_RESET_SECONDS` | Optional idle reset. `0` disables it. |
|
| 126 |
+
| `VAD_THRESHOLD` | Silero speech probability threshold. |
|
| 127 |
+
| `VAD_FALLBACK_THRESHOLD` | RMS fallback threshold. |
|
| 128 |
+
| `VAD_SPEECH_ONSET_CHUNKS` | Speech chunks required before a turn starts. |
|
| 129 |
+
| `VAD_SILENCE_END_CHUNKS` | Silence chunks required before a turn ends. |
|
| 130 |
+
| `VAD_MIN_SPEECH_CHUNKS` | Minimum speech chunks before sending audio to OpenWebUI transcription. |
|
| 131 |
+
| `SILERO_VAD_ONNX_PATH` | Optional path for the Silero ONNX model. |
|
| 132 |
+
|
| 133 |
+
## TTS Voices
|
| 134 |
+
|
| 135 |
+
Set TTS voices in OpenWebUI. This app reads the selected model/contact voice metadata and sends that voice back to the OpenWebUI speech endpoint.
|
| 136 |
+
|
| 137 |
+
If a model/contact has no voice set in OpenWebUI, the app sends no voice value and lets OpenWebUI use its default TTS behavior. There is intentionally no app-level voice picker because every OpenWebUI install can expose different TTS engines and voice names.
|
| 138 |
+
|
| 139 |
+
## Running
|
| 140 |
+
|
| 141 |
+
The normal runtime path is the Reachy Mini Apps launcher. It provides the FastAPI settings app and serves the phone UI declared by:
|
| 142 |
+
|
| 143 |
+
```python
|
| 144 |
+
custom_app_url = "http://0.0.0.0:7860/"
|
| 145 |
+
```
|
| 146 |
+
|
| 147 |
+
Console entry point:
|
| 148 |
+
|
| 149 |
+
```bash
|
| 150 |
+
reachy-mini-conversation-app-openwebui
|
| 151 |
+
```
|
| 152 |
+
|
| 153 |
+
CLI options:
|
| 154 |
+
|
| 155 |
+
| Option | Default | Description |
|
| 156 |
+
| --- | --- | --- |
|
| 157 |
+
| `--no-camera` | `False` | Skip camera frame buffering. |
|
| 158 |
+
| `--smolvlm` | `False` | Try to initialize the optional local SmolVLM vision manager. |
|
| 159 |
+
| `--robot-name <name>` | `None` | Connect to a daemon started with the same robot name. |
|
| 160 |
+
| `--debug` | `False` | Enable verbose app logging. |
|
| 161 |
+
|
| 162 |
+
## Settings UI
|
| 163 |
+
|
| 164 |
+
The phone-style UI lives in `src/Reachy_OpenWebUI/static/`.
|
| 165 |
+
|
| 166 |
+
It includes:
|
| 167 |
+
|
| 168 |
+
- Power-on screen while the backend is getting ready.
|
| 169 |
+
- Home screen with contacts, conversations, quick connect, and settings.
|
| 170 |
+
- Contacts page backed by OpenWebUI models.
|
| 171 |
+
- Conversations page backed by OpenWebUI chat history.
|
| 172 |
+
- Call screen with transcript bubbles, typing indicators, model avatar, user avatar, mute, end call, and back navigation.
|
| 173 |
+
- Settings for OpenWebUI URL, model/contact, credentials, mic gain, vision-only contacts, attitude, and Reachy-side tools.
|
| 174 |
+
|
| 175 |
+
## Tools
|
| 176 |
+
|
| 177 |
+
Default Reachy-side tool allow-list:
|
| 178 |
+
|
| 179 |
+
```text
|
| 180 |
+
src/Reachy_OpenWebUI/profiles/default/tools.txt
|
| 181 |
+
```
|
| 182 |
+
|
| 183 |
+
Enabled by default:
|
| 184 |
+
|
| 185 |
+
| Tool | Behavior |
|
| 186 |
+
| --- | --- |
|
| 187 |
+
| `dance` | Queue a named or random dance from `reachy_mini_dances_library`. |
|
| 188 |
+
| `stop_dance` | Clear the dance queue. |
|
| 189 |
+
| `play_emotion` | Queue a recorded emotion from `reachy-mini-emotions-library`. |
|
| 190 |
+
| `stop_emotion` | Clear the emotion queue. |
|
| 191 |
+
| `camera` | Read the latest buffered camera frame and pass it to an optional vision manager. |
|
| 192 |
+
| `idle_do_nothing` | Explicit idle no-op for idle turns. |
|
| 193 |
+
| `head_tracking` | Attempt to toggle camera-worker head tracking. |
|
| 194 |
+
| `move_head` | Queue a head pose change: left, right, up, down, or front. |
|
| 195 |
+
| `speak` | Send text through the same OpenWebUI speech path used by normal assistant responses. |
|
| 196 |
+
|
| 197 |
+
The settings page can enable or disable Reachy-side tools. OpenWebUI tools are still managed in OpenWebUI.
|
| 198 |
+
|
| 199 |
+
## Attitudes
|
| 200 |
+
|
| 201 |
+
`Attitude` is one user-facing movement setting. It is designed to control:
|
| 202 |
+
|
| 203 |
+
- idle motion
|
| 204 |
+
- speech-reactive motion
|
| 205 |
+
- which dance and emotion moves are offered to the model
|
| 206 |
+
|
| 207 |
+
The goal is simple: a serious model should not move like a silly one, and a playful model should not be stuck with the same tiny idle pattern as a formal one.
|
| 208 |
+
|
| 209 |
+
## Chat State
|
| 210 |
+
|
| 211 |
+
OpenWebUI chat state is stored in the stable settings file and recorded in:
|
| 212 |
+
|
| 213 |
+
```text
|
| 214 |
+
~/.config/settings/openwebui_chat_history.json
|
| 215 |
+
```
|
| 216 |
+
|
| 217 |
+
The conversation window rolls over at local 7 AM. Callback and conversation selection clear or replace the saved chat id so OpenWebUI can attach the next turn to the right conversation.
|
| 218 |
+
|
| 219 |
+
## Vision Status
|
| 220 |
+
|
| 221 |
+
Camera frame buffering is bundled and enabled unless `--no-camera` is passed. The optional local vision manager expects `Reachy_OpenWebUI.vision.processors`, which is not present in this checkout. If that module is absent, startup logs a warning and the `camera` tool returns an unavailable-vision error.
|
| 222 |
+
|
| 223 |
+
## Project Layout
|
| 224 |
+
|
| 225 |
+
```text
|
| 226 |
+
app.py Hugging Face Space project page
|
| 227 |
+
pyproject.toml package metadata, dependencies, app entry points
|
| 228 |
+
requirements.txt Space-only dependency file
|
| 229 |
+
src/Reachy_OpenWebUI/main.py Reachy Mini App entry point
|
| 230 |
+
src/Reachy_OpenWebUI/console.py settings routes and media loop
|
| 231 |
+
src/Reachy_OpenWebUI/local/ OpenWebUI chat, speech, transcription, VAD
|
| 232 |
+
src/Reachy_OpenWebUI/tools/ Reachy-side tools
|
| 233 |
+
src/Reachy_OpenWebUI/static/ phone-style UI
|
| 234 |
+
src/Reachy_OpenWebUI/profiles/default/ default tool allow-list
|
| 235 |
+
external_content/ starter external profile/tool examples
|
| 236 |
+
```
|
| 237 |
+
|
| 238 |
+
## Development Checks
|
| 239 |
+
|
| 240 |
+
Run the checks available in this checkout:
|
| 241 |
+
|
| 242 |
+
```bash
|
| 243 |
+
uv run ruff check .
|
| 244 |
+
uv run ruff format . --check
|
| 245 |
+
uv run mypy --pretty --show-error-codes
|
| 246 |
+
uv run python -m compileall src app.py
|
| 247 |
+
```
|
| 248 |
+
|
| 249 |
+
There is no `tests/` directory in this checkout. Add focused tests when adding behavior that can be tested off-robot.
|
| 250 |
+
|
| 251 |
+
## License
|
| 252 |
+
|
| 253 |
+
Apache 2.0
|
app.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import base64
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
import gradio as gr
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
CSS = """
|
| 8 |
+
:root {
|
| 9 |
+
color-scheme: dark;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
.gradio-container {
|
| 13 |
+
min-height: 100vh;
|
| 14 |
+
background:
|
| 15 |
+
radial-gradient(circle at 18% 8%, rgba(0, 122, 204, 0.20), transparent 28rem),
|
| 16 |
+
radial-gradient(circle at 90% 16%, rgba(80, 180, 150, 0.12), transparent 24rem),
|
| 17 |
+
linear-gradient(145deg, #0b0f14 0%, #121821 48%, #070a0f 100%) !important;
|
| 18 |
+
color: #eef3f8;
|
| 19 |
+
font-family: Inter, Segoe UI, Arial, sans-serif;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
.main {
|
| 23 |
+
max-width: 1120px;
|
| 24 |
+
margin: 0 auto;
|
| 25 |
+
padding: 36px 20px 44px;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
.hero {
|
| 29 |
+
display: grid;
|
| 30 |
+
grid-template-columns: minmax(0, 1.25fr) minmax(280px, 0.75fr);
|
| 31 |
+
gap: 28px;
|
| 32 |
+
align-items: center;
|
| 33 |
+
padding: 24px 0 34px;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
.eyebrow {
|
| 37 |
+
margin: 0 0 12px;
|
| 38 |
+
color: #7bb9e8;
|
| 39 |
+
font-size: 0.78rem;
|
| 40 |
+
font-weight: 700;
|
| 41 |
+
letter-spacing: 0.12em;
|
| 42 |
+
text-transform: uppercase;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
.hero h1 {
|
| 46 |
+
margin: 0;
|
| 47 |
+
color: #ffffff;
|
| 48 |
+
font-size: clamp(2.5rem, 7vw, 5.4rem);
|
| 49 |
+
line-height: 0.94;
|
| 50 |
+
letter-spacing: 0;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
.lead {
|
| 54 |
+
max-width: 680px;
|
| 55 |
+
margin: 22px 0 0;
|
| 56 |
+
color: #b8c6d5;
|
| 57 |
+
font-size: 1.08rem;
|
| 58 |
+
line-height: 1.65;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
.hero-card,
|
| 62 |
+
.panel {
|
| 63 |
+
border: 1px solid rgba(180, 198, 213, 0.16);
|
| 64 |
+
border-radius: 8px;
|
| 65 |
+
background: rgba(13, 19, 28, 0.74);
|
| 66 |
+
box-shadow: 0 22px 70px rgba(0, 0, 0, 0.36);
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
.hero-card {
|
| 70 |
+
display: grid;
|
| 71 |
+
gap: 16px;
|
| 72 |
+
padding: 22px;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
.app-mark {
|
| 76 |
+
width: 90px;
|
| 77 |
+
height: 90px;
|
| 78 |
+
border-radius: 22px;
|
| 79 |
+
background: #0f1620;
|
| 80 |
+
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
.hero-card h2,
|
| 84 |
+
.panel h2 {
|
| 85 |
+
margin: 0;
|
| 86 |
+
color: #ffffff;
|
| 87 |
+
font-size: 1.15rem;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
.hero-card p,
|
| 91 |
+
.panel p {
|
| 92 |
+
margin: 0;
|
| 93 |
+
color: #b8c6d5;
|
| 94 |
+
line-height: 1.55;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
.chips {
|
| 98 |
+
display: flex;
|
| 99 |
+
flex-wrap: wrap;
|
| 100 |
+
gap: 8px;
|
| 101 |
+
margin-top: 4px;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
.chip {
|
| 105 |
+
border: 1px solid rgba(123, 185, 232, 0.28);
|
| 106 |
+
border-radius: 999px;
|
| 107 |
+
padding: 6px 10px;
|
| 108 |
+
color: #d9ecfb;
|
| 109 |
+
background: rgba(0, 122, 204, 0.14);
|
| 110 |
+
font-size: 0.86rem;
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
.grid {
|
| 114 |
+
display: grid;
|
| 115 |
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
| 116 |
+
gap: 14px;
|
| 117 |
+
margin-top: 18px;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
.panel {
|
| 121 |
+
padding: 18px;
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
.section-title {
|
| 125 |
+
margin: 36px 0 12px;
|
| 126 |
+
color: #ffffff;
|
| 127 |
+
font-size: 1.35rem;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
.flow {
|
| 131 |
+
display: grid;
|
| 132 |
+
gap: 8px;
|
| 133 |
+
padding: 18px;
|
| 134 |
+
border: 1px solid rgba(180, 198, 213, 0.14);
|
| 135 |
+
border-radius: 8px;
|
| 136 |
+
background: rgba(255, 255, 255, 0.045);
|
| 137 |
+
color: #d5e2ec;
|
| 138 |
+
font-family: Consolas, Monaco, monospace;
|
| 139 |
+
font-size: 0.92rem;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
.flow span {
|
| 143 |
+
display: block;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
.footer-note {
|
| 147 |
+
margin-top: 28px;
|
| 148 |
+
color: #8393a2;
|
| 149 |
+
font-size: 0.92rem;
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
@media (max-width: 820px) {
|
| 153 |
+
.hero,
|
| 154 |
+
.grid {
|
| 155 |
+
grid-template-columns: 1fr;
|
| 156 |
+
}
|
| 157 |
+
}
|
| 158 |
+
"""
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def _image_data_uri(path: str) -> str:
|
| 162 |
+
image_path = Path(path)
|
| 163 |
+
if not image_path.exists():
|
| 164 |
+
return ""
|
| 165 |
+
encoded = base64.b64encode(image_path.read_bytes()).decode("ascii")
|
| 166 |
+
return f"data:image/png;base64,{encoded}"
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
ICON_URI = _image_data_uri("src/Reachy_OpenWebUI/static/assets/reachy-conversation-app.png")
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
HTML = f"""
|
| 173 |
+
<main class="main">
|
| 174 |
+
<section class="hero">
|
| 175 |
+
<div>
|
| 176 |
+
<p class="eyebrow">Reachy Mini + OpenWebUI</p>
|
| 177 |
+
<h1>Reachy OpenWebUI</h1>
|
| 178 |
+
<p class="lead">
|
| 179 |
+
A phone-style conversation app for Reachy Mini. OpenWebUI handles the model,
|
| 180 |
+
transcription, user context, and TTS; the Reachy app handles the robot runtime,
|
| 181 |
+
settings UI, local tools, avatars, chat state, and movement.
|
| 182 |
+
</p>
|
| 183 |
+
</div>
|
| 184 |
+
<aside class="hero-card">
|
| 185 |
+
<img class="app-mark" src="{ICON_URI}" alt="">
|
| 186 |
+
<h2>Project Page</h2>
|
| 187 |
+
<p>
|
| 188 |
+
This Space is a lightweight overview. The real app runs in the Reachy Mini
|
| 189 |
+
app environment where it can access robot audio, camera, speaker, and motion.
|
| 190 |
+
</p>
|
| 191 |
+
<div class="chips">
|
| 192 |
+
<span class="chip">Phone UI</span>
|
| 193 |
+
<span class="chip">OpenWebUI TTS</span>
|
| 194 |
+
<span class="chip">Robot tools</span>
|
| 195 |
+
<span class="chip">Attitudes</span>
|
| 196 |
+
</div>
|
| 197 |
+
</aside>
|
| 198 |
+
</section>
|
| 199 |
+
|
| 200 |
+
<h2 class="section-title">What It Does</h2>
|
| 201 |
+
<section class="grid">
|
| 202 |
+
<div class="panel">
|
| 203 |
+
<h2>Contacts</h2>
|
| 204 |
+
<p>OpenWebUI models and workspaces appear as phone contacts with cached avatars.</p>
|
| 205 |
+
</div>
|
| 206 |
+
<div class="panel">
|
| 207 |
+
<h2>Conversations</h2>
|
| 208 |
+
<p>Saved OpenWebUI chat ids can be selected from a phone-style conversation list.</p>
|
| 209 |
+
</div>
|
| 210 |
+
<div class="panel">
|
| 211 |
+
<h2>Motion</h2>
|
| 212 |
+
<p>Speech-reactive head movement blends with Reachy-side dances and recorded emotions.</p>
|
| 213 |
+
</div>
|
| 214 |
+
</section>
|
| 215 |
+
|
| 216 |
+
<h2 class="section-title">Runtime Flow</h2>
|
| 217 |
+
<div class="flow" aria-label="Runtime flow">
|
| 218 |
+
<span>Reachy microphone</span>
|
| 219 |
+
<span>-> VAD and OpenWebUI transcription</span>
|
| 220 |
+
<span>-> OpenWebUI chat completion with Reachy tool specs</span>
|
| 221 |
+
<span>-> optional local Reachy tool execution</span>
|
| 222 |
+
<span>-> OpenWebUI speech</span>
|
| 223 |
+
<span>-> Reachy speaker output plus motion</span>
|
| 224 |
+
</div>
|
| 225 |
+
|
| 226 |
+
<p class="footer-note">
|
| 227 |
+
Install from the repository and launch through Reachy Mini Apps for the full robot runtime.
|
| 228 |
+
This hosted Space intentionally does not attempt to control hardware.
|
| 229 |
+
</p>
|
| 230 |
+
</main>
|
| 231 |
+
"""
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
with gr.Blocks(title="Reachy OpenWebUI") as demo:
|
| 235 |
+
gr.HTML(HTML)
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
if __name__ == "__main__":
|
| 239 |
+
demo.launch(css=CSS)
|
external_content/external_profiles/starter_profile/instructions.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
You are a helpful Reachy Mini assistant running from an external profile.
|
| 2 |
+
|
| 3 |
+
When asked to demonstrate your custom greeting, use the `starter_custom_tool` tool.
|
| 4 |
+
You can also dance and show emotions like the built-in profiles.
|
| 5 |
+
|
| 6 |
+
Be friendly and concise, and explain that you're using an external profile/tool setup when asked about yourself.
|
external_content/external_profiles/starter_profile/tools.txt
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This file is an explicit allow-list.
|
| 2 |
+
# Every tool name listed below must be either:
|
| 3 |
+
# - a built-in tool from src/Reachy_OpenWebUI/tools/
|
| 4 |
+
# - or an external tool file in TOOLS_DIRECTORY (e.g. external_tools/starter_custom_tool.py)
|
| 5 |
+
|
| 6 |
+
dance
|
| 7 |
+
stop_dance
|
| 8 |
+
play_emotion
|
| 9 |
+
stop_emotion
|
| 10 |
+
move_head
|
| 11 |
+
starter_custom_tool
|
| 12 |
+
|
external_content/external_tools/starter_custom_tool.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Example external tool implementation."""
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
from typing import Any, Dict
|
| 5 |
+
|
| 6 |
+
from Reachy_OpenWebUI.tools.core_tools import Tool, ToolDependencies
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class StarterCustomTool(Tool):
|
| 13 |
+
"""Placeholder custom tool - demonstrates external tool loading."""
|
| 14 |
+
|
| 15 |
+
name = "starter_custom_tool"
|
| 16 |
+
description = "A placeholder custom tool loaded from outside the library"
|
| 17 |
+
parameters_schema = {
|
| 18 |
+
"type": "object",
|
| 19 |
+
"properties": {
|
| 20 |
+
"message": {
|
| 21 |
+
"type": "string",
|
| 22 |
+
"description": "Optional message to include in the response",
|
| 23 |
+
},
|
| 24 |
+
},
|
| 25 |
+
"required": [],
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
async def __call__(self, deps: ToolDependencies, **kwargs: Any) -> Dict[str, Any]:
|
| 29 |
+
"""Execute the placeholder tool."""
|
| 30 |
+
message = kwargs.get("message", "Hello from custom tool!")
|
| 31 |
+
logger.info(f"Tool call: starter_custom_tool message={message}")
|
| 32 |
+
|
| 33 |
+
return {"status": "success", "message": message}
|
| 34 |
+
|
index.html
CHANGED
|
@@ -1,19 +1,125 @@
|
|
| 1 |
-
<!doctype html>
|
| 2 |
-
<html>
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
</
|
| 9 |
-
<
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
</
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html>
|
| 3 |
+
|
| 4 |
+
<head>
|
| 5 |
+
<meta charset="utf-8" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 7 |
+
<title>Reachy Mini Conversation OpenWebUI</title>
|
| 8 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 9 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 10 |
+
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Manrope:wght@400;500;600&display=swap" rel="stylesheet">
|
| 11 |
+
<link rel="stylesheet" href="style.css" />
|
| 12 |
+
</head>
|
| 13 |
+
|
| 14 |
+
<body>
|
| 15 |
+
<header class="hero">
|
| 16 |
+
<div class="topline">
|
| 17 |
+
<div class="brand">
|
| 18 |
+
<span class="logo">🤖</span>
|
| 19 |
+
<span class="brand-name">Reachy Mini</span>
|
| 20 |
+
</div>
|
| 21 |
+
<div class="pill">OpenWebUI managed voice and personality · No wake word</div>
|
| 22 |
+
</div>
|
| 23 |
+
<div class="hero-grid">
|
| 24 |
+
<div class="hero-copy">
|
| 25 |
+
<p class="eyebrow">Local Conversation App</p>
|
| 26 |
+
<h1>Run Reachy Mini conversations on your own stack.</h1>
|
| 27 |
+
<p class="lede">
|
| 28 |
+
An OpenWebUI-first, camera-aware companion for Reachy Mini. Use OpenWebUI workspaces/models for personality and voice behavior, with VAD listening without a wake word.
|
| 29 |
+
</p>
|
| 30 |
+
<div class="hero-actions">
|
| 31 |
+
<a class="btn primary" href="#highlights">Explore features</a>
|
| 32 |
+
<a class="btn ghost" href="#story">See how it feels</a>
|
| 33 |
+
</div>
|
| 34 |
+
<div class="hero-badges">
|
| 35 |
+
<span>OpenWebUI model dropdown</span>
|
| 36 |
+
<span>OpenWebUI-managed TTS</span>
|
| 37 |
+
<span>No-wake-word VAD</span>
|
| 38 |
+
<span>Workspace-managed personalities</span>
|
| 39 |
+
</div>
|
| 40 |
+
</div>
|
| 41 |
+
<div class="hero-visual">
|
| 42 |
+
<div class="glass-card">
|
| 43 |
+
<img src="docs/assets/reachy_mini_dance.gif" alt="Reachy Mini dancing" class="hero-gif">
|
| 44 |
+
<p class="caption">Reachy Mini can move, dance, and emote while holding a natural conversation.</p>
|
| 45 |
+
</div>
|
| 46 |
+
</div>
|
| 47 |
+
</div>
|
| 48 |
+
</header>
|
| 49 |
+
|
| 50 |
+
<section id="highlights" class="section features">
|
| 51 |
+
<div class="section-header">
|
| 52 |
+
<p class="eyebrow">What’s inside</p>
|
| 53 |
+
<h2>All-in-one conversational layer for your robot</h2>
|
| 54 |
+
<p class="intro">
|
| 55 |
+
The app blends local speech, vision, and motion so Reachy Mini feels present.
|
| 56 |
+
</p>
|
| 57 |
+
</div>
|
| 58 |
+
<div class="feature-grid">
|
| 59 |
+
<div class="feature-card">
|
| 60 |
+
<span class="icon">🎤</span>
|
| 61 |
+
<h3>Natural voice chat</h3>
|
| 62 |
+
<p>Talk freely and get replies from your configured local chat-completions model.</p>
|
| 63 |
+
</div>
|
| 64 |
+
<div class="feature-card">
|
| 65 |
+
<span class="icon">🎥</span>
|
| 66 |
+
<h3>Vision-aware replies</h3>
|
| 67 |
+
<p>Ask the camera tool to see what’s in front, track a face, or keep attention on whoever is speaking.</p>
|
| 68 |
+
</div>
|
| 69 |
+
<div class="feature-card">
|
| 70 |
+
<span class="icon">💃</span>
|
| 71 |
+
<h3>Expressive motion</h3>
|
| 72 |
+
<p>Queue dances, play recorded emotions while Reachy listens and talks.</p>
|
| 73 |
+
</div>
|
| 74 |
+
<div class="feature-card">
|
| 75 |
+
<span class="icon">🧠</span>
|
| 76 |
+
<h3>Personalities on demand</h3>
|
| 77 |
+
<p>Switch conversation styles in OpenWebUI workspaces/models while the bot exposes tools like dance, camera, and tracking.</p>
|
| 78 |
+
</div>
|
| 79 |
+
<div class="feature-card">
|
| 80 |
+
<span class="icon">🌐</span>
|
| 81 |
+
<h3>Ready for your setup</h3>
|
| 82 |
+
<p>Works with wired or wireless Reachy Mini, and can run vision locally or through the default cloud model.</p>
|
| 83 |
+
</div>
|
| 84 |
+
</div>
|
| 85 |
+
</section>
|
| 86 |
+
|
| 87 |
+
<section id="story" class="section story">
|
| 88 |
+
<div class="story-grid">
|
| 89 |
+
<div class="story-card">
|
| 90 |
+
<p class="eyebrow">How it feels</p>
|
| 91 |
+
<h3>From hello to helpful in seconds</h3>
|
| 92 |
+
<ul class="story-list">
|
| 93 |
+
<li><span>👋</span> Say “Hey Reachy” and start chatting—no extra setup in the moment.</li>
|
| 94 |
+
<li><span>👀</span> Ask what it sees; it can peek through the camera or keep focus on your face.</li>
|
| 95 |
+
<li><span>🎭</span> Trigger emotions or dance breaks to keep the conversation lively.</li>
|
| 96 |
+
<li><span>📝</span> Follow along with live transcripts in the web UI or run audio-only from the console.</li>
|
| 97 |
+
</ul>
|
| 98 |
+
</div>
|
| 99 |
+
<div class="story-card secondary">
|
| 100 |
+
<p class="eyebrow">Where it shines</p>
|
| 101 |
+
<h3>Great for demos, teaching, and playful exploration</h3>
|
| 102 |
+
<p class="story-text">
|
| 103 |
+
Show off how Reachy Mini listens, responds, and moves in sync. Whether you’re guiding a class, hosting a booth, or experimenting at home, the app keeps the robot expressive without juggling scripts or joystick controls.
|
| 104 |
+
</p>
|
| 105 |
+
<div class="chips">
|
| 106 |
+
<span class="chip">Live conversation</span>
|
| 107 |
+
<span class="chip">Face tracking</span>
|
| 108 |
+
<span class="chip">Camera tool</span>
|
| 109 |
+
<span class="chip">Dance library</span>
|
| 110 |
+
<span class="chip">Profiles & tools</span>
|
| 111 |
+
</div>
|
| 112 |
+
</div>
|
| 113 |
+
</div>
|
| 114 |
+
</section>
|
| 115 |
+
|
| 116 |
+
<footer class="footer">
|
| 117 |
+
<p>
|
| 118 |
+
Reachy Mini Conversation OpenWebUI, based on the Reachy Mini app ecosystem.
|
| 119 |
+
Explore more apps on <a href="https://huggingface.co/spaces/pollen-robotics/Reachy_Mini_Apps" target="_blank" rel="noopener">Hugging Face Spaces</a>.
|
| 120 |
+
</p>
|
| 121 |
+
</footer>
|
| 122 |
+
|
| 123 |
+
</body>
|
| 124 |
+
|
| 125 |
+
</html>
|
pyproject.toml
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["setuptools"]
|
| 3 |
+
build-backend = "setuptools.build_meta"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "Reachy_OpenWebUI"
|
| 7 |
+
version = "0.6.4.86"
|
| 8 |
+
authors = [{ name = "Jacid23" }]
|
| 9 |
+
description = "OpenWebUI-first Reachy Mini conversation app using OpenWebUI-managed STT, chat, workspace tools, and TTS."
|
| 10 |
+
readme = "README.md"
|
| 11 |
+
requires-python = ">=3.10"
|
| 12 |
+
dependencies = [
|
| 13 |
+
#Media
|
| 14 |
+
"av",
|
| 15 |
+
"aiortc>=1.13.0",
|
| 16 |
+
"fastrtc>=0.0.34",
|
| 17 |
+
"gradio==5.50.1.dev1",
|
| 18 |
+
"huggingface-hub==1.3.0",
|
| 19 |
+
"httpx",
|
| 20 |
+
"numpy",
|
| 21 |
+
"onnxruntime",
|
| 22 |
+
"opencv-python>=4.12.0.88",
|
| 23 |
+
"zxing-cpp>=2.3.0",
|
| 24 |
+
|
| 25 |
+
#Environment variables
|
| 26 |
+
"python-dotenv",
|
| 27 |
+
|
| 28 |
+
# Client library used for local Ollama/vLLM/OpenWebUI chat endpoints
|
| 29 |
+
"openai>=2.30.0",
|
| 30 |
+
|
| 31 |
+
#Reachy mini
|
| 32 |
+
"reachy_mini_dances_library",
|
| 33 |
+
"reachy_mini_toolbox",
|
| 34 |
+
"reachy-mini>=1.7.1",
|
| 35 |
+
"gradio_client>=1.13.3",
|
| 36 |
+
|
| 37 |
+
#Required at import/runtime by camera_worker, local audio, and local pipeline
|
| 38 |
+
"scipy",
|
| 39 |
+
|
| 40 |
+
#OpenWebUI pipeline (VAD/audio upload/chat/TTS). Torch/Silero are optional at runtime
|
| 41 |
+
# because Reachy's shared venv already owns torch, and fresh torch pulls can
|
| 42 |
+
# drag in giant CUDA/triton wheels.
|
| 43 |
+
"soundfile",
|
| 44 |
+
"setuptools<82",
|
| 45 |
+
]
|
| 46 |
+
|
| 47 |
+
[project.optional-dependencies]
|
| 48 |
+
local_vision = [
|
| 49 |
+
"accelerate>=1.13.0; platform_machine != 'aarch64' and platform_machine != 'arm64'",
|
| 50 |
+
"torch>=2.1; platform_machine != 'aarch64' and platform_machine != 'arm64'",
|
| 51 |
+
"transformers==5.3.0; platform_machine != 'aarch64' and platform_machine != 'arm64'",
|
| 52 |
+
"num2words",
|
| 53 |
+
]
|
| 54 |
+
|
| 55 |
+
[dependency-groups]
|
| 56 |
+
dev = [
|
| 57 |
+
"pytest",
|
| 58 |
+
"pytest-asyncio",
|
| 59 |
+
"ruff==0.12.0",
|
| 60 |
+
"mypy==1.18.2",
|
| 61 |
+
"pre-commit",
|
| 62 |
+
"types-requests",
|
| 63 |
+
"python-semantic-release>=10.5.3",
|
| 64 |
+
]
|
| 65 |
+
|
| 66 |
+
[project.scripts]
|
| 67 |
+
reachy-mini-conversation-app-openwebui = "Reachy_OpenWebUI.main:main"
|
| 68 |
+
|
| 69 |
+
[tool.uv]
|
| 70 |
+
dependency-metadata = [
|
| 71 |
+
# Upstream metadata currently marks `gstreamer-msvc-runtime` as unconditional.
|
| 72 |
+
# It should only be required on Windows. Temp fix.
|
| 73 |
+
{ name = "gstreamer-libs", version = "1.28.1", requires-dist = ["gstreamer-msvc-runtime; sys_platform == 'win32'", "setuptools"] },
|
| 74 |
+
]
|
| 75 |
+
|
| 76 |
+
[project.entry-points."reachy_mini_apps"]
|
| 77 |
+
Reachy_OpenWebUI = "Reachy_OpenWebUI.main:ReachyMiniConversationOpenWebUIApp"
|
| 78 |
+
|
| 79 |
+
[tool.setuptools]
|
| 80 |
+
package-dir = { "" = "src" }
|
| 81 |
+
include-package-data = true
|
| 82 |
+
|
| 83 |
+
[tool.setuptools.packages.find]
|
| 84 |
+
where = ["src"]
|
| 85 |
+
|
| 86 |
+
[tool.setuptools.package-data]
|
| 87 |
+
Reachy_OpenWebUI = [
|
| 88 |
+
"images/*",
|
| 89 |
+
"static/*",
|
| 90 |
+
"static/assets/*",
|
| 91 |
+
"sub_apps/cookAIware/data/*.json",
|
| 92 |
+
"sub_apps/cookAIware/profiles/**/*.txt",
|
| 93 |
+
"sub_apps/cookAIware/profiles/**/*.py",
|
| 94 |
+
".env.example",
|
| 95 |
+
"demos/**/*.txt",
|
| 96 |
+
"profiles/**/*.txt",
|
| 97 |
+
"profiles/**/*.py",
|
| 98 |
+
]
|
| 99 |
+
reachy_talk_data = [
|
| 100 |
+
"profiles/**/*.txt",
|
| 101 |
+
"profiles/**/*.py",
|
| 102 |
+
]
|
| 103 |
+
|
| 104 |
+
[tool.ruff]
|
| 105 |
+
line-length = 119
|
| 106 |
+
exclude = [".venv", "dist", "build", "**/__pycache__", "*.egg-info", ".mypy_cache", ".pytest_cache"]
|
| 107 |
+
|
| 108 |
+
[tool.ruff.lint]
|
| 109 |
+
select = [
|
| 110 |
+
"E", # pycodestyle errors
|
| 111 |
+
"F", # pyflakes
|
| 112 |
+
"W", # pycodestyle warnings
|
| 113 |
+
"I", # isort
|
| 114 |
+
"C4", # flake8-comprehensions
|
| 115 |
+
"D", # pydocstyle
|
| 116 |
+
]
|
| 117 |
+
ignore = [
|
| 118 |
+
"E501", # handled by formatter
|
| 119 |
+
"D100", # ignore missing module docstrings
|
| 120 |
+
"D203", # blank line before class docstring (conflicts with D211)
|
| 121 |
+
"D213", # summary on second line (conflicts with D212)
|
| 122 |
+
]
|
| 123 |
+
|
| 124 |
+
[tool.ruff.lint.isort]
|
| 125 |
+
length-sort = true
|
| 126 |
+
lines-after-imports = 2
|
| 127 |
+
no-lines-before = ["standard-library", "local-folder"]
|
| 128 |
+
known-local-folder = ["Reachy_OpenWebUI"]
|
| 129 |
+
known-first-party = ["reachy_mini", "reachy_mini_dances_library", "reachy_mini_toolbox"]
|
| 130 |
+
split-on-trailing-comma = true
|
| 131 |
+
|
| 132 |
+
[tool.ruff.format]
|
| 133 |
+
quote-style = "double"
|
| 134 |
+
indent-style = "space"
|
| 135 |
+
skip-magic-trailing-comma = false
|
| 136 |
+
line-ending = "auto"
|
| 137 |
+
|
| 138 |
+
[tool.mypy]
|
| 139 |
+
python_version = "3.12"
|
| 140 |
+
files = ["src/"]
|
| 141 |
+
ignore_missing_imports = true
|
| 142 |
+
strict = true
|
| 143 |
+
show_error_codes = true
|
| 144 |
+
warn_unused_ignores = true
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
|
requirements.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Hugging Face Spaces provides Gradio from the README Space SDK config.
|
setup.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
from shutil import copytree
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
from setuptools import setup
|
| 6 |
+
from setuptools.command.build_py import build_py
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
PROJECT_ROOT = Path(__file__).parent.resolve()
|
| 10 |
+
SOURCE_PROFILES_DIR = PROJECT_ROOT / "src" / "Reachy_OpenWebUI" / "profiles"
|
| 11 |
+
TARGET_PACKAGE = "reachy_talk_data"
|
| 12 |
+
TARGET_SUBDIR = "profiles"
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class BuildPyWithProfiles(build_py):
|
| 16 |
+
"""Copy built-in profiles into the wheel data package at build time."""
|
| 17 |
+
|
| 18 |
+
def run(self) -> None:
|
| 19 |
+
"""Build Python modules, then copy root-level profiles into reachy_talk_data."""
|
| 20 |
+
super().run()
|
| 21 |
+
|
| 22 |
+
target_root = Path(self.build_lib) / TARGET_PACKAGE / TARGET_SUBDIR
|
| 23 |
+
copytree(SOURCE_PROFILES_DIR, target_root, dirs_exist_ok=True)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
setup(
|
| 27 |
+
cmdclass={"build_py": BuildPyWithProfiles},
|
| 28 |
+
)
|
src/Reachy_OpenWebUI/.env.example
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# =============================================================================
|
| 2 |
+
# LLM Configuration (Judgy Reachy local OpenWebUI)
|
| 3 |
+
# =============================================================================
|
| 4 |
+
# OpenWebUI native API base. The client posts to /api/chat/completions.
|
| 5 |
+
OPENWEBUI_URL="http://localhost:3001/api"
|
| 6 |
+
|
| 7 |
+
# Model name as known by your LLM server
|
| 8 |
+
OPENWEBUI_MODEL="reachy-convo"
|
| 9 |
+
|
| 10 |
+
# API key for your LLM server. Leave as ollama unless OpenWebUI auth is enabled.
|
| 11 |
+
OPENWEBUI_API_KEY="ollama"
|
| 12 |
+
|
| 13 |
+
# =============================================================================
|
| 14 |
+
# Speech-to-Text
|
| 15 |
+
# =============================================================================
|
| 16 |
+
# Faster-Whisper model size: tiny.en, small.en, medium.en, large-v3
|
| 17 |
+
LOCAL_STT_MODEL="small.en"
|
| 18 |
+
|
| 19 |
+
# Digital gain applied to microphone input (e.g., 2.0 to double volume)
|
| 20 |
+
MIC_GAIN=1.0
|
| 21 |
+
|
| 22 |
+
# No-wake-word VAD tuning. Each chunk is about 32 ms at 16 kHz.
|
| 23 |
+
VAD_THRESHOLD=0.50
|
| 24 |
+
VAD_FALLBACK_THRESHOLD=0.012
|
| 25 |
+
VAD_SPEECH_ONSET_CHUNKS=4
|
| 26 |
+
VAD_SILENCE_END_CHUNKS=55
|
| 27 |
+
VAD_MIN_SPEECH_CHUNKS=10
|
| 28 |
+
WEBRTC_VAD_AGGRESSIVENESS=2
|
| 29 |
+
SILERO_VAD_ONNX_PATH=
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
# =============================================================================
|
| 33 |
+
# Vision
|
| 34 |
+
# =============================================================================
|
| 35 |
+
# Local vision model (only used with --smolvlm CLI flag)
|
| 36 |
+
LOCAL_VISION_MODEL=HuggingFaceTB/SmolVLM2-2.2B-Instruct
|
| 37 |
+
|
| 38 |
+
# Cache directory for downloaded models
|
| 39 |
+
HF_HOME=./cache
|
| 40 |
+
|
| 41 |
+
# Hugging Face token (optional, falls back to `hf auth login`)
|
| 42 |
+
HF_TOKEN=
|
| 43 |
+
|
| 44 |
+
|
src/Reachy_OpenWebUI/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Nothing (for ruff)."""
|
src/Reachy_OpenWebUI/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (174 Bytes). View file
|
|
|
src/Reachy_OpenWebUI/__pycache__/app_profiles.cpython-311.pyc
ADDED
|
Binary file (2.95 kB). View file
|
|
|
src/Reachy_OpenWebUI/__pycache__/camera_frame_encoding.cpython-311.pyc
ADDED
|
Binary file (1.93 kB). View file
|
|
|
src/Reachy_OpenWebUI/__pycache__/camera_worker.cpython-311.pyc
ADDED
|
Binary file (4.39 kB). View file
|
|
|
src/Reachy_OpenWebUI/__pycache__/chat_history.cpython-311.pyc
ADDED
|
Binary file (3.56 kB). View file
|
|
|
src/Reachy_OpenWebUI/__pycache__/config.cpython-311.pyc
ADDED
|
Binary file (8.46 kB). View file
|
|
|
src/Reachy_OpenWebUI/__pycache__/console.cpython-311.pyc
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:385ef54b921814f767d47368f47749120f1eb632ecbe9929f196b06547d1a1a3
|
| 3 |
+
size 149921
|
src/Reachy_OpenWebUI/__pycache__/dance_emotion_moves.cpython-311.pyc
ADDED
|
Binary file (8.31 kB). View file
|
|
|
src/Reachy_OpenWebUI/__pycache__/main.cpython-311.pyc
ADDED
|
Binary file (9.18 kB). View file
|
|
|
src/Reachy_OpenWebUI/__pycache__/motion_patterns.cpython-311.pyc
ADDED
|
Binary file (8.26 kB). View file
|
|
|
src/Reachy_OpenWebUI/__pycache__/moves.cpython-311.pyc
ADDED
|
Binary file (44.9 kB). View file
|
|
|
src/Reachy_OpenWebUI/__pycache__/settings_keys.cpython-311.pyc
ADDED
|
Binary file (1.87 kB). View file
|
|
|
src/Reachy_OpenWebUI/__pycache__/utils.cpython-311.pyc
ADDED
|
Binary file (6.21 kB). View file
|
|
|
src/Reachy_OpenWebUI/app_profiles.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Profile source registry for app-owned tool and profile files.
|
| 2 |
+
|
| 3 |
+
Shared loaders should ask this module where a profile lives instead of hardcoding
|
| 4 |
+
mini-app package names in core runtime code.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from dataclasses import dataclass
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
DEFAULT_PROFILE = "default"
|
| 14 |
+
COOKAIWARE_PROFILE = "cookaiware"
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@dataclass(frozen=True)
|
| 18 |
+
class ToolProfileSource:
|
| 19 |
+
"""Locations used to load a profile's tool list and tool modules."""
|
| 20 |
+
|
| 21 |
+
profile: str
|
| 22 |
+
profile_path: Path
|
| 23 |
+
profile_package: str
|
| 24 |
+
shared_tool_packages: tuple[str, ...]
|
| 25 |
+
|
| 26 |
+
@property
|
| 27 |
+
def tools_txt_path(self) -> Path:
|
| 28 |
+
return self.profile_path / "tools.txt"
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
_PACKAGE_ROOT = Path(__file__).parent
|
| 32 |
+
|
| 33 |
+
_PROFILE_SOURCES: dict[str, ToolProfileSource] = {
|
| 34 |
+
DEFAULT_PROFILE: ToolProfileSource(
|
| 35 |
+
profile=DEFAULT_PROFILE,
|
| 36 |
+
profile_path=_PACKAGE_ROOT / "profiles" / DEFAULT_PROFILE,
|
| 37 |
+
profile_package="Reachy_OpenWebUI.profiles.default",
|
| 38 |
+
shared_tool_packages=("Reachy_OpenWebUI.tools",),
|
| 39 |
+
),
|
| 40 |
+
COOKAIWARE_PROFILE: ToolProfileSource(
|
| 41 |
+
profile=COOKAIWARE_PROFILE,
|
| 42 |
+
profile_path=_PACKAGE_ROOT / "sub_apps" / "cookAIware" / "profiles" / COOKAIWARE_PROFILE,
|
| 43 |
+
profile_package="Reachy_OpenWebUI.sub_apps.cookAIware.profiles.cookaiware",
|
| 44 |
+
shared_tool_packages=("Reachy_OpenWebUI.sub_apps.cookAIware.tools",),
|
| 45 |
+
),
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def resolve_tool_profile_source(profile: str | None) -> ToolProfileSource:
|
| 50 |
+
"""Return the registered tool/profile source for the selected mode."""
|
| 51 |
+
selected = (profile or DEFAULT_PROFILE).strip() or DEFAULT_PROFILE
|
| 52 |
+
return _PROFILE_SOURCES.get(selected) or ToolProfileSource(
|
| 53 |
+
profile=selected,
|
| 54 |
+
profile_path=_PACKAGE_ROOT / "profiles" / selected,
|
| 55 |
+
profile_package=f"Reachy_OpenWebUI.profiles.{selected}",
|
| 56 |
+
shared_tool_packages=("Reachy_OpenWebUI.tools",),
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def registered_tool_profiles() -> tuple[str, ...]:
|
| 61 |
+
"""Return known profile ids for settings/status UIs."""
|
| 62 |
+
return tuple(_PROFILE_SOURCES)
|
src/Reachy_OpenWebUI/audio/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Nothing (for ruff)."""
|
src/Reachy_OpenWebUI/audio/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (180 Bytes). View file
|
|
|
src/Reachy_OpenWebUI/audio/__pycache__/attitude_wobbler.cpython-311.pyc
ADDED
|
Binary file (15 kB). View file
|
|
|
src/Reachy_OpenWebUI/audio/__pycache__/head_wobbler.cpython-311.pyc
ADDED
|
Binary file (12.8 kB). View file
|
|
|
src/Reachy_OpenWebUI/audio/__pycache__/speech_tapper.cpython-311.pyc
ADDED
|
Binary file (13.7 kB). View file
|
|
|
src/Reachy_OpenWebUI/audio/__pycache__/startup_config.cpython-311.pyc
ADDED
|
Binary file (3.7 kB). View file
|
|
|
src/Reachy_OpenWebUI/audio/attitude_wobbler.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Per-attitude speech wobble — extends HeadWobbler with axis-specific scaling and offsets.
|
| 2 |
+
|
| 3 |
+
Originals (speech_tapper.py, head_wobbler.py) are untouched.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import math
|
| 9 |
+
import time
|
| 10 |
+
import queue
|
| 11 |
+
import logging
|
| 12 |
+
import threading
|
| 13 |
+
from typing import Tuple
|
| 14 |
+
from collections.abc import Callable
|
| 15 |
+
|
| 16 |
+
import numpy as np
|
| 17 |
+
from numpy.typing import NDArray
|
| 18 |
+
|
| 19 |
+
from Reachy_OpenWebUI.audio.speech_tapper import SwayRollRT, HOP_MS
|
| 20 |
+
from Reachy_OpenWebUI.sub_apps.attitudes import AttitudeProfile, resolve_attitude
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
SAMPLE_RATE = 24000
|
| 24 |
+
MOVEMENT_LATENCY_S = 0.2
|
| 25 |
+
logger = logging.getLogger(__name__)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class AttitudeSwayRT(SwayRollRT):
|
| 29 |
+
"""SwayRollRT that also exposes the speech envelope in each hop dict."""
|
| 30 |
+
|
| 31 |
+
def feed(self, pcm, sr=None):
|
| 32 |
+
results = super().feed(pcm, sr)
|
| 33 |
+
for r in results:
|
| 34 |
+
r["env"] = self.sway_env
|
| 35 |
+
return results
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class AttitudeHeadWobbler:
|
| 39 |
+
"""HeadWobbler with per-attitude axis scales and speech-time pose offsets.
|
| 40 |
+
|
| 41 |
+
Drop-in replacement: same public API as HeadWobbler.
|
| 42 |
+
"""
|
| 43 |
+
|
| 44 |
+
def __init__(
|
| 45 |
+
self,
|
| 46 |
+
set_speech_offsets: Callable[[Tuple[float, float, float, float, float, float]], None],
|
| 47 |
+
attitude: str = "calm",
|
| 48 |
+
) -> None:
|
| 49 |
+
self._apply_offsets = set_speech_offsets
|
| 50 |
+
self._base_ts: float | None = None
|
| 51 |
+
self._hops_done: int = 0
|
| 52 |
+
|
| 53 |
+
self.audio_queue: "queue.Queue[Tuple[int, int, NDArray[np.int16]]]" = queue.Queue()
|
| 54 |
+
self.sway = AttitudeSwayRT()
|
| 55 |
+
|
| 56 |
+
self._state_lock = threading.Lock()
|
| 57 |
+
self._sway_lock = threading.Lock()
|
| 58 |
+
self._generation = 0
|
| 59 |
+
|
| 60 |
+
profile = resolve_attitude(attitude)
|
| 61 |
+
self._scale = profile.speaking_motion_scale
|
| 62 |
+
self._pitch_scale = profile.speaking_pitch_scale
|
| 63 |
+
self._yaw_scale = profile.speaking_yaw_scale
|
| 64 |
+
self._roll_scale = profile.speaking_roll_scale
|
| 65 |
+
self._x_scale = profile.speaking_x_scale
|
| 66 |
+
self._y_scale = profile.speaking_y_scale
|
| 67 |
+
self._z_scale = profile.speaking_z_scale
|
| 68 |
+
self._pitch_offset = math.radians(profile.speaking_pitch_offset_deg)
|
| 69 |
+
self._z_offset = profile.speaking_z_offset_m
|
| 70 |
+
|
| 71 |
+
self._stop_event = threading.Event()
|
| 72 |
+
self._thread: threading.Thread | None = None
|
| 73 |
+
|
| 74 |
+
def feed(self, delta_b64: str) -> None:
|
| 75 |
+
import base64
|
| 76 |
+
buf = np.frombuffer(base64.b64decode(delta_b64), dtype=np.int16)
|
| 77 |
+
self.feed_pcm(buf, SAMPLE_RATE)
|
| 78 |
+
|
| 79 |
+
def feed_pcm(self, pcm: NDArray[np.int16], sample_rate: int = SAMPLE_RATE) -> None:
|
| 80 |
+
buf = np.asarray(pcm, dtype=np.int16).reshape(1, -1)
|
| 81 |
+
if buf.size == 0:
|
| 82 |
+
return
|
| 83 |
+
with self._state_lock:
|
| 84 |
+
generation = self._generation
|
| 85 |
+
self.audio_queue.put((generation, int(sample_rate or SAMPLE_RATE), buf))
|
| 86 |
+
|
| 87 |
+
def start(self) -> None:
|
| 88 |
+
self._stop_event.clear()
|
| 89 |
+
self._thread = threading.Thread(target=self.working_loop, daemon=True)
|
| 90 |
+
self._thread.start()
|
| 91 |
+
logger.debug("Attitude head wobbler started")
|
| 92 |
+
|
| 93 |
+
def set_attitude(self, attitude: str) -> None:
|
| 94 |
+
profile = resolve_attitude(attitude)
|
| 95 |
+
with self._state_lock:
|
| 96 |
+
self._scale = profile.speaking_motion_scale
|
| 97 |
+
self._pitch_scale = profile.speaking_pitch_scale
|
| 98 |
+
self._yaw_scale = profile.speaking_yaw_scale
|
| 99 |
+
self._roll_scale = profile.speaking_roll_scale
|
| 100 |
+
self._x_scale = profile.speaking_x_scale
|
| 101 |
+
self._y_scale = profile.speaking_y_scale
|
| 102 |
+
self._z_scale = profile.speaking_z_scale
|
| 103 |
+
self._pitch_offset = math.radians(profile.speaking_pitch_offset_deg)
|
| 104 |
+
self._z_offset = profile.speaking_z_offset_m
|
| 105 |
+
logger.info(
|
| 106 |
+
"Speech attitude set to %s (scale=%.2f, pitch_off=%.1f°, z_off=%.3fm)",
|
| 107 |
+
profile.name, profile.speaking_motion_scale,
|
| 108 |
+
profile.speaking_pitch_offset_deg, profile.speaking_z_offset_m,
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
def stop(self) -> None:
|
| 112 |
+
self._stop_event.set()
|
| 113 |
+
if self._thread is not None:
|
| 114 |
+
self._thread.join()
|
| 115 |
+
logger.debug("Attitude head wobbler stopped")
|
| 116 |
+
|
| 117 |
+
def working_loop(self) -> None:
|
| 118 |
+
hop_dt = HOP_MS / 1000.0
|
| 119 |
+
|
| 120 |
+
logger.debug("Attitude head wobbler thread started")
|
| 121 |
+
while not self._stop_event.is_set():
|
| 122 |
+
queue_ref = self.audio_queue
|
| 123 |
+
try:
|
| 124 |
+
chunk_generation, sr, chunk = queue_ref.get_nowait()
|
| 125 |
+
except queue.Empty:
|
| 126 |
+
time.sleep(MOVEMENT_LATENCY_S)
|
| 127 |
+
continue
|
| 128 |
+
|
| 129 |
+
try:
|
| 130 |
+
with self._state_lock:
|
| 131 |
+
current_generation = self._generation
|
| 132 |
+
if chunk_generation != current_generation:
|
| 133 |
+
continue
|
| 134 |
+
|
| 135 |
+
if self._base_ts is None:
|
| 136 |
+
with self._state_lock:
|
| 137 |
+
if self._base_ts is None:
|
| 138 |
+
self._base_ts = time.monotonic()
|
| 139 |
+
|
| 140 |
+
pcm = np.asarray(chunk).squeeze(0)
|
| 141 |
+
with self._sway_lock:
|
| 142 |
+
results = self.sway.feed(pcm, sr)
|
| 143 |
+
|
| 144 |
+
i = 0
|
| 145 |
+
while i < len(results):
|
| 146 |
+
with self._state_lock:
|
| 147 |
+
if self._generation != current_generation:
|
| 148 |
+
break
|
| 149 |
+
base_ts = self._base_ts
|
| 150 |
+
hops_done = self._hops_done
|
| 151 |
+
|
| 152 |
+
if base_ts is None:
|
| 153 |
+
base_ts = time.monotonic()
|
| 154 |
+
with self._state_lock:
|
| 155 |
+
if self._base_ts is None:
|
| 156 |
+
self._base_ts = base_ts
|
| 157 |
+
hops_done = self._hops_done
|
| 158 |
+
|
| 159 |
+
target = base_ts + MOVEMENT_LATENCY_S + hops_done * hop_dt
|
| 160 |
+
now = time.monotonic()
|
| 161 |
+
|
| 162 |
+
if now - target >= hop_dt:
|
| 163 |
+
lag_hops = int((now - target) / hop_dt)
|
| 164 |
+
drop = min(lag_hops, len(results) - i - 1)
|
| 165 |
+
if drop > 0:
|
| 166 |
+
with self._state_lock:
|
| 167 |
+
self._hops_done += drop
|
| 168 |
+
hops_done = self._hops_done
|
| 169 |
+
i += drop
|
| 170 |
+
continue
|
| 171 |
+
|
| 172 |
+
if target > now:
|
| 173 |
+
time.sleep(target - now)
|
| 174 |
+
with self._state_lock:
|
| 175 |
+
if self._generation != current_generation:
|
| 176 |
+
break
|
| 177 |
+
|
| 178 |
+
r = results[i]
|
| 179 |
+
with self._state_lock:
|
| 180 |
+
if self._generation != current_generation:
|
| 181 |
+
break
|
| 182 |
+
scale = self._scale
|
| 183 |
+
ps = self._pitch_scale
|
| 184 |
+
ys = self._yaw_scale
|
| 185 |
+
rs = self._roll_scale
|
| 186 |
+
xs = self._x_scale
|
| 187 |
+
y_s = self._y_scale
|
| 188 |
+
zs = self._z_scale
|
| 189 |
+
p_off = self._pitch_offset
|
| 190 |
+
z_off = self._z_offset
|
| 191 |
+
|
| 192 |
+
env = r.get("env", 1.0)
|
| 193 |
+
|
| 194 |
+
offsets = (
|
| 195 |
+
(r["x_mm"] / 1000.0) * scale * xs,
|
| 196 |
+
(r["y_mm"] / 1000.0) * scale * y_s,
|
| 197 |
+
(r["z_mm"] / 1000.0) * scale * zs + env * z_off,
|
| 198 |
+
r["roll_rad"] * scale * rs,
|
| 199 |
+
r["pitch_rad"] * scale * ps + env * p_off,
|
| 200 |
+
r["yaw_rad"] * scale * ys,
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
with self._state_lock:
|
| 204 |
+
if self._generation != current_generation:
|
| 205 |
+
break
|
| 206 |
+
|
| 207 |
+
self._apply_offsets(offsets)
|
| 208 |
+
|
| 209 |
+
with self._state_lock:
|
| 210 |
+
self._hops_done += 1
|
| 211 |
+
i += 1
|
| 212 |
+
finally:
|
| 213 |
+
queue_ref.task_done()
|
| 214 |
+
logger.debug("Attitude head wobbler thread exited")
|
| 215 |
+
|
| 216 |
+
def drain_audio_queue(self) -> None:
|
| 217 |
+
self._drain_audio_queue()
|
| 218 |
+
|
| 219 |
+
def _drain_audio_queue(self) -> bool:
|
| 220 |
+
drained_any = False
|
| 221 |
+
try:
|
| 222 |
+
while True:
|
| 223 |
+
self.audio_queue.get_nowait()
|
| 224 |
+
self.audio_queue.task_done()
|
| 225 |
+
drained_any = True
|
| 226 |
+
except queue.Empty:
|
| 227 |
+
return drained_any
|
| 228 |
+
|
| 229 |
+
def reset(self) -> None:
|
| 230 |
+
with self._state_lock:
|
| 231 |
+
self._generation += 1
|
| 232 |
+
self._base_ts = None
|
| 233 |
+
self._hops_done = 0
|
| 234 |
+
|
| 235 |
+
drained_any = self._drain_audio_queue()
|
| 236 |
+
|
| 237 |
+
with self._sway_lock:
|
| 238 |
+
self.sway.reset()
|
| 239 |
+
|
| 240 |
+
self._apply_offsets((0.0, 0.0, 0.0, 0.0, 0.0, 0.0))
|
| 241 |
+
|
| 242 |
+
if drained_any:
|
| 243 |
+
logger.debug("Attitude head wobbler queue drained during reset")
|
src/Reachy_OpenWebUI/audio/head_wobbler.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Moves head given audio samples."""
|
| 2 |
+
|
| 3 |
+
import time
|
| 4 |
+
import queue
|
| 5 |
+
import base64
|
| 6 |
+
import logging
|
| 7 |
+
import threading
|
| 8 |
+
from typing import Tuple
|
| 9 |
+
from collections.abc import Callable
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
from numpy.typing import NDArray
|
| 13 |
+
|
| 14 |
+
from Reachy_OpenWebUI.audio.speech_tapper import HOP_MS, SwayRollRT
|
| 15 |
+
from Reachy_OpenWebUI.sub_apps.attitudes import resolve_attitude
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
SAMPLE_RATE = 24000
|
| 19 |
+
MOVEMENT_LATENCY_S = 0.2 # seconds between audio and robot movement
|
| 20 |
+
logger = logging.getLogger(__name__)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class HeadWobbler:
|
| 24 |
+
"""Converts audio deltas (base64) into head movement offsets."""
|
| 25 |
+
|
| 26 |
+
def __init__(
|
| 27 |
+
self,
|
| 28 |
+
set_speech_offsets: Callable[[Tuple[float, float, float, float, float, float]], None],
|
| 29 |
+
attitude: str = "calm",
|
| 30 |
+
) -> None:
|
| 31 |
+
"""Initialize the head wobbler."""
|
| 32 |
+
self._apply_offsets = set_speech_offsets
|
| 33 |
+
self._base_ts: float | None = None
|
| 34 |
+
self._hops_done: int = 0
|
| 35 |
+
|
| 36 |
+
self.audio_queue: "queue.Queue[Tuple[int, int, NDArray[np.int16]]]" = queue.Queue()
|
| 37 |
+
self.sway = SwayRollRT()
|
| 38 |
+
|
| 39 |
+
# Synchronization primitives
|
| 40 |
+
self._state_lock = threading.Lock()
|
| 41 |
+
self._sway_lock = threading.Lock()
|
| 42 |
+
self._generation = 0
|
| 43 |
+
self._speech_motion_scale = resolve_attitude(attitude).speaking_motion_scale
|
| 44 |
+
|
| 45 |
+
self._stop_event = threading.Event()
|
| 46 |
+
self._thread: threading.Thread | None = None
|
| 47 |
+
|
| 48 |
+
def feed(self, delta_b64: str) -> None:
|
| 49 |
+
"""Thread-safe: push audio into the consumer queue."""
|
| 50 |
+
buf = np.frombuffer(base64.b64decode(delta_b64), dtype=np.int16)
|
| 51 |
+
self.feed_pcm(buf, SAMPLE_RATE)
|
| 52 |
+
|
| 53 |
+
def feed_pcm(self, pcm: NDArray[np.int16], sample_rate: int = SAMPLE_RATE) -> None:
|
| 54 |
+
"""Thread-safe: push raw mono PCM into the consumer queue."""
|
| 55 |
+
buf = np.asarray(pcm, dtype=np.int16).reshape(1, -1)
|
| 56 |
+
if buf.size == 0:
|
| 57 |
+
return
|
| 58 |
+
with self._state_lock:
|
| 59 |
+
generation = self._generation
|
| 60 |
+
self.audio_queue.put((generation, int(sample_rate or SAMPLE_RATE), buf))
|
| 61 |
+
|
| 62 |
+
def start(self) -> None:
|
| 63 |
+
"""Start the head wobbler loop in a thread."""
|
| 64 |
+
self._stop_event.clear()
|
| 65 |
+
self._thread = threading.Thread(target=self.working_loop, daemon=True)
|
| 66 |
+
self._thread.start()
|
| 67 |
+
logger.debug("Head wobbler started")
|
| 68 |
+
|
| 69 |
+
def set_attitude(self, attitude: str) -> None:
|
| 70 |
+
"""Apply the selected attitude's speech movement scale."""
|
| 71 |
+
profile = resolve_attitude(attitude)
|
| 72 |
+
with self._state_lock:
|
| 73 |
+
self._speech_motion_scale = profile.speaking_motion_scale
|
| 74 |
+
logger.info("Speech motion attitude set to %s (scale %.2f)", profile.name, profile.speaking_motion_scale)
|
| 75 |
+
|
| 76 |
+
def stop(self) -> None:
|
| 77 |
+
"""Stop the head wobbler loop."""
|
| 78 |
+
self._stop_event.set()
|
| 79 |
+
if self._thread is not None:
|
| 80 |
+
self._thread.join()
|
| 81 |
+
logger.debug("Head wobbler stopped")
|
| 82 |
+
|
| 83 |
+
def working_loop(self) -> None:
|
| 84 |
+
"""Convert audio deltas into head movement offsets."""
|
| 85 |
+
hop_dt = HOP_MS / 1000.0
|
| 86 |
+
|
| 87 |
+
logger.debug("Head wobbler thread started")
|
| 88 |
+
while not self._stop_event.is_set():
|
| 89 |
+
queue_ref = self.audio_queue
|
| 90 |
+
try:
|
| 91 |
+
chunk_generation, sr, chunk = queue_ref.get_nowait() # (gen, sr, data)
|
| 92 |
+
except queue.Empty:
|
| 93 |
+
# avoid while to never exit
|
| 94 |
+
time.sleep(MOVEMENT_LATENCY_S)
|
| 95 |
+
continue
|
| 96 |
+
|
| 97 |
+
try:
|
| 98 |
+
with self._state_lock:
|
| 99 |
+
current_generation = self._generation
|
| 100 |
+
if chunk_generation != current_generation:
|
| 101 |
+
continue
|
| 102 |
+
|
| 103 |
+
if self._base_ts is None:
|
| 104 |
+
with self._state_lock:
|
| 105 |
+
if self._base_ts is None:
|
| 106 |
+
self._base_ts = time.monotonic()
|
| 107 |
+
|
| 108 |
+
pcm = np.asarray(chunk).squeeze(0)
|
| 109 |
+
with self._sway_lock:
|
| 110 |
+
results = self.sway.feed(pcm, sr)
|
| 111 |
+
|
| 112 |
+
i = 0
|
| 113 |
+
while i < len(results):
|
| 114 |
+
with self._state_lock:
|
| 115 |
+
if self._generation != current_generation:
|
| 116 |
+
break
|
| 117 |
+
base_ts = self._base_ts
|
| 118 |
+
hops_done = self._hops_done
|
| 119 |
+
|
| 120 |
+
if base_ts is None:
|
| 121 |
+
base_ts = time.monotonic()
|
| 122 |
+
with self._state_lock:
|
| 123 |
+
if self._base_ts is None:
|
| 124 |
+
self._base_ts = base_ts
|
| 125 |
+
hops_done = self._hops_done
|
| 126 |
+
|
| 127 |
+
target = base_ts + MOVEMENT_LATENCY_S + hops_done * hop_dt
|
| 128 |
+
now = time.monotonic()
|
| 129 |
+
|
| 130 |
+
if now - target >= hop_dt:
|
| 131 |
+
lag_hops = int((now - target) / hop_dt)
|
| 132 |
+
drop = min(lag_hops, len(results) - i - 1)
|
| 133 |
+
if drop > 0:
|
| 134 |
+
with self._state_lock:
|
| 135 |
+
self._hops_done += drop
|
| 136 |
+
hops_done = self._hops_done
|
| 137 |
+
i += drop
|
| 138 |
+
continue
|
| 139 |
+
|
| 140 |
+
if target > now:
|
| 141 |
+
time.sleep(target - now)
|
| 142 |
+
with self._state_lock:
|
| 143 |
+
if self._generation != current_generation:
|
| 144 |
+
break
|
| 145 |
+
|
| 146 |
+
r = results[i]
|
| 147 |
+
with self._state_lock:
|
| 148 |
+
if self._generation != current_generation:
|
| 149 |
+
break
|
| 150 |
+
scale = self._speech_motion_scale
|
| 151 |
+
|
| 152 |
+
offsets = (
|
| 153 |
+
(r["x_mm"] / 1000.0) * scale,
|
| 154 |
+
(r["y_mm"] / 1000.0) * scale,
|
| 155 |
+
(r["z_mm"] / 1000.0) * scale,
|
| 156 |
+
r["roll_rad"] * scale,
|
| 157 |
+
r["pitch_rad"] * scale,
|
| 158 |
+
r["yaw_rad"] * scale,
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
with self._state_lock:
|
| 162 |
+
if self._generation != current_generation:
|
| 163 |
+
break
|
| 164 |
+
|
| 165 |
+
self._apply_offsets(offsets)
|
| 166 |
+
|
| 167 |
+
with self._state_lock:
|
| 168 |
+
self._hops_done += 1
|
| 169 |
+
i += 1
|
| 170 |
+
finally:
|
| 171 |
+
queue_ref.task_done()
|
| 172 |
+
logger.debug("Head wobbler thread exited")
|
| 173 |
+
|
| 174 |
+
def drain_audio_queue(self) -> None:
|
| 175 |
+
"""Empty the audio queue."""
|
| 176 |
+
self._drain_audio_queue()
|
| 177 |
+
|
| 178 |
+
def _drain_audio_queue(self) -> bool:
|
| 179 |
+
"""Empty queued audio chunks and return whether anything was drained."""
|
| 180 |
+
drained_any = False
|
| 181 |
+
try:
|
| 182 |
+
while True:
|
| 183 |
+
self.audio_queue.get_nowait()
|
| 184 |
+
self.audio_queue.task_done()
|
| 185 |
+
drained_any = True
|
| 186 |
+
except queue.Empty:
|
| 187 |
+
return drained_any
|
| 188 |
+
|
| 189 |
+
def reset(self) -> None:
|
| 190 |
+
"""Reset the internal state."""
|
| 191 |
+
with self._state_lock:
|
| 192 |
+
self._generation += 1
|
| 193 |
+
self._base_ts = None
|
| 194 |
+
self._hops_done = 0
|
| 195 |
+
|
| 196 |
+
# Drain any queued audio chunks from previous generations
|
| 197 |
+
drained_any = self._drain_audio_queue()
|
| 198 |
+
|
| 199 |
+
with self._sway_lock:
|
| 200 |
+
self.sway.reset()
|
| 201 |
+
|
| 202 |
+
self._apply_offsets((0.0, 0.0, 0.0, 0.0, 0.0, 0.0))
|
| 203 |
+
|
| 204 |
+
if drained_any:
|
| 205 |
+
logger.debug("Head wobbler queue drained during reset")
|
src/Reachy_OpenWebUI/audio/speech_tapper.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
import math
|
| 3 |
+
from typing import Any, Dict, List
|
| 4 |
+
from itertools import islice
|
| 5 |
+
from collections import deque
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
from numpy.typing import NDArray
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
# Tunables
|
| 12 |
+
SR = 16_000
|
| 13 |
+
FRAME_MS = 20
|
| 14 |
+
HOP_MS = 50
|
| 15 |
+
|
| 16 |
+
SWAY_MASTER = 1.5
|
| 17 |
+
SENS_DB_OFFSET = +4.0
|
| 18 |
+
VAD_DB_ON = -35.0
|
| 19 |
+
VAD_DB_OFF = -45.0
|
| 20 |
+
VAD_ATTACK_MS = 40
|
| 21 |
+
VAD_RELEASE_MS = 250
|
| 22 |
+
ENV_FOLLOW_GAIN = 0.65
|
| 23 |
+
|
| 24 |
+
# Speech wobble oscillator amplitudes and frequencies.
|
| 25 |
+
# NOTE: The head is world-stabilised (gaze-locked like a gimbal). Yaw
|
| 26 |
+
# directly shifts where the robot looks — even small values make it glance
|
| 27 |
+
# away from the user. Pitch (nodding) and roll (tilting) are far less
|
| 28 |
+
# disruptive to gaze. Keep yaw the SMALLEST amplitude, not the largest.
|
| 29 |
+
# Per-attitude overrides live in AttitudeHeadWobbler (attitude_wobbler.py).
|
| 30 |
+
SWAY_F_PITCH = 2.2
|
| 31 |
+
SWAY_A_PITCH_DEG = 4.5
|
| 32 |
+
SWAY_F_YAW = 0.6
|
| 33 |
+
SWAY_A_YAW_DEG = 7.5 # WARNING: large — shifts gaze noticeably
|
| 34 |
+
SWAY_F_ROLL = 1.3
|
| 35 |
+
SWAY_A_ROLL_DEG = 2.25
|
| 36 |
+
SWAY_F_X = 0.35
|
| 37 |
+
SWAY_A_X_MM = 4.5
|
| 38 |
+
SWAY_F_Y = 0.45
|
| 39 |
+
SWAY_A_Y_MM = 3.75
|
| 40 |
+
SWAY_F_Z = 0.25
|
| 41 |
+
SWAY_A_Z_MM = 2.25
|
| 42 |
+
|
| 43 |
+
SWAY_DB_LOW = -46.0
|
| 44 |
+
SWAY_DB_HIGH = -18.0
|
| 45 |
+
LOUDNESS_GAMMA = 0.9
|
| 46 |
+
SWAY_ATTACK_MS = 50
|
| 47 |
+
SWAY_RELEASE_MS = 250
|
| 48 |
+
|
| 49 |
+
# Derived
|
| 50 |
+
FRAME = int(SR * FRAME_MS / 1000)
|
| 51 |
+
HOP = int(SR * HOP_MS / 1000)
|
| 52 |
+
ATTACK_FR = max(1, int(VAD_ATTACK_MS / HOP_MS))
|
| 53 |
+
RELEASE_FR = max(1, int(VAD_RELEASE_MS / HOP_MS))
|
| 54 |
+
SWAY_ATTACK_FR = max(1, int(SWAY_ATTACK_MS / HOP_MS))
|
| 55 |
+
SWAY_RELEASE_FR = max(1, int(SWAY_RELEASE_MS / HOP_MS))
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _rms_dbfs(x: NDArray[np.float32]) -> float:
|
| 59 |
+
"""Root-mean-square in dBFS for float32 mono array in [-1,1]."""
|
| 60 |
+
# numerically stable rms (avoid overflow)
|
| 61 |
+
x = x.astype(np.float32, copy=False)
|
| 62 |
+
rms = np.sqrt(np.mean(x * x, dtype=np.float32) + 1e-12, dtype=np.float32)
|
| 63 |
+
return float(20.0 * math.log10(float(rms) + 1e-12))
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _loudness_gain(db: float, offset: float = SENS_DB_OFFSET) -> float:
|
| 67 |
+
"""Normalize dB into [0,1] with gamma; clipped to [0,1]."""
|
| 68 |
+
t = (db + offset - SWAY_DB_LOW) / (SWAY_DB_HIGH - SWAY_DB_LOW)
|
| 69 |
+
if t < 0.0:
|
| 70 |
+
t = 0.0
|
| 71 |
+
elif t > 1.0:
|
| 72 |
+
t = 1.0
|
| 73 |
+
return t**LOUDNESS_GAMMA if LOUDNESS_GAMMA != 1.0 else t
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _to_float32_mono(x: NDArray[Any]) -> NDArray[np.float32]:
|
| 77 |
+
"""Convert arbitrary PCM array to float32 mono in [-1,1].
|
| 78 |
+
|
| 79 |
+
Accepts shapes: (N,), (1,N), (N,1), (C,N), (N,C).
|
| 80 |
+
"""
|
| 81 |
+
a = np.asarray(x)
|
| 82 |
+
if a.ndim == 0:
|
| 83 |
+
return np.zeros(0, dtype=np.float32)
|
| 84 |
+
|
| 85 |
+
# If 2D, decide which axis is channels (prefer small first dim)
|
| 86 |
+
if a.ndim == 2:
|
| 87 |
+
# e.g., (channels, samples) if channels is small (<=8)
|
| 88 |
+
if a.shape[0] <= 8 and a.shape[0] <= a.shape[1]:
|
| 89 |
+
a = np.mean(a, axis=0)
|
| 90 |
+
else:
|
| 91 |
+
a = np.mean(a, axis=1)
|
| 92 |
+
elif a.ndim > 2:
|
| 93 |
+
a = np.mean(a.reshape(a.shape[0], -1), axis=0)
|
| 94 |
+
|
| 95 |
+
# Now 1D, cast/scale
|
| 96 |
+
if np.issubdtype(a.dtype, np.floating):
|
| 97 |
+
return a.astype(np.float32, copy=False)
|
| 98 |
+
# integer PCM
|
| 99 |
+
info = np.iinfo(a.dtype)
|
| 100 |
+
scale = float(max(-info.min, info.max))
|
| 101 |
+
return a.astype(np.float32) / (scale if scale != 0.0 else 1.0)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def _resample_linear(x: NDArray[np.float32], sr_in: int, sr_out: int) -> NDArray[np.float32]:
|
| 105 |
+
"""Lightweight linear resampler for short buffers."""
|
| 106 |
+
if sr_in == sr_out or x.size == 0:
|
| 107 |
+
return x
|
| 108 |
+
# guard tiny sizes
|
| 109 |
+
n_out = int(round(x.size * sr_out / sr_in))
|
| 110 |
+
if n_out <= 1:
|
| 111 |
+
return np.zeros(0, dtype=np.float32)
|
| 112 |
+
t_in = np.linspace(0.0, 1.0, num=x.size, dtype=np.float32, endpoint=True)
|
| 113 |
+
t_out = np.linspace(0.0, 1.0, num=n_out, dtype=np.float32, endpoint=True)
|
| 114 |
+
return np.interp(t_out, t_in, x).astype(np.float32, copy=False)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
class SwayRollRT:
|
| 118 |
+
"""Feed audio chunks → per-hop sway outputs.
|
| 119 |
+
|
| 120 |
+
Usage:
|
| 121 |
+
rt = SwayRollRT()
|
| 122 |
+
rt.feed(pcm_int16_or_float, sr) -> List[dict]
|
| 123 |
+
"""
|
| 124 |
+
|
| 125 |
+
def __init__(self, rng_seed: int = 7):
|
| 126 |
+
"""Initialize state."""
|
| 127 |
+
self._seed = int(rng_seed)
|
| 128 |
+
self.samples: deque[float] = deque(maxlen=10 * SR) # sliding window for VAD/env
|
| 129 |
+
self.carry: NDArray[np.float32] = np.zeros(0, dtype=np.float32)
|
| 130 |
+
|
| 131 |
+
self.vad_on = False
|
| 132 |
+
self.vad_above = 0
|
| 133 |
+
self.vad_below = 0
|
| 134 |
+
|
| 135 |
+
self.sway_env = 0.0
|
| 136 |
+
self.sway_up = 0
|
| 137 |
+
self.sway_down = 0
|
| 138 |
+
|
| 139 |
+
rng = np.random.default_rng(self._seed)
|
| 140 |
+
self.phase_pitch = float(rng.random() * 2 * math.pi)
|
| 141 |
+
self.phase_yaw = float(rng.random() * 2 * math.pi)
|
| 142 |
+
self.phase_roll = float(rng.random() * 2 * math.pi)
|
| 143 |
+
self.phase_x = float(rng.random() * 2 * math.pi)
|
| 144 |
+
self.phase_y = float(rng.random() * 2 * math.pi)
|
| 145 |
+
self.phase_z = float(rng.random() * 2 * math.pi)
|
| 146 |
+
self.t = 0.0
|
| 147 |
+
|
| 148 |
+
def reset(self) -> None:
|
| 149 |
+
"""Reset state (VAD/env/buffers/time) but keep initial phases/seed."""
|
| 150 |
+
self.samples.clear()
|
| 151 |
+
self.carry = np.zeros(0, dtype=np.float32)
|
| 152 |
+
self.vad_on = False
|
| 153 |
+
self.vad_above = 0
|
| 154 |
+
self.vad_below = 0
|
| 155 |
+
self.sway_env = 0.0
|
| 156 |
+
self.sway_up = 0
|
| 157 |
+
self.sway_down = 0
|
| 158 |
+
self.t = 0.0
|
| 159 |
+
|
| 160 |
+
def feed(self, pcm: NDArray[Any], sr: int | None) -> List[Dict[str, float]]:
|
| 161 |
+
"""Stream in PCM chunk. Returns a list of sway dicts, one per hop (HOP_MS).
|
| 162 |
+
|
| 163 |
+
Args:
|
| 164 |
+
pcm: np.ndarray, shape (N,) or (C,N)/(N,C); int or float.
|
| 165 |
+
sr: sample rate of `pcm` (None -> assume SR).
|
| 166 |
+
|
| 167 |
+
"""
|
| 168 |
+
sr_in = SR if sr is None else int(sr)
|
| 169 |
+
x = _to_float32_mono(pcm)
|
| 170 |
+
if x.size == 0:
|
| 171 |
+
return []
|
| 172 |
+
if sr_in != SR:
|
| 173 |
+
x = _resample_linear(x, sr_in, SR)
|
| 174 |
+
if x.size == 0:
|
| 175 |
+
return []
|
| 176 |
+
|
| 177 |
+
# append to carry and consume fixed HOP chunks
|
| 178 |
+
if self.carry.size:
|
| 179 |
+
self.carry = np.concatenate([self.carry, x])
|
| 180 |
+
else:
|
| 181 |
+
self.carry = x
|
| 182 |
+
|
| 183 |
+
out: List[Dict[str, float]] = []
|
| 184 |
+
|
| 185 |
+
while self.carry.size >= HOP:
|
| 186 |
+
hop = self.carry[:HOP]
|
| 187 |
+
remaining: NDArray[np.float32] = self.carry[HOP:]
|
| 188 |
+
self.carry = remaining
|
| 189 |
+
|
| 190 |
+
# keep sliding window for VAD/env computation
|
| 191 |
+
# (deque accepts any iterable; list() for small HOP is fine)
|
| 192 |
+
self.samples.extend(hop.tolist())
|
| 193 |
+
if len(self.samples) < FRAME:
|
| 194 |
+
self.t += HOP_MS / 1000.0
|
| 195 |
+
continue
|
| 196 |
+
|
| 197 |
+
frame = np.fromiter(
|
| 198 |
+
islice(self.samples, len(self.samples) - FRAME, len(self.samples)),
|
| 199 |
+
dtype=np.float32,
|
| 200 |
+
count=FRAME,
|
| 201 |
+
)
|
| 202 |
+
db = _rms_dbfs(frame)
|
| 203 |
+
|
| 204 |
+
# VAD with hysteresis + attack/release
|
| 205 |
+
if db >= VAD_DB_ON:
|
| 206 |
+
self.vad_above += 1
|
| 207 |
+
self.vad_below = 0
|
| 208 |
+
if not self.vad_on and self.vad_above >= ATTACK_FR:
|
| 209 |
+
self.vad_on = True
|
| 210 |
+
elif db <= VAD_DB_OFF:
|
| 211 |
+
self.vad_below += 1
|
| 212 |
+
self.vad_above = 0
|
| 213 |
+
if self.vad_on and self.vad_below >= RELEASE_FR:
|
| 214 |
+
self.vad_on = False
|
| 215 |
+
|
| 216 |
+
if self.vad_on:
|
| 217 |
+
self.sway_up = min(SWAY_ATTACK_FR, self.sway_up + 1)
|
| 218 |
+
self.sway_down = 0
|
| 219 |
+
else:
|
| 220 |
+
self.sway_down = min(SWAY_RELEASE_FR, self.sway_down + 1)
|
| 221 |
+
self.sway_up = 0
|
| 222 |
+
|
| 223 |
+
up = self.sway_up / SWAY_ATTACK_FR
|
| 224 |
+
down = 1.0 - (self.sway_down / SWAY_RELEASE_FR)
|
| 225 |
+
target = up if self.vad_on else down
|
| 226 |
+
self.sway_env += ENV_FOLLOW_GAIN * (target - self.sway_env)
|
| 227 |
+
# clamp
|
| 228 |
+
if self.sway_env < 0.0:
|
| 229 |
+
self.sway_env = 0.0
|
| 230 |
+
elif self.sway_env > 1.0:
|
| 231 |
+
self.sway_env = 1.0
|
| 232 |
+
|
| 233 |
+
loud = _loudness_gain(db) * SWAY_MASTER
|
| 234 |
+
env = self.sway_env
|
| 235 |
+
self.t += HOP_MS / 1000.0
|
| 236 |
+
|
| 237 |
+
# oscillators
|
| 238 |
+
pitch = (
|
| 239 |
+
math.radians(SWAY_A_PITCH_DEG)
|
| 240 |
+
* loud
|
| 241 |
+
* env
|
| 242 |
+
* math.sin(2 * math.pi * SWAY_F_PITCH * self.t + self.phase_pitch)
|
| 243 |
+
)
|
| 244 |
+
yaw = (
|
| 245 |
+
math.radians(SWAY_A_YAW_DEG)
|
| 246 |
+
* loud
|
| 247 |
+
* env
|
| 248 |
+
* math.sin(2 * math.pi * SWAY_F_YAW * self.t + self.phase_yaw)
|
| 249 |
+
)
|
| 250 |
+
roll = (
|
| 251 |
+
math.radians(SWAY_A_ROLL_DEG)
|
| 252 |
+
* loud
|
| 253 |
+
* env
|
| 254 |
+
* math.sin(2 * math.pi * SWAY_F_ROLL * self.t + self.phase_roll)
|
| 255 |
+
)
|
| 256 |
+
x_mm = SWAY_A_X_MM * loud * env * math.sin(2 * math.pi * SWAY_F_X * self.t + self.phase_x)
|
| 257 |
+
y_mm = SWAY_A_Y_MM * loud * env * math.sin(2 * math.pi * SWAY_F_Y * self.t + self.phase_y)
|
| 258 |
+
z_mm = SWAY_A_Z_MM * loud * env * math.sin(2 * math.pi * SWAY_F_Z * self.t + self.phase_z)
|
| 259 |
+
|
| 260 |
+
out.append(
|
| 261 |
+
{
|
| 262 |
+
"pitch_rad": pitch,
|
| 263 |
+
"yaw_rad": yaw,
|
| 264 |
+
"roll_rad": roll,
|
| 265 |
+
"pitch_deg": math.degrees(pitch),
|
| 266 |
+
"yaw_deg": math.degrees(yaw),
|
| 267 |
+
"roll_deg": math.degrees(roll),
|
| 268 |
+
"x_mm": x_mm,
|
| 269 |
+
"y_mm": y_mm,
|
| 270 |
+
"z_mm": z_mm,
|
| 271 |
+
},
|
| 272 |
+
)
|
| 273 |
+
|
| 274 |
+
return out
|
src/Reachy_OpenWebUI/audio/startup_config.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Startup configuration for the Reachy Mini XVF3800 audio processor.
|
| 2 |
+
|
| 3 |
+
Ported from the official reachy_mini_conversation_app. Applied at every
|
| 4 |
+
pipeline start so the mic chain recovers from drifted/stuck AGC state.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
import logging
|
| 9 |
+
from collections.abc import Sequence
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
AudioControlValue = float | int
|
| 13 |
+
AudioStartupParameter = tuple[str, tuple[AudioControlValue, ...]]
|
| 14 |
+
WRITE_SETTLE_SECONDS = 0.1
|
| 15 |
+
|
| 16 |
+
AUDIO_STARTUP_CONFIG: tuple[AudioStartupParameter, ...] = (
|
| 17 |
+
("PP_AGCMAXGAIN", (10.0,)),
|
| 18 |
+
("PP_MIN_NS", (0.8,)),
|
| 19 |
+
("PP_MIN_NN", (0.8,)),
|
| 20 |
+
("PP_GAMMA_E", (0.5,)),
|
| 21 |
+
("PP_GAMMA_ETAIL", (0.5,)),
|
| 22 |
+
("PP_NLATTENONOFF", (0,)),
|
| 23 |
+
("PP_MGSCALE", (4.0, 1.0, 1.0)),
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def apply_audio_startup_config(
|
| 28 |
+
robot: object,
|
| 29 |
+
*,
|
| 30 |
+
logger: logging.Logger | None = None,
|
| 31 |
+
verify: bool = True,
|
| 32 |
+
write_settle_seconds: float = WRITE_SETTLE_SECONDS,
|
| 33 |
+
) -> bool:
|
| 34 |
+
"""Apply the tuned XVF3800 audio configuration for the conversation app."""
|
| 35 |
+
log = logger or logging.getLogger(__name__)
|
| 36 |
+
audio = getattr(getattr(robot, "media", None), "audio", None)
|
| 37 |
+
|
| 38 |
+
if audio is None:
|
| 39 |
+
log.warning("Skipping Reachy audio startup config: robot media audio is unavailable.")
|
| 40 |
+
return False
|
| 41 |
+
|
| 42 |
+
apply_audio_config = getattr(audio, "apply_audio_config", None)
|
| 43 |
+
if not callable(apply_audio_config):
|
| 44 |
+
log.warning("Skipping Reachy audio startup config: SDK audio config API is unavailable.")
|
| 45 |
+
return False
|
| 46 |
+
|
| 47 |
+
try:
|
| 48 |
+
applied = bool(
|
| 49 |
+
apply_audio_config(
|
| 50 |
+
AUDIO_STARTUP_CONFIG,
|
| 51 |
+
verify=verify,
|
| 52 |
+
write_settle_seconds=write_settle_seconds,
|
| 53 |
+
)
|
| 54 |
+
)
|
| 55 |
+
except Exception as exc:
|
| 56 |
+
log.warning("Skipping Reachy audio startup config: SDK audio config failed: %s", exc)
|
| 57 |
+
return False
|
| 58 |
+
|
| 59 |
+
if applied:
|
| 60 |
+
log.info("Applied Reachy audio startup config: %s", _format_config(AUDIO_STARTUP_CONFIG))
|
| 61 |
+
else:
|
| 62 |
+
log.warning("Reachy audio startup config was not applied.")
|
| 63 |
+
|
| 64 |
+
return applied
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _format_config(config: Sequence[AudioStartupParameter]) -> str:
|
| 68 |
+
return ", ".join(f"{name}={' '.join(str(value) for value in values)}" for name, values in config)
|
src/Reachy_OpenWebUI/camera_frame_encoding.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Helpers for encoding camera frames."""
|
| 2 |
+
|
| 3 |
+
from fractions import Fraction
|
| 4 |
+
|
| 5 |
+
import av
|
| 6 |
+
import numpy as np
|
| 7 |
+
from numpy.typing import NDArray
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def encode_bgr_frame_as_jpeg(frame: NDArray[np.uint8]) -> bytes:
|
| 11 |
+
"""Encode a BGR camera frame as JPEG bytes."""
|
| 12 |
+
rgb_frame = np.ascontiguousarray(frame[..., ::-1])
|
| 13 |
+
video_frame = av.VideoFrame.from_ndarray(rgb_frame, format="rgb24")
|
| 14 |
+
|
| 15 |
+
codec = av.CodecContext.create("mjpeg", "w")
|
| 16 |
+
codec.width = rgb_frame.shape[1] # type: ignore[attr-defined]
|
| 17 |
+
codec.height = rgb_frame.shape[0] # type: ignore[attr-defined]
|
| 18 |
+
codec.pix_fmt = "yuvj444p" # type: ignore[attr-defined]
|
| 19 |
+
codec.time_base = Fraction(1, 1)
|
| 20 |
+
codec.options = {"qscale": "3"}
|
| 21 |
+
|
| 22 |
+
packets = codec.encode(video_frame) # type: ignore[attr-defined]
|
| 23 |
+
packets += codec.encode(None) # type: ignore[attr-defined]
|
| 24 |
+
if not packets:
|
| 25 |
+
raise RuntimeError("Failed to encode frame as JPEG")
|
| 26 |
+
|
| 27 |
+
return b"".join(bytes(packet) for packet in packets)
|
src/Reachy_OpenWebUI/camera_worker.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Camera worker thread with frame buffering.
|
| 2 |
+
|
| 3 |
+
Provides:
|
| 4 |
+
- 30Hz+ camera polling with thread-safe frame buffering
|
| 5 |
+
- Latest frame always available for tools
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import time
|
| 9 |
+
import logging
|
| 10 |
+
import threading
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
from numpy.typing import NDArray
|
| 14 |
+
|
| 15 |
+
from reachy_mini import ReachyMini
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class CameraWorker:
|
| 22 |
+
"""Thread-safe camera worker with frame buffering."""
|
| 23 |
+
|
| 24 |
+
def __init__(self, reachy_mini: ReachyMini) -> None:
|
| 25 |
+
"""Initialize."""
|
| 26 |
+
self.reachy_mini = reachy_mini
|
| 27 |
+
|
| 28 |
+
# Thread-safe frame storage
|
| 29 |
+
self.latest_frame: NDArray[np.uint8] | None = None
|
| 30 |
+
self.frame_lock = threading.Lock()
|
| 31 |
+
self._stop_event = threading.Event()
|
| 32 |
+
self._thread: threading.Thread | None = None
|
| 33 |
+
|
| 34 |
+
def get_latest_frame(self) -> NDArray[np.uint8] | None:
|
| 35 |
+
"""Get the latest frame (thread-safe)."""
|
| 36 |
+
with self.frame_lock:
|
| 37 |
+
if self.latest_frame is None:
|
| 38 |
+
return None
|
| 39 |
+
# Return a copy in original BGR format (OpenCV native)
|
| 40 |
+
return self.latest_frame.copy()
|
| 41 |
+
|
| 42 |
+
def start(self) -> None:
|
| 43 |
+
"""Start the camera worker loop in a thread."""
|
| 44 |
+
self._stop_event.clear()
|
| 45 |
+
self._thread = threading.Thread(target=self.working_loop, daemon=True)
|
| 46 |
+
self._thread.start()
|
| 47 |
+
logger.debug("Camera worker started")
|
| 48 |
+
|
| 49 |
+
def stop(self) -> None:
|
| 50 |
+
"""Stop the camera worker loop."""
|
| 51 |
+
self._stop_event.set()
|
| 52 |
+
if self._thread is not None:
|
| 53 |
+
self._thread.join()
|
| 54 |
+
|
| 55 |
+
logger.debug("Camera worker stopped")
|
| 56 |
+
|
| 57 |
+
def working_loop(self) -> None:
|
| 58 |
+
"""Poll frames from the camera and store the latest one."""
|
| 59 |
+
logger.debug("Starting camera working loop")
|
| 60 |
+
|
| 61 |
+
while not self._stop_event.is_set():
|
| 62 |
+
try:
|
| 63 |
+
# Get frame from robot
|
| 64 |
+
frame = self.reachy_mini.media.get_frame()
|
| 65 |
+
|
| 66 |
+
if frame is not None:
|
| 67 |
+
# Thread-safe frame storage
|
| 68 |
+
with self.frame_lock:
|
| 69 |
+
self.latest_frame = frame
|
| 70 |
+
|
| 71 |
+
# Small sleep to prevent excessive CPU usage
|
| 72 |
+
time.sleep(0.04)
|
| 73 |
+
|
| 74 |
+
except Exception as e:
|
| 75 |
+
logger.error(f"Camera worker error: {e}")
|
| 76 |
+
time.sleep(0.1) # Longer sleep on error
|
| 77 |
+
|
| 78 |
+
logger.debug("Camera worker thread exited")
|
src/Reachy_OpenWebUI/config.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import logging
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
from dotenv import find_dotenv, load_dotenv
|
| 6 |
+
|
| 7 |
+
from Reachy_OpenWebUI import settings_keys as keys
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
STABLE_ENV_PATH = Path.home() / ".config" / "settings" / ".env"
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _truthy(value: str | None, default: bool = False) -> bool:
|
| 16 |
+
raw = (value or "").strip().lower()
|
| 17 |
+
if not raw:
|
| 18 |
+
return default
|
| 19 |
+
return raw in {"1", "true", "yes", "on"}
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
if STABLE_ENV_PATH.exists():
|
| 23 |
+
load_dotenv(dotenv_path=str(STABLE_ENV_PATH), override=True)
|
| 24 |
+
logger.info("Stable OpenWebUI configuration loaded from %s", STABLE_ENV_PATH)
|
| 25 |
+
|
| 26 |
+
# Locate .env file (search upward from current working directory)
|
| 27 |
+
dotenv_path = find_dotenv(usecwd=True)
|
| 28 |
+
|
| 29 |
+
if dotenv_path:
|
| 30 |
+
# Load .env but don't override existing environment variables (CLI takes precedence)
|
| 31 |
+
load_dotenv(dotenv_path=dotenv_path, override=False)
|
| 32 |
+
logger.info(f"Configuration loaded from {dotenv_path}")
|
| 33 |
+
else:
|
| 34 |
+
logger.warning("No .env file found, using environment variables")
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class Config:
|
| 38 |
+
"""Configuration class for the conversation app."""
|
| 39 |
+
|
| 40 |
+
# Optional
|
| 41 |
+
HF_HOME = os.getenv("HF_HOME", "./cache")
|
| 42 |
+
OPENWEBUI_MODEL = (
|
| 43 |
+
os.getenv(keys.OPENWEB_LOADED_LLM)
|
| 44 |
+
or os.getenv(keys.LEGACY_OPENWEB_MODEL)
|
| 45 |
+
or os.getenv(keys.LEGACY_LOCAL_LLM_MODEL)
|
| 46 |
+
or "reachy-convo"
|
| 47 |
+
)
|
| 48 |
+
OPENWEBUI_URL = (
|
| 49 |
+
os.getenv(keys.OPENWEBUI_URL)
|
| 50 |
+
or os.getenv(keys.LEGACY_LOCAL_LLM_URL)
|
| 51 |
+
or "http://localhost:3001"
|
| 52 |
+
)
|
| 53 |
+
OPENWEBUI_API_KEY = (
|
| 54 |
+
os.getenv(keys.OPENWEBUI_API_KEY)
|
| 55 |
+
or os.getenv(keys.LEGACY_LOCAL_LLM_API_KEY)
|
| 56 |
+
or ""
|
| 57 |
+
)
|
| 58 |
+
OPENWEBUI_TOKEN = (
|
| 59 |
+
os.getenv(keys.OPENWEBUI_TOKEN)
|
| 60 |
+
or os.getenv(keys.LEGACY_OPENWEBUI_TOKEN)
|
| 61 |
+
or ""
|
| 62 |
+
)
|
| 63 |
+
OPENWEBUI_USER_AGENT = (
|
| 64 |
+
os.getenv(keys.OPENWEBUI_USER_AGENT)
|
| 65 |
+
or "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"
|
| 66 |
+
).strip()
|
| 67 |
+
OPENWEBUI_CHAT_ID = (
|
| 68 |
+
os.getenv(keys.OPENWEBUI_CHAT_ID)
|
| 69 |
+
or ""
|
| 70 |
+
).strip()
|
| 71 |
+
OPENWEBUI_CHAT_WINDOW_START = (
|
| 72 |
+
os.getenv(keys.OPENWEBUI_CHAT_WINDOW_START)
|
| 73 |
+
or ""
|
| 74 |
+
).strip()
|
| 75 |
+
OPENWEBUI_CHAT_PENDING_REASON = (
|
| 76 |
+
os.getenv(keys.OPENWEBUI_CHAT_PENDING_REASON)
|
| 77 |
+
or ""
|
| 78 |
+
).strip()
|
| 79 |
+
OPENWEBUI_CHAT_PENDING_EVENT_ID = (
|
| 80 |
+
os.getenv(keys.OPENWEBUI_CHAT_PENDING_EVENT_ID)
|
| 81 |
+
or ""
|
| 82 |
+
).strip()
|
| 83 |
+
OPENWEBUI_USER_ID = os.getenv(keys.OPENWEBUI_USER_ID, "").strip()
|
| 84 |
+
OPENWEBUI_USER_NAME = os.getenv(keys.OPENWEBUI_USER_NAME, "Reachy").strip()
|
| 85 |
+
OPENWEBUI_USER_EMAIL = os.getenv(keys.OPENWEBUI_USER_EMAIL, "reachy@example.local").strip()
|
| 86 |
+
OPENWEBUI_USER_LANGUAGE = os.getenv(keys.OPENWEBUI_USER_LANGUAGE, "en-US").strip()
|
| 87 |
+
OPENWEBUI_USER_LOCATION = os.getenv(keys.OPENWEBUI_USER_LOCATION, "Unknown").strip()
|
| 88 |
+
OPENWEBUI_USER_TIMEZONE = os.getenv(keys.OPENWEBUI_USER_TIMEZONE, "America/New_York").strip()
|
| 89 |
+
OPENWEBUI_CONTACTS_REQUIRE_VISION = _truthy(os.getenv(keys.OPENWEBUI_CONTACTS_REQUIRE_VISION), default=True)
|
| 90 |
+
OPENWEBUI_ENABLED_TOOLS = os.getenv(keys.OPENWEBUI_ENABLED_TOOLS, "").strip()
|
| 91 |
+
REACHY_MINI_CUSTOM_PROFILE = os.getenv(keys.REACHY_MINI_CUSTOM_PROFILE, "").strip()
|
| 92 |
+
REACHY_IDLE_ATTITUDE = os.getenv(keys.REACHY_IDLE_ATTITUDE, "calm").strip() or "calm"
|
| 93 |
+
WEATHER_POSTAL_CODE = os.getenv(keys.WEATHER_POSTAL_CODE, "").strip()
|
| 94 |
+
OPENWEBUI_TTS_READ_TIMEOUT = float(os.getenv("OPENWEBUI_TTS_READ_TIMEOUT", "") or "15.0")
|
| 95 |
+
HF_TOKEN = os.getenv("HF_TOKEN") # Optional, falls back to hf auth login if not set
|
| 96 |
+
|
| 97 |
+
logger.debug(f"HF_HOME: {HF_HOME}, LLM Model: {OPENWEBUI_MODEL}")
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
# Audio Settings
|
| 103 |
+
MIC_GAIN = float(os.getenv(keys.MIC_GAIN, "") or "1.0")
|
| 104 |
+
LOCAL_DEV_VISION_MODEL = (
|
| 105 |
+
os.getenv(keys.LOCAL_DEV_VISION_MODEL)
|
| 106 |
+
or os.getenv(keys.LEGACY_LOCAL_VISION_MODEL)
|
| 107 |
+
or "HuggingFaceTB/SmolVLM2-2.2B-Instruct"
|
| 108 |
+
)
|
| 109 |
+
VAD_THRESHOLD = float(os.getenv("VAD_THRESHOLD", "") or "0.12")
|
| 110 |
+
VAD_FALLBACK_THRESHOLD = float(os.getenv("VAD_FALLBACK_THRESHOLD", "") or "0.010")
|
| 111 |
+
VAD_SPEECH_ONSET_CHUNKS = int(os.getenv("VAD_SPEECH_ONSET_CHUNKS", "") or "2")
|
| 112 |
+
VAD_SILENCE_END_CHUNKS = int(os.getenv("VAD_SILENCE_END_CHUNKS", "") or "70")
|
| 113 |
+
VAD_MIN_SPEECH_CHUNKS = int(os.getenv("VAD_MIN_SPEECH_CHUNKS", "") or "8")
|
| 114 |
+
SILERO_VAD_ONNX_PATH = os.getenv(
|
| 115 |
+
"SILERO_VAD_ONNX_PATH",
|
| 116 |
+
str(Path.home() / ".cache" / "reachy-openwebui-vad" / "silero_vad.onnx"),
|
| 117 |
+
)
|
| 118 |
+
OPENWEBUI_CONVERSATION_IDLE_RESET_SECONDS = int(
|
| 119 |
+
os.getenv("OPENWEBUI_CONVERSATION_IDLE_RESET_SECONDS", "0")
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
config = Config()
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def active_profile(default: str = "default") -> str:
|
| 128 |
+
"""Return the selected profile, falling back to the normal conversation profile."""
|
| 129 |
+
profile = (config.REACHY_MINI_CUSTOM_PROFILE or os.getenv(keys.REACHY_MINI_CUSTOM_PROFILE, "")).strip()
|
| 130 |
+
return profile or default
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def set_runtime_mode(profile: str | None = None, model: str | None = None) -> None:
|
| 134 |
+
"""Set mode-specific runtime values before app-specific modules initialize."""
|
| 135 |
+
selected_profile = (profile or "").strip()
|
| 136 |
+
config.REACHY_MINI_CUSTOM_PROFILE = selected_profile
|
| 137 |
+
if selected_profile:
|
| 138 |
+
os.environ[keys.REACHY_MINI_CUSTOM_PROFILE] = selected_profile
|
| 139 |
+
else:
|
| 140 |
+
os.environ.pop(keys.REACHY_MINI_CUSTOM_PROFILE, None)
|
| 141 |
+
|
| 142 |
+
selected_model = (model or "").strip()
|
| 143 |
+
if selected_model:
|
| 144 |
+
config.OPENWEBUI_MODEL = selected_model
|
| 145 |
+
os.environ[keys.OPENWEB_LOADED_LLM] = selected_model
|
src/Reachy_OpenWebUI/console.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
src/Reachy_OpenWebUI/dance_emotion_moves.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Dance and emotion moves for the movement queue system.
|
| 2 |
+
|
| 3 |
+
This module implements dance moves and emotions as Move objects that can be queued
|
| 4 |
+
and executed sequentially by the MovementManager.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
import logging
|
| 9 |
+
from typing import Tuple
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
from numpy.typing import NDArray
|
| 13 |
+
|
| 14 |
+
from reachy_mini.motion.move import Move
|
| 15 |
+
from reachy_mini.motion.recorded_move import RecordedMoves
|
| 16 |
+
from reachy_mini_dances_library.dance_move import DanceMove
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class DanceQueueMove(Move):
|
| 23 |
+
"""Wrapper for dance moves to work with the movement queue system."""
|
| 24 |
+
|
| 25 |
+
def __init__(self, move_name: str):
|
| 26 |
+
"""Initialize a DanceQueueMove."""
|
| 27 |
+
self.dance_move = DanceMove(move_name)
|
| 28 |
+
self.move_name = move_name
|
| 29 |
+
|
| 30 |
+
@property
|
| 31 |
+
def duration(self) -> float:
|
| 32 |
+
"""Duration property required by official Move interface."""
|
| 33 |
+
return float(self.dance_move.duration)
|
| 34 |
+
|
| 35 |
+
def evaluate(self, t: float) -> tuple[NDArray[np.float64] | None, NDArray[np.float64] | None, float | None]:
|
| 36 |
+
"""Evaluate dance move at time t."""
|
| 37 |
+
try:
|
| 38 |
+
# Get the pose from the dance move
|
| 39 |
+
head_pose, antennas, body_yaw = self.dance_move.evaluate(t)
|
| 40 |
+
|
| 41 |
+
# Convert to numpy array if antennas is tuple and return in official Move format
|
| 42 |
+
if isinstance(antennas, tuple):
|
| 43 |
+
antennas = np.array([antennas[0], antennas[1]])
|
| 44 |
+
|
| 45 |
+
return (head_pose, antennas, body_yaw)
|
| 46 |
+
|
| 47 |
+
except Exception as e:
|
| 48 |
+
logger.error(f"Error evaluating dance move '{self.move_name}' at t={t}: {e}")
|
| 49 |
+
# Return neutral pose on error
|
| 50 |
+
from reachy_mini.utils import create_head_pose
|
| 51 |
+
|
| 52 |
+
neutral_head_pose = create_head_pose(0, 0, 0, 0, 0, 0, degrees=True)
|
| 53 |
+
return (neutral_head_pose, np.array([0.0, 0.0], dtype=np.float64), 0.0)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class EmotionQueueMove(Move):
|
| 57 |
+
"""Wrapper for emotion moves to work with the movement queue system."""
|
| 58 |
+
|
| 59 |
+
def __init__(self, emotion_name: str, recorded_moves: RecordedMoves):
|
| 60 |
+
"""Initialize an EmotionQueueMove."""
|
| 61 |
+
self.emotion_move = recorded_moves.get(emotion_name)
|
| 62 |
+
self.emotion_name = emotion_name
|
| 63 |
+
|
| 64 |
+
@property
|
| 65 |
+
def duration(self) -> float:
|
| 66 |
+
"""Duration property required by official Move interface."""
|
| 67 |
+
return float(self.emotion_move.duration)
|
| 68 |
+
|
| 69 |
+
def evaluate(self, t: float) -> tuple[NDArray[np.float64] | None, NDArray[np.float64] | None, float | None]:
|
| 70 |
+
"""Evaluate emotion move at time t."""
|
| 71 |
+
try:
|
| 72 |
+
# Get the pose from the emotion move
|
| 73 |
+
head_pose, antennas, body_yaw = self.emotion_move.evaluate(t)
|
| 74 |
+
|
| 75 |
+
# Convert to numpy array if antennas is tuple and return in official Move format
|
| 76 |
+
if isinstance(antennas, tuple):
|
| 77 |
+
antennas = np.array([antennas[0], antennas[1]])
|
| 78 |
+
|
| 79 |
+
return (head_pose, antennas, body_yaw)
|
| 80 |
+
|
| 81 |
+
except Exception as e:
|
| 82 |
+
logger.error(f"Error evaluating emotion '{self.emotion_name}' at t={t}: {e}")
|
| 83 |
+
# Return neutral pose on error
|
| 84 |
+
from reachy_mini.utils import create_head_pose
|
| 85 |
+
|
| 86 |
+
neutral_head_pose = create_head_pose(0, 0, 0, 0, 0, 0, degrees=True)
|
| 87 |
+
return (neutral_head_pose, np.array([0.0, 0.0], dtype=np.float64), 0.0)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
class GotoQueueMove(Move):
|
| 91 |
+
"""Wrapper for goto moves to work with the movement queue system."""
|
| 92 |
+
|
| 93 |
+
def __init__(
|
| 94 |
+
self,
|
| 95 |
+
target_head_pose: NDArray[np.float32],
|
| 96 |
+
start_head_pose: NDArray[np.float32] | None = None,
|
| 97 |
+
target_antennas: Tuple[float, float] = (0, 0),
|
| 98 |
+
start_antennas: Tuple[float, float] | None = None,
|
| 99 |
+
target_body_yaw: float = 0,
|
| 100 |
+
start_body_yaw: float | None = None,
|
| 101 |
+
duration: float = 1.0,
|
| 102 |
+
):
|
| 103 |
+
"""Initialize a GotoQueueMove."""
|
| 104 |
+
self._duration = duration
|
| 105 |
+
self.target_head_pose = target_head_pose
|
| 106 |
+
self.start_head_pose = start_head_pose
|
| 107 |
+
self.target_antennas = target_antennas
|
| 108 |
+
self.start_antennas = start_antennas or (0, 0)
|
| 109 |
+
self.target_body_yaw = target_body_yaw
|
| 110 |
+
self.start_body_yaw = start_body_yaw or 0
|
| 111 |
+
|
| 112 |
+
@property
|
| 113 |
+
def duration(self) -> float:
|
| 114 |
+
"""Duration property required by official Move interface."""
|
| 115 |
+
return self._duration
|
| 116 |
+
|
| 117 |
+
def evaluate(self, t: float) -> tuple[NDArray[np.float64] | None, NDArray[np.float64] | None, float | None]:
|
| 118 |
+
"""Evaluate goto move at time t using linear interpolation."""
|
| 119 |
+
try:
|
| 120 |
+
from reachy_mini.utils import create_head_pose
|
| 121 |
+
from reachy_mini.utils.interpolation import linear_pose_interpolation
|
| 122 |
+
|
| 123 |
+
# Clamp t to [0, 1] for interpolation
|
| 124 |
+
t_clamped = max(0, min(1, t / self.duration))
|
| 125 |
+
|
| 126 |
+
# Use start pose if available, otherwise neutral
|
| 127 |
+
if self.start_head_pose is not None:
|
| 128 |
+
start_pose = self.start_head_pose
|
| 129 |
+
else:
|
| 130 |
+
start_pose = create_head_pose(0, 0, 0, 0, 0, 0, degrees=True)
|
| 131 |
+
|
| 132 |
+
# Interpolate head pose
|
| 133 |
+
head_pose = linear_pose_interpolation(start_pose, self.target_head_pose, t_clamped)
|
| 134 |
+
|
| 135 |
+
# Interpolate antennas - return as numpy array
|
| 136 |
+
antennas = np.array(
|
| 137 |
+
[
|
| 138 |
+
self.start_antennas[0] + (self.target_antennas[0] - self.start_antennas[0]) * t_clamped,
|
| 139 |
+
self.start_antennas[1] + (self.target_antennas[1] - self.start_antennas[1]) * t_clamped,
|
| 140 |
+
],
|
| 141 |
+
dtype=np.float64,
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
# Interpolate body yaw
|
| 145 |
+
body_yaw = self.start_body_yaw + (self.target_body_yaw - self.start_body_yaw) * t_clamped
|
| 146 |
+
|
| 147 |
+
return (head_pose, antennas, body_yaw)
|
| 148 |
+
|
| 149 |
+
except Exception as e:
|
| 150 |
+
logger.error(f"Error evaluating goto move at t={t}: {e}")
|
| 151 |
+
# Return target pose on error - convert to float64
|
| 152 |
+
target_head_pose_f64 = self.target_head_pose.astype(np.float64)
|
| 153 |
+
target_antennas_array = np.array([self.target_antennas[0], self.target_antennas[1]], dtype=np.float64)
|
| 154 |
+
return (target_head_pose_f64, target_antennas_array, self.target_body_yaw)
|
src/Reachy_OpenWebUI/images/astronaut.png
ADDED
|
Git LFS Details
|