Jacid23 commited on
Commit
cf1f373
·
verified ·
1 Parent(s): 90c84bb

Upload 344 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env.example +79 -0
  2. .gitattributes +61 -35
  3. .gitignore +15 -0
  4. APP_INTEGRATION_LAYER.md +57 -0
  5. ATTITUDE_PLAN.md +128 -0
  6. CODE_OF_CONDUCT.md +89 -0
  7. CONTRIBUTING.md +93 -0
  8. LICENSE +201 -0
  9. MANIFEST.in +1 -0
  10. README.md +253 -11
  11. app.py +239 -0
  12. external_content/external_profiles/starter_profile/instructions.txt +6 -0
  13. external_content/external_profiles/starter_profile/tools.txt +12 -0
  14. external_content/external_tools/starter_custom_tool.py +34 -0
  15. index.html +125 -19
  16. pyproject.toml +150 -0
  17. requirements.txt +1 -0
  18. setup.py +28 -0
  19. src/Reachy_OpenWebUI/.env.example +44 -0
  20. src/Reachy_OpenWebUI/__init__.py +1 -0
  21. src/Reachy_OpenWebUI/__pycache__/__init__.cpython-311.pyc +0 -0
  22. src/Reachy_OpenWebUI/__pycache__/app_profiles.cpython-311.pyc +0 -0
  23. src/Reachy_OpenWebUI/__pycache__/camera_frame_encoding.cpython-311.pyc +0 -0
  24. src/Reachy_OpenWebUI/__pycache__/camera_worker.cpython-311.pyc +0 -0
  25. src/Reachy_OpenWebUI/__pycache__/chat_history.cpython-311.pyc +0 -0
  26. src/Reachy_OpenWebUI/__pycache__/config.cpython-311.pyc +0 -0
  27. src/Reachy_OpenWebUI/__pycache__/console.cpython-311.pyc +3 -0
  28. src/Reachy_OpenWebUI/__pycache__/dance_emotion_moves.cpython-311.pyc +0 -0
  29. src/Reachy_OpenWebUI/__pycache__/main.cpython-311.pyc +0 -0
  30. src/Reachy_OpenWebUI/__pycache__/motion_patterns.cpython-311.pyc +0 -0
  31. src/Reachy_OpenWebUI/__pycache__/moves.cpython-311.pyc +0 -0
  32. src/Reachy_OpenWebUI/__pycache__/settings_keys.cpython-311.pyc +0 -0
  33. src/Reachy_OpenWebUI/__pycache__/utils.cpython-311.pyc +0 -0
  34. src/Reachy_OpenWebUI/app_profiles.py +62 -0
  35. src/Reachy_OpenWebUI/audio/__init__.py +1 -0
  36. src/Reachy_OpenWebUI/audio/__pycache__/__init__.cpython-311.pyc +0 -0
  37. src/Reachy_OpenWebUI/audio/__pycache__/attitude_wobbler.cpython-311.pyc +0 -0
  38. src/Reachy_OpenWebUI/audio/__pycache__/head_wobbler.cpython-311.pyc +0 -0
  39. src/Reachy_OpenWebUI/audio/__pycache__/speech_tapper.cpython-311.pyc +0 -0
  40. src/Reachy_OpenWebUI/audio/__pycache__/startup_config.cpython-311.pyc +0 -0
  41. src/Reachy_OpenWebUI/audio/attitude_wobbler.py +243 -0
  42. src/Reachy_OpenWebUI/audio/head_wobbler.py +205 -0
  43. src/Reachy_OpenWebUI/audio/speech_tapper.py +274 -0
  44. src/Reachy_OpenWebUI/audio/startup_config.py +68 -0
  45. src/Reachy_OpenWebUI/camera_frame_encoding.py +27 -0
  46. src/Reachy_OpenWebUI/camera_worker.py +78 -0
  47. src/Reachy_OpenWebUI/config.py +145 -0
  48. src/Reachy_OpenWebUI/console.py +0 -0
  49. src/Reachy_OpenWebUI/dance_emotion_moves.py +154 -0
  50. 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
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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: red
6
- sdk: static
7
- pinned: false
8
- license: apache-2.0
9
- ---
10
-
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
19
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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

  • SHA256: 8696cef88041c72a51e9ee269b10b65813b88bd4df0e27a0b82faea9901cf136
  • Pointer size: 131 Bytes
  • Size of remote file: 306 kB