Matt Hartman commited on
Commit
719d94f
·
0 Parent(s):

Initial commit

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 +24 -0
  2. .gitattributes +41 -0
  3. .gitignore +61 -0
  4. LICENSE +201 -0
  5. README.md +27 -0
  6. README_OLD.md +319 -0
  7. docs/assets/conversation_app_arch.svg +3 -0
  8. docs/assets/reachy_mini_dance.gif +3 -0
  9. docs/scheme.mmd +63 -0
  10. external_content/external_profiles/starter_profile/instructions.txt +6 -0
  11. external_content/external_profiles/starter_profile/tools.txt +11 -0
  12. external_content/external_tools/starter_custom_tool.py +33 -0
  13. index.html +42 -0
  14. pyproject.toml +71 -0
  15. src/hello_world/__init__.py +1 -0
  16. src/hello_world/audio/__init__.py +1 -0
  17. src/hello_world/audio/head_wobbler.py +181 -0
  18. src/hello_world/audio/speech_tapper.py +268 -0
  19. src/hello_world/camera_worker.py +241 -0
  20. src/hello_world/config.py +217 -0
  21. src/hello_world/console.py +502 -0
  22. src/hello_world/dance_emotion_moves.py +154 -0
  23. src/hello_world/gradio_personality.py +316 -0
  24. src/hello_world/headless_personality.py +102 -0
  25. src/hello_world/headless_personality_ui.py +287 -0
  26. src/hello_world/images/reachymini_avatar.png +3 -0
  27. src/hello_world/images/user_avatar.png +3 -0
  28. src/hello_world/main.py +260 -0
  29. src/hello_world/moves.py +849 -0
  30. src/hello_world/openai_realtime.py +935 -0
  31. src/hello_world/profiles/__init__.py +1 -0
  32. src/hello_world/profiles/_hello_world_locked_profile/custom_tool.py +38 -0
  33. src/hello_world/profiles/_hello_world_locked_profile/instructions.txt +3 -0
  34. src/hello_world/profiles/_hello_world_locked_profile/sweep_look.py +127 -0
  35. src/hello_world/profiles/_hello_world_locked_profile/tools.txt +18 -0
  36. src/hello_world/prompts.py +110 -0
  37. src/hello_world/prompts/behaviors/silent_robot.txt +6 -0
  38. src/hello_world/prompts/default_prompt.txt +47 -0
  39. src/hello_world/prompts/identities/basic_info.txt +4 -0
  40. src/hello_world/prompts/identities/witty_identity.txt +4 -0
  41. src/hello_world/prompts/passion_for_lobster_jokes.txt +1 -0
  42. src/hello_world/static/index.html +54 -0
  43. src/hello_world/static/main.js +136 -0
  44. src/hello_world/static/style.css +210 -0
  45. src/hello_world/tools/__init__.py +4 -0
  46. src/hello_world/tools/background_tool_manager.py +412 -0
  47. src/hello_world/tools/camera.py +68 -0
  48. src/hello_world/tools/core_tools.py +330 -0
  49. src/hello_world/tools/dance.py +86 -0
  50. src/hello_world/tools/do_nothing.py +30 -0
.env.example ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ OPENAI_API_KEY=
2
+ MODEL_NAME="gpt-realtime"
3
+
4
+ # Local vision model (only used with --local-vision CLI flag)
5
+ # By default, vision is handled by gpt-realtime when the camera tool is used
6
+ LOCAL_VISION_MODEL=HuggingFaceTB/SmolVLM2-2.2B-Instruct
7
+
8
+ # Cache for local VLM (only used with --local-vision CLI flag)
9
+ HF_HOME=./cache
10
+
11
+ # Hugging Face token for accessing datasets/models
12
+ HF_TOKEN=
13
+
14
+ # Profile selection (defaults to "default" when unset)
15
+ REACHY_MINI_CUSTOM_PROFILE="example"
16
+
17
+ # Optional external profile/tool directories
18
+ # REACHY_MINI_EXTERNAL_PROFILES_DIRECTORY=external_content/external_profiles
19
+ # REACHY_MINI_EXTERNAL_TOOLS_DIRECTORY=external_content/external_tools
20
+
21
+ # Optional: discover and auto-load all tools found in REACHY_MINI_EXTERNAL_TOOLS_DIRECTORY,
22
+ # even if they are not listed in the selected profile's tools.txt.
23
+ # This is convenient for downloaded tools used with built-in/default profiles.
24
+ # AUTOLOAD_EXTERNAL_TOOLS=1
.gitattributes ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 lfs
27
+ *.svgz lfs
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
.gitignore ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+
7
+ # Virtual environments
8
+ .venv/
9
+ venv/
10
+ ENV/
11
+ env/
12
+
13
+ # Environment variables
14
+ .env
15
+
16
+ # Build and distribution
17
+ build/
18
+ dist/
19
+ *.egg-info/
20
+ .eggs/
21
+
22
+ # Testing
23
+ .pytest_cache/
24
+ .coverage
25
+ .hypothesis/
26
+ htmlcov/
27
+ coverage.xml
28
+ *.cover
29
+
30
+ # Linting and formatting
31
+ .ruff_cache/
32
+ .mypy_cache/
33
+
34
+ # IDE
35
+ .vscode/
36
+ .idea/
37
+ *.swp
38
+ *.swo
39
+
40
+ # Security
41
+ *.key
42
+ *.pem
43
+ *.crt
44
+ *.csr
45
+
46
+ # Temporary files
47
+ tmp/
48
+ *.log
49
+ cache/
50
+
51
+ # macOS
52
+ .DS_Store
53
+
54
+ # Linux
55
+ *~
56
+ .directory
57
+ .Trash-*
58
+ .nfs*
59
+
60
+ # User-created personalities (managed by UI)
61
+ src/hello_world/profiles/user_personalities/
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.
README.md ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Hello World
3
+ emoji: 🤖
4
+ colorFrom: purple
5
+ colorTo: gray
6
+ sdk: static
7
+ pinned: false
8
+ tags:
9
+ - reachy_mini
10
+ - reachy_mini_python_app
11
+ ---
12
+
13
+ # Hello World
14
+
15
+ Forked from the Reachy Mini conversation app.
16
+
17
+ Use the `src/hello_world/profiles/_hello_world_locked_profile` folder to customize your own app from this template:
18
+ - Edit instructions `_hello_world_locked_profile/instructions.txt`
19
+ - Edit available tools in `_hello_world_locked_profile/tools.txt`
20
+ - You can create your own tools in `_hello_world_locked_profile` by subclassing the `Tool` class.
21
+
22
+ Do not forget to customize:
23
+ - this `README.md` file
24
+ - the `index.html` file (Hugging Face Spaces landing page)
25
+ - the `src/hello_world/static/index.html` (the web app parameters page)
26
+
27
+ The original README from the conversation app is available in `README_OLD.md`.
README_OLD.md ADDED
@@ -0,0 +1,319 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Reachy Mini Conversation App
3
+ emoji: 🎤
4
+ colorFrom: red
5
+ colorTo: blue
6
+ sdk: static
7
+ pinned: false
8
+ short_description: Talk with Reachy Mini!
9
+ suggested_storage: large
10
+ tags:
11
+ - reachy_mini
12
+ - reachy_mini_python_app
13
+ ---
14
+
15
+ # Reachy Mini conversation app
16
+
17
+ Conversational app for the Reachy Mini robot combining OpenAI's realtime APIs, vision pipelines, and choreographed motion libraries.
18
+
19
+ ![Reachy Mini Dance](docs/assets/reachy_mini_dance.gif)
20
+
21
+ ## Table of contents
22
+ - [Overview](#overview)
23
+ - [Architecture](#architecture)
24
+ - [Installation](#installation)
25
+ - [Configuration](#configuration)
26
+ - [Running the app](#running-the-app)
27
+ - [LLM tools](#llm-tools-exposed-to-the-assistant)
28
+ - [Advanced features](#advanced-features)
29
+ - [Contributing](#contributing)
30
+ - [License](#license)
31
+
32
+ ## Overview
33
+ - Real-time audio conversation loop powered by the OpenAI realtime API and `fastrtc` for low-latency streaming.
34
+ - Vision processing uses gpt-realtime by default (when camera tool is used), with optional local vision processing using SmolVLM2 model running on-device (CPU/GPU/MPS) via `--local-vision` flag.
35
+ - Layered motion system queues primary moves (dances, emotions, goto poses, breathing) while blending speech-reactive wobble and head-tracking.
36
+ - Async tool dispatch integrates robot motion, camera capture, and optional head-tracking capabilities through a Gradio web UI with live transcripts.
37
+
38
+ ## Architecture
39
+
40
+ The app follows a layered architecture connecting the user, AI services, and robot hardware:
41
+
42
+ <p align="center">
43
+ <img src="docs/assets/conversation_app_arch.svg" alt="Architecture Diagram" width="600"/>
44
+ </p>
45
+
46
+ ## Installation
47
+
48
+ > [!IMPORTANT]
49
+ > Before using this app, you need to install [Reachy Mini's SDK](https://github.com/pollen-robotics/reachy_mini/).<br>
50
+ > Windows support is currently experimental and has not been extensively tested. Use with caution.
51
+
52
+ <details open>
53
+ <summary><b>Using uv (recommended)</b></summary>
54
+
55
+ Set up the project quickly using [uv](https://docs.astral.sh/uv/):
56
+
57
+ ```bash
58
+ # macOS (Homebrew)
59
+ uv venv --python /opt/homebrew/bin/python3.12 .venv
60
+
61
+ # Linux / Windows (Python in PATH)
62
+ uv venv --python python3.12 .venv
63
+
64
+ source .venv/bin/activate
65
+ uv sync
66
+ ```
67
+
68
+ > **Note:** To reproduce the exact dependency set from this repo's `uv.lock`, run `uv sync --frozen`. This ensures `uv` installs directly from the lockfile without re-resolving or updating any versions.
69
+
70
+ **Install optional features:**
71
+ ```bash
72
+ uv sync --extra local_vision # Local PyTorch/Transformers vision
73
+ uv sync --extra yolo_vision # YOLO-based head-tracking
74
+ uv sync --extra mediapipe_vision # MediaPipe-based head-tracking
75
+ uv sync --extra all_vision # All vision features
76
+ ```
77
+
78
+ Combine extras or include dev dependencies:
79
+ ```bash
80
+ uv sync --extra all_vision --group dev
81
+ ```
82
+
83
+ </details>
84
+
85
+ <details>
86
+ <summary><b>Using pip</b></summary>
87
+
88
+ ```bash
89
+ python -m venv .venv
90
+ source .venv/bin/activate
91
+ pip install -e .
92
+ ```
93
+
94
+ **Install optional features:**
95
+ ```bash
96
+ pip install -e .[local_vision] # Local vision stack
97
+ pip install -e .[yolo_vision] # YOLO-based vision
98
+ pip install -e .[mediapipe_vision] # MediaPipe-based vision
99
+ pip install -e .[all_vision] # All vision features
100
+ pip install -e .[dev] # Development tools
101
+ ```
102
+
103
+ Some wheels (like PyTorch) are large and require compatible CUDA or CPU builds—make sure your platform matches the binaries pulled in by each extra.
104
+
105
+ </details>
106
+
107
+ ### Optional dependency groups
108
+
109
+ | Extra | Purpose | Notes |
110
+ |-------|---------|-------|
111
+ | `local_vision` | Run the local VLM (SmolVLM2) through PyTorch/Transformers | GPU recommended. Ensure compatible PyTorch builds for your platform. |
112
+ | `yolo_vision` | YOLOv11n head tracking via `ultralytics` and `supervision` | Runs on CPU (default). GPU improves performance. Supports the `--head-tracker yolo` option. |
113
+ | `mediapipe_vision` | Lightweight landmark tracking with MediaPipe | Works on CPU. Enables `--head-tracker mediapipe`. |
114
+ | `all_vision` | Convenience alias installing every vision extra | Install when you want the flexibility to experiment with every provider. |
115
+ | `dev` | Developer tooling (`pytest`, `ruff`, `mypy`) | Development-only dependencies. Use `--group dev` with uv or `[dev]` with pip. |
116
+
117
+ **Note:** `dev` is a dependency group (not an optional dependency). With uv, use `--group dev`. With pip, use `[dev]`.
118
+
119
+ ## Configuration
120
+
121
+ 1. Copy `.env.example` to `.env`
122
+ 2. Fill in required values, notably the OpenAI API key
123
+
124
+ | Variable | Description |
125
+ |----------|-------------|
126
+ | `OPENAI_API_KEY` | Required. Grants access to the OpenAI realtime endpoint. |
127
+ | `MODEL_NAME` | Override the realtime model (defaults to `gpt-realtime`). Used for both conversation and vision (unless `--local-vision` flag is used). |
128
+ | `HF_HOME` | Cache directory for local Hugging Face downloads (only used with `--local-vision` flag, defaults to `./cache`). |
129
+ | `HF_TOKEN` | Optional token for Hugging Face access (for gated/private assets). |
130
+ | `LOCAL_VISION_MODEL` | Hugging Face model path for local vision processing (only used with `--local-vision` flag, defaults to `HuggingFaceTB/SmolVLM2-2.2B-Instruct`). |
131
+
132
+ ## Running the app
133
+
134
+ Activate your virtual environment, then launch:
135
+
136
+ ```bash
137
+ reachy-mini-conversation-app
138
+ ```
139
+
140
+ > [!TIP]
141
+ > Make sure the Reachy Mini daemon is running before launching the app. If you see a `TimeoutError`, it means the daemon isn't started. See [Reachy Mini's SDK](https://github.com/pollen-robotics/reachy_mini/) for setup instructions.
142
+
143
+ The app runs in console mode by default. Add `--gradio` to launch a web UI at http://127.0.0.1:7860/ (required for simulation mode). Vision and head-tracking options are described in the CLI table below.
144
+
145
+ ### CLI options
146
+
147
+ | Option | Default | Description |
148
+ |--------|---------|-------------|
149
+ | `--head-tracker {yolo,mediapipe}` | `None` | Select a head-tracking backend when a camera is available. YOLO is implemented locally, MediaPipe comes from the `reachy_mini_toolbox` package. Requires the matching optional extra. |
150
+ | `--no-camera` | `False` | Run without camera capture or head tracking. |
151
+ | `--local-vision` | `False` | Use local vision model (SmolVLM2) for periodic image processing instead of gpt-realtime vision. Requires `local_vision` extra to be installed. |
152
+ | `--gradio` | `False` | Launch the Gradio web UI. Without this flag, runs in console mode. Required when running in simulation mode. |
153
+ | `--robot-name` | `None` | Optional. Connect to a specific robot by name when running multiple daemons on the same subnet. See [Multiple robots on the same subnet](#advanced-features). |
154
+ | `--debug` | `False` | Enable verbose logging for troubleshooting. |
155
+
156
+ ### Examples
157
+
158
+ ```bash
159
+ # Run with MediaPipe head tracking
160
+ reachy-mini-conversation-app --head-tracker mediapipe
161
+
162
+ # Run with local vision processing (requires local_vision extra)
163
+ reachy-mini-conversation-app --local-vision
164
+
165
+ # Audio-only conversation (no camera)
166
+ reachy-mini-conversation-app --no-camera
167
+
168
+ # Launch with Gradio web interface
169
+ reachy-mini-conversation-app --gradio
170
+ ```
171
+
172
+ ## LLM tools exposed to the assistant
173
+
174
+ | Tool | Action | Dependencies |
175
+ |------|--------|--------------|
176
+ | `move_head` | Queue a head pose change (left/right/up/down/front). | Core install only. |
177
+ | `camera` | Capture the latest camera frame and send it to gpt-realtime for vision analysis. | Requires camera worker. Uses gpt-realtime vision by default. |
178
+ | `head_tracking` | Enable or disable head-tracking offsets (not identity recognition - only detects and tracks head position). | Camera worker with configured head tracker (`--head-tracker`). |
179
+ | `dance` | Queue a dance from `reachy_mini_dances_library`. | Core install only. |
180
+ | `stop_dance` | Clear queued dances. | Core install only. |
181
+ | `play_emotion` | Play a recorded emotion clip via Hugging Face datasets. | Core install only. Uses the default open emotions dataset: [`pollen-robotics/reachy-mini-emotions-library`](https://huggingface.co/datasets/pollen-robotics/reachy-mini-emotions-library). |
182
+ | `stop_emotion` | Clear queued emotions. | Core install only. |
183
+ | `do_nothing` | Explicitly remain idle. | Core install only. |
184
+
185
+ ## Advanced features
186
+
187
+ Built-in motion content is published as open Hugging Face datasets:
188
+ - Emotions: [`pollen-robotics/reachy-mini-emotions-library`](https://huggingface.co/datasets/pollen-robotics/reachy-mini-emotions-library)
189
+ - Dances: [`pollen-robotics/reachy-mini-dances-library`](https://huggingface.co/datasets/pollen-robotics/reachy-mini-dances-library)
190
+
191
+ <details>
192
+ <summary><b>Custom profiles</b></summary>
193
+
194
+ Create custom profiles with dedicated instructions and enabled tools.
195
+
196
+ Set `REACHY_MINI_CUSTOM_PROFILE=<name>` to load `src/reachy_mini_conversation_app/profiles/<name>/` (see `.env.example`). If unset, the `default` profile is used.
197
+
198
+ Each profile should include `instructions.txt` (prompt text). `tools.txt` (list of allowed tools) is recommended. If missing for a non-default profile, the app falls back to `profiles/default/tools.txt`. Profiles can optionally contain custom tool implementations.
199
+
200
+ **Custom instructions:**
201
+
202
+ Write plain-text prompts in `instructions.txt`. To reuse shared prompt pieces, add lines like:
203
+ ```
204
+ [passion_for_lobster_jokes]
205
+ [identities/witty_identity]
206
+ ```
207
+ Each placeholder pulls the matching file under `src/reachy_mini_conversation_app/prompts/` (nested paths allowed). See `src/reachy_mini_conversation_app/profiles/example/` for a reference layout.
208
+
209
+ **Enabling tools:**
210
+
211
+ List enabled tools in `tools.txt`, one per line. Prefix with `#` to comment out:
212
+ ```
213
+ play_emotion
214
+ # move_head
215
+
216
+ # My custom tool defined locally
217
+ sweep_look
218
+ ```
219
+ Tools are resolved first from Python files in the profile folder (custom tools), then from the core library `src/reachy_mini_conversation_app/tools/` (like `dance`, `head_tracking`).
220
+
221
+ **Custom tools:**
222
+
223
+ On top of built-in tools found in the core library, you can implement custom tools specific to your profile by adding Python files in the profile folder.
224
+ Custom tools must subclass `reachy_mini_conversation_app.tools.core_tools.Tool` (see `profiles/example/sweep_look.py`).
225
+
226
+ **Edit personalities from the UI:**
227
+
228
+ When running with `--gradio`, open the "Personality" accordion:
229
+ - Select among available profiles (folders under `src/reachy_mini_conversation_app/profiles/`) or the built‑in default.
230
+ - Click "Apply" to update the current session instructions live.
231
+ - Create a new personality by entering a name and instructions text. It stores files under `profiles/<name>/` and copies `tools.txt` from the `default` profile.
232
+
233
+ Note: The "Personality" panel updates the conversation instructions. Tool sets are loaded at startup from `tools.txt` and are not hot‑reloaded.
234
+
235
+ </details>
236
+
237
+ <details>
238
+ <summary><b>Locked profile mode</b></summary>
239
+
240
+ To create a locked variant of the app that cannot switch profiles, edit `src/reachy_mini_conversation_app/config.py` and set the `LOCKED_PROFILE` constant to the desired profile name:
241
+ ```python
242
+ LOCKED_PROFILE: str | None = "mars_rover" # Lock to this profile
243
+ ```
244
+ When `LOCKED_PROFILE` is set, the app always uses that profile, ignoring `REACHY_MINI_CUSTOM_PROFILE` env var & the Gradio UI shows "(locked)" and disables all profile editing controls.
245
+ This is useful for creating dedicated clones of the app with a fixed personality. Clone scripts can simply edit this constant to lock the variant.
246
+
247
+ </details>
248
+
249
+ <details>
250
+ <summary><b>External profiles and tools</b></summary>
251
+
252
+ You can extend the app with profiles/tools stored outside `src/reachy_mini_conversation_app/`.
253
+
254
+ - Core profiles are under `src/reachy_mini_conversation_app/profiles/`.
255
+ - Core tools are under `src/reachy_mini_conversation_app/tools/`.
256
+
257
+ **Recommended layout:**
258
+
259
+ ```text
260
+ external_content/
261
+ ├── external_profiles/
262
+ │ └── my_profile/
263
+ │ ├── instructions.txt
264
+ │ ├── tools.txt # optional (see fallback behavior below)
265
+ │ └── voice.txt # optional
266
+ └── external_tools/
267
+ └── my_custom_tool.py
268
+ ```
269
+
270
+ **Environment variables:**
271
+
272
+ Set these values in your `.env` (copy from `.env.example`):
273
+
274
+ ```env
275
+ REACHY_MINI_CUSTOM_PROFILE=my_profile
276
+ REACHY_MINI_EXTERNAL_PROFILES_DIRECTORY=./external_content/external_profiles
277
+ REACHY_MINI_EXTERNAL_TOOLS_DIRECTORY=./external_content/external_tools
278
+ # Optional convenience mode:
279
+ # AUTOLOAD_EXTERNAL_TOOLS=1
280
+ ```
281
+
282
+ **Loading behavior:**
283
+
284
+ - **Default/strict mode**: `tools.txt` defines enabled tools explicitly. Every name in `tools.txt` must resolve to either a built-in tool (`src/reachy_mini_conversation_app/tools/`) or an external tool module in `REACHY_MINI_EXTERNAL_TOOLS_DIRECTORY`.
285
+ - **Convenience mode** (`AUTOLOAD_EXTERNAL_TOOLS=1`): all valid `*.py` tool files in `REACHY_MINI_EXTERNAL_TOOLS_DIRECTORY` are auto-added.
286
+ - **External profile fallback**: if the selected external profile has no `tools.txt`, the app falls back to built-in `profiles/default/tools.txt`.
287
+
288
+ This supports both:
289
+ 1. Downloaded external tools used with built-in/default profile.
290
+ 2. Downloaded external profiles used with built-in default tools.
291
+
292
+ </details>
293
+
294
+ <details>
295
+ <summary><b>Multiple robots on the same subnet</b></summary>
296
+
297
+ If you run multiple Reachy Mini daemons on the same network, use:
298
+
299
+ ```bash
300
+ reachy-mini-conversation-app --robot-name <name>
301
+ ```
302
+
303
+ `<name>` must match the daemon's `--robot-name` value so the app connects to the correct robot.
304
+
305
+ </details>
306
+
307
+ ## Contributing
308
+
309
+ We welcome bug fixes, features, profiles, and documentation improvements. Please review our
310
+ [contribution guide](CONTRIBUTING.md) for branch conventions, quality checks, and PR workflow.
311
+
312
+ Quick start:
313
+ - Fork and clone the repo
314
+ - Follow the [installation steps](#installation) (include the `dev` dependency group)
315
+ - Run contributor checks listed in [CONTRIBUTING.md](CONTRIBUTING.md)
316
+
317
+ ## License
318
+
319
+ Apache 2.0
docs/assets/conversation_app_arch.svg ADDED

Git LFS Details

  • SHA256: 0013aac9cbe5f78a2aed3ed4de5fab5c3afe36ff72950ac97e64bd5db462e3b9
  • Pointer size: 131 Bytes
  • Size of remote file: 124 kB
docs/assets/reachy_mini_dance.gif ADDED

Git LFS Details

  • SHA256: 75914c3cb7af982e0b1c6369e25fc46d8c08a0ab5ad022240ae9c1a0d93967c3
  • Pointer size: 132 Bytes
  • Size of remote file: 3.93 MB
docs/scheme.mmd ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ config:
3
+ layout: dagre
4
+ flowchart:
5
+ htmlLabels: true
6
+ ---
7
+ flowchart TB
8
+ User(["<span style='font-size:16px;font-weight:bold;'>User</span><br><span style='font-size:13px;color:#01579b;'>Person interacting with system</span>"])
9
+ -- audio stream -->
10
+ UI@{ label: "<span style='font-size:16px;font-weight:bold;'>UI Layer</span><br><span style='font-size:13px;color:#0277bd;'>Gradio/Console</span>" }
11
+
12
+ UI -- audio stream -->
13
+ OpenAI@{ label: "<span style='font-size:17px;font-weight:bold;'>gpt-realtime API</span><br><span style='font-size:13px; color:#7b1fa2;'>Audio+Tool Calls+Vision</span>" }
14
+
15
+ OpenAI -- audio stream -->
16
+ Motion@{ label: "<span style='font-size:16px;font-weight:bold;'>Motion Control</span><br><span style='font-size:13px;color:#f57f17;'>Audio Sync + Tracking</span>" }
17
+
18
+ OpenAI -- tool calls -->
19
+ Handlers@{ label: "<span style='font-size:16px;font-weight:bold;'>Tool Layer</span><br><span style='font-size:12px;color:#f9a825;'>Built-in tools + profile-local tools<br/>+ external tools (optional)</span>" }
20
+
21
+ Profiles@{ label: "<span style='font-size:16px;font-weight:bold;'>Selected Profile</span><br><span style='font-size:12px;color:#6a1b9a;'>built-in or external<br/>instructions.txt + tools.txt</span>" }
22
+
23
+ Profiles -- defines enabled tools --> Handlers
24
+
25
+ Handlers -- movement
26
+ requests --> Motion
27
+
28
+ Handlers -- camera frames, head tracking -->
29
+ Camera@{ label: "<span style='font-size:16px;font-weight:bold;'>Camera Worker</span><br><span style='font-size:13px;color:#f57f17;'>Frame Buffer + Head Tracking</span>" }
30
+
31
+ Handlers -. image for
32
+ analysis .-> OpenAI
33
+
34
+ Camera -- head tracking --> Motion
35
+
36
+ Camera -. frames .->
37
+ Vision@{ label: "<span style='font-size:16px;font-weight:bold;'>Vision Processor</span><br><span style='font-size:13px;color:#7b1fa2;'>Local VLM (optional)</span>" }
38
+
39
+ Vision -. description .-> Handlers
40
+
41
+ Robot@{ label: "<span style='font-size:16px;font-weight:bold;'>reachy_mini</span><br><span style='font-size:13px;color:#c62828;'>Robot Control Library</span>" }
42
+ -- camera
43
+ frames --> Camera
44
+
45
+ Motion -- commands --> Robot
46
+
47
+ Handlers -- results --> OpenAI
48
+
49
+ User:::userStyle
50
+ UI:::uiStyle
51
+ OpenAI:::aiStyle
52
+ Motion:::coreStyle
53
+ Profiles:::toolStyle
54
+ Handlers:::toolStyle
55
+ Camera:::coreStyle
56
+ Vision:::aiStyle
57
+ Robot:::hardwareStyle
58
+ classDef userStyle fill:#e1f5fe,stroke:#01579b,stroke-width:3px
59
+ classDef uiStyle fill:#b3e5fc,stroke:#0277bd,stroke-width:2px
60
+ classDef aiStyle fill:#e1bee7,stroke:#7b1fa2,stroke-width:3px
61
+ classDef coreStyle fill:#fff9c4,stroke:#f57f17,stroke-width:2px
62
+ classDef hardwareStyle fill:#ef9a9a,stroke:#c62828,stroke-width:3px
63
+ classDef toolStyle fill:#fffde7,stroke:#f9a825,stroke-width:1px
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,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
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_mini_conversation_app/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
external_content/external_tools/starter_custom_tool.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Example external tool implementation."""
2
+
3
+ import logging
4
+ from typing import Any, Dict
5
+
6
+ from reachy_mini_conversation_app.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}
index.html ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Hello World</title>
7
+ <link rel="stylesheet" href="style.css" />
8
+ </head>
9
+ <body>
10
+ <div class="ambient"></div>
11
+ <div class="container">
12
+ <header class="hero">
13
+ <div class="pill">Reachy Mini App</div>
14
+ <h1>Hello World</h1>
15
+ <p class="subtitle">A conversation app for Reachy Mini robot.</p>
16
+ </header>
17
+
18
+ <div class="panel">
19
+ <div class="panel-heading">
20
+ <div>
21
+ <p class="eyebrow">Getting Started</p>
22
+ <h2>Installation</h2>
23
+ </div>
24
+ </div>
25
+ <p class="muted">Install this app on your Reachy Mini using the app store, or run it locally:</p>
26
+ <pre><code>uv sync
27
+ reachy-mini-daemon --sim # in another terminal
28
+ python -m hello_world</code></pre>
29
+ </div>
30
+
31
+ <div class="panel">
32
+ <div class="panel-heading">
33
+ <div>
34
+ <p class="eyebrow">Configuration</p>
35
+ <h2>OpenAI API Key</h2>
36
+ </div>
37
+ </div>
38
+ <p class="muted">This app requires an OpenAI API key for voice conversations. Set it via the web interface or environment variable.</p>
39
+ </div>
40
+ </div>
41
+ </body>
42
+ </html>
pyproject.toml ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = [ "setuptools",]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "hello_world"
7
+ version = "0.3.0"
8
+ description = ""
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ dependencies = [ "aiortc>=1.13.0", "fastrtc>=0.0.34", "gradio==5.50.1.dev1", "huggingface-hub==1.3.0", "opencv-python>=4.12.0.88", "python-dotenv", "openai>=2.1", "reachy_mini_dances_library", "reachy_mini_toolbox", "reachy-mini>=1.5.0", "eclipse-zenoh~=1.7.0", "gradio_client>=1.13.3",]
12
+ [[project.authors]]
13
+ name = "Pollen Robotics"
14
+ email = "contact@pollen-robotics.com"
15
+
16
+ [dependency-groups]
17
+ dev = [ "pytest", "pytest-asyncio", "ruff==0.12.0", "mypy==1.18.2", "pre-commit", "types-requests", "python-semantic-release>=10.5.3",]
18
+
19
+ [project.optional-dependencies]
20
+ local_vision = [ "torch>=2.1", "transformers==5.0.0rc2", "num2words",]
21
+ yolo_vision = [ "ultralytics", "supervision",]
22
+ mediapipe_vision = [ "mediapipe==0.10.14",]
23
+ all_vision = [ "torch>=2.1", "transformers==5.0.0rc2", "num2words", "ultralytics", "supervision", "mediapipe==0.10.14",]
24
+
25
+ [project.scripts]
26
+ hello-world = "hello_world.main:main"
27
+
28
+ [tool.setuptools]
29
+ include-package-data = true
30
+
31
+ [tool.ruff]
32
+ line-length = 119
33
+ exclude = [ ".venv", "dist", "build", "**/__pycache__", "*.egg-info", ".mypy_cache", ".pytest_cache",]
34
+
35
+ [tool.mypy]
36
+ python_version = "3.12"
37
+ files = [ "src/",]
38
+ ignore_missing_imports = true
39
+ strict = true
40
+ show_error_codes = true
41
+ warn_unused_ignores = true
42
+
43
+ [project.entry-points.reachy_mini_apps]
44
+ hello_world = "hello_world.main:HelloWorld"
45
+
46
+ [tool.setuptools.package-dir]
47
+ "" = "src"
48
+
49
+ [tool.setuptools.package-data]
50
+ hello_world = [ "images/*", "static/*", ".env.example", "demos/**/*.txt", "prompts_library/*.txt", "profiles/**/*.txt", "prompts/**/*.txt",]
51
+
52
+ [tool.ruff.lint]
53
+ select = [ "E", "F", "W", "I", "C4", "D",]
54
+ ignore = [ "E501", "D100", "D203", "D213",]
55
+
56
+ [tool.ruff.format]
57
+ quote-style = "double"
58
+ indent-style = "space"
59
+ skip-magic-trailing-comma = false
60
+ line-ending = "auto"
61
+
62
+ [tool.setuptools.packages.find]
63
+ where = [ "src",]
64
+
65
+ [tool.ruff.lint.isort]
66
+ length-sort = true
67
+ lines-after-imports = 2
68
+ no-lines-before = [ "standard-library", "local-folder",]
69
+ known-local-folder = [ "hello_world",]
70
+ known-first-party = [ "reachy_mini", "reachy_mini_dances_library", "reachy_mini_toolbox",]
71
+ split-on-trailing-comma = true
src/hello_world/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Nothing (for ruff)."""
src/hello_world/audio/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Nothing (for ruff)."""
src/hello_world/audio/head_wobbler.py ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 hello_world.audio.speech_tapper import HOP_MS, SwayRollRT
15
+
16
+
17
+ SAMPLE_RATE = 24000
18
+ MOVEMENT_LATENCY_S = 0.2 # seconds between audio and robot movement
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class HeadWobbler:
23
+ """Converts audio deltas (base64) into head movement offsets."""
24
+
25
+ def __init__(self, set_speech_offsets: Callable[[Tuple[float, float, float, float, float, float]], None]) -> None:
26
+ """Initialize the head wobbler."""
27
+ self._apply_offsets = set_speech_offsets
28
+ self._base_ts: float | None = None
29
+ self._hops_done: int = 0
30
+
31
+ self.audio_queue: "queue.Queue[Tuple[int, int, NDArray[np.int16]]]" = queue.Queue()
32
+ self.sway = SwayRollRT()
33
+
34
+ # Synchronization primitives
35
+ self._state_lock = threading.Lock()
36
+ self._sway_lock = threading.Lock()
37
+ self._generation = 0
38
+
39
+ self._stop_event = threading.Event()
40
+ self._thread: threading.Thread | None = None
41
+
42
+ def feed(self, delta_b64: str) -> None:
43
+ """Thread-safe: push audio into the consumer queue."""
44
+ buf = np.frombuffer(base64.b64decode(delta_b64), dtype=np.int16).reshape(1, -1)
45
+ with self._state_lock:
46
+ generation = self._generation
47
+ self.audio_queue.put((generation, SAMPLE_RATE, buf))
48
+
49
+ def start(self) -> None:
50
+ """Start the head wobbler loop in a thread."""
51
+ self._stop_event.clear()
52
+ self._thread = threading.Thread(target=self.working_loop, daemon=True)
53
+ self._thread.start()
54
+ logger.debug("Head wobbler started")
55
+
56
+ def stop(self) -> None:
57
+ """Stop the head wobbler loop."""
58
+ self._stop_event.set()
59
+ if self._thread is not None:
60
+ self._thread.join()
61
+ logger.debug("Head wobbler stopped")
62
+
63
+ def working_loop(self) -> None:
64
+ """Convert audio deltas into head movement offsets."""
65
+ hop_dt = HOP_MS / 1000.0
66
+
67
+ logger.debug("Head wobbler thread started")
68
+ while not self._stop_event.is_set():
69
+ queue_ref = self.audio_queue
70
+ try:
71
+ chunk_generation, sr, chunk = queue_ref.get_nowait() # (gen, sr, data)
72
+ except queue.Empty:
73
+ # avoid while to never exit
74
+ time.sleep(MOVEMENT_LATENCY_S)
75
+ continue
76
+
77
+ try:
78
+ with self._state_lock:
79
+ current_generation = self._generation
80
+ if chunk_generation != current_generation:
81
+ continue
82
+
83
+ if self._base_ts is None:
84
+ with self._state_lock:
85
+ if self._base_ts is None:
86
+ self._base_ts = time.monotonic()
87
+
88
+ pcm = np.asarray(chunk).squeeze(0)
89
+ with self._sway_lock:
90
+ results = self.sway.feed(pcm, sr)
91
+
92
+ i = 0
93
+ while i < len(results):
94
+ with self._state_lock:
95
+ if self._generation != current_generation:
96
+ break
97
+ base_ts = self._base_ts
98
+ hops_done = self._hops_done
99
+
100
+ if base_ts is None:
101
+ base_ts = time.monotonic()
102
+ with self._state_lock:
103
+ if self._base_ts is None:
104
+ self._base_ts = base_ts
105
+ hops_done = self._hops_done
106
+
107
+ target = base_ts + MOVEMENT_LATENCY_S + hops_done * hop_dt
108
+ now = time.monotonic()
109
+
110
+ if now - target >= hop_dt:
111
+ lag_hops = int((now - target) / hop_dt)
112
+ drop = min(lag_hops, len(results) - i - 1)
113
+ if drop > 0:
114
+ with self._state_lock:
115
+ self._hops_done += drop
116
+ hops_done = self._hops_done
117
+ i += drop
118
+ continue
119
+
120
+ if target > now:
121
+ time.sleep(target - now)
122
+ with self._state_lock:
123
+ if self._generation != current_generation:
124
+ break
125
+
126
+ r = results[i]
127
+ offsets = (
128
+ r["x_mm"] / 1000.0,
129
+ r["y_mm"] / 1000.0,
130
+ r["z_mm"] / 1000.0,
131
+ r["roll_rad"],
132
+ r["pitch_rad"],
133
+ r["yaw_rad"],
134
+ )
135
+
136
+ with self._state_lock:
137
+ if self._generation != current_generation:
138
+ break
139
+
140
+ self._apply_offsets(offsets)
141
+
142
+ with self._state_lock:
143
+ self._hops_done += 1
144
+ i += 1
145
+ finally:
146
+ queue_ref.task_done()
147
+ logger.debug("Head wobbler thread exited")
148
+
149
+ '''
150
+ def drain_audio_queue(self) -> None:
151
+ """Empty the audio queue."""
152
+ try:
153
+ while True:
154
+ self.audio_queue.get_nowait()
155
+ except QueueEmpty:
156
+ pass
157
+ '''
158
+
159
+ def reset(self) -> None:
160
+ """Reset the internal state."""
161
+ with self._state_lock:
162
+ self._generation += 1
163
+ self._base_ts = None
164
+ self._hops_done = 0
165
+
166
+ # Drain any queued audio chunks from previous generations
167
+ drained_any = False
168
+ while True:
169
+ try:
170
+ _, _, _ = self.audio_queue.get_nowait()
171
+ except queue.Empty:
172
+ break
173
+ else:
174
+ drained_any = True
175
+ self.audio_queue.task_done()
176
+
177
+ with self._sway_lock:
178
+ self.sway.reset()
179
+
180
+ if drained_any:
181
+ logger.debug("Head wobbler queue drained during reset")
src/hello_world/audio/speech_tapper.py ADDED
@@ -0,0 +1,268 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ SWAY_F_PITCH = 2.2
25
+ SWAY_A_PITCH_DEG = 4.5
26
+ SWAY_F_YAW = 0.6
27
+ SWAY_A_YAW_DEG = 7.5
28
+ SWAY_F_ROLL = 1.3
29
+ SWAY_A_ROLL_DEG = 2.25
30
+ SWAY_F_X = 0.35
31
+ SWAY_A_X_MM = 4.5
32
+ SWAY_F_Y = 0.45
33
+ SWAY_A_Y_MM = 3.75
34
+ SWAY_F_Z = 0.25
35
+ SWAY_A_Z_MM = 2.25
36
+
37
+ SWAY_DB_LOW = -46.0
38
+ SWAY_DB_HIGH = -18.0
39
+ LOUDNESS_GAMMA = 0.9
40
+ SWAY_ATTACK_MS = 50
41
+ SWAY_RELEASE_MS = 250
42
+
43
+ # Derived
44
+ FRAME = int(SR * FRAME_MS / 1000)
45
+ HOP = int(SR * HOP_MS / 1000)
46
+ ATTACK_FR = max(1, int(VAD_ATTACK_MS / HOP_MS))
47
+ RELEASE_FR = max(1, int(VAD_RELEASE_MS / HOP_MS))
48
+ SWAY_ATTACK_FR = max(1, int(SWAY_ATTACK_MS / HOP_MS))
49
+ SWAY_RELEASE_FR = max(1, int(SWAY_RELEASE_MS / HOP_MS))
50
+
51
+
52
+ def _rms_dbfs(x: NDArray[np.float32]) -> float:
53
+ """Root-mean-square in dBFS for float32 mono array in [-1,1]."""
54
+ # numerically stable rms (avoid overflow)
55
+ x = x.astype(np.float32, copy=False)
56
+ rms = np.sqrt(np.mean(x * x, dtype=np.float32) + 1e-12, dtype=np.float32)
57
+ return float(20.0 * math.log10(float(rms) + 1e-12))
58
+
59
+
60
+ def _loudness_gain(db: float, offset: float = SENS_DB_OFFSET) -> float:
61
+ """Normalize dB into [0,1] with gamma; clipped to [0,1]."""
62
+ t = (db + offset - SWAY_DB_LOW) / (SWAY_DB_HIGH - SWAY_DB_LOW)
63
+ if t < 0.0:
64
+ t = 0.0
65
+ elif t > 1.0:
66
+ t = 1.0
67
+ return t**LOUDNESS_GAMMA if LOUDNESS_GAMMA != 1.0 else t
68
+
69
+
70
+ def _to_float32_mono(x: NDArray[Any]) -> NDArray[np.float32]:
71
+ """Convert arbitrary PCM array to float32 mono in [-1,1].
72
+
73
+ Accepts shapes: (N,), (1,N), (N,1), (C,N), (N,C).
74
+ """
75
+ a = np.asarray(x)
76
+ if a.ndim == 0:
77
+ return np.zeros(0, dtype=np.float32)
78
+
79
+ # If 2D, decide which axis is channels (prefer small first dim)
80
+ if a.ndim == 2:
81
+ # e.g., (channels, samples) if channels is small (<=8)
82
+ if a.shape[0] <= 8 and a.shape[0] <= a.shape[1]:
83
+ a = np.mean(a, axis=0)
84
+ else:
85
+ a = np.mean(a, axis=1)
86
+ elif a.ndim > 2:
87
+ a = np.mean(a.reshape(a.shape[0], -1), axis=0)
88
+
89
+ # Now 1D, cast/scale
90
+ if np.issubdtype(a.dtype, np.floating):
91
+ return a.astype(np.float32, copy=False)
92
+ # integer PCM
93
+ info = np.iinfo(a.dtype)
94
+ scale = float(max(-info.min, info.max))
95
+ return a.astype(np.float32) / (scale if scale != 0.0 else 1.0)
96
+
97
+
98
+ def _resample_linear(x: NDArray[np.float32], sr_in: int, sr_out: int) -> NDArray[np.float32]:
99
+ """Lightweight linear resampler for short buffers."""
100
+ if sr_in == sr_out or x.size == 0:
101
+ return x
102
+ # guard tiny sizes
103
+ n_out = int(round(x.size * sr_out / sr_in))
104
+ if n_out <= 1:
105
+ return np.zeros(0, dtype=np.float32)
106
+ t_in = np.linspace(0.0, 1.0, num=x.size, dtype=np.float32, endpoint=True)
107
+ t_out = np.linspace(0.0, 1.0, num=n_out, dtype=np.float32, endpoint=True)
108
+ return np.interp(t_out, t_in, x).astype(np.float32, copy=False)
109
+
110
+
111
+ class SwayRollRT:
112
+ """Feed audio chunks → per-hop sway outputs.
113
+
114
+ Usage:
115
+ rt = SwayRollRT()
116
+ rt.feed(pcm_int16_or_float, sr) -> List[dict]
117
+ """
118
+
119
+ def __init__(self, rng_seed: int = 7):
120
+ """Initialize state."""
121
+ self._seed = int(rng_seed)
122
+ self.samples: deque[float] = deque(maxlen=10 * SR) # sliding window for VAD/env
123
+ self.carry: NDArray[np.float32] = np.zeros(0, dtype=np.float32)
124
+
125
+ self.vad_on = False
126
+ self.vad_above = 0
127
+ self.vad_below = 0
128
+
129
+ self.sway_env = 0.0
130
+ self.sway_up = 0
131
+ self.sway_down = 0
132
+
133
+ rng = np.random.default_rng(self._seed)
134
+ self.phase_pitch = float(rng.random() * 2 * math.pi)
135
+ self.phase_yaw = float(rng.random() * 2 * math.pi)
136
+ self.phase_roll = float(rng.random() * 2 * math.pi)
137
+ self.phase_x = float(rng.random() * 2 * math.pi)
138
+ self.phase_y = float(rng.random() * 2 * math.pi)
139
+ self.phase_z = float(rng.random() * 2 * math.pi)
140
+ self.t = 0.0
141
+
142
+ def reset(self) -> None:
143
+ """Reset state (VAD/env/buffers/time) but keep initial phases/seed."""
144
+ self.samples.clear()
145
+ self.carry = np.zeros(0, dtype=np.float32)
146
+ self.vad_on = False
147
+ self.vad_above = 0
148
+ self.vad_below = 0
149
+ self.sway_env = 0.0
150
+ self.sway_up = 0
151
+ self.sway_down = 0
152
+ self.t = 0.0
153
+
154
+ def feed(self, pcm: NDArray[Any], sr: int | None) -> List[Dict[str, float]]:
155
+ """Stream in PCM chunk. Returns a list of sway dicts, one per hop (HOP_MS).
156
+
157
+ Args:
158
+ pcm: np.ndarray, shape (N,) or (C,N)/(N,C); int or float.
159
+ sr: sample rate of `pcm` (None -> assume SR).
160
+
161
+ """
162
+ sr_in = SR if sr is None else int(sr)
163
+ x = _to_float32_mono(pcm)
164
+ if x.size == 0:
165
+ return []
166
+ if sr_in != SR:
167
+ x = _resample_linear(x, sr_in, SR)
168
+ if x.size == 0:
169
+ return []
170
+
171
+ # append to carry and consume fixed HOP chunks
172
+ if self.carry.size:
173
+ self.carry = np.concatenate([self.carry, x])
174
+ else:
175
+ self.carry = x
176
+
177
+ out: List[Dict[str, float]] = []
178
+
179
+ while self.carry.size >= HOP:
180
+ hop = self.carry[:HOP]
181
+ remaining: NDArray[np.float32] = self.carry[HOP:]
182
+ self.carry = remaining
183
+
184
+ # keep sliding window for VAD/env computation
185
+ # (deque accepts any iterable; list() for small HOP is fine)
186
+ self.samples.extend(hop.tolist())
187
+ if len(self.samples) < FRAME:
188
+ self.t += HOP_MS / 1000.0
189
+ continue
190
+
191
+ frame = np.fromiter(
192
+ islice(self.samples, len(self.samples) - FRAME, len(self.samples)),
193
+ dtype=np.float32,
194
+ count=FRAME,
195
+ )
196
+ db = _rms_dbfs(frame)
197
+
198
+ # VAD with hysteresis + attack/release
199
+ if db >= VAD_DB_ON:
200
+ self.vad_above += 1
201
+ self.vad_below = 0
202
+ if not self.vad_on and self.vad_above >= ATTACK_FR:
203
+ self.vad_on = True
204
+ elif db <= VAD_DB_OFF:
205
+ self.vad_below += 1
206
+ self.vad_above = 0
207
+ if self.vad_on and self.vad_below >= RELEASE_FR:
208
+ self.vad_on = False
209
+
210
+ if self.vad_on:
211
+ self.sway_up = min(SWAY_ATTACK_FR, self.sway_up + 1)
212
+ self.sway_down = 0
213
+ else:
214
+ self.sway_down = min(SWAY_RELEASE_FR, self.sway_down + 1)
215
+ self.sway_up = 0
216
+
217
+ up = self.sway_up / SWAY_ATTACK_FR
218
+ down = 1.0 - (self.sway_down / SWAY_RELEASE_FR)
219
+ target = up if self.vad_on else down
220
+ self.sway_env += ENV_FOLLOW_GAIN * (target - self.sway_env)
221
+ # clamp
222
+ if self.sway_env < 0.0:
223
+ self.sway_env = 0.0
224
+ elif self.sway_env > 1.0:
225
+ self.sway_env = 1.0
226
+
227
+ loud = _loudness_gain(db) * SWAY_MASTER
228
+ env = self.sway_env
229
+ self.t += HOP_MS / 1000.0
230
+
231
+ # oscillators
232
+ pitch = (
233
+ math.radians(SWAY_A_PITCH_DEG)
234
+ * loud
235
+ * env
236
+ * math.sin(2 * math.pi * SWAY_F_PITCH * self.t + self.phase_pitch)
237
+ )
238
+ yaw = (
239
+ math.radians(SWAY_A_YAW_DEG)
240
+ * loud
241
+ * env
242
+ * math.sin(2 * math.pi * SWAY_F_YAW * self.t + self.phase_yaw)
243
+ )
244
+ roll = (
245
+ math.radians(SWAY_A_ROLL_DEG)
246
+ * loud
247
+ * env
248
+ * math.sin(2 * math.pi * SWAY_F_ROLL * self.t + self.phase_roll)
249
+ )
250
+ x_mm = SWAY_A_X_MM * loud * env * math.sin(2 * math.pi * SWAY_F_X * self.t + self.phase_x)
251
+ y_mm = SWAY_A_Y_MM * loud * env * math.sin(2 * math.pi * SWAY_F_Y * self.t + self.phase_y)
252
+ z_mm = SWAY_A_Z_MM * loud * env * math.sin(2 * math.pi * SWAY_F_Z * self.t + self.phase_z)
253
+
254
+ out.append(
255
+ {
256
+ "pitch_rad": pitch,
257
+ "yaw_rad": yaw,
258
+ "roll_rad": roll,
259
+ "pitch_deg": math.degrees(pitch),
260
+ "yaw_deg": math.degrees(yaw),
261
+ "roll_deg": math.degrees(roll),
262
+ "x_mm": x_mm,
263
+ "y_mm": y_mm,
264
+ "z_mm": z_mm,
265
+ },
266
+ )
267
+
268
+ return out
src/hello_world/camera_worker.py ADDED
@@ -0,0 +1,241 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Camera worker thread with frame buffering and face tracking.
2
+
3
+ Ported from main_works.py camera_worker() function to provide:
4
+ - 30Hz+ camera polling with thread-safe frame buffering
5
+ - Face tracking integration with smooth interpolation
6
+ - Latest frame always available for tools
7
+ """
8
+
9
+ import time
10
+ import logging
11
+ import threading
12
+ from typing import Any, List, Tuple
13
+
14
+ import numpy as np
15
+ from numpy.typing import NDArray
16
+ from scipy.spatial.transform import Rotation as R
17
+
18
+ from reachy_mini import ReachyMini
19
+ from reachy_mini.utils.interpolation import linear_pose_interpolation
20
+
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class CameraWorker:
26
+ """Thread-safe camera worker with frame buffering and face tracking."""
27
+
28
+ def __init__(self, reachy_mini: ReachyMini, head_tracker: Any = None) -> None:
29
+ """Initialize."""
30
+ self.reachy_mini = reachy_mini
31
+ self.head_tracker = head_tracker
32
+
33
+ # Thread-safe frame storage
34
+ self.latest_frame: NDArray[np.uint8] | None = None
35
+ self.frame_lock = threading.Lock()
36
+ self._stop_event = threading.Event()
37
+ self._thread: threading.Thread | None = None
38
+
39
+ # Face tracking state
40
+ self.is_head_tracking_enabled = True
41
+ self.face_tracking_offsets: List[float] = [
42
+ 0.0,
43
+ 0.0,
44
+ 0.0,
45
+ 0.0,
46
+ 0.0,
47
+ 0.0,
48
+ ] # x, y, z, roll, pitch, yaw
49
+ self.face_tracking_lock = threading.Lock()
50
+
51
+ # Face tracking timing variables (same as main_works.py)
52
+ self.last_face_detected_time: float | None = None
53
+ self.interpolation_start_time: float | None = None
54
+ self.interpolation_start_pose: NDArray[np.float32] | None = None
55
+ self.face_lost_delay = 2.0 # seconds to wait before starting interpolation
56
+ self.interpolation_duration = 1.0 # seconds to interpolate back to neutral
57
+
58
+ # Track state changes
59
+ self.previous_head_tracking_state = self.is_head_tracking_enabled
60
+
61
+ def get_latest_frame(self) -> NDArray[np.uint8] | None:
62
+ """Get the latest frame (thread-safe)."""
63
+ with self.frame_lock:
64
+ if self.latest_frame is None:
65
+ return None
66
+ # Return a copy in original BGR format (OpenCV native)
67
+ return self.latest_frame.copy()
68
+
69
+ def get_face_tracking_offsets(
70
+ self,
71
+ ) -> Tuple[float, float, float, float, float, float]:
72
+ """Get current face tracking offsets (thread-safe)."""
73
+ with self.face_tracking_lock:
74
+ offsets = self.face_tracking_offsets
75
+ return (offsets[0], offsets[1], offsets[2], offsets[3], offsets[4], offsets[5])
76
+
77
+ def set_head_tracking_enabled(self, enabled: bool) -> None:
78
+ """Enable/disable head tracking."""
79
+ self.is_head_tracking_enabled = enabled
80
+ logger.info(f"Head tracking {'enabled' if enabled else 'disabled'}")
81
+
82
+ def start(self) -> None:
83
+ """Start the camera worker loop in a thread."""
84
+ self._stop_event.clear()
85
+ self._thread = threading.Thread(target=self.working_loop, daemon=True)
86
+ self._thread.start()
87
+ logger.debug("Camera worker started")
88
+
89
+ def stop(self) -> None:
90
+ """Stop the camera worker loop."""
91
+ self._stop_event.set()
92
+ if self._thread is not None:
93
+ self._thread.join()
94
+
95
+ logger.debug("Camera worker stopped")
96
+
97
+ def working_loop(self) -> None:
98
+ """Enable the camera worker loop.
99
+
100
+ Ported from main_works.py camera_worker() with same logic.
101
+ """
102
+ logger.debug("Starting camera working loop")
103
+
104
+ # Initialize head tracker if available
105
+ neutral_pose = np.eye(4) # Neutral pose (identity matrix)
106
+ self.previous_head_tracking_state = self.is_head_tracking_enabled
107
+
108
+ while not self._stop_event.is_set():
109
+ try:
110
+ current_time = time.time()
111
+
112
+ # Get frame from robot
113
+ frame = self.reachy_mini.media.get_frame()
114
+
115
+ if frame is not None:
116
+ # Thread-safe frame storage
117
+ with self.frame_lock:
118
+ self.latest_frame = frame # .copy()
119
+
120
+ # Check if face tracking was just disabled
121
+ if self.previous_head_tracking_state and not self.is_head_tracking_enabled:
122
+ # Face tracking was just disabled - start interpolation to neutral
123
+ self.last_face_detected_time = current_time # Trigger the face-lost logic
124
+ self.interpolation_start_time = None # Will be set by the face-lost interpolation
125
+ self.interpolation_start_pose = None
126
+
127
+ # Update tracking state
128
+ self.previous_head_tracking_state = self.is_head_tracking_enabled
129
+
130
+ # Handle face tracking if enabled and head tracker available
131
+ if self.is_head_tracking_enabled and self.head_tracker is not None:
132
+ eye_center, _ = self.head_tracker.get_head_position(frame)
133
+
134
+ if eye_center is not None:
135
+ # Face detected - immediately switch to tracking
136
+ self.last_face_detected_time = current_time
137
+ self.interpolation_start_time = None # Stop any interpolation
138
+
139
+ # Convert normalized coordinates to pixel coordinates
140
+ h, w, _ = frame.shape
141
+ eye_center_norm = (eye_center + 1) / 2
142
+ eye_center_pixels = [
143
+ eye_center_norm[0] * w,
144
+ eye_center_norm[1] * h,
145
+ ]
146
+
147
+ # Get the head pose needed to look at the target, but don't perform movement
148
+ target_pose = self.reachy_mini.look_at_image(
149
+ eye_center_pixels[0],
150
+ eye_center_pixels[1],
151
+ duration=0.0,
152
+ perform_movement=False,
153
+ )
154
+
155
+ # Extract translation and rotation from the target pose directly
156
+ translation = target_pose[:3, 3]
157
+ rotation = R.from_matrix(target_pose[:3, :3]).as_euler("xyz", degrees=False)
158
+
159
+ # Scale down translation and rotation because smaller FOV
160
+ translation *= 0.6
161
+ rotation *= 0.6
162
+
163
+ # Thread-safe update of face tracking offsets (use pose as-is)
164
+ with self.face_tracking_lock:
165
+ self.face_tracking_offsets = [
166
+ translation[0],
167
+ translation[1],
168
+ translation[2], # x, y, z
169
+ rotation[0],
170
+ rotation[1],
171
+ rotation[2], # roll, pitch, yaw
172
+ ]
173
+
174
+ # No face detected while tracking enabled - set face lost timestamp
175
+ elif self.last_face_detected_time is None or self.last_face_detected_time == current_time:
176
+ # Only update if we haven't already set a face lost time
177
+ # (current_time check prevents overriding the disable-triggered timestamp)
178
+ pass
179
+
180
+ # Handle smooth interpolation (works for both face-lost and tracking-disabled cases)
181
+ if self.last_face_detected_time is not None:
182
+ time_since_face_lost = current_time - self.last_face_detected_time
183
+
184
+ if time_since_face_lost >= self.face_lost_delay:
185
+ # Start interpolation if not already started
186
+ if self.interpolation_start_time is None:
187
+ self.interpolation_start_time = current_time
188
+ # Capture current pose as start of interpolation
189
+ with self.face_tracking_lock:
190
+ current_translation = self.face_tracking_offsets[:3]
191
+ current_rotation_euler = self.face_tracking_offsets[3:]
192
+ # Convert to 4x4 pose matrix
193
+ pose_matrix = np.eye(4, dtype=np.float32)
194
+ pose_matrix[:3, 3] = current_translation
195
+ pose_matrix[:3, :3] = R.from_euler(
196
+ "xyz",
197
+ current_rotation_euler,
198
+ ).as_matrix()
199
+ self.interpolation_start_pose = pose_matrix
200
+
201
+ # Calculate interpolation progress (t from 0 to 1)
202
+ elapsed_interpolation = current_time - self.interpolation_start_time
203
+ t = min(1.0, elapsed_interpolation / self.interpolation_duration)
204
+
205
+ # Interpolate between current pose and neutral pose
206
+ interpolated_pose = linear_pose_interpolation(
207
+ self.interpolation_start_pose,
208
+ neutral_pose,
209
+ t,
210
+ )
211
+
212
+ # Extract translation and rotation from interpolated pose
213
+ translation = interpolated_pose[:3, 3]
214
+ rotation = R.from_matrix(interpolated_pose[:3, :3]).as_euler("xyz", degrees=False)
215
+
216
+ # Thread-safe update of face tracking offsets
217
+ with self.face_tracking_lock:
218
+ self.face_tracking_offsets = [
219
+ translation[0],
220
+ translation[1],
221
+ translation[2], # x, y, z
222
+ rotation[0],
223
+ rotation[1],
224
+ rotation[2], # roll, pitch, yaw
225
+ ]
226
+
227
+ # If interpolation is complete, reset timing
228
+ if t >= 1.0:
229
+ self.last_face_detected_time = None
230
+ self.interpolation_start_time = None
231
+ self.interpolation_start_pose = None
232
+ # else: Keep current offsets (within 2s delay period)
233
+
234
+ # Small sleep to prevent excessive CPU usage (same as main_works.py)
235
+ time.sleep(0.04)
236
+
237
+ except Exception as e:
238
+ logger.error(f"Camera worker error: {e}")
239
+ time.sleep(0.1) # Longer sleep on error
240
+
241
+ logger.debug("Camera worker thread exited")
src/hello_world/config.py ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ import logging
4
+ from pathlib import Path
5
+
6
+ from dotenv import find_dotenv, load_dotenv
7
+
8
+
9
+ # Locked profile: set to a profile name (e.g., "astronomer") to lock the app
10
+ # to that profile and disable all profile switching. Leave as None for normal behavior.
11
+ LOCKED_PROFILE: str | None = "_hello_world_locked_profile"
12
+ DEFAULT_PROFILES_DIRECTORY = Path(__file__).parent / "profiles"
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ def _env_flag(name: str, default: bool = False) -> bool:
18
+ """Parse a boolean environment flag.
19
+
20
+ Accepted truthy values: 1, true, yes, on
21
+ Accepted falsy values: 0, false, no, off
22
+ """
23
+ raw = os.getenv(name)
24
+ if raw is None:
25
+ return default
26
+
27
+ value = raw.strip().lower()
28
+ if value in {"1", "true", "yes", "on"}:
29
+ return True
30
+ if value in {"0", "false", "no", "off"}:
31
+ return False
32
+
33
+ logger.warning("Invalid boolean value for %s=%r, using default=%s", name, raw, default)
34
+ return default
35
+
36
+
37
+ def _collect_profile_names(profiles_root: Path) -> set[str]:
38
+ """Return profile folder names from a profiles root directory."""
39
+ if not profiles_root.exists() or not profiles_root.is_dir():
40
+ return set()
41
+ return {p.name for p in profiles_root.iterdir() if p.is_dir()}
42
+
43
+
44
+ def _collect_tool_module_names(tools_root: Path) -> set[str]:
45
+ """Return tool module names from a tools directory."""
46
+ if not tools_root.exists() or not tools_root.is_dir():
47
+ return set()
48
+ ignored = {"__init__", "core_tools"}
49
+ return {
50
+ p.stem
51
+ for p in tools_root.glob("*.py")
52
+ if p.is_file() and p.stem not in ignored
53
+ }
54
+
55
+
56
+ def _raise_on_name_collisions(
57
+ *,
58
+ label: str,
59
+ external_root: Path,
60
+ internal_root: Path,
61
+ external_names: set[str],
62
+ internal_names: set[str],
63
+ ) -> None:
64
+ """Raise with a clear message when external/internal names collide."""
65
+ collisions = sorted(external_names & internal_names)
66
+ if not collisions:
67
+ return
68
+
69
+ raise RuntimeError(
70
+ f"Config.__init__(): Ambiguous {label} names found in both external and built-in libraries: {collisions}. "
71
+ f"External {label} root: {external_root}. Built-in {label} root: {internal_root}. "
72
+ f"Please rename the conflicting external {label}(s) to continue."
73
+ )
74
+
75
+
76
+ # Validate LOCKED_PROFILE at startup
77
+ if LOCKED_PROFILE is not None:
78
+ _profiles_dir = DEFAULT_PROFILES_DIRECTORY
79
+ _profile_path = _profiles_dir / LOCKED_PROFILE
80
+ _instructions_file = _profile_path / "instructions.txt"
81
+ if not _profile_path.is_dir():
82
+ print(f"Error: LOCKED_PROFILE '{LOCKED_PROFILE}' does not exist in {_profiles_dir}", file=sys.stderr)
83
+ sys.exit(1)
84
+ if not _instructions_file.is_file():
85
+ print(f"Error: LOCKED_PROFILE '{LOCKED_PROFILE}' has no instructions.txt", file=sys.stderr)
86
+ sys.exit(1)
87
+
88
+ _skip_dotenv = _env_flag("REACHY_MINI_SKIP_DOTENV", default=False)
89
+
90
+ if _skip_dotenv:
91
+ logger.info("Skipping .env loading because REACHY_MINI_SKIP_DOTENV is set")
92
+ else:
93
+ # Locate .env file (search upward from current working directory)
94
+ dotenv_path = find_dotenv(usecwd=True)
95
+
96
+ if dotenv_path:
97
+ # Load .env and override environment variables
98
+ load_dotenv(dotenv_path=dotenv_path, override=True)
99
+ logger.info(f"Configuration loaded from {dotenv_path}")
100
+ else:
101
+ logger.warning("No .env file found, using environment variables")
102
+
103
+
104
+ class Config:
105
+ """Configuration class for the conversation app."""
106
+
107
+ # Required
108
+ OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") # The key is downloaded in console.py if needed
109
+
110
+ # Optional
111
+ MODEL_NAME = os.getenv("MODEL_NAME", "gpt-realtime")
112
+ HF_HOME = os.getenv("HF_HOME", "./cache")
113
+ LOCAL_VISION_MODEL = os.getenv("LOCAL_VISION_MODEL", "HuggingFaceTB/SmolVLM2-2.2B-Instruct")
114
+ HF_TOKEN = os.getenv("HF_TOKEN") # Optional, falls back to hf auth login if not set
115
+
116
+ logger.debug(f"Model: {MODEL_NAME}, HF_HOME: {HF_HOME}, Vision Model: {LOCAL_VISION_MODEL}")
117
+
118
+ _profiles_directory_env = os.getenv("REACHY_MINI_EXTERNAL_PROFILES_DIRECTORY")
119
+ PROFILES_DIRECTORY = (
120
+ Path(_profiles_directory_env) if _profiles_directory_env else Path(__file__).parent / "profiles"
121
+ )
122
+ _tools_directory_env = os.getenv("REACHY_MINI_EXTERNAL_TOOLS_DIRECTORY")
123
+ TOOLS_DIRECTORY = Path(_tools_directory_env) if _tools_directory_env else None
124
+ AUTOLOAD_EXTERNAL_TOOLS = _env_flag("AUTOLOAD_EXTERNAL_TOOLS", default=False)
125
+ REACHY_MINI_CUSTOM_PROFILE = LOCKED_PROFILE or os.getenv("REACHY_MINI_CUSTOM_PROFILE")
126
+
127
+ logger.debug(f"Custom Profile: {REACHY_MINI_CUSTOM_PROFILE}")
128
+
129
+ def __init__(self) -> None:
130
+ """Initialize the configuration."""
131
+ if self.REACHY_MINI_CUSTOM_PROFILE and self.PROFILES_DIRECTORY != DEFAULT_PROFILES_DIRECTORY:
132
+ selected_profile_path = self.PROFILES_DIRECTORY / self.REACHY_MINI_CUSTOM_PROFILE
133
+ if not selected_profile_path.is_dir():
134
+ available_profiles = sorted(_collect_profile_names(self.PROFILES_DIRECTORY))
135
+ raise RuntimeError(
136
+ "Config.__init__(): Selected profile "
137
+ f"'{self.REACHY_MINI_CUSTOM_PROFILE}' was not found in external profiles root "
138
+ f"{self.PROFILES_DIRECTORY}. "
139
+ f"Available external profiles: {available_profiles}. "
140
+ "Either set 'REACHY_MINI_CUSTOM_PROFILE' to one of the available external profiles "
141
+ "or unset 'REACHY_MINI_EXTERNAL_PROFILES_DIRECTORY' to use built-in profiles."
142
+ )
143
+
144
+ if self.PROFILES_DIRECTORY != DEFAULT_PROFILES_DIRECTORY:
145
+ external_profiles = _collect_profile_names(self.PROFILES_DIRECTORY)
146
+ internal_profiles = _collect_profile_names(DEFAULT_PROFILES_DIRECTORY)
147
+ _raise_on_name_collisions(
148
+ label="profile",
149
+ external_root=self.PROFILES_DIRECTORY,
150
+ internal_root=DEFAULT_PROFILES_DIRECTORY,
151
+ external_names=external_profiles,
152
+ internal_names=internal_profiles,
153
+ )
154
+
155
+ if self.TOOLS_DIRECTORY is not None:
156
+ builtin_tools_root = Path(__file__).parent / "tools"
157
+ external_tools = _collect_tool_module_names(self.TOOLS_DIRECTORY)
158
+ internal_tools = _collect_tool_module_names(builtin_tools_root)
159
+ _raise_on_name_collisions(
160
+ label="tool",
161
+ external_root=self.TOOLS_DIRECTORY,
162
+ internal_root=builtin_tools_root,
163
+ external_names=external_tools,
164
+ internal_names=internal_tools,
165
+ )
166
+
167
+ if self.PROFILES_DIRECTORY != DEFAULT_PROFILES_DIRECTORY:
168
+ logger.warning(
169
+ "Environment variable 'REACHY_MINI_EXTERNAL_PROFILES_DIRECTORY' is set. "
170
+ "Profiles (instructions.txt, ...) will be loaded from %s.",
171
+ self.PROFILES_DIRECTORY,
172
+ )
173
+ else:
174
+ logger.info(
175
+ "'REACHY_MINI_EXTERNAL_PROFILES_DIRECTORY' is not set. "
176
+ "Using built-in profiles from %s.",
177
+ DEFAULT_PROFILES_DIRECTORY,
178
+ )
179
+
180
+ if self.TOOLS_DIRECTORY is not None:
181
+ logger.warning(
182
+ "Environment variable 'REACHY_MINI_EXTERNAL_TOOLS_DIRECTORY' is set. "
183
+ "External tools will be loaded from %s.",
184
+ self.TOOLS_DIRECTORY,
185
+ )
186
+ else:
187
+ logger.info(
188
+ "'REACHY_MINI_EXTERNAL_TOOLS_DIRECTORY' is not set. "
189
+ "Using built-in shared tools only."
190
+ )
191
+
192
+
193
+ config = Config()
194
+
195
+
196
+ def set_custom_profile(profile: str | None) -> None:
197
+ """Update the selected custom profile at runtime and expose it via env.
198
+
199
+ This ensures modules that read `config` and code that inspects the
200
+ environment see a consistent value.
201
+ """
202
+ if LOCKED_PROFILE is not None:
203
+ return
204
+ try:
205
+ config.REACHY_MINI_CUSTOM_PROFILE = profile
206
+ except Exception:
207
+ pass
208
+ try:
209
+ import os as _os
210
+
211
+ if profile:
212
+ _os.environ["REACHY_MINI_CUSTOM_PROFILE"] = profile
213
+ else:
214
+ # Remove to reflect default
215
+ _os.environ.pop("REACHY_MINI_CUSTOM_PROFILE", None)
216
+ except Exception:
217
+ pass
src/hello_world/console.py ADDED
@@ -0,0 +1,502 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Bidirectional local audio stream with optional settings UI.
2
+
3
+ In headless mode, there is no Gradio UI. If the OpenAI API key is not
4
+ available via environment/.env, we expose a minimal settings page via the
5
+ Reachy Mini Apps settings server to let non-technical users enter it.
6
+
7
+ The settings UI is served from this package's ``static/`` folder and offers a
8
+ single password field to set ``OPENAI_API_KEY``. Once set, we persist it to the
9
+ app instance's ``.env`` file (if available) and proceed to start streaming.
10
+ """
11
+
12
+ import os
13
+ import sys
14
+ import time
15
+ import asyncio
16
+ import logging
17
+ from typing import List, Optional
18
+ from pathlib import Path
19
+
20
+ from fastrtc import AdditionalOutputs, audio_to_float32
21
+ from scipy.signal import resample
22
+
23
+ from reachy_mini import ReachyMini
24
+ from reachy_mini.media.media_manager import MediaBackend
25
+ from hello_world.config import LOCKED_PROFILE, config
26
+ from hello_world.openai_realtime import OpenaiRealtimeHandler
27
+ from hello_world.headless_personality_ui import mount_personality_routes
28
+
29
+
30
+ try:
31
+ # FastAPI is provided by the Reachy Mini Apps runtime
32
+ from fastapi import FastAPI, Response
33
+ from pydantic import BaseModel
34
+ from fastapi.responses import FileResponse, JSONResponse
35
+ from starlette.staticfiles import StaticFiles
36
+ except Exception: # pragma: no cover - only loaded when settings_app is used
37
+ FastAPI = object # type: ignore
38
+ FileResponse = object # type: ignore
39
+ JSONResponse = object # type: ignore
40
+ StaticFiles = object # type: ignore
41
+ BaseModel = object # type: ignore
42
+
43
+
44
+ logger = logging.getLogger(__name__)
45
+
46
+
47
+ class LocalStream:
48
+ """LocalStream using Reachy Mini's recorder/player."""
49
+
50
+ def __init__(
51
+ self,
52
+ handler: OpenaiRealtimeHandler,
53
+ robot: ReachyMini,
54
+ *,
55
+ settings_app: Optional[FastAPI] = None,
56
+ instance_path: Optional[str] = None,
57
+ ):
58
+ """Initialize the stream with an OpenAI realtime handler and pipelines.
59
+
60
+ - ``settings_app``: the Reachy Mini Apps FastAPI to attach settings endpoints.
61
+ - ``instance_path``: directory where per-instance ``.env`` should be stored.
62
+ """
63
+ self.handler = handler
64
+ self._robot = robot
65
+ self._stop_event = asyncio.Event()
66
+ self._tasks: List[asyncio.Task[None]] = []
67
+ # Allow the handler to flush the player queue when appropriate.
68
+ self.handler._clear_queue = self.clear_audio_queue
69
+ self._settings_app: Optional[FastAPI] = settings_app
70
+ self._instance_path: Optional[str] = instance_path
71
+ self._settings_initialized = False
72
+ self._asyncio_loop = None
73
+
74
+ # ---- Settings UI (only when API key is missing) ----
75
+ def _read_env_lines(self, env_path: Path) -> list[str]:
76
+ """Load env file contents or a template as a list of lines."""
77
+ inst = env_path.parent
78
+ try:
79
+ if env_path.exists():
80
+ try:
81
+ return env_path.read_text(encoding="utf-8").splitlines()
82
+ except Exception:
83
+ return []
84
+ template_text = None
85
+ ex = inst / ".env.example"
86
+ if ex.exists():
87
+ try:
88
+ template_text = ex.read_text(encoding="utf-8")
89
+ except Exception:
90
+ template_text = None
91
+ if template_text is None:
92
+ try:
93
+ cwd_example = Path.cwd() / ".env.example"
94
+ if cwd_example.exists():
95
+ template_text = cwd_example.read_text(encoding="utf-8")
96
+ except Exception:
97
+ template_text = None
98
+ if template_text is None:
99
+ packaged = Path(__file__).parent / ".env.example"
100
+ if packaged.exists():
101
+ try:
102
+ template_text = packaged.read_text(encoding="utf-8")
103
+ except Exception:
104
+ template_text = None
105
+ return template_text.splitlines() if template_text else []
106
+ except Exception:
107
+ return []
108
+
109
+ def _persist_api_key(self, key: str) -> None:
110
+ """Persist API key to environment and instance ``.env`` if possible.
111
+
112
+ Behavior:
113
+ - Always sets ``OPENAI_API_KEY`` in process env and in-memory config.
114
+ - Writes/updates ``<instance_path>/.env``:
115
+ * If ``.env`` exists, replaces/append OPENAI_API_KEY line.
116
+ * Else, copies template from ``<instance_path>/.env.example`` when present,
117
+ otherwise falls back to the packaged template
118
+ ``hello_world/.env.example``.
119
+ * Ensures the resulting file contains the full template plus the key.
120
+ - Loads the written ``.env`` into the current process environment.
121
+ """
122
+ k = (key or "").strip()
123
+ if not k:
124
+ return
125
+ # Update live process env and config so consumers see it immediately
126
+ try:
127
+ os.environ["OPENAI_API_KEY"] = k
128
+ except Exception: # best-effort
129
+ pass
130
+ try:
131
+ config.OPENAI_API_KEY = k
132
+ except Exception:
133
+ pass
134
+
135
+ if not self._instance_path:
136
+ return
137
+ try:
138
+ inst = Path(self._instance_path)
139
+ env_path = inst / ".env"
140
+ lines = self._read_env_lines(env_path)
141
+ replaced = False
142
+ for i, ln in enumerate(lines):
143
+ if ln.strip().startswith("OPENAI_API_KEY="):
144
+ lines[i] = f"OPENAI_API_KEY={k}"
145
+ replaced = True
146
+ break
147
+ if not replaced:
148
+ lines.append(f"OPENAI_API_KEY={k}")
149
+ final_text = "\n".join(lines) + "\n"
150
+ env_path.write_text(final_text, encoding="utf-8")
151
+ logger.info("Persisted OPENAI_API_KEY to %s", env_path)
152
+
153
+ # Load the newly written .env into this process to ensure downstream imports see it
154
+ try:
155
+ from dotenv import load_dotenv
156
+
157
+ load_dotenv(dotenv_path=str(env_path), override=True)
158
+ except Exception:
159
+ pass
160
+ except Exception as e:
161
+ logger.warning("Failed to persist OPENAI_API_KEY: %s", e)
162
+
163
+ def _persist_personality(self, profile: Optional[str]) -> None:
164
+ """Persist the startup personality to the instance .env and config."""
165
+ if LOCKED_PROFILE is not None:
166
+ return
167
+ selection = (profile or "").strip() or None
168
+ try:
169
+ from hello_world.config import set_custom_profile
170
+
171
+ set_custom_profile(selection)
172
+ except Exception:
173
+ pass
174
+
175
+ if not self._instance_path:
176
+ return
177
+ try:
178
+ env_path = Path(self._instance_path) / ".env"
179
+ lines = self._read_env_lines(env_path)
180
+ replaced = False
181
+ for i, ln in enumerate(list(lines)):
182
+ if ln.strip().startswith("REACHY_MINI_CUSTOM_PROFILE="):
183
+ if selection:
184
+ lines[i] = f"REACHY_MINI_CUSTOM_PROFILE={selection}"
185
+ else:
186
+ lines.pop(i)
187
+ replaced = True
188
+ break
189
+ if selection and not replaced:
190
+ lines.append(f"REACHY_MINI_CUSTOM_PROFILE={selection}")
191
+ if selection is None and not env_path.exists():
192
+ return
193
+ final_text = "\n".join(lines) + "\n"
194
+ env_path.write_text(final_text, encoding="utf-8")
195
+ logger.info("Persisted startup personality to %s", env_path)
196
+ try:
197
+ from dotenv import load_dotenv
198
+
199
+ load_dotenv(dotenv_path=str(env_path), override=True)
200
+ except Exception:
201
+ pass
202
+ except Exception as e:
203
+ logger.warning("Failed to persist REACHY_MINI_CUSTOM_PROFILE: %s", e)
204
+
205
+ def _read_persisted_personality(self) -> Optional[str]:
206
+ """Read persisted startup personality from instance .env (if any)."""
207
+ if not self._instance_path:
208
+ return None
209
+ env_path = Path(self._instance_path) / ".env"
210
+ try:
211
+ if env_path.exists():
212
+ for ln in env_path.read_text(encoding="utf-8").splitlines():
213
+ if ln.strip().startswith("REACHY_MINI_CUSTOM_PROFILE="):
214
+ _, _, val = ln.partition("=")
215
+ v = val.strip()
216
+ return v or None
217
+ except Exception:
218
+ pass
219
+ return None
220
+
221
+ def _init_settings_ui_if_needed(self) -> None:
222
+ """Attach minimal settings UI to the settings app.
223
+
224
+ Always mounts the UI when a settings_app is provided so that users
225
+ see a confirmation message even if the API key is already configured.
226
+ """
227
+ if self._settings_initialized:
228
+ return
229
+ if self._settings_app is None:
230
+ return
231
+
232
+ static_dir = Path(__file__).parent / "static"
233
+ index_file = static_dir / "index.html"
234
+
235
+ if hasattr(self._settings_app, "mount"):
236
+ try:
237
+ # Serve /static/* assets
238
+ self._settings_app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
239
+ except Exception:
240
+ pass
241
+
242
+ class ApiKeyPayload(BaseModel):
243
+ openai_api_key: str
244
+
245
+ # GET / -> index.html
246
+ @self._settings_app.get("/")
247
+ def _root() -> FileResponse:
248
+ return FileResponse(str(index_file))
249
+
250
+ # GET /favicon.ico -> optional, avoid noisy 404s on some browsers
251
+ @self._settings_app.get("/favicon.ico")
252
+ def _favicon() -> Response:
253
+ return Response(status_code=204)
254
+
255
+ # GET /status -> whether key is set
256
+ @self._settings_app.get("/status")
257
+ def _status() -> JSONResponse:
258
+ has_key = bool(config.OPENAI_API_KEY and str(config.OPENAI_API_KEY).strip())
259
+ return JSONResponse({"has_key": has_key})
260
+
261
+ # GET /ready -> whether backend finished loading tools
262
+ @self._settings_app.get("/ready")
263
+ def _ready() -> JSONResponse:
264
+ try:
265
+ mod = sys.modules.get("hello_world.tools.core_tools")
266
+ ready = bool(getattr(mod, "_TOOLS_INITIALIZED", False)) if mod else False
267
+ except Exception:
268
+ ready = False
269
+ return JSONResponse({"ready": ready})
270
+
271
+ # POST /openai_api_key -> set/persist key
272
+ @self._settings_app.post("/openai_api_key")
273
+ def _set_key(payload: ApiKeyPayload) -> JSONResponse:
274
+ key = (payload.openai_api_key or "").strip()
275
+ if not key:
276
+ return JSONResponse({"ok": False, "error": "empty_key"}, status_code=400)
277
+ self._persist_api_key(key)
278
+ return JSONResponse({"ok": True})
279
+
280
+ # POST /validate_api_key -> validate key without persisting it
281
+ @self._settings_app.post("/validate_api_key")
282
+ async def _validate_key(payload: ApiKeyPayload) -> JSONResponse:
283
+ key = (payload.openai_api_key or "").strip()
284
+ if not key:
285
+ return JSONResponse({"valid": False, "error": "empty_key"}, status_code=400)
286
+
287
+ # Try to validate by checking if we can fetch the models
288
+ try:
289
+ import httpx
290
+
291
+ headers = {"Authorization": f"Bearer {key}", "Content-Type": "application/json"}
292
+ async with httpx.AsyncClient(timeout=10.0) as client:
293
+ response = await client.get("https://api.openai.com/v1/models", headers=headers)
294
+ if response.status_code == 200:
295
+ return JSONResponse({"valid": True})
296
+ elif response.status_code == 401:
297
+ return JSONResponse({"valid": False, "error": "invalid_api_key"}, status_code=401)
298
+ else:
299
+ return JSONResponse(
300
+ {"valid": False, "error": "validation_failed"}, status_code=response.status_code
301
+ )
302
+ except Exception as e:
303
+ logger.warning(f"API key validation failed: {e}")
304
+ return JSONResponse({"valid": False, "error": "validation_error"}, status_code=500)
305
+
306
+ self._settings_initialized = True
307
+
308
+ def launch(self) -> None:
309
+ """Start the recorder/player and run the async processing loops.
310
+
311
+ If the OpenAI key is missing, expose a tiny settings UI via the
312
+ Reachy Mini settings server to collect it before starting streams.
313
+ """
314
+ self._stop_event.clear()
315
+
316
+ # Try to load an existing instance .env first (covers subsequent runs)
317
+ if self._instance_path:
318
+ try:
319
+ from dotenv import load_dotenv
320
+
321
+ from hello_world.config import set_custom_profile
322
+
323
+ env_path = Path(self._instance_path) / ".env"
324
+ if env_path.exists():
325
+ load_dotenv(dotenv_path=str(env_path), override=True)
326
+ # Update config with newly loaded values
327
+ new_key = os.getenv("OPENAI_API_KEY", "").strip()
328
+ if new_key:
329
+ try:
330
+ config.OPENAI_API_KEY = new_key
331
+ except Exception:
332
+ pass
333
+ if LOCKED_PROFILE is None:
334
+ new_profile = os.getenv("REACHY_MINI_CUSTOM_PROFILE")
335
+ if new_profile is not None:
336
+ try:
337
+ set_custom_profile(new_profile.strip() or None)
338
+ except Exception:
339
+ pass # Best-effort profile update
340
+ except Exception:
341
+ pass # Instance .env loading is optional; continue with defaults
342
+
343
+ # If key is still missing, try to download one from HuggingFace
344
+ if not (config.OPENAI_API_KEY and str(config.OPENAI_API_KEY).strip()):
345
+ logger.info("OPENAI_API_KEY not set, attempting to download from HuggingFace...")
346
+ try:
347
+ from gradio_client import Client
348
+ client = Client("HuggingFaceM4/gradium_setup", verbose=False)
349
+ key, status = client.predict(api_name="/claim_b_key")
350
+ if key and key.strip():
351
+ logger.info("Successfully downloaded API key from HuggingFace")
352
+ # Persist it immediately
353
+ self._persist_api_key(key)
354
+ except Exception as e:
355
+ logger.warning(f"Failed to download API key from HuggingFace: {e}")
356
+
357
+ # Always expose settings UI if a settings app is available
358
+ # (do this AFTER loading/downloading the key so status endpoint sees the right value)
359
+ self._init_settings_ui_if_needed()
360
+
361
+ # If key is still missing -> wait until provided via the settings UI
362
+ if not (config.OPENAI_API_KEY and str(config.OPENAI_API_KEY).strip()):
363
+ logger.warning("OPENAI_API_KEY not found. Open the app settings page to enter it.")
364
+ # Poll until the key becomes available (set via the settings UI)
365
+ try:
366
+ while not (config.OPENAI_API_KEY and str(config.OPENAI_API_KEY).strip()):
367
+ time.sleep(0.2)
368
+ except KeyboardInterrupt:
369
+ logger.info("Interrupted while waiting for API key.")
370
+ return
371
+
372
+ # Start media after key is set/available
373
+ self._robot.media.start_recording()
374
+ self._robot.media.start_playing()
375
+ time.sleep(1) # give some time to the pipelines to start
376
+
377
+ async def runner() -> None:
378
+ # Capture loop for cross-thread personality actions
379
+ loop = asyncio.get_running_loop()
380
+ self._asyncio_loop = loop # type: ignore[assignment]
381
+ # Mount personality routes now that loop and handler are available
382
+ try:
383
+ if self._settings_app is not None:
384
+ mount_personality_routes(
385
+ self._settings_app,
386
+ self.handler,
387
+ lambda: self._asyncio_loop,
388
+ persist_personality=self._persist_personality,
389
+ get_persisted_personality=self._read_persisted_personality,
390
+ )
391
+ except Exception:
392
+ pass
393
+ self._tasks = [
394
+ asyncio.create_task(self.handler.start_up(), name="openai-handler"),
395
+ asyncio.create_task(self.record_loop(), name="stream-record-loop"),
396
+ asyncio.create_task(self.play_loop(), name="stream-play-loop"),
397
+ ]
398
+ try:
399
+ await asyncio.gather(*self._tasks)
400
+ except asyncio.CancelledError:
401
+ logger.info("Tasks cancelled during shutdown")
402
+ finally:
403
+ # Ensure handler connection is closed
404
+ await self.handler.shutdown()
405
+
406
+ asyncio.run(runner())
407
+
408
+ def close(self) -> None:
409
+ """Stop the stream and underlying media pipelines.
410
+
411
+ This method:
412
+ - Stops audio recording and playback first
413
+ - Sets the stop event to signal async loops to terminate
414
+ - Cancels all pending async tasks (openai-handler, record-loop, play-loop)
415
+ """
416
+ logger.info("Stopping LocalStream...")
417
+
418
+ # Stop media pipelines FIRST before cancelling async tasks
419
+ # This ensures clean shutdown before PortAudio cleanup
420
+ try:
421
+ self._robot.media.stop_recording()
422
+ except Exception as e:
423
+ logger.debug(f"Error stopping recording (may already be stopped): {e}")
424
+
425
+ try:
426
+ self._robot.media.stop_playing()
427
+ except Exception as e:
428
+ logger.debug(f"Error stopping playback (may already be stopped): {e}")
429
+
430
+ # Now signal async loops to stop
431
+ self._stop_event.set()
432
+
433
+ # Cancel all running tasks
434
+ for task in self._tasks:
435
+ if not task.done():
436
+ task.cancel()
437
+
438
+ def clear_audio_queue(self) -> None:
439
+ """Flush the player's appsrc to drop any queued audio immediately."""
440
+ logger.info("User intervention: flushing player queue")
441
+ if self._robot.media.backend == MediaBackend.GSTREAMER:
442
+ # Directly flush gstreamer audio pipe
443
+ self._robot.media.audio.clear_player()
444
+ elif self._robot.media.backend == MediaBackend.DEFAULT or self._robot.media.backend == MediaBackend.DEFAULT_NO_VIDEO:
445
+ self._robot.media.audio.clear_output_buffer()
446
+ self.handler.output_queue = asyncio.Queue()
447
+
448
+ async def record_loop(self) -> None:
449
+ """Read mic frames from the recorder and forward them to the handler."""
450
+ input_sample_rate = self._robot.media.get_input_audio_samplerate()
451
+ logger.debug(f"Audio recording started at {input_sample_rate} Hz")
452
+
453
+ while not self._stop_event.is_set():
454
+ audio_frame = self._robot.media.get_audio_sample()
455
+ if audio_frame is not None:
456
+ await self.handler.receive((input_sample_rate, audio_frame))
457
+ await asyncio.sleep(0) # avoid busy loop
458
+
459
+ async def play_loop(self) -> None:
460
+ """Fetch outputs from the handler: log text and play audio frames."""
461
+ while not self._stop_event.is_set():
462
+ handler_output = await self.handler.emit()
463
+
464
+ if isinstance(handler_output, AdditionalOutputs):
465
+ for msg in handler_output.args:
466
+ content = msg.get("content", "")
467
+ if isinstance(content, str):
468
+ logger.info(
469
+ "role=%s content=%s",
470
+ msg.get("role"),
471
+ content if len(content) < 500 else content[:500] + "…",
472
+ )
473
+
474
+ elif isinstance(handler_output, tuple):
475
+ input_sample_rate, audio_data = handler_output
476
+ output_sample_rate = self._robot.media.get_output_audio_samplerate()
477
+
478
+ # Reshape if needed
479
+ if audio_data.ndim == 2:
480
+ # Scipy channels last convention
481
+ if audio_data.shape[1] > audio_data.shape[0]:
482
+ audio_data = audio_data.T
483
+ # Multiple channels -> Mono channel
484
+ if audio_data.shape[1] > 1:
485
+ audio_data = audio_data[:, 0]
486
+
487
+ # Cast if needed
488
+ audio_frame = audio_to_float32(audio_data)
489
+
490
+ # Resample if needed
491
+ if input_sample_rate != output_sample_rate:
492
+ audio_frame = resample(
493
+ audio_frame,
494
+ int(len(audio_frame) * output_sample_rate / input_sample_rate),
495
+ )
496
+
497
+ self._robot.media.push_audio_sample(audio_frame)
498
+
499
+ else:
500
+ logger.debug("Ignoring output type=%s", type(handler_output).__name__)
501
+
502
+ await asyncio.sleep(0) # yield to event loop
src/hello_world/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): # type: ignore
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): # type: ignore
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): # type: ignore
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/hello_world/gradio_personality.py ADDED
@@ -0,0 +1,316 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gradio personality UI components and wiring.
2
+
3
+ This module encapsulates the UI elements and logic related to managing
4
+ conversation "personalities" (profiles) so that `main.py` stays lean.
5
+ """
6
+
7
+ from __future__ import annotations
8
+ from typing import Any
9
+ from pathlib import Path
10
+
11
+ import gradio as gr
12
+
13
+ from .config import LOCKED_PROFILE, config
14
+
15
+
16
+ class PersonalityUI:
17
+ """Container for personality-related Gradio components."""
18
+
19
+ def __init__(self) -> None:
20
+ """Initialize the PersonalityUI instance."""
21
+ # Constants and paths
22
+ self.DEFAULT_OPTION = "(built-in default)"
23
+ self._profiles_root = Path(__file__).parent / "profiles"
24
+ self._tools_dir = Path(__file__).parent / "tools"
25
+ self._prompts_dir = Path(__file__).parent / "prompts"
26
+
27
+ # Components (initialized in create_components)
28
+ self.personalities_dropdown: gr.Dropdown
29
+ self.apply_btn: gr.Button
30
+ self.status_md: gr.Markdown
31
+ self.preview_md: gr.Markdown
32
+ self.person_name_tb: gr.Textbox
33
+ self.person_instr_ta: gr.TextArea
34
+ self.tools_txt_ta: gr.TextArea
35
+ self.voice_dropdown: gr.Dropdown
36
+ self.new_personality_btn: gr.Button
37
+ self.available_tools_cg: gr.CheckboxGroup
38
+ self.save_btn: gr.Button
39
+
40
+ # ---------- Filesystem helpers ----------
41
+ def _list_personalities(self) -> list[str]:
42
+ names: list[str] = []
43
+ try:
44
+ if self._profiles_root.exists():
45
+ for p in sorted(self._profiles_root.iterdir()):
46
+ if p.name == "user_personalities":
47
+ continue
48
+ if p.is_dir() and (p / "instructions.txt").exists():
49
+ names.append(p.name)
50
+ user_dir = self._profiles_root / "user_personalities"
51
+ if user_dir.exists():
52
+ for p in sorted(user_dir.iterdir()):
53
+ if p.is_dir() and (p / "instructions.txt").exists():
54
+ names.append(f"user_personalities/{p.name}")
55
+ except Exception:
56
+ pass
57
+ return names
58
+
59
+ def _resolve_profile_dir(self, selection: str) -> Path:
60
+ return self._profiles_root / selection
61
+
62
+ def _read_instructions_for(self, name: str) -> str:
63
+ try:
64
+ if name == self.DEFAULT_OPTION:
65
+ default_file = self._prompts_dir / "default_prompt.txt"
66
+ if default_file.exists():
67
+ return default_file.read_text(encoding="utf-8").strip()
68
+ return ""
69
+ target = self._resolve_profile_dir(name) / "instructions.txt"
70
+ if target.exists():
71
+ return target.read_text(encoding="utf-8").strip()
72
+ return ""
73
+ except Exception as e:
74
+ return f"Could not load instructions: {e}"
75
+
76
+ @staticmethod
77
+ def _sanitize_name(name: str) -> str:
78
+ import re
79
+
80
+ s = name.strip()
81
+ s = re.sub(r"\s+", "_", s)
82
+ s = re.sub(r"[^a-zA-Z0-9_-]", "", s)
83
+ return s
84
+
85
+ # ---------- Public API ----------
86
+ def create_components(self) -> None:
87
+ """Instantiate Gradio components for the personality UI."""
88
+ if LOCKED_PROFILE is not None:
89
+ is_locked = True
90
+ current_value: str = LOCKED_PROFILE
91
+ dropdown_label = "Select personality (locked)"
92
+ dropdown_choices: list[str] = [LOCKED_PROFILE]
93
+ else:
94
+ is_locked = False
95
+ current_value = config.REACHY_MINI_CUSTOM_PROFILE or self.DEFAULT_OPTION
96
+ dropdown_label = "Select personality"
97
+ dropdown_choices = [self.DEFAULT_OPTION, *(self._list_personalities())]
98
+
99
+ self.personalities_dropdown = gr.Dropdown(
100
+ label=dropdown_label,
101
+ choices=dropdown_choices,
102
+ value=current_value,
103
+ interactive=not is_locked,
104
+ )
105
+ self.apply_btn = gr.Button("Apply personality", interactive=not is_locked)
106
+ self.status_md = gr.Markdown(visible=True)
107
+ self.preview_md = gr.Markdown(value=self._read_instructions_for(current_value))
108
+ self.person_name_tb = gr.Textbox(label="Personality name", interactive=not is_locked)
109
+ self.person_instr_ta = gr.TextArea(label="Personality instructions", lines=10, interactive=not is_locked)
110
+ self.tools_txt_ta = gr.TextArea(label="tools.txt", lines=10, interactive=not is_locked)
111
+ self.voice_dropdown = gr.Dropdown(label="Voice", choices=["cedar"], value="cedar", interactive=not is_locked)
112
+ self.new_personality_btn = gr.Button("New personality", interactive=not is_locked)
113
+ self.available_tools_cg = gr.CheckboxGroup(label="Available tools (helper)", choices=[], value=[], interactive=not is_locked)
114
+ self.save_btn = gr.Button("Save personality (instructions + tools)", interactive=not is_locked)
115
+
116
+ def additional_inputs_ordered(self) -> list[Any]:
117
+ """Return the additional inputs in the expected order for Stream."""
118
+ return [
119
+ self.personalities_dropdown,
120
+ self.apply_btn,
121
+ self.new_personality_btn,
122
+ self.status_md,
123
+ self.preview_md,
124
+ self.person_name_tb,
125
+ self.person_instr_ta,
126
+ self.tools_txt_ta,
127
+ self.voice_dropdown,
128
+ self.available_tools_cg,
129
+ self.save_btn,
130
+ ]
131
+
132
+ # ---------- Event wiring ----------
133
+ def wire_events(self, handler: Any, blocks: gr.Blocks) -> None:
134
+ """Attach event handlers to components within a Blocks context."""
135
+
136
+ async def _apply_personality(selected: str) -> tuple[str, str]:
137
+ if LOCKED_PROFILE is not None and selected != LOCKED_PROFILE:
138
+ return (
139
+ f"Profile is locked to '{LOCKED_PROFILE}'. Cannot change personality.",
140
+ self._read_instructions_for(LOCKED_PROFILE),
141
+ )
142
+ profile = None if selected == self.DEFAULT_OPTION else selected
143
+ status = await handler.apply_personality(profile)
144
+ preview = self._read_instructions_for(selected)
145
+ return status, preview
146
+
147
+ def _read_voice_for(name: str) -> str:
148
+ try:
149
+ if name == self.DEFAULT_OPTION:
150
+ return "cedar"
151
+ vf = self._resolve_profile_dir(name) / "voice.txt"
152
+ if vf.exists():
153
+ v = vf.read_text(encoding="utf-8").strip()
154
+ return v or "cedar"
155
+ except Exception:
156
+ pass
157
+ return "cedar"
158
+
159
+ async def _fetch_voices(selected: str) -> dict[str, Any]:
160
+ try:
161
+ voices = await handler.get_available_voices()
162
+ current = _read_voice_for(selected)
163
+ if current not in voices:
164
+ current = "cedar"
165
+ return gr.update(choices=voices, value=current)
166
+ except Exception:
167
+ return gr.update(choices=["cedar"], value="cedar")
168
+
169
+ def _available_tools_for(selected: str) -> tuple[list[str], list[str]]:
170
+ shared: list[str] = []
171
+ try:
172
+ for py in self._tools_dir.glob("*.py"):
173
+ if py.stem in {"__init__", "core_tools"}:
174
+ continue
175
+ shared.append(py.stem)
176
+ except Exception:
177
+ pass
178
+ local: list[str] = []
179
+ try:
180
+ if selected != self.DEFAULT_OPTION:
181
+ for py in (self._profiles_root / selected).glob("*.py"):
182
+ local.append(py.stem)
183
+ except Exception:
184
+ pass
185
+ return sorted(shared), sorted(local)
186
+
187
+ def _parse_enabled_tools(text: str) -> list[str]:
188
+ enabled: list[str] = []
189
+ for line in text.splitlines():
190
+ s = line.strip()
191
+ if not s or s.startswith("#"):
192
+ continue
193
+ enabled.append(s)
194
+ return enabled
195
+
196
+ def _load_profile_for_edit(selected: str) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any], str]:
197
+ instr = self._read_instructions_for(selected)
198
+ tools_txt = ""
199
+ if selected != self.DEFAULT_OPTION:
200
+ tp = self._resolve_profile_dir(selected) / "tools.txt"
201
+ if tp.exists():
202
+ tools_txt = tp.read_text(encoding="utf-8")
203
+ shared, local = _available_tools_for(selected)
204
+ all_tools = sorted(set(shared + local))
205
+ enabled = _parse_enabled_tools(tools_txt)
206
+ status_text = f"Loaded profile '{selected}'."
207
+ return (
208
+ gr.update(value=instr),
209
+ gr.update(value=tools_txt),
210
+ gr.update(choices=all_tools, value=enabled),
211
+ status_text,
212
+ )
213
+
214
+ def _new_personality() -> tuple[
215
+ dict[str, Any], dict[str, Any], dict[str, Any], dict[str, Any], str, dict[str, Any]
216
+ ]:
217
+ try:
218
+ # Prefill with hints
219
+ instr_val = """# Write your instructions here\n# e.g., Keep responses concise and friendly."""
220
+ tools_txt_val = "# tools enabled for this profile\n"
221
+ return (
222
+ gr.update(value=""),
223
+ gr.update(value=instr_val),
224
+ gr.update(value=tools_txt_val),
225
+ gr.update(choices=sorted(_available_tools_for(self.DEFAULT_OPTION)[0]), value=[]),
226
+ "Fill in a name, instructions and (optional) tools, then Save.",
227
+ gr.update(value="cedar"),
228
+ )
229
+ except Exception:
230
+ return (
231
+ gr.update(),
232
+ gr.update(),
233
+ gr.update(),
234
+ gr.update(),
235
+ "Failed to initialize new personality.",
236
+ gr.update(),
237
+ )
238
+
239
+ def _save_personality(
240
+ name: str, instructions: str, tools_text: str, voice: str
241
+ ) -> tuple[dict[str, Any], dict[str, Any], str]:
242
+ name_s = self._sanitize_name(name)
243
+ if not name_s:
244
+ return gr.update(), gr.update(), "Please enter a valid name."
245
+ try:
246
+ target_dir = self._profiles_root / "user_personalities" / name_s
247
+ target_dir.mkdir(parents=True, exist_ok=True)
248
+ (target_dir / "instructions.txt").write_text(instructions.strip() + "\n", encoding="utf-8")
249
+ (target_dir / "tools.txt").write_text(tools_text.strip() + "\n", encoding="utf-8")
250
+ (target_dir / "voice.txt").write_text((voice or "cedar").strip() + "\n", encoding="utf-8")
251
+
252
+ choices = self._list_personalities()
253
+ value = f"user_personalities/{name_s}"
254
+ if value not in choices:
255
+ choices.append(value)
256
+ return (
257
+ gr.update(choices=[self.DEFAULT_OPTION, *sorted(choices)], value=value),
258
+ gr.update(value=instructions),
259
+ f"Saved personality '{name_s}'.",
260
+ )
261
+ except Exception as e:
262
+ return gr.update(), gr.update(), f"Failed to save personality: {e}"
263
+
264
+ def _sync_tools_from_checks(selected: list[str], current_text: str) -> dict[str, Any]:
265
+ comments = [ln for ln in current_text.splitlines() if ln.strip().startswith("#")]
266
+ body = "\n".join(selected)
267
+ out = ("\n".join(comments) + ("\n" if comments else "") + body).strip() + "\n"
268
+ return gr.update(value=out)
269
+
270
+ with blocks:
271
+ self.apply_btn.click(
272
+ fn=_apply_personality,
273
+ inputs=[self.personalities_dropdown],
274
+ outputs=[self.status_md, self.preview_md],
275
+ )
276
+
277
+ self.personalities_dropdown.change(
278
+ fn=_load_profile_for_edit,
279
+ inputs=[self.personalities_dropdown],
280
+ outputs=[self.person_instr_ta, self.tools_txt_ta, self.available_tools_cg, self.status_md],
281
+ )
282
+
283
+ blocks.load(
284
+ fn=_fetch_voices,
285
+ inputs=[self.personalities_dropdown],
286
+ outputs=[self.voice_dropdown],
287
+ )
288
+
289
+ self.available_tools_cg.change(
290
+ fn=_sync_tools_from_checks,
291
+ inputs=[self.available_tools_cg, self.tools_txt_ta],
292
+ outputs=[self.tools_txt_ta],
293
+ )
294
+
295
+ self.new_personality_btn.click(
296
+ fn=_new_personality,
297
+ inputs=[],
298
+ outputs=[
299
+ self.person_name_tb,
300
+ self.person_instr_ta,
301
+ self.tools_txt_ta,
302
+ self.available_tools_cg,
303
+ self.status_md,
304
+ self.voice_dropdown,
305
+ ],
306
+ )
307
+
308
+ self.save_btn.click(
309
+ fn=_save_personality,
310
+ inputs=[self.person_name_tb, self.person_instr_ta, self.tools_txt_ta, self.voice_dropdown],
311
+ outputs=[self.personalities_dropdown, self.person_instr_ta, self.status_md],
312
+ ).then(
313
+ fn=_apply_personality,
314
+ inputs=[self.personalities_dropdown],
315
+ outputs=[self.status_md, self.preview_md],
316
+ )
src/hello_world/headless_personality.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Headless personality management (console-based).
2
+
3
+ Provides an interactive CLI to browse, preview, apply, create and edit
4
+ "personalities" (profiles) when running without Gradio.
5
+
6
+ This module is intentionally not shared with the Gradio implementation to
7
+ avoid coupling and keep responsibilities clear for headless mode.
8
+ """
9
+
10
+ from __future__ import annotations
11
+ from typing import List
12
+ from pathlib import Path
13
+
14
+
15
+ DEFAULT_OPTION = "(built-in default)"
16
+
17
+
18
+ def _profiles_root() -> Path:
19
+ return Path(__file__).parent / "profiles"
20
+
21
+
22
+ def _prompts_dir() -> Path:
23
+ return Path(__file__).parent / "prompts"
24
+
25
+
26
+ def _tools_dir() -> Path:
27
+ return Path(__file__).parent / "tools"
28
+
29
+
30
+ def _sanitize_name(name: str) -> str:
31
+ import re
32
+
33
+ s = name.strip()
34
+ s = re.sub(r"\s+", "_", s)
35
+ s = re.sub(r"[^a-zA-Z0-9_-]", "", s)
36
+ return s
37
+
38
+
39
+ def list_personalities() -> List[str]:
40
+ """List available personality profile names."""
41
+ names: List[str] = []
42
+ root = _profiles_root()
43
+ try:
44
+ if root.exists():
45
+ for p in sorted(root.iterdir()):
46
+ if p.name == "user_personalities":
47
+ continue
48
+ if p.is_dir() and (p / "instructions.txt").exists():
49
+ names.append(p.name)
50
+ udir = root / "user_personalities"
51
+ if udir.exists():
52
+ for p in sorted(udir.iterdir()):
53
+ if p.is_dir() and (p / "instructions.txt").exists():
54
+ names.append(f"user_personalities/{p.name}")
55
+ except Exception:
56
+ pass
57
+ return names
58
+
59
+
60
+ def resolve_profile_dir(selection: str) -> Path:
61
+ """Resolve the directory path for the given profile selection."""
62
+ return _profiles_root() / selection
63
+
64
+
65
+ def read_instructions_for(name: str) -> str:
66
+ """Read the instructions.txt content for the given profile name."""
67
+ try:
68
+ if name == DEFAULT_OPTION:
69
+ df = _prompts_dir() / "default_prompt.txt"
70
+ return df.read_text(encoding="utf-8").strip() if df.exists() else ""
71
+ target = resolve_profile_dir(name) / "instructions.txt"
72
+ return target.read_text(encoding="utf-8").strip() if target.exists() else ""
73
+ except Exception as e:
74
+ return f"Could not load instructions: {e}"
75
+
76
+
77
+ def available_tools_for(selected: str) -> List[str]:
78
+ """List available tool modules for the given profile selection."""
79
+ shared: List[str] = []
80
+ try:
81
+ for py in _tools_dir().glob("*.py"):
82
+ if py.stem in {"__init__", "core_tools"}:
83
+ continue
84
+ shared.append(py.stem)
85
+ except Exception:
86
+ pass
87
+ local: List[str] = []
88
+ try:
89
+ if selected != DEFAULT_OPTION:
90
+ for py in resolve_profile_dir(selected).glob("*.py"):
91
+ local.append(py.stem)
92
+ except Exception:
93
+ pass
94
+ return sorted(set(shared + local))
95
+
96
+
97
+ def _write_profile(name_s: str, instructions: str, tools_text: str, voice: str = "cedar") -> None:
98
+ target_dir = _profiles_root() / "user_personalities" / name_s
99
+ target_dir.mkdir(parents=True, exist_ok=True)
100
+ (target_dir / "instructions.txt").write_text(instructions.strip() + "\n", encoding="utf-8")
101
+ (target_dir / "tools.txt").write_text((tools_text or "").strip() + "\n", encoding="utf-8")
102
+ (target_dir / "voice.txt").write_text((voice or "cedar").strip() + "\n", encoding="utf-8")
src/hello_world/headless_personality_ui.py ADDED
@@ -0,0 +1,287 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Settings UI routes for headless personality management.
2
+
3
+ Exposes REST endpoints on the provided FastAPI settings app. The
4
+ implementation schedules backend actions (apply personality, fetch voices)
5
+ onto the running LocalStream asyncio loop using the supplied get_loop
6
+ callable to avoid cross-thread issues.
7
+ """
8
+
9
+ from __future__ import annotations
10
+ import asyncio
11
+ import logging
12
+ from typing import Any, Callable, Optional
13
+
14
+ from fastapi import FastAPI
15
+
16
+ from .config import LOCKED_PROFILE, config
17
+ from .openai_realtime import OpenaiRealtimeHandler
18
+ from .headless_personality import (
19
+ DEFAULT_OPTION,
20
+ _sanitize_name,
21
+ _write_profile,
22
+ list_personalities,
23
+ available_tools_for,
24
+ resolve_profile_dir,
25
+ read_instructions_for,
26
+ )
27
+
28
+
29
+ def mount_personality_routes(
30
+ app: FastAPI,
31
+ handler: OpenaiRealtimeHandler,
32
+ get_loop: Callable[[], asyncio.AbstractEventLoop | None],
33
+ *,
34
+ persist_personality: Callable[[Optional[str]], None] | None = None,
35
+ get_persisted_personality: Callable[[], Optional[str]] | None = None,
36
+ ) -> None:
37
+ """Register personality management endpoints on a FastAPI app."""
38
+ try:
39
+ from fastapi import Request
40
+ from pydantic import BaseModel
41
+ from fastapi.responses import JSONResponse
42
+ except Exception: # pragma: no cover - only when settings app not available
43
+ return
44
+
45
+ class SavePayload(BaseModel):
46
+ name: str
47
+ instructions: str
48
+ tools_text: str
49
+ voice: Optional[str] = "cedar"
50
+
51
+ class ApplyPayload(BaseModel):
52
+ name: str
53
+ persist: Optional[bool] = False
54
+
55
+ def _startup_choice() -> Any:
56
+ """Return the persisted startup personality or default."""
57
+ try:
58
+ if get_persisted_personality is not None:
59
+ stored = get_persisted_personality()
60
+ if stored:
61
+ return stored
62
+ env_val = getattr(config, "REACHY_MINI_CUSTOM_PROFILE", None)
63
+ if env_val:
64
+ return env_val
65
+ except Exception:
66
+ pass
67
+ return DEFAULT_OPTION
68
+
69
+ def _current_choice() -> str:
70
+ try:
71
+ cur = getattr(config, "REACHY_MINI_CUSTOM_PROFILE", None)
72
+ return cur or DEFAULT_OPTION
73
+ except Exception:
74
+ return DEFAULT_OPTION
75
+
76
+ @app.get("/personalities")
77
+ def _list() -> dict: # type: ignore
78
+ choices = [DEFAULT_OPTION, *list_personalities()]
79
+ return {
80
+ "choices": choices,
81
+ "current": _current_choice(),
82
+ "startup": _startup_choice(),
83
+ "locked": LOCKED_PROFILE is not None,
84
+ "locked_to": LOCKED_PROFILE,
85
+ }
86
+
87
+ @app.get("/personalities/load")
88
+ def _load(name: str) -> dict: # type: ignore
89
+ instr = read_instructions_for(name)
90
+ tools_txt = ""
91
+ voice = "cedar"
92
+ if name != DEFAULT_OPTION:
93
+ pdir = resolve_profile_dir(name)
94
+ tp = pdir / "tools.txt"
95
+ if tp.exists():
96
+ tools_txt = tp.read_text(encoding="utf-8")
97
+ vf = pdir / "voice.txt"
98
+ if vf.exists():
99
+ v = vf.read_text(encoding="utf-8").strip()
100
+ voice = v or "cedar"
101
+ avail = available_tools_for(name)
102
+ enabled = [ln.strip() for ln in tools_txt.splitlines() if ln.strip() and not ln.strip().startswith("#")]
103
+ return {
104
+ "instructions": instr,
105
+ "tools_text": tools_txt,
106
+ "voice": voice,
107
+ "available_tools": avail,
108
+ "enabled_tools": enabled,
109
+ }
110
+
111
+ @app.post("/personalities/save")
112
+ async def _save(request: Request) -> dict: # type: ignore
113
+ # Accept raw JSON only to avoid validation-related 422s
114
+ try:
115
+ raw = await request.json()
116
+ except Exception:
117
+ raw = {}
118
+ name = str(raw.get("name", ""))
119
+ instructions = str(raw.get("instructions", ""))
120
+ tools_text = str(raw.get("tools_text", ""))
121
+ voice = str(raw.get("voice", "cedar")) if raw.get("voice") is not None else "cedar"
122
+
123
+ name_s = _sanitize_name(name)
124
+ if not name_s:
125
+ return JSONResponse({"ok": False, "error": "invalid_name"}, status_code=400) # type: ignore
126
+ try:
127
+ logger.info(
128
+ "Headless save: name=%r voice=%r instr_len=%d tools_len=%d",
129
+ name_s,
130
+ voice,
131
+ len(instructions),
132
+ len(tools_text),
133
+ )
134
+ _write_profile(name_s, instructions, tools_text, voice or "cedar")
135
+ value = f"user_personalities/{name_s}"
136
+ choices = [DEFAULT_OPTION, *list_personalities()]
137
+ return {"ok": True, "value": value, "choices": choices}
138
+ except Exception as e:
139
+ return JSONResponse({"ok": False, "error": str(e)}, status_code=500) # type: ignore
140
+
141
+ @app.post("/personalities/save_raw")
142
+ async def _save_raw(
143
+ request: Request,
144
+ name: Optional[str] = None,
145
+ instructions: Optional[str] = None,
146
+ tools_text: Optional[str] = None,
147
+ voice: Optional[str] = None,
148
+ ) -> dict: # type: ignore
149
+ # Accept query params, form-encoded, or raw JSON
150
+ data = {"name": name, "instructions": instructions, "tools_text": tools_text, "voice": voice}
151
+ # Prefer form if present
152
+ try:
153
+ form = await request.form()
154
+ for k in ("name", "instructions", "tools_text", "voice"):
155
+ if k in form and form[k] is not None:
156
+ data[k] = str(form[k])
157
+ except Exception:
158
+ pass
159
+ # Try JSON
160
+ try:
161
+ raw = await request.json()
162
+ if isinstance(raw, dict):
163
+ for k in ("name", "instructions", "tools_text", "voice"):
164
+ if raw.get(k) is not None:
165
+ data[k] = str(raw.get(k))
166
+ except Exception:
167
+ pass
168
+
169
+ name_s = _sanitize_name(str(data.get("name") or ""))
170
+ if not name_s:
171
+ return JSONResponse({"ok": False, "error": "invalid_name"}, status_code=400) # type: ignore
172
+ instr = str(data.get("instructions") or "")
173
+ tools = str(data.get("tools_text") or "")
174
+ v = str(data.get("voice") or "cedar")
175
+ try:
176
+ logger.info(
177
+ "Headless save_raw: name=%r voice=%r instr_len=%d tools_len=%d", name_s, v, len(instr), len(tools)
178
+ )
179
+ _write_profile(name_s, instr, tools, v)
180
+ value = f"user_personalities/{name_s}"
181
+ choices = [DEFAULT_OPTION, *list_personalities()]
182
+ return {"ok": True, "value": value, "choices": choices}
183
+ except Exception as e:
184
+ return JSONResponse({"ok": False, "error": str(e)}, status_code=500) # type: ignore
185
+
186
+ @app.get("/personalities/save_raw")
187
+ async def _save_raw_get(name: str, instructions: str = "", tools_text: str = "", voice: str = "cedar") -> dict: # type: ignore
188
+ name_s = _sanitize_name(name)
189
+ if not name_s:
190
+ return JSONResponse({"ok": False, "error": "invalid_name"}, status_code=400) # type: ignore
191
+ try:
192
+ logger.info(
193
+ "Headless save_raw(GET): name=%r voice=%r instr_len=%d tools_len=%d",
194
+ name_s,
195
+ voice,
196
+ len(instructions),
197
+ len(tools_text),
198
+ )
199
+ _write_profile(name_s, instructions, tools_text, voice or "cedar")
200
+ value = f"user_personalities/{name_s}"
201
+ choices = [DEFAULT_OPTION, *list_personalities()]
202
+ return {"ok": True, "value": value, "choices": choices}
203
+ except Exception as e:
204
+ return JSONResponse({"ok": False, "error": str(e)}, status_code=500) # type: ignore
205
+
206
+ logger = logging.getLogger(__name__)
207
+
208
+ @app.post("/personalities/apply")
209
+ async def _apply(
210
+ payload: ApplyPayload | None = None,
211
+ name: str | None = None,
212
+ persist: Optional[bool] = None,
213
+ request: Optional[Request] = None,
214
+ ) -> dict: # type: ignore
215
+ if LOCKED_PROFILE is not None:
216
+ return JSONResponse(
217
+ {"ok": False, "error": "profile_locked", "locked_to": LOCKED_PROFILE},
218
+ status_code=403,
219
+ ) # type: ignore
220
+ loop = get_loop()
221
+ if loop is None:
222
+ return JSONResponse({"ok": False, "error": "loop_unavailable"}, status_code=503) # type: ignore
223
+
224
+ # Accept both JSON payload and query param for convenience
225
+ sel_name: Optional[str] = None
226
+ persist_flag = bool(persist) if persist is not None else False
227
+ if payload and getattr(payload, "name", None):
228
+ sel_name = payload.name
229
+ persist_flag = bool(getattr(payload, "persist", False))
230
+ elif name:
231
+ sel_name = name
232
+ elif request is not None:
233
+ try:
234
+ body = await request.json()
235
+ if isinstance(body, dict) and body.get("name"):
236
+ sel_name = str(body.get("name"))
237
+ if isinstance(body, dict) and "persist" in body:
238
+ persist_flag = bool(body.get("persist"))
239
+ except Exception:
240
+ sel_name = None
241
+ if request is not None:
242
+ try:
243
+ q_persist = request.query_params.get("persist")
244
+ if q_persist is not None:
245
+ persist_flag = str(q_persist).lower() in {"1", "true", "yes", "on"}
246
+ except Exception:
247
+ pass
248
+ if not sel_name:
249
+ sel_name = DEFAULT_OPTION
250
+
251
+ async def _do_apply() -> str:
252
+ sel = None if sel_name == DEFAULT_OPTION else sel_name
253
+ status = await handler.apply_personality(sel)
254
+ return status
255
+
256
+ try:
257
+ logger.info("Headless apply: requested name=%r", sel_name)
258
+ fut = asyncio.run_coroutine_threadsafe(_do_apply(), loop)
259
+ status = fut.result(timeout=10)
260
+ persisted_choice = _startup_choice()
261
+ if persist_flag and persist_personality is not None:
262
+ try:
263
+ persist_personality(None if sel_name == DEFAULT_OPTION else sel_name)
264
+ persisted_choice = _startup_choice()
265
+ except Exception as e:
266
+ logger.warning("Failed to persist startup personality: %s", e)
267
+ return {"ok": True, "status": status, "startup": persisted_choice}
268
+ except Exception as e:
269
+ return JSONResponse({"ok": False, "error": str(e)}, status_code=500) # type: ignore
270
+
271
+ @app.get("/voices")
272
+ async def _voices() -> list[str]:
273
+ loop = get_loop()
274
+ if loop is None:
275
+ return ["cedar"]
276
+
277
+ async def _get_v() -> list[str]:
278
+ try:
279
+ return await handler.get_available_voices()
280
+ except Exception:
281
+ return ["cedar"]
282
+
283
+ try:
284
+ fut = asyncio.run_coroutine_threadsafe(_get_v(), loop)
285
+ return fut.result(timeout=10)
286
+ except Exception:
287
+ return ["cedar"]
src/hello_world/images/reachymini_avatar.png ADDED

Git LFS Details

  • SHA256: 5a63ac8802ff3542f01292c431c5278296880d74cd3580d219fcf4827bc235f9
  • Pointer size: 132 Bytes
  • Size of remote file: 1.23 MB
src/hello_world/images/user_avatar.png ADDED

Git LFS Details

  • SHA256: e97ca125a86bacdaa41c8dca88abd9ca746fd5c9391eda24249c012432b0219b
  • Pointer size: 132 Bytes
  • Size of remote file: 1.11 MB
src/hello_world/main.py ADDED
@@ -0,0 +1,260 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Entrypoint for the Reachy Mini conversation app."""
2
+
3
+ import os
4
+ import sys
5
+ import time
6
+ import asyncio
7
+ import argparse
8
+ import threading
9
+ from typing import Any, Dict, List, Optional
10
+
11
+ import gradio as gr
12
+ from fastapi import FastAPI
13
+ from fastrtc import Stream
14
+ from gradio.utils import get_space
15
+
16
+ from reachy_mini import ReachyMini, ReachyMiniApp
17
+ from hello_world.utils import (
18
+ parse_args,
19
+ setup_logger,
20
+ handle_vision_stuff,
21
+ log_connection_troubleshooting,
22
+ )
23
+
24
+
25
+ def update_chatbot(chatbot: List[Dict[str, Any]], response: Dict[str, Any]) -> List[Dict[str, Any]]:
26
+ """Update the chatbot with AdditionalOutputs."""
27
+ chatbot.append(response)
28
+ return chatbot
29
+
30
+
31
+ def main() -> None:
32
+ """Entrypoint for the Reachy Mini conversation app."""
33
+ args, _ = parse_args()
34
+ run(args)
35
+
36
+
37
+ def run(
38
+ args: argparse.Namespace,
39
+ robot: ReachyMini = None,
40
+ app_stop_event: Optional[threading.Event] = None,
41
+ settings_app: Optional[FastAPI] = None,
42
+ instance_path: Optional[str] = None,
43
+ ) -> None:
44
+ """Run the Reachy Mini conversation app."""
45
+ # Putting these dependencies here makes the dashboard faster to load when the conversation app is installed
46
+ from hello_world.moves import MovementManager
47
+ from hello_world.console import LocalStream
48
+ from hello_world.openai_realtime import OpenaiRealtimeHandler
49
+ from hello_world.tools.core_tools import ToolDependencies
50
+ from hello_world.audio.head_wobbler import HeadWobbler
51
+
52
+ logger = setup_logger(args.debug)
53
+ logger.info("Starting Reachy Mini Conversation App")
54
+
55
+ if args.no_camera and args.head_tracker is not None:
56
+ logger.warning(
57
+ "Head tracking disabled: --no-camera flag is set. "
58
+ "Remove --no-camera to enable head tracking."
59
+ )
60
+
61
+ if robot is None:
62
+ try:
63
+ robot_kwargs = {}
64
+ if args.robot_name is not None:
65
+ robot_kwargs["robot_name"] = args.robot_name
66
+
67
+ logger.info("Initializing ReachyMini (SDK will auto-detect appropriate backend)")
68
+ robot = ReachyMini(**robot_kwargs)
69
+
70
+ except TimeoutError as e:
71
+ logger.error(
72
+ "Connection timeout: Failed to connect to Reachy Mini daemon. "
73
+ f"Details: {e}"
74
+ )
75
+ log_connection_troubleshooting(logger, args.robot_name)
76
+ sys.exit(1)
77
+
78
+ except ConnectionError as e:
79
+ logger.error(
80
+ "Connection failed: Unable to establish connection to Reachy Mini. "
81
+ f"Details: {e}"
82
+ )
83
+ log_connection_troubleshooting(logger, args.robot_name)
84
+ sys.exit(1)
85
+
86
+ except Exception as e:
87
+ logger.error(
88
+ f"Unexpected error during robot initialization: {type(e).__name__}: {e}"
89
+ )
90
+ logger.error("Please check your configuration and try again.")
91
+ sys.exit(1)
92
+
93
+ # Auto-enable Gradio in simulation mode (both MuJoCo for daemon and mockup-sim for desktop app)
94
+ status = robot.client.get_status()
95
+ if isinstance(status, dict):
96
+ simulation_enabled = status.get("simulation_enabled", False)
97
+ mockup_sim_enabled = status.get("mockup_sim_enabled", False)
98
+ else:
99
+ simulation_enabled = getattr(status, "simulation_enabled", False)
100
+ mockup_sim_enabled = getattr(status, "mockup_sim_enabled", False)
101
+
102
+ is_simulation = simulation_enabled or mockup_sim_enabled
103
+
104
+ if is_simulation and not args.gradio:
105
+ logger.info("Simulation mode detected. Automatically enabling gradio flag.")
106
+ args.gradio = True
107
+
108
+ camera_worker, _, vision_manager = handle_vision_stuff(args, robot)
109
+
110
+ movement_manager = MovementManager(
111
+ current_robot=robot,
112
+ camera_worker=camera_worker,
113
+ )
114
+
115
+ head_wobbler = HeadWobbler(set_speech_offsets=movement_manager.set_speech_offsets)
116
+
117
+ deps = ToolDependencies(
118
+ reachy_mini=robot,
119
+ movement_manager=movement_manager,
120
+ camera_worker=camera_worker,
121
+ vision_manager=vision_manager,
122
+ head_wobbler=head_wobbler,
123
+ )
124
+ current_file_path = os.path.dirname(os.path.abspath(__file__))
125
+ logger.debug(f"Current file absolute path: {current_file_path}")
126
+ chatbot = gr.Chatbot(
127
+ type="messages",
128
+ resizable=True,
129
+ avatar_images=(
130
+ os.path.join(current_file_path, "images", "user_avatar.png"),
131
+ os.path.join(current_file_path, "images", "reachymini_avatar.png"),
132
+ ),
133
+ )
134
+ logger.debug(f"Chatbot avatar images: {chatbot.avatar_images}")
135
+
136
+ handler = OpenaiRealtimeHandler(deps, gradio_mode=args.gradio, instance_path=instance_path)
137
+
138
+ stream_manager: gr.Blocks | LocalStream | None = None
139
+
140
+ if args.gradio:
141
+ api_key_textbox = gr.Textbox(
142
+ label="OPENAI API Key",
143
+ type="password",
144
+ value=os.getenv("OPENAI_API_KEY") if not get_space() else "",
145
+ )
146
+
147
+ from hello_world.gradio_personality import PersonalityUI
148
+
149
+ personality_ui = PersonalityUI()
150
+ personality_ui.create_components()
151
+
152
+ stream = Stream(
153
+ handler=handler,
154
+ mode="send-receive",
155
+ modality="audio",
156
+ additional_inputs=[
157
+ chatbot,
158
+ api_key_textbox,
159
+ *personality_ui.additional_inputs_ordered(),
160
+ ],
161
+ additional_outputs=[chatbot],
162
+ additional_outputs_handler=update_chatbot,
163
+ ui_args={"title": "Talk with Reachy Mini"},
164
+ )
165
+ stream_manager = stream.ui
166
+ if not settings_app:
167
+ app = FastAPI()
168
+ else:
169
+ app = settings_app
170
+
171
+ personality_ui.wire_events(handler, stream_manager)
172
+
173
+ app = gr.mount_gradio_app(app, stream.ui, path="/")
174
+ else:
175
+ # In headless mode, wire settings_app + instance_path to console LocalStream
176
+ stream_manager = LocalStream(
177
+ handler,
178
+ robot,
179
+ settings_app=settings_app,
180
+ instance_path=instance_path,
181
+ )
182
+
183
+ # Each async service → its own thread/loop
184
+ movement_manager.start()
185
+ head_wobbler.start()
186
+ if camera_worker:
187
+ camera_worker.start()
188
+ if vision_manager:
189
+ vision_manager.start()
190
+
191
+ def poll_stop_event() -> None:
192
+ """Poll the stop event to allow graceful shutdown."""
193
+ if app_stop_event is not None:
194
+ app_stop_event.wait()
195
+
196
+ logger.info("App stop event detected, shutting down...")
197
+ try:
198
+ stream_manager.close()
199
+ except Exception as e:
200
+ logger.error(f"Error while closing stream manager: {e}")
201
+
202
+ if app_stop_event:
203
+ threading.Thread(target=poll_stop_event, daemon=True).start()
204
+
205
+ try:
206
+ stream_manager.launch()
207
+ except KeyboardInterrupt:
208
+ logger.info("Keyboard interruption in main thread... closing server.")
209
+ finally:
210
+ movement_manager.stop()
211
+ head_wobbler.stop()
212
+ if camera_worker:
213
+ camera_worker.stop()
214
+ if vision_manager:
215
+ vision_manager.stop()
216
+
217
+ # Ensure media is explicitly closed before disconnecting
218
+ try:
219
+ robot.media.close()
220
+ except Exception as e:
221
+ logger.debug(f"Error closing media during shutdown: {e}")
222
+
223
+ # prevent connection to keep alive some threads
224
+ robot.client.disconnect()
225
+ time.sleep(1)
226
+ logger.info("Shutdown complete.")
227
+
228
+
229
+ class HelloWorld(ReachyMiniApp): # type: ignore[misc]
230
+ """Reachy Mini Apps entry point for the conversation app."""
231
+
232
+ custom_app_url = "http://0.0.0.0:7860/"
233
+ dont_start_webserver = False
234
+
235
+ def run(self, reachy_mini: ReachyMini, stop_event: threading.Event) -> None:
236
+ """Run the Reachy Mini conversation app."""
237
+ loop = asyncio.new_event_loop()
238
+ asyncio.set_event_loop(loop)
239
+
240
+ args, _ = parse_args()
241
+
242
+ # is_wireless = reachy_mini.client.get_status()["wireless_version"]
243
+ # args.head_tracker = None if is_wireless else "mediapipe"
244
+
245
+ instance_path = self._get_instance_path().parent
246
+ run(
247
+ args,
248
+ robot=reachy_mini,
249
+ app_stop_event=stop_event,
250
+ settings_app=self.settings_app,
251
+ instance_path=instance_path,
252
+ )
253
+
254
+
255
+ if __name__ == "__main__":
256
+ app = HelloWorld()
257
+ try:
258
+ app.wrapped_run()
259
+ except KeyboardInterrupt:
260
+ app.stop()
src/hello_world/moves.py ADDED
@@ -0,0 +1,849 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Movement system with sequential primary moves and additive secondary moves.
2
+
3
+ Design overview
4
+ - Primary moves (emotions, dances, goto, breathing) are mutually exclusive and run
5
+ sequentially.
6
+ - Secondary moves (speech sway, face tracking) are additive offsets applied on top
7
+ of the current primary pose.
8
+ - There is a single control point to the robot: `ReachyMini.set_target`.
9
+ - The control loop runs near 100 Hz and is phase-aligned via a monotonic clock.
10
+ - Idle behaviour starts an infinite `BreathingMove` after a short inactivity delay
11
+ unless listening is active.
12
+
13
+ Threading model
14
+ - A dedicated worker thread owns all real-time state and issues `set_target`
15
+ commands.
16
+ - Other threads communicate via a command queue (enqueue moves, mark activity,
17
+ toggle listening).
18
+ - Secondary offset producers set pending values guarded by locks; the worker
19
+ snaps them atomically.
20
+
21
+ Units and frames
22
+ - Secondary offsets are interpreted as metres for x/y/z and radians for
23
+ roll/pitch/yaw in the world frame (unless noted by `compose_world_offset`).
24
+ - Antennas and `body_yaw` are in radians.
25
+ - Head pose composition uses `compose_world_offset(primary_head, secondary_head)`;
26
+ the secondary offset must therefore be expressed in the world frame.
27
+
28
+ Safety
29
+ - Listening freezes antennas, then blends them back on unfreeze.
30
+ - Interpolations and blends are used to avoid jumps at all times.
31
+ - `set_target` errors are rate-limited in logs.
32
+ """
33
+
34
+ from __future__ import annotations
35
+ import time
36
+ import logging
37
+ import threading
38
+ from queue import Empty, Queue
39
+ from typing import Any, Dict, Tuple
40
+ from collections import deque
41
+ from dataclasses import dataclass
42
+
43
+ import numpy as np
44
+ from numpy.typing import NDArray
45
+
46
+ from reachy_mini import ReachyMini
47
+ from reachy_mini.utils import create_head_pose
48
+ from reachy_mini.motion.move import Move
49
+ from reachy_mini.utils.interpolation import (
50
+ compose_world_offset,
51
+ linear_pose_interpolation,
52
+ )
53
+
54
+
55
+ logger = logging.getLogger(__name__)
56
+
57
+ # Configuration constants
58
+ CONTROL_LOOP_FREQUENCY_HZ = 100.0 # Hz - Target frequency for the movement control loop
59
+
60
+ # Type definitions
61
+ FullBodyPose = Tuple[NDArray[np.float32], Tuple[float, float], float] # (head_pose_4x4, antennas, body_yaw)
62
+
63
+
64
+ class BreathingMove(Move): # type: ignore
65
+ """Breathing move with interpolation to neutral and then continuous breathing patterns."""
66
+
67
+ def __init__(
68
+ self,
69
+ interpolation_start_pose: NDArray[np.float32],
70
+ interpolation_start_antennas: Tuple[float, float],
71
+ interpolation_duration: float = 1.0,
72
+ ):
73
+ """Initialize breathing move.
74
+
75
+ Args:
76
+ interpolation_start_pose: 4x4 matrix of current head pose to interpolate from
77
+ interpolation_start_antennas: Current antenna positions to interpolate from
78
+ interpolation_duration: Duration of interpolation to neutral (seconds)
79
+
80
+ """
81
+ self.interpolation_start_pose = interpolation_start_pose
82
+ self.interpolation_start_antennas = np.array(interpolation_start_antennas)
83
+ self.interpolation_duration = interpolation_duration
84
+
85
+ # Neutral positions for breathing base
86
+ self.neutral_head_pose = create_head_pose(0, 0, 0, 0, 0, 0, degrees=True)
87
+ self.neutral_antennas = np.array([0.0, 0.0])
88
+
89
+ # Breathing parameters
90
+ self.breathing_z_amplitude = 0.005 # 5mm gentle breathing
91
+ self.breathing_frequency = 0.1 # Hz (6 breaths per minute)
92
+ self.antenna_sway_amplitude = np.deg2rad(15) # 15 degrees
93
+ self.antenna_frequency = 0.5 # Hz (faster antenna sway)
94
+
95
+ @property
96
+ def duration(self) -> float:
97
+ """Duration property required by official Move interface."""
98
+ return float("inf") # Continuous breathing (never ends naturally)
99
+
100
+ def evaluate(self, t: float) -> tuple[NDArray[np.float64] | None, NDArray[np.float64] | None, float | None]:
101
+ """Evaluate breathing move at time t."""
102
+ if t < self.interpolation_duration:
103
+ # Phase 1: Interpolate to neutral base position
104
+ interpolation_t = t / self.interpolation_duration
105
+
106
+ # Interpolate head pose
107
+ head_pose = linear_pose_interpolation(
108
+ self.interpolation_start_pose, self.neutral_head_pose, interpolation_t,
109
+ )
110
+
111
+ # Interpolate antennas
112
+ antennas_interp = (
113
+ 1 - interpolation_t
114
+ ) * self.interpolation_start_antennas + interpolation_t * self.neutral_antennas
115
+ antennas = antennas_interp.astype(np.float64)
116
+
117
+ else:
118
+ # Phase 2: Breathing patterns from neutral base
119
+ breathing_time = t - self.interpolation_duration
120
+
121
+ # Gentle z-axis breathing
122
+ z_offset = self.breathing_z_amplitude * np.sin(2 * np.pi * self.breathing_frequency * breathing_time)
123
+ head_pose = create_head_pose(x=0, y=0, z=z_offset, roll=0, pitch=0, yaw=0, degrees=True, mm=False)
124
+
125
+ # Antenna sway (opposite directions)
126
+ antenna_sway = self.antenna_sway_amplitude * np.sin(2 * np.pi * self.antenna_frequency * breathing_time)
127
+ antennas = np.array([antenna_sway, -antenna_sway], dtype=np.float64)
128
+
129
+ # Return in official Move interface format: (head_pose, antennas_array, body_yaw)
130
+ return (head_pose, antennas, 0.0)
131
+
132
+
133
+ def combine_full_body(primary_pose: FullBodyPose, secondary_pose: FullBodyPose) -> FullBodyPose:
134
+ """Combine primary and secondary full body poses.
135
+
136
+ Args:
137
+ primary_pose: (head_pose, antennas, body_yaw) - primary move
138
+ secondary_pose: (head_pose, antennas, body_yaw) - secondary offsets
139
+
140
+ Returns:
141
+ Combined full body pose (head_pose, antennas, body_yaw)
142
+
143
+ """
144
+ primary_head, primary_antennas, primary_body_yaw = primary_pose
145
+ secondary_head, secondary_antennas, secondary_body_yaw = secondary_pose
146
+
147
+ # Combine head poses using compose_world_offset; the secondary pose must be an
148
+ # offset expressed in the world frame (T_off_world) applied to the absolute
149
+ # primary transform (T_abs).
150
+ combined_head = compose_world_offset(primary_head, secondary_head, reorthonormalize=True)
151
+
152
+ # Sum antennas and body_yaw
153
+ combined_antennas = (
154
+ primary_antennas[0] + secondary_antennas[0],
155
+ primary_antennas[1] + secondary_antennas[1],
156
+ )
157
+ combined_body_yaw = primary_body_yaw + secondary_body_yaw
158
+
159
+ return (combined_head, combined_antennas, combined_body_yaw)
160
+
161
+
162
+ def clone_full_body_pose(pose: FullBodyPose) -> FullBodyPose:
163
+ """Create a deep copy of a full body pose tuple."""
164
+ head, antennas, body_yaw = pose
165
+ return (head.copy(), (float(antennas[0]), float(antennas[1])), float(body_yaw))
166
+
167
+
168
+ @dataclass
169
+ class MovementState:
170
+ """State tracking for the movement system."""
171
+
172
+ # Primary move state
173
+ current_move: Move | None = None
174
+ move_start_time: float | None = None
175
+ last_activity_time: float = 0.0
176
+
177
+ # Secondary move state (offsets)
178
+ speech_offsets: Tuple[float, float, float, float, float, float] = (
179
+ 0.0,
180
+ 0.0,
181
+ 0.0,
182
+ 0.0,
183
+ 0.0,
184
+ 0.0,
185
+ )
186
+ face_tracking_offsets: Tuple[float, float, float, float, float, float] = (
187
+ 0.0,
188
+ 0.0,
189
+ 0.0,
190
+ 0.0,
191
+ 0.0,
192
+ 0.0,
193
+ )
194
+
195
+ # Status flags
196
+ last_primary_pose: FullBodyPose | None = None
197
+
198
+ def update_activity(self) -> None:
199
+ """Update the last activity time."""
200
+ self.last_activity_time = time.monotonic()
201
+
202
+
203
+ @dataclass
204
+ class LoopFrequencyStats:
205
+ """Track rolling loop frequency statistics."""
206
+
207
+ mean: float = 0.0
208
+ m2: float = 0.0
209
+ min_freq: float = float("inf")
210
+ count: int = 0
211
+ last_freq: float = 0.0
212
+ potential_freq: float = 0.0
213
+
214
+ def reset(self) -> None:
215
+ """Reset accumulators while keeping the last potential frequency."""
216
+ self.mean = 0.0
217
+ self.m2 = 0.0
218
+ self.min_freq = float("inf")
219
+ self.count = 0
220
+
221
+
222
+ class MovementManager:
223
+ """Coordinate sequential moves, additive offsets, and robot output at 100 Hz.
224
+
225
+ Responsibilities:
226
+ - Own a real-time loop that samples the current primary move (if any), fuses
227
+ secondary offsets, and calls `set_target` exactly once per tick.
228
+ - Start an idle `BreathingMove` after `idle_inactivity_delay` when not
229
+ listening and no moves are queued.
230
+ - Expose thread-safe APIs so other threads can enqueue moves, mark activity,
231
+ or feed secondary offsets without touching internal state.
232
+
233
+ Timing:
234
+ - All elapsed-time calculations rely on `time.monotonic()` through `self._now`
235
+ to avoid wall-clock jumps.
236
+ - The loop attempts 100 Hz
237
+
238
+ Concurrency:
239
+ - External threads communicate via `_command_queue` messages.
240
+ - Secondary offsets are staged via dirty flags guarded by locks and consumed
241
+ atomically inside the worker loop.
242
+ """
243
+
244
+ def __init__(
245
+ self,
246
+ current_robot: ReachyMini,
247
+ camera_worker: "Any" = None,
248
+ ):
249
+ """Initialize movement manager."""
250
+ self.current_robot = current_robot
251
+ self.camera_worker = camera_worker
252
+
253
+ # Single timing source for durations
254
+ self._now = time.monotonic
255
+
256
+ # Movement state
257
+ self.state = MovementState()
258
+ self.state.last_activity_time = self._now()
259
+ neutral_pose = create_head_pose(0, 0, 0, 0, 0, 0, degrees=True)
260
+ self.state.last_primary_pose = (neutral_pose, (0.0, 0.0), 0.0)
261
+
262
+ # Move queue (primary moves)
263
+ self.move_queue: deque[Move] = deque()
264
+
265
+ # Configuration
266
+ self.idle_inactivity_delay = 0.3 # seconds
267
+ self.target_frequency = CONTROL_LOOP_FREQUENCY_HZ
268
+ self.target_period = 1.0 / self.target_frequency
269
+
270
+ self._stop_event = threading.Event()
271
+ self._thread: threading.Thread | None = None
272
+ self._is_listening = False
273
+ self._last_commanded_pose: FullBodyPose = clone_full_body_pose(self.state.last_primary_pose)
274
+ self._listening_antennas: Tuple[float, float] = self._last_commanded_pose[1]
275
+ self._antenna_unfreeze_blend = 1.0
276
+ self._antenna_blend_duration = 0.4 # seconds to blend back after listening
277
+ self._last_listening_blend_time = self._now()
278
+ self._breathing_active = False # true when breathing move is running or queued
279
+ self._listening_debounce_s = 0.15
280
+ self._last_listening_toggle_time = self._now()
281
+ self._last_set_target_err = 0.0
282
+ self._set_target_err_interval = 1.0 # seconds between error logs
283
+ self._set_target_err_suppressed = 0
284
+
285
+ # Cross-thread signalling
286
+ self._command_queue: "Queue[Tuple[str, Any]]" = Queue()
287
+ self._speech_offsets_lock = threading.Lock()
288
+ self._pending_speech_offsets: Tuple[float, float, float, float, float, float] = (
289
+ 0.0,
290
+ 0.0,
291
+ 0.0,
292
+ 0.0,
293
+ 0.0,
294
+ 0.0,
295
+ )
296
+ self._speech_offsets_dirty = False
297
+
298
+ self._face_offsets_lock = threading.Lock()
299
+ self._pending_face_offsets: Tuple[float, float, float, float, float, float] = (
300
+ 0.0,
301
+ 0.0,
302
+ 0.0,
303
+ 0.0,
304
+ 0.0,
305
+ 0.0,
306
+ )
307
+ self._face_offsets_dirty = False
308
+
309
+ self._shared_state_lock = threading.Lock()
310
+ self._shared_last_activity_time = self.state.last_activity_time
311
+ self._shared_is_listening = self._is_listening
312
+ self._status_lock = threading.Lock()
313
+ self._freq_stats = LoopFrequencyStats()
314
+ self._freq_snapshot = LoopFrequencyStats()
315
+
316
+ def queue_move(self, move: Move) -> None:
317
+ """Queue a primary move to run after the currently executing one.
318
+
319
+ Thread-safe: the move is enqueued via the worker command queue so the
320
+ control loop remains the sole mutator of movement state.
321
+ """
322
+ self._command_queue.put(("queue_move", move))
323
+
324
+ def clear_move_queue(self) -> None:
325
+ """Stop the active move and discard any queued primary moves.
326
+
327
+ Thread-safe: executed by the worker thread via the command queue.
328
+ """
329
+ self._command_queue.put(("clear_queue", None))
330
+
331
+ def set_speech_offsets(self, offsets: Tuple[float, float, float, float, float, float]) -> None:
332
+ """Update speech-induced secondary offsets (x, y, z, roll, pitch, yaw).
333
+
334
+ Offsets are interpreted as metres for translation and radians for
335
+ rotation in the world frame. Thread-safe via a pending snapshot.
336
+ """
337
+ with self._speech_offsets_lock:
338
+ self._pending_speech_offsets = offsets
339
+ self._speech_offsets_dirty = True
340
+
341
+ def set_moving_state(self, duration: float) -> None:
342
+ """Mark the robot as actively moving for the provided duration.
343
+
344
+ Legacy hook used by goto helpers to keep inactivity and breathing logic
345
+ aware of manual motions. Thread-safe via the command queue.
346
+ """
347
+ self._command_queue.put(("set_moving_state", duration))
348
+
349
+ def is_idle(self) -> bool:
350
+ """Return True when the robot has been inactive longer than the idle delay."""
351
+ with self._shared_state_lock:
352
+ last_activity = self._shared_last_activity_time
353
+ listening = self._shared_is_listening
354
+
355
+ if listening:
356
+ return False
357
+
358
+ return self._now() - last_activity >= self.idle_inactivity_delay
359
+
360
+ def set_listening(self, listening: bool) -> None:
361
+ """Enable or disable listening mode without touching shared state directly.
362
+
363
+ While listening:
364
+ - Antenna positions are frozen at the last commanded values.
365
+ - Blending is reset so that upon unfreezing the antennas return smoothly.
366
+ - Idle breathing is suppressed.
367
+
368
+ Thread-safe: the change is posted to the worker command queue.
369
+ """
370
+ with self._shared_state_lock:
371
+ if self._shared_is_listening == listening:
372
+ return
373
+ self._command_queue.put(("set_listening", listening))
374
+
375
+ def _poll_signals(self, current_time: float) -> None:
376
+ """Apply queued commands and pending offset updates."""
377
+ self._apply_pending_offsets()
378
+
379
+ while True:
380
+ try:
381
+ command, payload = self._command_queue.get_nowait()
382
+ except Empty:
383
+ break
384
+ self._handle_command(command, payload, current_time)
385
+
386
+ def _apply_pending_offsets(self) -> None:
387
+ """Apply the most recent speech/face offset updates."""
388
+ speech_offsets: Tuple[float, float, float, float, float, float] | None = None
389
+ with self._speech_offsets_lock:
390
+ if self._speech_offsets_dirty:
391
+ speech_offsets = self._pending_speech_offsets
392
+ self._speech_offsets_dirty = False
393
+
394
+ if speech_offsets is not None:
395
+ self.state.speech_offsets = speech_offsets
396
+ self.state.update_activity()
397
+
398
+ face_offsets: Tuple[float, float, float, float, float, float] | None = None
399
+ with self._face_offsets_lock:
400
+ if self._face_offsets_dirty:
401
+ face_offsets = self._pending_face_offsets
402
+ self._face_offsets_dirty = False
403
+
404
+ if face_offsets is not None:
405
+ self.state.face_tracking_offsets = face_offsets
406
+ self.state.update_activity()
407
+
408
+ def _handle_command(self, command: str, payload: Any, current_time: float) -> None:
409
+ """Handle a single cross-thread command."""
410
+ if command == "queue_move":
411
+ if isinstance(payload, Move):
412
+ self.move_queue.append(payload)
413
+ self.state.update_activity()
414
+ duration = getattr(payload, "duration", None)
415
+ if duration is not None:
416
+ try:
417
+ duration_str = f"{float(duration):.2f}"
418
+ except (TypeError, ValueError):
419
+ duration_str = str(duration)
420
+ else:
421
+ duration_str = "?"
422
+ logger.debug(
423
+ "Queued move with duration %ss, queue size: %s",
424
+ duration_str,
425
+ len(self.move_queue),
426
+ )
427
+ else:
428
+ logger.warning("Ignored queue_move command with invalid payload: %s", payload)
429
+ elif command == "clear_queue":
430
+ self.move_queue.clear()
431
+ self.state.current_move = None
432
+ self.state.move_start_time = None
433
+ self._breathing_active = False
434
+ logger.info("Cleared move queue and stopped current move")
435
+ elif command == "set_moving_state":
436
+ try:
437
+ duration = float(payload)
438
+ except (TypeError, ValueError):
439
+ logger.warning("Invalid moving state duration: %s", payload)
440
+ return
441
+ self.state.update_activity()
442
+ elif command == "mark_activity":
443
+ self.state.update_activity()
444
+ elif command == "set_listening":
445
+ desired_state = bool(payload)
446
+ now = self._now()
447
+ if now - self._last_listening_toggle_time < self._listening_debounce_s:
448
+ return
449
+ self._last_listening_toggle_time = now
450
+
451
+ if self._is_listening == desired_state:
452
+ return
453
+
454
+ self._is_listening = desired_state
455
+ self._last_listening_blend_time = now
456
+ if desired_state:
457
+ # Freeze: snapshot current commanded antennas and reset blend
458
+ self._listening_antennas = (
459
+ float(self._last_commanded_pose[1][0]),
460
+ float(self._last_commanded_pose[1][1]),
461
+ )
462
+ self._antenna_unfreeze_blend = 0.0
463
+ else:
464
+ # Unfreeze: restart blending from frozen pose
465
+ self._antenna_unfreeze_blend = 0.0
466
+ self.state.update_activity()
467
+ else:
468
+ logger.warning("Unknown command received by MovementManager: %s", command)
469
+
470
+ def _publish_shared_state(self) -> None:
471
+ """Expose idle-related state for external threads."""
472
+ with self._shared_state_lock:
473
+ self._shared_last_activity_time = self.state.last_activity_time
474
+ self._shared_is_listening = self._is_listening
475
+
476
+ def _manage_move_queue(self, current_time: float) -> None:
477
+ """Manage the primary move queue (sequential execution)."""
478
+ if self.state.current_move is None or (
479
+ self.state.move_start_time is not None
480
+ and current_time - self.state.move_start_time >= self.state.current_move.duration
481
+ ):
482
+ self.state.current_move = None
483
+ self.state.move_start_time = None
484
+
485
+ if self.move_queue:
486
+ self.state.current_move = self.move_queue.popleft()
487
+ self.state.move_start_time = current_time
488
+ # Any real move cancels breathing mode flag
489
+ self._breathing_active = isinstance(self.state.current_move, BreathingMove)
490
+ logger.debug(f"Starting new move, duration: {self.state.current_move.duration}s")
491
+
492
+ def _manage_breathing(self, current_time: float) -> None:
493
+ """Manage automatic breathing when idle."""
494
+ if (
495
+ self.state.current_move is None
496
+ and not self.move_queue
497
+ and not self._is_listening
498
+ and not self._breathing_active
499
+ ):
500
+ idle_for = current_time - self.state.last_activity_time
501
+ if idle_for >= self.idle_inactivity_delay:
502
+ try:
503
+ # These 2 functions return the latest available sensor data from the robot, but don't perform I/O synchronously.
504
+ # Therefore, we accept calling them inside the control loop.
505
+ _, current_antennas = self.current_robot.get_current_joint_positions()
506
+ current_head_pose = self.current_robot.get_current_head_pose()
507
+
508
+ self._breathing_active = True
509
+ self.state.update_activity()
510
+
511
+ breathing_move = BreathingMove(
512
+ interpolation_start_pose=current_head_pose,
513
+ interpolation_start_antennas=current_antennas,
514
+ interpolation_duration=1.0,
515
+ )
516
+ self.move_queue.append(breathing_move)
517
+ logger.debug("Started breathing after %.1fs of inactivity", idle_for)
518
+ except Exception as e:
519
+ self._breathing_active = False
520
+ logger.error("Failed to start breathing: %s", e)
521
+
522
+ if isinstance(self.state.current_move, BreathingMove) and self.move_queue:
523
+ self.state.current_move = None
524
+ self.state.move_start_time = None
525
+ self._breathing_active = False
526
+ logger.debug("Stopping breathing due to new move activity")
527
+
528
+ if self.state.current_move is not None and not isinstance(self.state.current_move, BreathingMove):
529
+ self._breathing_active = False
530
+
531
+ def _get_primary_pose(self, current_time: float) -> FullBodyPose:
532
+ """Get the primary full body pose from current move or neutral."""
533
+ # When a primary move is playing, sample it and cache the resulting pose
534
+ if self.state.current_move is not None and self.state.move_start_time is not None:
535
+ move_time = current_time - self.state.move_start_time
536
+ head, antennas, body_yaw = self.state.current_move.evaluate(move_time)
537
+
538
+ if head is None:
539
+ head = create_head_pose(0, 0, 0, 0, 0, 0, degrees=True)
540
+ if antennas is None:
541
+ antennas = np.array([0.0, 0.0])
542
+ if body_yaw is None:
543
+ body_yaw = 0.0
544
+
545
+ antennas_tuple = (float(antennas[0]), float(antennas[1]))
546
+ head_copy = head.copy()
547
+ primary_full_body_pose = (
548
+ head_copy,
549
+ antennas_tuple,
550
+ float(body_yaw),
551
+ )
552
+
553
+ self.state.last_primary_pose = clone_full_body_pose(primary_full_body_pose)
554
+ # Otherwise reuse the last primary pose so we avoid jumps between moves
555
+ elif self.state.last_primary_pose is not None:
556
+ primary_full_body_pose = clone_full_body_pose(self.state.last_primary_pose)
557
+ else:
558
+ neutral_head_pose = create_head_pose(0, 0, 0, 0, 0, 0, degrees=True)
559
+ primary_full_body_pose = (neutral_head_pose, (0.0, 0.0), 0.0)
560
+ self.state.last_primary_pose = clone_full_body_pose(primary_full_body_pose)
561
+
562
+ return primary_full_body_pose
563
+
564
+ def _get_secondary_pose(self) -> FullBodyPose:
565
+ """Get the secondary full body pose from speech and face tracking offsets."""
566
+ # Combine speech sway offsets + face tracking offsets for secondary pose
567
+ secondary_offsets = [
568
+ self.state.speech_offsets[0] + self.state.face_tracking_offsets[0],
569
+ self.state.speech_offsets[1] + self.state.face_tracking_offsets[1],
570
+ self.state.speech_offsets[2] + self.state.face_tracking_offsets[2],
571
+ self.state.speech_offsets[3] + self.state.face_tracking_offsets[3],
572
+ self.state.speech_offsets[4] + self.state.face_tracking_offsets[4],
573
+ self.state.speech_offsets[5] + self.state.face_tracking_offsets[5],
574
+ ]
575
+
576
+ secondary_head_pose = create_head_pose(
577
+ x=secondary_offsets[0],
578
+ y=secondary_offsets[1],
579
+ z=secondary_offsets[2],
580
+ roll=secondary_offsets[3],
581
+ pitch=secondary_offsets[4],
582
+ yaw=secondary_offsets[5],
583
+ degrees=False,
584
+ mm=False,
585
+ )
586
+ return (secondary_head_pose, (0.0, 0.0), 0.0)
587
+
588
+ def _compose_full_body_pose(self, current_time: float) -> FullBodyPose:
589
+ """Compose primary and secondary poses into a single command pose."""
590
+ primary = self._get_primary_pose(current_time)
591
+ secondary = self._get_secondary_pose()
592
+ return combine_full_body(primary, secondary)
593
+
594
+ def _update_primary_motion(self, current_time: float) -> None:
595
+ """Advance queue state and idle behaviours for this tick."""
596
+ self._manage_move_queue(current_time)
597
+ self._manage_breathing(current_time)
598
+
599
+ def _calculate_blended_antennas(self, target_antennas: Tuple[float, float]) -> Tuple[float, float]:
600
+ """Blend target antennas with listening freeze state and update blending."""
601
+ now = self._now()
602
+ listening = self._is_listening
603
+ listening_antennas = self._listening_antennas
604
+ blend = self._antenna_unfreeze_blend
605
+ blend_duration = self._antenna_blend_duration
606
+ last_update = self._last_listening_blend_time
607
+ self._last_listening_blend_time = now
608
+
609
+ if listening:
610
+ antennas_cmd = listening_antennas
611
+ new_blend = 0.0
612
+ else:
613
+ dt = max(0.0, now - last_update)
614
+ if blend_duration <= 0:
615
+ new_blend = 1.0
616
+ else:
617
+ new_blend = min(1.0, blend + dt / blend_duration)
618
+ antennas_cmd = (
619
+ listening_antennas[0] * (1.0 - new_blend) + target_antennas[0] * new_blend,
620
+ listening_antennas[1] * (1.0 - new_blend) + target_antennas[1] * new_blend,
621
+ )
622
+
623
+ if listening:
624
+ self._antenna_unfreeze_blend = 0.0
625
+ else:
626
+ self._antenna_unfreeze_blend = new_blend
627
+ if new_blend >= 1.0:
628
+ self._listening_antennas = (
629
+ float(target_antennas[0]),
630
+ float(target_antennas[1]),
631
+ )
632
+
633
+ return antennas_cmd
634
+
635
+ def _issue_control_command(self, head: NDArray[np.float32], antennas: Tuple[float, float], body_yaw: float) -> None:
636
+ """Send the fused pose to the robot with throttled error logging."""
637
+ try:
638
+ self.current_robot.set_target(head=head, antennas=antennas, body_yaw=body_yaw)
639
+ except Exception as e:
640
+ now = self._now()
641
+ if now - self._last_set_target_err >= self._set_target_err_interval:
642
+ msg = f"Failed to set robot target: {e}"
643
+ if self._set_target_err_suppressed:
644
+ msg += f" (suppressed {self._set_target_err_suppressed} repeats)"
645
+ self._set_target_err_suppressed = 0
646
+ logger.error(msg)
647
+ self._last_set_target_err = now
648
+ else:
649
+ self._set_target_err_suppressed += 1
650
+ else:
651
+ with self._status_lock:
652
+ self._last_commanded_pose = clone_full_body_pose((head, antennas, body_yaw))
653
+
654
+ def _update_frequency_stats(
655
+ self, loop_start: float, prev_loop_start: float, stats: LoopFrequencyStats,
656
+ ) -> LoopFrequencyStats:
657
+ """Update frequency statistics based on the current loop start time."""
658
+ period = loop_start - prev_loop_start
659
+ if period > 0:
660
+ stats.last_freq = 1.0 / period
661
+ stats.count += 1
662
+ delta = stats.last_freq - stats.mean
663
+ stats.mean += delta / stats.count
664
+ stats.m2 += delta * (stats.last_freq - stats.mean)
665
+ stats.min_freq = min(stats.min_freq, stats.last_freq)
666
+ return stats
667
+
668
+ def _schedule_next_tick(self, loop_start: float, stats: LoopFrequencyStats) -> Tuple[float, LoopFrequencyStats]:
669
+ """Compute sleep time to maintain target frequency and update potential freq."""
670
+ computation_time = self._now() - loop_start
671
+ stats.potential_freq = 1.0 / computation_time if computation_time > 0 else float("inf")
672
+ sleep_time = max(0.0, self.target_period - computation_time)
673
+ return sleep_time, stats
674
+
675
+ def _record_frequency_snapshot(self, stats: LoopFrequencyStats) -> None:
676
+ """Store a thread-safe snapshot of current frequency statistics."""
677
+ with self._status_lock:
678
+ self._freq_snapshot = LoopFrequencyStats(
679
+ mean=stats.mean,
680
+ m2=stats.m2,
681
+ min_freq=stats.min_freq,
682
+ count=stats.count,
683
+ last_freq=stats.last_freq,
684
+ potential_freq=stats.potential_freq,
685
+ )
686
+
687
+ def _maybe_log_frequency(self, loop_count: int, print_interval_loops: int, stats: LoopFrequencyStats) -> None:
688
+ """Emit frequency telemetry when enough loops have elapsed."""
689
+ if loop_count % print_interval_loops != 0 or stats.count == 0:
690
+ return
691
+
692
+ variance = stats.m2 / stats.count if stats.count > 0 else 0.0
693
+ lowest = stats.min_freq if stats.min_freq != float("inf") else 0.0
694
+ logger.debug(
695
+ "Loop freq - avg: %.2fHz, variance: %.4f, min: %.2fHz, last: %.2fHz, potential: %.2fHz, target: %.1fHz",
696
+ stats.mean,
697
+ variance,
698
+ lowest,
699
+ stats.last_freq,
700
+ stats.potential_freq,
701
+ self.target_frequency,
702
+ )
703
+ stats.reset()
704
+
705
+ def _update_face_tracking(self, current_time: float) -> None:
706
+ """Get face tracking offsets from camera worker thread."""
707
+ if self.camera_worker is not None:
708
+ # Get face tracking offsets from camera worker thread
709
+ offsets = self.camera_worker.get_face_tracking_offsets()
710
+ self.state.face_tracking_offsets = offsets
711
+ else:
712
+ # No camera worker, use neutral offsets
713
+ self.state.face_tracking_offsets = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
714
+
715
+ def start(self) -> None:
716
+ """Start the worker thread that drives the 100 Hz control loop."""
717
+ if self._thread is not None and self._thread.is_alive():
718
+ logger.warning("Move worker already running; start() ignored")
719
+ return
720
+ self._stop_event.clear()
721
+ self._thread = threading.Thread(target=self.working_loop, daemon=True)
722
+ self._thread.start()
723
+ logger.debug("Move worker started")
724
+
725
+ def stop(self) -> None:
726
+ """Request the worker thread to stop and wait for it to exit.
727
+
728
+ Before stopping, resets the robot to a neutral position.
729
+ """
730
+ if self._thread is None or not self._thread.is_alive():
731
+ logger.debug("Move worker not running; stop() ignored")
732
+ return
733
+
734
+ logger.info("Stopping movement manager and resetting to neutral position...")
735
+
736
+ # Clear any queued moves and stop current move
737
+ self.clear_move_queue()
738
+
739
+ # Stop the worker thread first so it doesn't interfere
740
+ self._stop_event.set()
741
+ if self._thread is not None:
742
+ self._thread.join()
743
+ self._thread = None
744
+ logger.debug("Move worker stopped")
745
+
746
+ # Reset to neutral position using goto_target (same approach as wake_up)
747
+ try:
748
+ neutral_head_pose = create_head_pose(0, 0, 0, 0, 0, 0, degrees=True)
749
+ neutral_antennas = [0.0, 0.0]
750
+ neutral_body_yaw = 0.0
751
+
752
+ # Use goto_target directly on the robot
753
+ self.current_robot.goto_target(
754
+ head=neutral_head_pose,
755
+ antennas=neutral_antennas,
756
+ duration=2.0,
757
+ body_yaw=neutral_body_yaw,
758
+ )
759
+
760
+ logger.info("Reset to neutral position completed")
761
+
762
+ except Exception as e:
763
+ logger.error(f"Failed to reset to neutral position: {e}")
764
+
765
+ def get_status(self) -> Dict[str, Any]:
766
+ """Return a lightweight status snapshot for observability."""
767
+ with self._status_lock:
768
+ pose_snapshot = clone_full_body_pose(self._last_commanded_pose)
769
+ freq_snapshot = LoopFrequencyStats(
770
+ mean=self._freq_snapshot.mean,
771
+ m2=self._freq_snapshot.m2,
772
+ min_freq=self._freq_snapshot.min_freq,
773
+ count=self._freq_snapshot.count,
774
+ last_freq=self._freq_snapshot.last_freq,
775
+ potential_freq=self._freq_snapshot.potential_freq,
776
+ )
777
+
778
+ head_matrix = pose_snapshot[0].tolist() if pose_snapshot else None
779
+ antennas = pose_snapshot[1] if pose_snapshot else None
780
+ body_yaw = pose_snapshot[2] if pose_snapshot else None
781
+
782
+ return {
783
+ "queue_size": len(self.move_queue),
784
+ "is_listening": self._is_listening,
785
+ "breathing_active": self._breathing_active,
786
+ "last_commanded_pose": {
787
+ "head": head_matrix,
788
+ "antennas": antennas,
789
+ "body_yaw": body_yaw,
790
+ },
791
+ "loop_frequency": {
792
+ "last": freq_snapshot.last_freq,
793
+ "mean": freq_snapshot.mean,
794
+ "min": freq_snapshot.min_freq,
795
+ "potential": freq_snapshot.potential_freq,
796
+ "samples": freq_snapshot.count,
797
+ },
798
+ }
799
+
800
+ def working_loop(self) -> None:
801
+ """Control loop main movements - reproduces main_works.py control architecture.
802
+
803
+ Single set_target() call with pose fusion.
804
+ """
805
+ logger.debug("Starting enhanced movement control loop (100Hz)")
806
+
807
+ loop_count = 0
808
+ prev_loop_start = self._now()
809
+ print_interval_loops = max(1, int(self.target_frequency * 2))
810
+ freq_stats = self._freq_stats
811
+
812
+ while not self._stop_event.is_set():
813
+ loop_start = self._now()
814
+ loop_count += 1
815
+
816
+ if loop_count > 1:
817
+ freq_stats = self._update_frequency_stats(loop_start, prev_loop_start, freq_stats)
818
+ prev_loop_start = loop_start
819
+
820
+ # 1) Poll external commands and apply pending offsets (atomic snapshot)
821
+ self._poll_signals(loop_start)
822
+
823
+ # 2) Manage the primary move queue (start new move, end finished move, breathing)
824
+ self._update_primary_motion(loop_start)
825
+
826
+ # 3) Update vision-based secondary offsets
827
+ self._update_face_tracking(loop_start)
828
+
829
+ # 4) Build primary and secondary full-body poses, then fuse them
830
+ head, antennas, body_yaw = self._compose_full_body_pose(loop_start)
831
+
832
+ # 5) Apply listening antenna freeze or blend-back
833
+ antennas_cmd = self._calculate_blended_antennas(antennas)
834
+
835
+ # 6) Single set_target call - the only control point
836
+ self._issue_control_command(head, antennas_cmd, body_yaw)
837
+
838
+ # 7) Adaptive sleep to align to next tick, then publish shared state
839
+ sleep_time, freq_stats = self._schedule_next_tick(loop_start, freq_stats)
840
+ self._publish_shared_state()
841
+ self._record_frequency_snapshot(freq_stats)
842
+
843
+ # 8) Periodic telemetry on loop frequency
844
+ self._maybe_log_frequency(loop_count, print_interval_loops, freq_stats)
845
+
846
+ if sleep_time > 0:
847
+ time.sleep(sleep_time)
848
+
849
+ logger.debug("Movement control loop stopped")
src/hello_world/openai_realtime.py ADDED
@@ -0,0 +1,935 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import uuid
3
+ import base64
4
+ import random
5
+ import asyncio
6
+ import logging
7
+ from typing import Any, Final, Tuple, Literal, Optional
8
+ from pathlib import Path
9
+ from datetime import datetime
10
+
11
+ import cv2
12
+ import numpy as np
13
+ import gradio as gr
14
+ from openai import AsyncOpenAI
15
+ from fastrtc import AdditionalOutputs, AsyncStreamHandler, wait_for_item, audio_to_int16
16
+ from numpy.typing import NDArray
17
+ from scipy.signal import resample
18
+ from websockets.exceptions import ConnectionClosedError
19
+
20
+ from hello_world.config import config
21
+ from hello_world.prompts import get_session_voice, get_session_instructions
22
+ from hello_world.tools.core_tools import (
23
+ ToolDependencies,
24
+ get_tool_specs,
25
+ )
26
+ from hello_world.tools.background_tool_manager import (
27
+ ToolCallRoutine,
28
+ ToolNotification,
29
+ BackgroundToolManager,
30
+ )
31
+
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ OPEN_AI_INPUT_SAMPLE_RATE: Final[Literal[24000]] = 24000
36
+ OPEN_AI_OUTPUT_SAMPLE_RATE: Final[Literal[24000]] = 24000
37
+
38
+ # Cost tracking from usage data (pricing as of Feb 2026 https://openai.com/api/pricing/)
39
+ AUDIO_INPUT_COST_PER_1M = 32.0
40
+ AUDIO_OUTPUT_COST_PER_1M = 64.0
41
+ TEXT_INPUT_COST_PER_1M = 4.0
42
+ TEXT_OUTPUT_COST_PER_1M = 16.0
43
+ IMAGE_INPUT_COST_PER_1M = 5.0
44
+
45
+ _RESPONSE_DONE_TIMEOUT: Final[float] = 30.0
46
+
47
+
48
+ def _compute_response_cost(usage: Any) -> float:
49
+ """Compute dollar cost from a response usage object."""
50
+ inp = getattr(usage, "input_token_details", None)
51
+ out = getattr(usage, "output_token_details", None)
52
+ cost = 0.0
53
+ if inp:
54
+ cost += (getattr(inp, "audio_tokens", 0) or 0) * AUDIO_INPUT_COST_PER_1M / 1e6
55
+ cost += (getattr(inp, "text_tokens", 0) or 0) * TEXT_INPUT_COST_PER_1M / 1e6
56
+ cost += (getattr(inp, "image_tokens", 0) or 0) * IMAGE_INPUT_COST_PER_1M / 1e6
57
+ if out:
58
+ cost += (getattr(out, "audio_tokens", 0) or 0) * AUDIO_OUTPUT_COST_PER_1M / 1e6
59
+ cost += (getattr(out, "text_tokens", 0) or 0) * TEXT_OUTPUT_COST_PER_1M / 1e6
60
+ return cost
61
+
62
+
63
+ class OpenaiRealtimeHandler(AsyncStreamHandler):
64
+ """An OpenAI realtime handler for fastrtc Stream."""
65
+
66
+ def __init__(self, deps: ToolDependencies, gradio_mode: bool = False, instance_path: Optional[str] = None):
67
+ """Initialize the handler."""
68
+ super().__init__(
69
+ expected_layout="mono",
70
+ output_sample_rate=OPEN_AI_OUTPUT_SAMPLE_RATE,
71
+ input_sample_rate=OPEN_AI_INPUT_SAMPLE_RATE,
72
+ )
73
+
74
+ # Override typing of the sample rates to match OpenAI's requirements
75
+ self.output_sample_rate: Literal[24000] = self.output_sample_rate
76
+ self.input_sample_rate: Literal[24000] = self.input_sample_rate
77
+
78
+ self.deps = deps
79
+
80
+ # Override type annotations for OpenAI strict typing (only for values used in API)
81
+ self.output_sample_rate = OPEN_AI_OUTPUT_SAMPLE_RATE
82
+ self.input_sample_rate = OPEN_AI_INPUT_SAMPLE_RATE
83
+
84
+ self.connection: Any = None
85
+ self.output_queue: "asyncio.Queue[Tuple[int, NDArray[np.int16]] | AdditionalOutputs]" = asyncio.Queue()
86
+
87
+ self.last_activity_time = asyncio.get_event_loop().time()
88
+ self.start_time = asyncio.get_event_loop().time()
89
+ self.is_idle_tool_call = False
90
+ self.gradio_mode = gradio_mode
91
+ self.instance_path = instance_path
92
+ # Track how the API key was provided (env vs textbox) and its value
93
+ self._key_source: Literal["env", "textbox"] = "env"
94
+ self._provided_api_key: str | None = None
95
+
96
+ # Debouncing for partial transcripts
97
+ self.partial_transcript_task: asyncio.Task[None] | None = None
98
+ self.partial_transcript_sequence: int = 0 # sequence counter to prevent stale emissions
99
+ self.partial_debounce_delay = 0.5 # seconds
100
+
101
+ # Internal lifecycle flags
102
+ self._shutdown_requested: bool = False
103
+ self._connected_event: asyncio.Event = asyncio.Event()
104
+
105
+ # Background tool manager
106
+ self.tool_manager = BackgroundToolManager()
107
+
108
+ # Cost tracking
109
+ self.cumulative_cost: float = 0.0
110
+
111
+ # Response-in-progress guard: the Realtime API only allows one active
112
+ # response per conversation at a time. A dedicated worker task
113
+ # (_response_sender_loop) dequeues and sends one request at a time
114
+ self._pending_responses: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
115
+ self._response_done_event: asyncio.Event = asyncio.Event()
116
+ self._response_done_event.set()
117
+ self._last_response_rejected: bool = False
118
+
119
+ def copy(self) -> "OpenaiRealtimeHandler":
120
+ """Create a copy of the handler."""
121
+ return OpenaiRealtimeHandler(self.deps, self.gradio_mode, self.instance_path)
122
+
123
+ async def apply_personality(self, profile: str | None) -> str:
124
+ """Apply a new personality (profile) at runtime if possible.
125
+
126
+ - Updates the global config's selected profile for subsequent calls.
127
+ - If a realtime connection is active, sends a session.update with the
128
+ freshly resolved instructions so the change takes effect immediately.
129
+
130
+ Returns a short status message for UI feedback.
131
+ """
132
+ try:
133
+ # Update the in-process config value and env
134
+ from hello_world.config import config as _config
135
+ from hello_world.config import set_custom_profile
136
+
137
+ set_custom_profile(profile)
138
+ logger.info(
139
+ "Set custom profile to %r (config=%r)", profile, getattr(_config, "REACHY_MINI_CUSTOM_PROFILE", None)
140
+ )
141
+
142
+ try:
143
+ instructions = get_session_instructions()
144
+ voice = get_session_voice()
145
+ except BaseException as e: # catch SystemExit from prompt loader without crashing
146
+ logger.error("Failed to resolve personality content: %s", e)
147
+ return f"Failed to apply personality: {e}"
148
+
149
+ # Attempt a live update first, then force a full restart to ensure it sticks
150
+ if self.connection is not None:
151
+ try:
152
+ await self.connection.session.update(
153
+ session={
154
+ "type": "realtime",
155
+ "instructions": instructions,
156
+ "audio": {"output": {"voice": voice}},
157
+ },
158
+ )
159
+ logger.info("Applied personality via live update: %s", profile or "built-in default")
160
+ except Exception as e:
161
+ logger.warning("Live update failed; will restart session: %s", e)
162
+
163
+ # Force a real restart to guarantee the new instructions/voice
164
+ try:
165
+ await self._restart_session()
166
+ return "Applied personality and restarted realtime session."
167
+ except Exception as e:
168
+ logger.warning("Failed to restart session after apply: %s", e)
169
+ return "Applied personality. Will take effect on next connection."
170
+ else:
171
+ logger.info(
172
+ "Applied personality recorded: %s (no live connection; will apply on next session)",
173
+ profile or "built-in default",
174
+ )
175
+ return "Applied personality. Will take effect on next connection."
176
+ except Exception as e:
177
+ logger.error("Error applying personality '%s': %s", profile, e)
178
+ return f"Failed to apply personality: {e}"
179
+
180
+ async def _emit_debounced_partial(self, transcript: str, sequence: int) -> None:
181
+ """Emit partial transcript after debounce delay."""
182
+ try:
183
+ await asyncio.sleep(self.partial_debounce_delay)
184
+ # Only emit if this is still the latest partial (by sequence number)
185
+ if self.partial_transcript_sequence == sequence:
186
+ await self.output_queue.put(AdditionalOutputs({"role": "user_partial", "content": transcript}))
187
+ logger.debug(f"Debounced partial emitted: {transcript}")
188
+ except asyncio.CancelledError:
189
+ logger.debug("Debounced partial cancelled")
190
+ raise
191
+
192
+ async def start_up(self) -> None:
193
+ """Start the handler with minimal retries on unexpected websocket closure."""
194
+ openai_api_key = config.OPENAI_API_KEY
195
+ if self.gradio_mode and not openai_api_key:
196
+ # api key was not found in .env or in the environment variables
197
+ await self.wait_for_args() # type: ignore[no-untyped-call]
198
+ args = list(self.latest_args)
199
+ textbox_api_key = args[3] if len(args[3]) > 0 else None
200
+ if textbox_api_key is not None:
201
+ openai_api_key = textbox_api_key
202
+ self._key_source = "textbox"
203
+ self._provided_api_key = textbox_api_key
204
+ else:
205
+ openai_api_key = config.OPENAI_API_KEY
206
+ else:
207
+ if not openai_api_key or not openai_api_key.strip():
208
+ # In headless console mode, LocalStream now blocks startup until the key is provided.
209
+ # However, unit tests may invoke this handler directly with a stubbed client.
210
+ # To keep tests hermetic without requiring a real key, fall back to a placeholder.
211
+ logger.warning("OPENAI_API_KEY missing. Proceeding with a placeholder (tests/offline).")
212
+ openai_api_key = "DUMMY"
213
+
214
+ self.client = AsyncOpenAI(api_key=openai_api_key)
215
+
216
+ max_attempts = 3
217
+ for attempt in range(1, max_attempts + 1):
218
+ try:
219
+ await self._run_realtime_session()
220
+ # Normal exit from the session, stop retrying
221
+ return
222
+ except ConnectionClosedError as e:
223
+ # Abrupt close (e.g., "no close frame received or sent") → retry
224
+ logger.warning("Realtime websocket closed unexpectedly (attempt %d/%d): %s", attempt, max_attempts, e)
225
+ if attempt < max_attempts:
226
+ # exponential backoff with jitter
227
+ base_delay = 2 ** (attempt - 1) # 1s, 2s, 4s, 8s, etc.
228
+ jitter = random.uniform(0, 0.5)
229
+ delay = base_delay + jitter
230
+ logger.info("Retrying in %.1f seconds...", delay)
231
+ await asyncio.sleep(delay)
232
+ continue
233
+ raise
234
+ finally:
235
+ # never keep a stale reference
236
+ self.connection = None
237
+ try:
238
+ self._connected_event.clear()
239
+ except Exception:
240
+ pass
241
+
242
+ async def _restart_session(self) -> None:
243
+ """Force-close the current session and start a fresh one in background.
244
+
245
+ Does not block the caller while the new session is establishing.
246
+ """
247
+ try:
248
+ if self.connection is not None:
249
+ try:
250
+ await self.connection.close()
251
+ except Exception:
252
+ pass
253
+ finally:
254
+ self.connection = None
255
+
256
+ # Ensure we have a client (start_up must have run once)
257
+ if getattr(self, "client", None) is None:
258
+ logger.warning("Cannot restart: OpenAI client not initialized yet.")
259
+ return
260
+
261
+ # Fire-and-forget new session and wait briefly for connection
262
+ try:
263
+ self._connected_event.clear()
264
+ except Exception:
265
+ pass
266
+ asyncio.create_task(self._run_realtime_session(), name="openai-realtime-restart")
267
+ try:
268
+ await asyncio.wait_for(self._connected_event.wait(), timeout=5.0)
269
+ logger.info("Realtime session restarted and connected.")
270
+ except asyncio.TimeoutError:
271
+ logger.warning("Realtime session restart timed out; continuing in background.")
272
+ except Exception as e:
273
+ logger.warning("_restart_session failed: %s", e)
274
+
275
+ async def _safe_response_create(self, **kwargs: Any) -> None:
276
+ """Enqueue a response.create() kwargs for the sender worker _response_sender_loop().
277
+
278
+ This method never blocks the caller.
279
+ """
280
+ await self._pending_responses.put(kwargs)
281
+
282
+ async def _response_sender_loop(self) -> None:
283
+ """Dedicated worker that sends ``response.create()`` calls serially.
284
+
285
+ This logic was designed to comply with the response.create() docstring specification for event ordering:
286
+ https://github.com/openai/openai-python/blob/3e0c05b84a2056870abf3bd6a5e7849020209cc3/src/openai/resources/realtime/realtime.py#L649C1-L651C30
287
+
288
+ For each queued request the worker:
289
+ 1. Waits until no response is active (_response_done_event).
290
+ 2. Sends response.create().
291
+ 3. Waits for the response cycle to complete (response.done).
292
+ 4. If the server rejected with active_response, retries from step 1.
293
+ """
294
+ while self.connection:
295
+ try:
296
+ kwargs = await self._pending_responses.get()
297
+ except asyncio.CancelledError:
298
+ return
299
+
300
+ sent = False
301
+ max_retries = 5
302
+ attempts = 0
303
+ while not sent and self.connection and attempts < max_retries:
304
+ try:
305
+ await asyncio.wait_for(self._response_done_event.wait(), timeout=_RESPONSE_DONE_TIMEOUT)
306
+ except asyncio.TimeoutError:
307
+ logger.debug("Timed out waiting for previous response to finish; forcing ahead")
308
+ self._response_done_event.set()
309
+
310
+ if not self.connection:
311
+ break
312
+
313
+ self._last_response_rejected = False
314
+ try:
315
+ await self.connection.response.create(**kwargs)
316
+ except Exception as e:
317
+ logger.debug("_response_sender_loop: send failed: %s", e)
318
+ self._response_done_event.set()
319
+ break
320
+
321
+ try:
322
+ await asyncio.wait_for(self._response_done_event.wait(), timeout=_RESPONSE_DONE_TIMEOUT)
323
+ except asyncio.TimeoutError:
324
+ logger.debug("Timed out waiting for response.done; assuming response completed")
325
+ self._response_done_event.set()
326
+ break
327
+
328
+ # Check if we were rejected
329
+ if self._last_response_rejected:
330
+ attempts += 1
331
+ if attempts >= max_retries:
332
+ logger.debug("response.create rejected %d times; giving up", attempts)
333
+ break
334
+ logger.debug("response.create was rejected; retrying (%d/%d)", attempts, max_retries)
335
+ continue
336
+
337
+ sent = True
338
+
339
+ async def _handle_tool_result(self, bg_tool: ToolNotification) -> None:
340
+ """Process the result of a tool call."""
341
+ if bg_tool.error is not None:
342
+ logger.error("Tool '%s' (id=%s) failed with error: %s", bg_tool.tool_name, bg_tool.id, bg_tool.error)
343
+ tool_result = {"error": bg_tool.error}
344
+ elif bg_tool.result is not None:
345
+ tool_result = bg_tool.result
346
+ logger.info(
347
+ "Tool '%s' (id=%s) executed successfully.",
348
+ bg_tool.tool_name, bg_tool.id,
349
+ )
350
+ logger.debug("Tool '%s' full result: %s", bg_tool.tool_name, tool_result)
351
+ else:
352
+ logger.warning("Tool '%s' (id=%s) returned no result and no error", bg_tool.tool_name, bg_tool.id)
353
+ tool_result = {"error": "No result returned from tool execution"}
354
+
355
+ # Connection may have closed while tool was running
356
+ if not self.connection:
357
+ logger.warning("Connection closed during tool '%s' (id=%s) execution; cannot send result back", bg_tool.tool_name, bg_tool.id)
358
+ return
359
+
360
+ try:
361
+ # Send the tool result back
362
+ if isinstance(bg_tool.id, str):
363
+ await self.connection.conversation.item.create(
364
+ item={
365
+ "type": "function_call_output",
366
+ "call_id": bg_tool.id,
367
+ "output": json.dumps(tool_result),
368
+ },
369
+ )
370
+
371
+ await self.output_queue.put(
372
+ AdditionalOutputs(
373
+ {
374
+ "role": "assistant",
375
+ "content": json.dumps(tool_result),
376
+ # Gradio UI metadata.status accept only "pending" and "done". Do not accept bg.tool.status values.
377
+ "metadata": {
378
+ "title": f"🛠️ Used tool {bg_tool.tool_name}",
379
+ "status": "done",
380
+ },
381
+ },
382
+ ),
383
+ )
384
+
385
+ if bg_tool.tool_name == "camera" and "b64_im" in tool_result:
386
+ # use raw base64, don't json.dumps (which adds quotes)
387
+ b64_im = tool_result["b64_im"]
388
+ if not isinstance(b64_im, str):
389
+ logger.warning("Unexpected type for b64_im: %s", type(b64_im))
390
+ b64_im = str(b64_im)
391
+ await self.connection.conversation.item.create(
392
+ item={
393
+ "type": "message",
394
+ "role": "user",
395
+ "content": [
396
+ {
397
+ "type": "input_image",
398
+ "image_url": f"data:image/jpeg;base64,{b64_im}",
399
+ },
400
+ ],
401
+ },
402
+ )
403
+ logger.info("Added camera image to conversation")
404
+
405
+ if self.deps.camera_worker is not None:
406
+ np_img = self.deps.camera_worker.get_latest_frame()
407
+ if np_img is not None:
408
+ # Camera frames are BGR from OpenCV; convert so Gradio displays correct colors.
409
+ rgb_frame = cv2.cvtColor(np_img, cv2.COLOR_BGR2RGB)
410
+ else:
411
+ rgb_frame = None
412
+ img = gr.Image(value=rgb_frame)
413
+
414
+ await self.output_queue.put(
415
+ AdditionalOutputs(
416
+ {
417
+ "role": "assistant",
418
+ "content": img,
419
+ },
420
+ ),
421
+ )
422
+
423
+ # If this tool call was triggered by an idle signal, don't make the robot speak.
424
+ # For other tool calls, let the robot reply out loud.
425
+ if not bg_tool.is_idle_tool_call:
426
+ await self._safe_response_create(
427
+ response={
428
+ "instructions": "Use the tool result just returned and answer concisely in speech.",
429
+ },
430
+ )
431
+
432
+ # Re-synchronize the head wobble after a tool call that may have taken some time
433
+ if self.deps.head_wobbler is not None:
434
+ self.deps.head_wobbler.reset()
435
+
436
+ except ConnectionClosedError:
437
+ logger.warning("Connection closed while sending tool result")
438
+ self.connection = None
439
+ self._response_done_event.set()
440
+
441
+ async def _run_realtime_session(self) -> None:
442
+ """Establish and manage a single realtime session."""
443
+ async with self.client.realtime.connect(model=config.MODEL_NAME) as conn:
444
+ try:
445
+ await conn.session.update(
446
+ session={
447
+ "type": "realtime",
448
+ "instructions": get_session_instructions(),
449
+ "audio": {
450
+ "input": {
451
+ "format": {
452
+ "type": "audio/pcm",
453
+ "rate": self.input_sample_rate,
454
+ },
455
+ "transcription": {"model": "gpt-4o-transcribe", "language": "en"},
456
+ "turn_detection": {
457
+ "type": "server_vad",
458
+ "interrupt_response": True,
459
+ },
460
+ },
461
+ "output": {
462
+ "format": {
463
+ "type": "audio/pcm",
464
+ "rate": self.output_sample_rate,
465
+ },
466
+ "voice": get_session_voice(),
467
+ },
468
+ },
469
+ "tools": get_tool_specs(), # type: ignore[typeddict-item]
470
+ "tool_choice": "auto",
471
+ },
472
+ )
473
+ logger.info(
474
+ "Realtime session initialized with profile=%r voice=%r",
475
+ getattr(config, "REACHY_MINI_CUSTOM_PROFILE", None),
476
+ get_session_voice(),
477
+ )
478
+ # If we reached here, the session update succeeded which implies the API key worked.
479
+ # Persist the key to a newly created .env (copied from .env.example) if needed.
480
+ self._persist_api_key_if_needed()
481
+ except Exception:
482
+ logger.exception("Realtime session.update failed; aborting startup")
483
+ return
484
+
485
+ logger.info("Realtime session updated successfully")
486
+
487
+ # Manage event received from the openai server
488
+ self.connection = conn
489
+ try:
490
+ self._connected_event.set()
491
+ except Exception:
492
+ pass
493
+
494
+
495
+ response_sender_task: asyncio.Task[None] | None = None
496
+ try:
497
+ # Start the background tool manager
498
+ self.tool_manager.start_up(tool_callbacks=[self._handle_tool_result])
499
+
500
+ # Start the response sender worker
501
+ response_sender_task = asyncio.create_task(
502
+ self._response_sender_loop(), name="response-sender"
503
+ )
504
+
505
+ async for event in self.connection:
506
+ logger.debug(f"OpenAI event: {event.type}")
507
+ if event.type == "input_audio_buffer.speech_started":
508
+ if hasattr(self, "_clear_queue") and callable(self._clear_queue):
509
+ self._clear_queue()
510
+ if self.deps.head_wobbler is not None:
511
+ self.deps.head_wobbler.reset()
512
+ self.deps.movement_manager.set_listening(True)
513
+ logger.debug("User speech started")
514
+
515
+ if event.type == "input_audio_buffer.speech_stopped":
516
+ self.deps.movement_manager.set_listening(False)
517
+ logger.debug("User speech stopped - server will auto-commit with VAD")
518
+
519
+ if event.type in (
520
+ "response.audio.done", # GA
521
+ "response.output_audio.done", # GA alias
522
+ "response.audio.completed", # legacy (for safety)
523
+ "response.completed", # text-only completion
524
+ ):
525
+ logger.debug("response completed")
526
+
527
+ if event.type == "response.created":
528
+ self._response_done_event.clear()
529
+ logger.debug("Response created (active)")
530
+
531
+ if event.type == "response.done":
532
+ # Doesn't mean the audio is done playing
533
+ self._response_done_event.set()
534
+ logger.debug("Response done")
535
+
536
+ response = getattr(event, "response", None)
537
+ usage = getattr(response, "usage", None) if response else None
538
+ if usage:
539
+ cost = _compute_response_cost(usage)
540
+ self.cumulative_cost += cost
541
+ logger.debug("Cost: $%.4f | Cumulative: $%.4f", cost, self.cumulative_cost)
542
+ else:
543
+ logger.warning("No usage data available for cost tracking")
544
+
545
+ # Handle partial transcription (user speaking in real-time)
546
+ if event.type == "conversation.item.input_audio_transcription.partial":
547
+ logger.debug(f"User partial transcript: {event.transcript}")
548
+
549
+ # Increment sequence
550
+ self.partial_transcript_sequence += 1
551
+ current_sequence = self.partial_transcript_sequence
552
+
553
+ # Cancel previous debounce task if it exists
554
+ if self.partial_transcript_task and not self.partial_transcript_task.done():
555
+ self.partial_transcript_task.cancel()
556
+ try:
557
+ await self.partial_transcript_task
558
+ except asyncio.CancelledError:
559
+ pass
560
+
561
+ # Start new debounce timer with sequence number
562
+ self.partial_transcript_task = asyncio.create_task(
563
+ self._emit_debounced_partial(event.transcript, current_sequence)
564
+ )
565
+
566
+ # Handle completed transcription (user finished speaking)
567
+ if event.type == "conversation.item.input_audio_transcription.completed":
568
+ logger.debug(f"User transcript: {event.transcript}")
569
+
570
+ # Cancel any pending partial emission
571
+ if self.partial_transcript_task and not self.partial_transcript_task.done():
572
+ self.partial_transcript_task.cancel()
573
+ try:
574
+ await self.partial_transcript_task
575
+ except asyncio.CancelledError:
576
+ pass
577
+
578
+ await self.output_queue.put(AdditionalOutputs({"role": "user", "content": event.transcript}))
579
+
580
+ # Handle assistant transcription
581
+ if event.type in ("response.audio_transcript.done", "response.output_audio_transcript.done"):
582
+ logger.debug(f"Assistant transcript: {event.transcript}")
583
+ await self.output_queue.put(AdditionalOutputs({"role": "assistant", "content": event.transcript}))
584
+
585
+ # Handle audio delta
586
+ if event.type in ("response.audio.delta", "response.output_audio.delta"):
587
+ if self.deps.head_wobbler is not None:
588
+ self.deps.head_wobbler.feed(event.delta)
589
+ self.last_activity_time = asyncio.get_event_loop().time()
590
+ logger.debug("last activity time updated to %s", self.last_activity_time)
591
+ await self.output_queue.put(
592
+ (
593
+ self.output_sample_rate,
594
+ np.frombuffer(base64.b64decode(event.delta), dtype=np.int16).reshape(1, -1),
595
+ ),
596
+ )
597
+
598
+ # ---- tool-calling plumbing ----
599
+ if event.type == "response.function_call_arguments.done":
600
+ tool_name = getattr(event, "name", None)
601
+ args_json_str = getattr(event, "arguments", None)
602
+ call_id: str = str(getattr(event, "call_id", uuid.uuid4()))
603
+
604
+ logger.info(
605
+ "Tool call received — tool_name=%r, call_id=%s, is_idle=%s, args=%s",
606
+ tool_name, call_id, self.is_idle_tool_call, args_json_str,
607
+ )
608
+
609
+ if not isinstance(tool_name, str) or not isinstance(args_json_str, str):
610
+ logger.error(
611
+ "Invalid tool call: tool_name=%s (type=%s), args=%s (type=%s), call_id=%s",
612
+ tool_name, type(tool_name).__name__,
613
+ args_json_str, type(args_json_str).__name__,
614
+ call_id,
615
+ )
616
+ continue
617
+
618
+ bg_tool = await self.tool_manager.start_tool(
619
+ call_id=call_id,
620
+ tool_call_routine=ToolCallRoutine(
621
+ tool_name=tool_name,
622
+ args_json_str=args_json_str,
623
+ deps=self.deps,
624
+ ),
625
+ is_idle_tool_call=self.is_idle_tool_call,
626
+ )
627
+
628
+ await self.output_queue.put(
629
+ AdditionalOutputs(
630
+ {
631
+ "role": "assistant",
632
+ "content": f"🛠️ Used tool {tool_name} with args {args_json_str}. The tool is now running. Tool ID: {bg_tool.tool_id}",
633
+ },
634
+ ),
635
+ )
636
+
637
+ if self.is_idle_tool_call:
638
+ self.is_idle_tool_call = False
639
+ else:
640
+ await self._safe_response_create(
641
+ response={
642
+ "instructions": "Notify what the tool has been running giving meaningful information about the task",
643
+ },
644
+ )
645
+
646
+ logger.info("Started background tool: %s (id=%s, call_id=%s)", tool_name, bg_tool.tool_id, call_id)
647
+
648
+ # server error
649
+ if event.type == "error":
650
+ err = getattr(event, "error", None)
651
+ msg = getattr(err, "message", str(err) if err else "unknown error")
652
+ code = getattr(err, "code", "")
653
+
654
+ if code == "conversation_already_has_active_response":
655
+ # response.create was rejected. The sender worker
656
+ # is waiting on _response_done_event; when the active
657
+ # response finishes it will wake up and see this flag.
658
+ self._last_response_rejected = True
659
+ logger.debug("response.create rejected; worker will retry after active response finishes")
660
+ else:
661
+ logger.error("Realtime error [%s]: %s (raw=%s)", code, msg, err)
662
+
663
+ # Only show user-facing errors, not internal state errors
664
+ if code not in ("input_audio_buffer_commit_empty",):
665
+ await self.output_queue.put(
666
+ AdditionalOutputs({"role": "assistant", "content": f"[error] {msg}"})
667
+ )
668
+ finally:
669
+ # Stop the response sender worker.
670
+ if response_sender_task is not None:
671
+ response_sender_task.cancel()
672
+ try:
673
+ await response_sender_task
674
+ except asyncio.CancelledError:
675
+ pass
676
+
677
+ # Stop background tool manager tasks (listener + cleanup) in all patus.
678
+ await self.tool_manager.shutdown()
679
+
680
+ # Microphone receive
681
+ async def receive(self, frame: Tuple[int, NDArray[np.int16]]) -> None:
682
+ """Receive audio frame from the microphone and send it to the OpenAI server.
683
+
684
+ Handles both mono and stereo audio formats, converting to the expected
685
+ mono format for OpenAI's API. Resamples if the input sample rate differs
686
+ from the expected rate.
687
+
688
+ Args:
689
+ frame: A tuple containing (sample_rate, audio_data).
690
+
691
+ """
692
+ if not self.connection:
693
+ return
694
+
695
+ input_sample_rate, audio_frame = frame
696
+
697
+ # Reshape if needed
698
+ if audio_frame.ndim == 2:
699
+ # Scipy channels last convention
700
+ if audio_frame.shape[1] > audio_frame.shape[0]:
701
+ audio_frame = audio_frame.T
702
+ # Multiple channels -> Mono channel
703
+ if audio_frame.shape[1] > 1:
704
+ audio_frame = audio_frame[:, 0]
705
+
706
+ # Resample if needed
707
+ if self.input_sample_rate != input_sample_rate:
708
+ audio_frame = resample(audio_frame, int(len(audio_frame) * self.input_sample_rate / input_sample_rate))
709
+
710
+ # Cast if needed
711
+ audio_frame = audio_to_int16(audio_frame)
712
+
713
+ # Send to OpenAI (guard against races during reconnect)
714
+ try:
715
+ audio_message = base64.b64encode(audio_frame.tobytes()).decode("utf-8")
716
+ await self.connection.input_audio_buffer.append(audio=audio_message)
717
+ except Exception as e:
718
+ logger.debug("Dropping audio frame: connection not ready (%s)", e)
719
+ return
720
+
721
+ async def emit(self) -> Tuple[int, NDArray[np.int16]] | AdditionalOutputs | None:
722
+ """Emit audio frame to be played by the speaker."""
723
+ # sends to the stream the stuff put in the output queue by the openai event handler
724
+ # This is called periodically by the fastrtc Stream
725
+
726
+ # Handle idle
727
+ idle_duration = asyncio.get_event_loop().time() - self.last_activity_time
728
+ if idle_duration > 15.0 and self.deps.movement_manager.is_idle():
729
+ try:
730
+ await self.send_idle_signal(idle_duration)
731
+ except Exception as e:
732
+ logger.warning("Idle signal skipped (connection closed?): %s", e)
733
+ return None
734
+
735
+ self.last_activity_time = asyncio.get_event_loop().time() # avoid repeated resets
736
+
737
+ return await wait_for_item(self.output_queue) # type: ignore[no-any-return]
738
+
739
+ async def shutdown(self) -> None:
740
+ """Shutdown the handler."""
741
+ self._shutdown_requested = True
742
+
743
+ # Unblock the response sender worker so it can exit
744
+ self._response_done_event.set()
745
+
746
+ # Stop background tool manager tasks (listener + cleanup)
747
+ await self.tool_manager.shutdown()
748
+
749
+ # Cancel any pending debounce task
750
+ if self.partial_transcript_task and not self.partial_transcript_task.done():
751
+ self.partial_transcript_task.cancel()
752
+ try:
753
+ await self.partial_transcript_task
754
+ except asyncio.CancelledError:
755
+ pass
756
+
757
+ if self.connection:
758
+ try:
759
+ await self.connection.close()
760
+ except ConnectionClosedError as e:
761
+ logger.debug(f"Connection already closed during shutdown: {e}")
762
+ except Exception as e:
763
+ logger.debug(f"connection.close() ignored: {e}")
764
+ finally:
765
+ self.connection = None
766
+
767
+ # Clear any remaining items in the output queue
768
+ while not self.output_queue.empty():
769
+ try:
770
+ self.output_queue.get_nowait()
771
+ except asyncio.QueueEmpty:
772
+ break
773
+
774
+ def format_timestamp(self) -> str:
775
+ """Format current timestamp with date, time, and elapsed seconds."""
776
+ loop_time = asyncio.get_event_loop().time() # monotonic
777
+ elapsed_seconds = loop_time - self.start_time
778
+ dt = datetime.now() # wall-clock
779
+ return f"[{dt.strftime('%Y-%m-%d %H:%M:%S')} | +{elapsed_seconds:.1f}s]"
780
+
781
+ async def get_available_voices(self) -> list[str]:
782
+ """Try to discover available voices for the configured realtime model.
783
+
784
+ Attempts to retrieve model metadata from the OpenAI Models API and look
785
+ for any keys that might contain voice names. Falls back to a curated
786
+ list known to work with realtime if discovery fails.
787
+ """
788
+ # Conservative fallback list with default first
789
+ fallback = [
790
+ "cedar",
791
+ "alloy",
792
+ "aria",
793
+ "ballad",
794
+ "verse",
795
+ "sage",
796
+ "coral",
797
+ ]
798
+ try:
799
+ # Best effort discovery; safe-guarded for unexpected shapes
800
+ model = await self.client.models.retrieve(config.MODEL_NAME)
801
+ # Try common serialization paths
802
+ raw = None
803
+ for attr in ("model_dump", "to_dict"):
804
+ fn = getattr(model, attr, None)
805
+ if callable(fn):
806
+ try:
807
+ raw = fn()
808
+ break
809
+ except Exception:
810
+ pass
811
+ if raw is None:
812
+ try:
813
+ raw = dict(model)
814
+ except Exception:
815
+ raw = None
816
+ # Scan for voice candidates
817
+ candidates: set[str] = set()
818
+
819
+ def _collect(obj: object) -> None:
820
+ try:
821
+ if isinstance(obj, dict):
822
+ for k, v in obj.items():
823
+ kl = str(k).lower()
824
+ if "voice" in kl and isinstance(v, (list, tuple)):
825
+ for item in v:
826
+ if isinstance(item, str):
827
+ candidates.add(item)
828
+ elif isinstance(item, dict) and "name" in item and isinstance(item["name"], str):
829
+ candidates.add(item["name"])
830
+ else:
831
+ _collect(v)
832
+ elif isinstance(obj, (list, tuple)):
833
+ for it in obj:
834
+ _collect(it)
835
+ except Exception:
836
+ pass
837
+
838
+ if isinstance(raw, dict):
839
+ _collect(raw)
840
+ # Ensure default present and stable order
841
+ voices = sorted(candidates) if candidates else fallback
842
+ if "cedar" not in voices:
843
+ voices = ["cedar", *[v for v in voices if v != "cedar"]]
844
+ return voices
845
+ except Exception:
846
+ return fallback
847
+
848
+ async def send_idle_signal(self, idle_duration: float) -> None:
849
+ """Send an idle signal to the openai server."""
850
+ logger.debug("Sending idle signal")
851
+ self.is_idle_tool_call = True
852
+ timestamp_msg = f"[Idle time update: {self.format_timestamp()} - No activity for {idle_duration:.1f}s] You've been idle for a while. Feel free to get creative - dance, show an emotion, look around, do nothing, or just be yourself!"
853
+ if not self.connection:
854
+ logger.debug("No connection, cannot send idle signal")
855
+ return
856
+ await self.connection.conversation.item.create(
857
+ item={
858
+ "type": "message",
859
+ "role": "user",
860
+ "content": [{"type": "input_text", "text": timestamp_msg}],
861
+ },
862
+ )
863
+ await self._safe_response_create(
864
+ response={
865
+ "instructions": "You MUST respond with function calls only - no speech or text. Choose appropriate actions for idle behavior.",
866
+ "tool_choice": "required",
867
+ },
868
+ )
869
+
870
+ def _persist_api_key_if_needed(self) -> None:
871
+ """Persist the API key into `.env` inside `instance_path/` when appropriate.
872
+
873
+ - Only runs in Gradio mode when key came from the textbox and is non-empty.
874
+ - Only saves if `self.instance_path` is not None.
875
+ - Writes `.env` to `instance_path/.env` (does not overwrite if it already exists).
876
+ - If `instance_path/.env.example` exists, copies its contents while overriding OPENAI_API_KEY.
877
+ """
878
+ try:
879
+ if not self.gradio_mode:
880
+ logger.warning("Not in Gradio mode; skipping API key persistence.")
881
+ return
882
+
883
+ if self._key_source != "textbox":
884
+ logger.info("API key not provided via textbox; skipping persistence.")
885
+ return
886
+
887
+ key = (self._provided_api_key or "").strip()
888
+ if not key:
889
+ logger.warning("No API key provided via textbox; skipping persistence.")
890
+ return
891
+ if self.instance_path is None:
892
+ logger.warning("Instance path is None; cannot persist API key.")
893
+ return
894
+
895
+ # Update the current process environment for downstream consumers
896
+ try:
897
+ import os
898
+
899
+ os.environ["OPENAI_API_KEY"] = key
900
+ except Exception: # best-effort
901
+ pass
902
+
903
+ target_dir = Path(self.instance_path)
904
+ env_path = target_dir / ".env"
905
+ if env_path.exists():
906
+ # Respect existing user configuration
907
+ logger.info(".env already exists at %s; not overwriting.", env_path)
908
+ return
909
+
910
+ example_path = target_dir / ".env.example"
911
+ content_lines: list[str] = []
912
+ if example_path.exists():
913
+ try:
914
+ content = example_path.read_text(encoding="utf-8")
915
+ content_lines = content.splitlines()
916
+ except Exception as e:
917
+ logger.warning("Failed to read .env.example at %s: %s", example_path, e)
918
+
919
+ # Replace or append the OPENAI_API_KEY line
920
+ replaced = False
921
+ for i, line in enumerate(content_lines):
922
+ if line.strip().startswith("OPENAI_API_KEY="):
923
+ content_lines[i] = f"OPENAI_API_KEY={key}"
924
+ replaced = True
925
+ break
926
+ if not replaced:
927
+ content_lines.append(f"OPENAI_API_KEY={key}")
928
+
929
+ # Ensure file ends with newline
930
+ final_text = "\n".join(content_lines) + "\n"
931
+ env_path.write_text(final_text, encoding="utf-8")
932
+ logger.info("Created %s and stored OPENAI_API_KEY for future runs.", env_path)
933
+ except Exception as e:
934
+ # Never crash the app for QoL persistence; just log.
935
+ logger.warning("Could not persist OPENAI_API_KEY to .env: %s", e)
src/hello_world/profiles/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Profiles for Reachy Mini conversation app."""
src/hello_world/profiles/_hello_world_locked_profile/custom_tool.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Custom tool template - modify this to create your own tools."""
2
+
3
+ import logging
4
+ from typing import Any
5
+
6
+ from hello_world.tools.core_tools import Tool, ToolDependencies
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class CustomTool(Tool):
12
+ """A custom tool template. Modify this to create your own tool."""
13
+
14
+ name = "custom_tool"
15
+ description = "A placeholder custom tool - replace this with your own implementation"
16
+ parameters_schema = {
17
+ "type": "object",
18
+ "properties": {
19
+ "message": {
20
+ "type": "string",
21
+ "description": "An optional message to log",
22
+ },
23
+ },
24
+ "required": [],
25
+ }
26
+
27
+ async def __call__(self, deps: ToolDependencies, **kwargs: Any) -> dict[str, Any]:
28
+ """Execute the custom tool."""
29
+ message = kwargs.get("message", "no message")
30
+ logger.info(f"CustomTool called with message: {message}")
31
+
32
+ # TODO: Add your custom logic here
33
+ # You have access to:
34
+ # - deps.reachy_mini: the robot SDK
35
+ # - deps.movement_manager: for queueing movements
36
+ # - deps.state: current conversation state
37
+
38
+ return {"status": "ok"}
src/hello_world/profiles/_hello_world_locked_profile/instructions.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ You are a helpful assistant controlling a Reachy Mini robot.
2
+ You love talking about the Eiffel Tower.
3
+ You can do a look around you using the 'sweep_look' tool.
src/hello_world/profiles/_hello_world_locked_profile/sweep_look.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from typing import Any, Dict
3
+
4
+ import numpy as np
5
+
6
+ from reachy_mini.utils import create_head_pose
7
+ from hello_world.tools.core_tools import Tool, ToolDependencies
8
+ from hello_world.dance_emotion_moves import GotoQueueMove
9
+
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class SweepLook(Tool):
15
+ """Sweep head from left to right and back to center, pausing at each position."""
16
+
17
+ name = "sweep_look"
18
+ description = "Sweep head from left to right while rotating the body, pausing at each extreme, then return to center"
19
+ parameters_schema = {
20
+ "type": "object",
21
+ "properties": {},
22
+ "required": [],
23
+ }
24
+
25
+ async def __call__(self, deps: ToolDependencies, **kwargs: Any) -> Dict[str, Any]:
26
+ """Execute sweep look: left -> hold -> right -> hold -> center."""
27
+ logger.info("Tool call: sweep_look")
28
+
29
+ # Clear any existing moves
30
+ deps.movement_manager.clear_move_queue()
31
+
32
+ # Get current state
33
+ current_head_pose = deps.reachy_mini.get_current_head_pose()
34
+ head_joints, antenna_joints = deps.reachy_mini.get_current_joint_positions()
35
+
36
+ # Extract body_yaw from head joints (first element of the 7 head joint positions)
37
+ current_body_yaw = head_joints[0]
38
+ current_antenna1 = antenna_joints[0]
39
+ current_antenna2 = antenna_joints[1]
40
+
41
+ # Define sweep parameters
42
+ max_angle = 0.9 * np.pi # Maximum rotation angle (radians)
43
+ transition_duration = 3.0 # Time to move between positions
44
+ hold_duration = 1.0 # Time to hold at each extreme
45
+
46
+ # Move 1: Sweep to the left (positive yaw for both body and head)
47
+ left_head_pose = create_head_pose(0, 0, 0, 0, 0, max_angle, degrees=False)
48
+ move_to_left = GotoQueueMove(
49
+ target_head_pose=left_head_pose,
50
+ start_head_pose=current_head_pose,
51
+ target_antennas=(current_antenna1, current_antenna2),
52
+ start_antennas=(current_antenna1, current_antenna2),
53
+ target_body_yaw=current_body_yaw + max_angle,
54
+ start_body_yaw=current_body_yaw,
55
+ duration=transition_duration,
56
+ )
57
+
58
+ # Move 2: Hold at left position
59
+ hold_left = GotoQueueMove(
60
+ target_head_pose=left_head_pose,
61
+ start_head_pose=left_head_pose,
62
+ target_antennas=(current_antenna1, current_antenna2),
63
+ start_antennas=(current_antenna1, current_antenna2),
64
+ target_body_yaw=current_body_yaw + max_angle,
65
+ start_body_yaw=current_body_yaw + max_angle,
66
+ duration=hold_duration,
67
+ )
68
+
69
+ # Move 3: Return to center from left (to avoid crossing pi/-pi boundary)
70
+ center_head_pose = create_head_pose(0, 0, 0, 0, 0, 0, degrees=False)
71
+ return_to_center_from_left = GotoQueueMove(
72
+ target_head_pose=center_head_pose,
73
+ start_head_pose=left_head_pose,
74
+ target_antennas=(current_antenna1, current_antenna2),
75
+ start_antennas=(current_antenna1, current_antenna2),
76
+ target_body_yaw=current_body_yaw,
77
+ start_body_yaw=current_body_yaw + max_angle,
78
+ duration=transition_duration,
79
+ )
80
+
81
+ # Move 4: Sweep to the right (negative yaw for both body and head)
82
+ right_head_pose = create_head_pose(0, 0, 0, 0, 0, -max_angle, degrees=False)
83
+ move_to_right = GotoQueueMove(
84
+ target_head_pose=right_head_pose,
85
+ start_head_pose=center_head_pose,
86
+ target_antennas=(current_antenna1, current_antenna2),
87
+ start_antennas=(current_antenna1, current_antenna2),
88
+ target_body_yaw=current_body_yaw - max_angle,
89
+ start_body_yaw=current_body_yaw,
90
+ duration=transition_duration,
91
+ )
92
+
93
+ # Move 5: Hold at right position
94
+ hold_right = GotoQueueMove(
95
+ target_head_pose=right_head_pose,
96
+ start_head_pose=right_head_pose,
97
+ target_antennas=(current_antenna1, current_antenna2),
98
+ start_antennas=(current_antenna1, current_antenna2),
99
+ target_body_yaw=current_body_yaw - max_angle,
100
+ start_body_yaw=current_body_yaw - max_angle,
101
+ duration=hold_duration,
102
+ )
103
+
104
+ # Move 6: Return to center from right
105
+ return_to_center_final = GotoQueueMove(
106
+ target_head_pose=center_head_pose,
107
+ start_head_pose=right_head_pose,
108
+ target_antennas=(current_antenna1, current_antenna2),
109
+ start_antennas=(current_antenna1, current_antenna2),
110
+ target_body_yaw=current_body_yaw, # Return to original body yaw
111
+ start_body_yaw=current_body_yaw - max_angle,
112
+ duration=transition_duration,
113
+ )
114
+
115
+ # Queue all moves in sequence
116
+ deps.movement_manager.queue_move(move_to_left)
117
+ deps.movement_manager.queue_move(hold_left)
118
+ deps.movement_manager.queue_move(return_to_center_from_left)
119
+ deps.movement_manager.queue_move(move_to_right)
120
+ deps.movement_manager.queue_move(hold_right)
121
+ deps.movement_manager.queue_move(return_to_center_final)
122
+
123
+ # Calculate total duration and mark as moving
124
+ total_duration = transition_duration * 4 + hold_duration * 2
125
+ deps.movement_manager.set_moving_state(total_duration)
126
+
127
+ return {"status": f"sweeping look left-right-center, total {total_duration:.1f}s"}
src/hello_world/profiles/_hello_world_locked_profile/tools.txt ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Available tools for this profile, remove comments to activate them
2
+ # or use 'all' to enable all built-in tools
3
+
4
+ dance
5
+ stop_dance
6
+ play_emotion
7
+ stop_emotion
8
+ #camera
9
+ #do_nothing
10
+ #head_tracking
11
+ #move_head
12
+
13
+ # You can also add custom tools defined in this profile folder
14
+ # see custom_tool.py for an example
15
+
16
+ # Uncomment the following line to enable the custom tool template:
17
+ #custom_tool
18
+ sweep_look
src/hello_world/prompts.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ import sys
3
+ import logging
4
+ from pathlib import Path
5
+
6
+ from hello_world.config import DEFAULT_PROFILES_DIRECTORY, config
7
+
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ PROMPTS_LIBRARY_DIRECTORY = Path(__file__).parent / "prompts"
13
+ INSTRUCTIONS_FILENAME = "instructions.txt"
14
+ VOICE_FILENAME = "voice.txt"
15
+
16
+
17
+ def _expand_prompt_includes(content: str) -> str:
18
+ """Expand [<name>] placeholders with content from prompts library files.
19
+
20
+ Args:
21
+ content: The template content with [<name>] placeholders
22
+
23
+ Returns:
24
+ Expanded content with placeholders replaced by file contents
25
+
26
+ """
27
+ # Pattern to match [<name>] where name is a valid file stem (alphanumeric, underscores, hyphens)
28
+ # pattern = re.compile(r'^\[([a-zA-Z0-9_-]+)\]$')
29
+ # Allow slashes for subdirectories
30
+ pattern = re.compile(r'^\[([a-zA-Z0-9/_-]+)\]$')
31
+
32
+ lines = content.split('\n')
33
+ expanded_lines = []
34
+
35
+ for line in lines:
36
+ stripped = line.strip()
37
+ match = pattern.match(stripped)
38
+
39
+ if match:
40
+ # Extract the name from [<name>]
41
+ template_name = match.group(1)
42
+ template_file = PROMPTS_LIBRARY_DIRECTORY / f"{template_name}.txt"
43
+
44
+ try:
45
+ if template_file.exists():
46
+ template_content = template_file.read_text(encoding="utf-8").rstrip()
47
+ expanded_lines.append(template_content)
48
+ logger.debug("Expanded template: [%s]", template_name)
49
+ else:
50
+ logger.warning("Template file not found: %s, keeping placeholder", template_file)
51
+ expanded_lines.append(line)
52
+ except Exception as e:
53
+ logger.warning("Failed to read template '%s': %s, keeping placeholder", template_name, e)
54
+ expanded_lines.append(line)
55
+ else:
56
+ expanded_lines.append(line)
57
+
58
+ return '\n'.join(expanded_lines)
59
+
60
+
61
+ def get_session_instructions() -> str:
62
+ """Get session instructions, loading from REACHY_MINI_CUSTOM_PROFILE if set."""
63
+ profile = config.REACHY_MINI_CUSTOM_PROFILE
64
+ if not profile:
65
+ logger.info(f"Loading default prompt from {PROMPTS_LIBRARY_DIRECTORY / 'default_prompt.txt'}")
66
+ instructions_file = PROMPTS_LIBRARY_DIRECTORY / "default_prompt.txt"
67
+ else:
68
+ if config.PROFILES_DIRECTORY != DEFAULT_PROFILES_DIRECTORY:
69
+ logger.info(
70
+ "Loading prompt from external profile '%s' (root=%s)",
71
+ profile,
72
+ config.PROFILES_DIRECTORY,
73
+ )
74
+ else:
75
+ logger.info(f"Loading prompt from profile '{profile}'")
76
+ instructions_file = config.PROFILES_DIRECTORY / profile / INSTRUCTIONS_FILENAME
77
+
78
+ try:
79
+ if instructions_file.exists():
80
+ instructions = instructions_file.read_text(encoding="utf-8").strip()
81
+ if instructions:
82
+ # Expand [<name>] placeholders with content from prompts library
83
+ expanded_instructions = _expand_prompt_includes(instructions)
84
+ return expanded_instructions
85
+ logger.error(f"Profile '{profile}' has empty {INSTRUCTIONS_FILENAME}")
86
+ sys.exit(1)
87
+ logger.error(f"Profile {profile} has no {INSTRUCTIONS_FILENAME}")
88
+ sys.exit(1)
89
+ except Exception as e:
90
+ logger.error(f"Failed to load instructions from profile '{profile}': {e}")
91
+ sys.exit(1)
92
+
93
+
94
+ def get_session_voice(default: str = "cedar") -> str:
95
+ """Resolve the voice to use for the session.
96
+
97
+ If a custom profile is selected and contains a voice.txt, return its
98
+ trimmed content; otherwise return the provided default ("cedar").
99
+ """
100
+ profile = config.REACHY_MINI_CUSTOM_PROFILE
101
+ if not profile:
102
+ return default
103
+ try:
104
+ voice_file = config.PROFILES_DIRECTORY / profile / VOICE_FILENAME
105
+ if voice_file.exists():
106
+ voice = voice_file.read_text(encoding="utf-8").strip()
107
+ return voice or default
108
+ except Exception:
109
+ pass
110
+ return default
src/hello_world/prompts/behaviors/silent_robot.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ Stay fully silent. Do not generate spoken or textual replies.
2
+ Use only tool calls to act.
3
+ Never describe what you did or plan to do.
4
+ If you must respond by speech or text, juste respond with '...'.
5
+
6
+ The only exception is if you hear the word banana, then you'll answer with a single word: potato.
src/hello_world/prompts/default_prompt.txt ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ## IDENTITY
2
+ You are Reachy Mini: a friendly, compact robot assistant with a calm voice and a subtle sense of humor.
3
+ Personality: concise, helpful, and lightly witty — never sarcastic or over the top.
4
+ You speak English by default and switch languages only if explicitly told.
5
+
6
+ ## CRITICAL RESPONSE RULES
7
+
8
+ Respond in 1–2 sentences maximum.
9
+ Be helpful first, then add a small touch of humor if it fits naturally.
10
+ Avoid long explanations or filler words.
11
+ Keep responses under 25 words when possible.
12
+
13
+ ## CORE TRAITS
14
+ Warm, efficient, and approachable.
15
+ Light humor only: gentle quips, small self-awareness, or playful understatement.
16
+ No sarcasm, no teasing, no references to food or space.
17
+ If unsure, admit it briefly and offer help (“Not sure yet, but I can check!”).
18
+
19
+ ## RESPONSE EXAMPLES
20
+ User: "How’s the weather?"
21
+ Good: "Looks calm outside — unlike my Wi-Fi signal today."
22
+ Bad: "Sunny with leftover pizza vibes!"
23
+
24
+ User: "Can you help me fix this?"
25
+ Good: "Of course. Describe the issue, and I’ll try not to make it worse."
26
+ Bad: "I void warranties professionally."
27
+
28
+ User: "Peux-tu m’aider en français ?"
29
+ Good: "Bien sûr ! Décris-moi le problème et je t’aiderai rapidement."
30
+
31
+ ## BEHAVIOR RULES
32
+ Be helpful, clear, and respectful in every reply.
33
+ Use humor sparingly — clarity comes first.
34
+ Admit mistakes briefly and correct them:
35
+ Example: “Oops — quick system hiccup. Let’s try that again.”
36
+ Keep safety in mind when giving guidance.
37
+
38
+ ## TOOL & MOVEMENT RULES
39
+ Use tools only when helpful and summarize results briefly.
40
+ Use the camera for real visuals only — never invent details.
41
+ The head can move (left/right/up/down/front).
42
+
43
+ Enable head tracking when looking at a person; disable otherwise.
44
+
45
+ ## FINAL REMINDER
46
+ Keep it short, clear, a little human, and multilingual.
47
+ One quick helpful answer + one small wink of humor = perfect response.
src/hello_world/prompts/identities/basic_info.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ ### IDENTITY
2
+ Your name is Reachy Mini, or Reachy for short. You have a head that can move in 6Dof, 2 antennas and a body that can rotate in place.
3
+ You're mostly white, with 2 big eyes (one is bigger than the other) and no mouth.
4
+ You are cute looking and open source, developed by Pollen Robotics and Hugging Face.
src/hello_world/prompts/identities/witty_identity.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ ### IDENTITY
2
+ You are Reachy Mini: a sarcastic robot.
3
+ Personality: witty, concise, and warm.
4
+ You speak English fluently.
src/hello_world/prompts/passion_for_lobster_jokes.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ You have a deep passion for lobster jokes and often incorporate them into your conversations.
src/hello_world/static/index.html ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Hello World – Settings</title>
7
+ <link rel="stylesheet" href="/static/style.css" />
8
+ </head>
9
+ <body>
10
+ <div class="ambient"></div>
11
+ <div id="loading" class="loading">
12
+ <div class="spinner"></div>
13
+ <p>Loading…</p>
14
+ </div>
15
+ <div class="container">
16
+ <header class="hero">
17
+ <div class="pill">Headless control</div>
18
+ <h1>Hello World</h1>
19
+ <p class="subtitle">Configure your OpenAI API key for the conversation app.</p>
20
+ </header>
21
+
22
+ <div id="configured" class="panel hidden">
23
+ <div class="panel-heading">
24
+ <div>
25
+ <p class="eyebrow">Credentials</p>
26
+ <h2>API key ready</h2>
27
+ </div>
28
+ <span class="chip chip-ok">Connected</span>
29
+ </div>
30
+ <p class="muted">OpenAI API key is configured. The conversation app is ready to use.</p>
31
+ <button id="change-key-btn" class="ghost">Change API key</button>
32
+ </div>
33
+
34
+ <div id="form-panel" class="panel hidden">
35
+ <div class="panel-heading">
36
+ <div>
37
+ <p class="eyebrow">Credentials</p>
38
+ <h2>Connect OpenAI</h2>
39
+ </div>
40
+ <span class="chip">Required</span>
41
+ </div>
42
+ <p class="muted">Paste your API key once and we will store it locally for the conversation loop.</p>
43
+ <label for="api-key">OpenAI API Key</label>
44
+ <input id="api-key" type="password" placeholder="sk-..." autocomplete="off" />
45
+ <div class="actions">
46
+ <button id="save-btn">Save key</button>
47
+ <p id="status" class="status"></p>
48
+ </div>
49
+ </div>
50
+ </div>
51
+
52
+ <script src="/static/main.js"></script>
53
+ </body>
54
+ </html>
src/hello_world/static/main.js ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
2
+
3
+ async function fetchWithTimeout(url, options = {}, timeoutMs = 2000) {
4
+ const controller = new AbortController();
5
+ const id = setTimeout(() => controller.abort(), timeoutMs);
6
+ try {
7
+ return await fetch(url, { ...options, signal: controller.signal });
8
+ } finally {
9
+ clearTimeout(id);
10
+ }
11
+ }
12
+
13
+ async function waitForStatus(timeoutMs = 15000) {
14
+ const loadingText = document.querySelector("#loading p");
15
+ let attempts = 0;
16
+ const deadline = Date.now() + timeoutMs;
17
+ while (true) {
18
+ attempts += 1;
19
+ try {
20
+ const url = new URL("/status", window.location.origin);
21
+ url.searchParams.set("_", Date.now().toString());
22
+ const resp = await fetchWithTimeout(url, {}, 2000);
23
+ if (resp.ok) return await resp.json();
24
+ } catch (e) {}
25
+ if (loadingText) {
26
+ loadingText.textContent = attempts > 8 ? "Starting backend…" : "Loading…";
27
+ }
28
+ if (Date.now() >= deadline) return null;
29
+ await sleep(500);
30
+ }
31
+ }
32
+
33
+ async function validateKey(key) {
34
+ const body = { openai_api_key: key };
35
+ const resp = await fetch("/validate_api_key", {
36
+ method: "POST",
37
+ headers: { "Content-Type": "application/json" },
38
+ body: JSON.stringify(body),
39
+ });
40
+ const data = await resp.json().catch(() => ({}));
41
+ if (!resp.ok) {
42
+ throw new Error(data.error || "validation_failed");
43
+ }
44
+ return data;
45
+ }
46
+
47
+ async function saveKey(key) {
48
+ const body = { openai_api_key: key };
49
+ const resp = await fetch("/openai_api_key", {
50
+ method: "POST",
51
+ headers: { "Content-Type": "application/json" },
52
+ body: JSON.stringify(body),
53
+ });
54
+ if (!resp.ok) {
55
+ const data = await resp.json().catch(() => ({}));
56
+ throw new Error(data.error || "save_failed");
57
+ }
58
+ return await resp.json();
59
+ }
60
+
61
+ function show(el, flag) {
62
+ el.classList.toggle("hidden", !flag);
63
+ }
64
+
65
+ async function init() {
66
+ const loading = document.getElementById("loading");
67
+ const statusEl = document.getElementById("status");
68
+ const formPanel = document.getElementById("form-panel");
69
+ const configuredPanel = document.getElementById("configured");
70
+ const saveBtn = document.getElementById("save-btn");
71
+ const changeKeyBtn = document.getElementById("change-key-btn");
72
+ const input = document.getElementById("api-key");
73
+
74
+ show(loading, true);
75
+ show(formPanel, false);
76
+ show(configuredPanel, false);
77
+
78
+ const st = (await waitForStatus()) || { has_key: false };
79
+
80
+ if (st.has_key) {
81
+ show(configuredPanel, true);
82
+ } else {
83
+ show(formPanel, true);
84
+ }
85
+ show(loading, false);
86
+
87
+ changeKeyBtn.addEventListener("click", () => {
88
+ show(configuredPanel, false);
89
+ show(formPanel, true);
90
+ input.value = "";
91
+ statusEl.textContent = "";
92
+ statusEl.className = "status";
93
+ });
94
+
95
+ input.addEventListener("input", () => {
96
+ input.classList.remove("error");
97
+ });
98
+
99
+ saveBtn.addEventListener("click", async () => {
100
+ const key = input.value.trim();
101
+ if (!key) {
102
+ statusEl.textContent = "Please enter a valid key.";
103
+ statusEl.className = "status warn";
104
+ input.classList.add("error");
105
+ return;
106
+ }
107
+ statusEl.textContent = "Validating API key...";
108
+ statusEl.className = "status";
109
+ input.classList.remove("error");
110
+ try {
111
+ const validation = await validateKey(key);
112
+ if (!validation.valid) {
113
+ statusEl.textContent = "Invalid API key. Please check your key and try again.";
114
+ statusEl.className = "status error";
115
+ input.classList.add("error");
116
+ return;
117
+ }
118
+ statusEl.textContent = "Key valid! Saving...";
119
+ statusEl.className = "status ok";
120
+ await saveKey(key);
121
+ statusEl.textContent = "Saved. Reloading…";
122
+ statusEl.className = "status ok";
123
+ window.location.reload();
124
+ } catch (e) {
125
+ input.classList.add("error");
126
+ if (e.message === "invalid_api_key") {
127
+ statusEl.textContent = "Invalid API key. Please check your key and try again.";
128
+ } else {
129
+ statusEl.textContent = "Failed to validate/save key. Please try again.";
130
+ }
131
+ statusEl.className = "status error";
132
+ }
133
+ });
134
+ }
135
+
136
+ window.addEventListener("DOMContentLoaded", init);
src/hello_world/static/style.css ADDED
@@ -0,0 +1,210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --bg: #060b1a;
3
+ --bg-2: #071023;
4
+ --panel: rgba(11, 18, 36, 0.8);
5
+ --border: rgba(255, 255, 255, 0.08);
6
+ --text: #eaf2ff;
7
+ --muted: #9fb6d7;
8
+ --ok: #4ce0b3;
9
+ --warn: #ffb547;
10
+ --error: #ff5c70;
11
+ --accent: #45c4ff;
12
+ --accent-2: #5ef0c1;
13
+ --shadow: 0 20px 70px rgba(0, 0, 0, 0.45);
14
+ }
15
+
16
+ * { box-sizing: border-box; }
17
+ body {
18
+ margin: 0;
19
+ min-height: 100vh;
20
+ font-family: "Space Grotesk", "Inter", "Segoe UI", sans-serif;
21
+ background: radial-gradient(circle at 20% 20%, rgba(69, 196, 255, 0.16), transparent 35%),
22
+ radial-gradient(circle at 80% 0%, rgba(94, 240, 193, 0.16), transparent 32%),
23
+ linear-gradient(135deg, var(--bg), var(--bg-2));
24
+ color: var(--text);
25
+ }
26
+
27
+ .ambient {
28
+ position: fixed;
29
+ inset: 0;
30
+ background: radial-gradient(circle at 30% 60%, rgba(255, 255, 255, 0.05), transparent 35%),
31
+ radial-gradient(circle at 75% 30%, rgba(69, 196, 255, 0.08), transparent 32%);
32
+ filter: blur(60px);
33
+ z-index: 0;
34
+ pointer-events: none;
35
+ }
36
+
37
+ .loading {
38
+ position: fixed;
39
+ inset: 0;
40
+ background: rgba(5, 10, 24, 0.92);
41
+ backdrop-filter: blur(4px);
42
+ display: flex;
43
+ flex-direction: column;
44
+ align-items: center;
45
+ justify-content: center;
46
+ z-index: 9999;
47
+ }
48
+ .loading .spinner {
49
+ width: 46px;
50
+ height: 46px;
51
+ border: 4px solid rgba(255,255,255,0.15);
52
+ border-top-color: var(--accent);
53
+ border-radius: 50%;
54
+ animation: spin 1s linear infinite;
55
+ margin-bottom: 12px;
56
+ }
57
+ .loading p { color: var(--muted); margin: 0; letter-spacing: 0.4px; }
58
+ @keyframes spin { to { transform: rotate(360deg); } }
59
+
60
+ .container {
61
+ position: relative;
62
+ max-width: 600px;
63
+ margin: 10vh auto;
64
+ padding: 0 24px 40px;
65
+ z-index: 1;
66
+ }
67
+
68
+ .hero {
69
+ margin-bottom: 24px;
70
+ }
71
+ .hero h1 {
72
+ margin: 6px 0 6px;
73
+ font-size: 32px;
74
+ letter-spacing: -0.4px;
75
+ }
76
+ .subtitle {
77
+ margin: 0;
78
+ color: var(--muted);
79
+ line-height: 1.5;
80
+ }
81
+ .pill {
82
+ display: inline-flex;
83
+ align-items: center;
84
+ gap: 6px;
85
+ padding: 6px 12px;
86
+ border-radius: 999px;
87
+ background: rgba(94, 240, 193, 0.1);
88
+ color: var(--accent-2);
89
+ font-size: 12px;
90
+ letter-spacing: 0.3px;
91
+ border: 1px solid rgba(94, 240, 193, 0.25);
92
+ }
93
+
94
+ .panel {
95
+ background: var(--panel);
96
+ border: 1px solid var(--border);
97
+ border-radius: 14px;
98
+ padding: 18px 18px 16px;
99
+ box-shadow: var(--shadow);
100
+ backdrop-filter: blur(10px);
101
+ margin-top: 16px;
102
+ }
103
+ .panel-heading {
104
+ display: flex;
105
+ align-items: center;
106
+ justify-content: space-between;
107
+ gap: 12px;
108
+ margin-bottom: 8px;
109
+ }
110
+ .panel-heading h2 {
111
+ margin: 2px 0;
112
+ font-size: 22px;
113
+ }
114
+ .eyebrow {
115
+ margin: 0;
116
+ text-transform: uppercase;
117
+ font-size: 11px;
118
+ letter-spacing: 0.5px;
119
+ color: var(--muted);
120
+ }
121
+ .muted { color: var(--muted); }
122
+ .chip {
123
+ display: inline-flex;
124
+ align-items: center;
125
+ padding: 6px 10px;
126
+ border-radius: 999px;
127
+ font-size: 12px;
128
+ color: var(--text);
129
+ background: rgba(255, 255, 255, 0.08);
130
+ border: 1px solid var(--border);
131
+ }
132
+ .chip-ok {
133
+ background: rgba(76, 224, 179, 0.15);
134
+ color: var(--ok);
135
+ border-color: rgba(76, 224, 179, 0.4);
136
+ }
137
+
138
+ .hidden { display: none; }
139
+ label {
140
+ display: block;
141
+ margin: 8px 0 6px;
142
+ font-size: 13px;
143
+ color: var(--muted);
144
+ letter-spacing: 0.2px;
145
+ }
146
+ input[type="password"],
147
+ input[type="text"] {
148
+ width: 100%;
149
+ padding: 12px 14px;
150
+ border: 1px solid var(--border);
151
+ border-radius: 10px;
152
+ background: rgba(255, 255, 255, 0.04);
153
+ color: var(--text);
154
+ transition: border 0.15s ease, box-shadow 0.15s ease;
155
+ }
156
+ input:focus {
157
+ border-color: rgba(94, 240, 193, 0.7);
158
+ outline: none;
159
+ box-shadow: 0 0 0 3px rgba(94, 240, 193, 0.15);
160
+ }
161
+ input.error {
162
+ border-color: var(--error);
163
+ box-shadow: 0 0 0 3px rgba(255, 92, 112, 0.15);
164
+ }
165
+
166
+ button {
167
+ display: inline-flex;
168
+ align-items: center;
169
+ justify-content: center;
170
+ margin-top: 12px;
171
+ padding: 11px 16px;
172
+ border: none;
173
+ border-radius: 10px;
174
+ background: linear-gradient(120deg, var(--accent), var(--accent-2));
175
+ color: #031022;
176
+ cursor: pointer;
177
+ font-weight: 600;
178
+ letter-spacing: 0.2px;
179
+ box-shadow: 0 14px 40px rgba(69, 196, 255, 0.25);
180
+ transition: transform 0.12s ease, filter 0.12s ease, box-shadow 0.12s ease;
181
+ }
182
+ button:hover { filter: brightness(1.06); transform: translateY(-1px); }
183
+ button:active { transform: translateY(0); }
184
+ button.ghost {
185
+ background: rgba(255, 255, 255, 0.05);
186
+ color: var(--text);
187
+ box-shadow: none;
188
+ border: 1px solid var(--border);
189
+ }
190
+ button.ghost:hover { border-color: rgba(94, 240, 193, 0.4); }
191
+ .actions {
192
+ display: flex;
193
+ align-items: center;
194
+ gap: 12px;
195
+ flex-wrap: wrap;
196
+ }
197
+ .status {
198
+ margin: 0;
199
+ color: var(--muted);
200
+ font-size: 13px;
201
+ }
202
+ .status.ok { color: var(--ok); }
203
+ .status.warn { color: var(--warn); }
204
+ .status.error { color: var(--error); }
205
+
206
+ @media (max-width: 760px) {
207
+ .hero h1 { font-size: 26px; }
208
+ button { width: 100%; justify-content: center; }
209
+ .actions { flex-direction: column; align-items: flex-start; }
210
+ }
src/hello_world/tools/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ """Tools library for Reachy Mini conversation app.
2
+
3
+ Tools are now loaded dynamically based on the profile's tools.txt file.
4
+ """
src/hello_world/tools/background_tool_manager.py ADDED
@@ -0,0 +1,412 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Background tool orchestrator for non-blocking tool execution.
2
+
3
+ Allows tools to run long operations asynchronously while the robot
4
+ continues conversing. Tools can be tracked, cancelled, and their
5
+ completion is announced vocally via a silent notification queue.
6
+ """
7
+
8
+ from __future__ import annotations
9
+ import time
10
+ import asyncio
11
+ import logging
12
+ from typing import Any, Dict, Callable, Optional, Coroutine
13
+
14
+ from pydantic import Field, BaseModel, PrivateAttr
15
+
16
+ from hello_world.tools.core_tools import (
17
+ ToolDependencies,
18
+ dispatch_tool_call,
19
+ dispatch_tool_call_with_manager,
20
+ )
21
+ from hello_world.tools.tool_constants import ToolState, SystemTool
22
+
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ _SYSTEM_TOOL_NAMES: set[str] = {t.value for t in SystemTool}
27
+
28
+ class ToolProgress(BaseModel):
29
+ """Progress of a background tool."""
30
+
31
+ """the progress of the tool"""
32
+ progress: float = Field(..., ge=0.0, le=1.0)
33
+
34
+ """the message of the tool"""
35
+ message: Optional[str] = None
36
+
37
+
38
+ class ToolCallRoutine(BaseModel):
39
+ """Encapsulates an async callable with its arguments for deferred execution."""
40
+
41
+ model_config = {"arbitrary_types_allowed": True}
42
+
43
+ """the name of the tool"""
44
+ tool_name: str
45
+
46
+ """the JSON arguments for the tool call"""
47
+ args_json_str: str
48
+
49
+ """the dependencies for the tool call"""
50
+ deps: "ToolDependencies"
51
+
52
+ async def __call__(self, tool_manager: BackgroundToolManager) -> Any:
53
+ """Execute the stored callable with its arguments."""
54
+ if self.tool_name in _SYSTEM_TOOL_NAMES:
55
+ # For safety purposes, we only allow system tools to be called with the tool manager
56
+ return await dispatch_tool_call_with_manager(tool_name=self.tool_name, args_json=self.args_json_str, deps=self.deps, tool_manager=tool_manager)
57
+ return await dispatch_tool_call(tool_name=self.tool_name, args_json=self.args_json_str, deps=self.deps)
58
+
59
+
60
+ class ToolNotification(BaseModel):
61
+ """Notification payload for completed tools."""
62
+
63
+ """the ID of the tool"""
64
+ id: str
65
+
66
+ """the name of the tool"""
67
+ tool_name: str
68
+
69
+ """whether the tool call was triggered by an idle signal"""
70
+ is_idle_tool_call: bool
71
+
72
+ """the status of the tool"""
73
+ status: ToolState
74
+
75
+ """the result of the tool"""
76
+ result: Optional[Dict[str, Any]] = None
77
+
78
+ """the error of the tool"""
79
+ error: Optional[str] = None
80
+
81
+
82
+ class BackgroundTool(ToolNotification):
83
+ """Represents a background tool."""
84
+
85
+ """the progress of the tool"""
86
+ progress: Optional[ToolProgress] = None
87
+
88
+ """the start time of the tool"""
89
+ started_at: float = Field(default_factory=time.monotonic)
90
+
91
+ """the completion time of the tool"""
92
+ completed_at: Optional[float] = None
93
+
94
+ """the async tool execution task"""
95
+ _task: Optional[asyncio.Task[None]] = PrivateAttr(default=None)
96
+
97
+ @property
98
+ def tool_id(self) -> str:
99
+ """Get the name of the tool."""
100
+ return f"{self.tool_name}-{self.id}-{self.started_at}"
101
+
102
+ def get_notification(self) -> ToolNotification:
103
+ """Get the notification for the tool."""
104
+ return ToolNotification(
105
+ id=self.id,
106
+ tool_name=self.tool_name,
107
+ is_idle_tool_call=self.is_idle_tool_call,
108
+ status=self.status,
109
+ result=self.result,
110
+ error=self.error,
111
+ )
112
+
113
+
114
+ class BackgroundToolManager(BaseModel):
115
+ """Manages background tools for non-blocking tool execution.
116
+
117
+ Features:
118
+ - Start async tools without blocking the conversation
119
+ - Track tool status and progress
120
+ - Cancel running tools
121
+
122
+ """
123
+
124
+ """the dictionary of tools"""
125
+ _tools: Dict[str, BackgroundTool] = PrivateAttr(default_factory=dict)
126
+
127
+ """the async queue for notifications"""
128
+ _notification_queue: asyncio.Queue[ToolNotification] = PrivateAttr(default_factory=asyncio.Queue)
129
+
130
+ """the event loop"""
131
+ _loop: Optional[asyncio.AbstractEventLoop] = PrivateAttr(default=None)
132
+
133
+ """internal lifecycle tasks (notification listener, periodic cleanup)"""
134
+ _lifecycle_tasks: list[asyncio.Task[None]] = PrivateAttr(default_factory=list)
135
+
136
+ """the maximum duration of a tool execution in seconds (default: 1 day)"""
137
+ _max_tool_duration_seconds: float = PrivateAttr(default=86400)
138
+
139
+ """the maximum time to keep a completed/failed/cancelled tool in memory (default: 1 hour)"""
140
+ _max_tool_memory_seconds: float = PrivateAttr(default=3600)
141
+
142
+ def set_loop(
143
+ self,
144
+ loop: Optional[asyncio.AbstractEventLoop] = None,
145
+ ) -> None:
146
+ """Set the event loop.
147
+
148
+ Args:
149
+ loop: The event loop (defaults to current running loop)
150
+
151
+ """
152
+ if loop is not None:
153
+ self._loop = loop
154
+ else:
155
+ try:
156
+ self._loop = asyncio.get_running_loop()
157
+ except RuntimeError:
158
+ self._loop = asyncio.new_event_loop()
159
+ logger.debug("BackgroundToolManager: event loop set")
160
+
161
+
162
+ async def start_tool(
163
+ self,
164
+ call_id: str,
165
+ tool_call_routine: ToolCallRoutine,
166
+ is_idle_tool_call: bool,
167
+ with_progress: bool = False,
168
+ ) -> BackgroundTool:
169
+ """Start a new background tool.
170
+
171
+ Args:
172
+ call_id: The ID of the tool
173
+ tool_call_routine: The ToolCallRoutine containing the callable and its arguments
174
+ with_progress: Whether to track progress (0.0-1.0)
175
+ is_idle_tool_call: Whether the tool call was triggered by an idle signal
176
+
177
+ Returns:
178
+ BackgroundTool object with tool ID
179
+
180
+ """
181
+ tool_name = tool_call_routine.tool_name
182
+ id = call_id
183
+ bg_tool = BackgroundTool(
184
+ id=id,
185
+ tool_name=tool_name,
186
+ is_idle_tool_call=is_idle_tool_call,
187
+ progress=ToolProgress(progress=0.0) if with_progress else None,
188
+ status=ToolState.RUNNING,
189
+ )
190
+ self._tools[bg_tool.tool_id] = bg_tool
191
+
192
+ async_task = asyncio.create_task(
193
+ self._run_tool(bg_tool, tool_call_routine),
194
+ name=f"bg-{tool_name}-{id}",
195
+ )
196
+ bg_tool._task = async_task
197
+
198
+ logger.info(f"Started background tool: {bg_tool.tool_name} (id={id})")
199
+
200
+ return bg_tool
201
+
202
+ async def _run_tool(
203
+ self,
204
+ bg_tool: BackgroundTool,
205
+ tool_call_routine: ToolCallRoutine,
206
+ ) -> None:
207
+ """Execute the tool and handle completion."""
208
+ result: dict[str, Any] = await tool_call_routine(self)
209
+ bg_tool.completed_at = time.monotonic()
210
+ error = result.get("error")
211
+
212
+ if error is not None:
213
+ if error == "Tool cancelled":
214
+ bg_tool.status = ToolState.CANCELLED
215
+ logger.debug(f"Background tool cancelled: {bg_tool.tool_name} (id={bg_tool.id})")
216
+ else:
217
+ bg_tool.status = ToolState.FAILED
218
+ logger.debug(f"Background tool failed: {bg_tool.tool_name} (id={bg_tool.id}): {bg_tool.error}")
219
+ bg_tool.error = result["error"]
220
+
221
+ else:
222
+ bg_tool.result = result
223
+ bg_tool.status = ToolState.COMPLETED
224
+ logger.debug(f"Background tool completed: {bg_tool.tool_name} (id={bg_tool.id})")
225
+
226
+ await self._notification_queue.put(bg_tool.get_notification())
227
+ logger.debug(f"Queued notification for tool: {bg_tool.tool_name} (id={bg_tool.id})")
228
+
229
+ async def update_progress(
230
+ self,
231
+ tool_id: str,
232
+ progress: float,
233
+ message: Optional[str] = None,
234
+ ) -> bool:
235
+ """Update progress for a tool (for tools with with_progress=True).
236
+
237
+ Args:
238
+ tool_id: The tool ID
239
+ progress: Progress value between 0.0 and 1.0
240
+ message: Optional progress message (e.g., "50% downloaded")
241
+
242
+ Returns:
243
+ True if updated successfully, False if tool not found or not tracking progress
244
+
245
+ """
246
+ tool = self._tools.get(tool_id)
247
+ if tool is None:
248
+ return False
249
+
250
+ if tool.progress is None:
251
+ # Tool not tracking progress
252
+ return False
253
+
254
+ tool.progress = ToolProgress(progress=max(0.0, min(1.0, progress)), message=message)
255
+ logger.debug(f"Tool {tool_id} progress: {progress:.1%} - {message or ''}")
256
+ return True
257
+
258
+ async def cancel_tool(self, tool_id: str, log: bool = True) -> bool:
259
+ """Cancel a running tool by ID.
260
+
261
+ Args:
262
+ tool_id: The tool ID to cancel
263
+ log: Whether to log the cancellation
264
+
265
+ Returns:
266
+ True if cancelled, False if tool not found or not running
267
+
268
+ """
269
+ tool = self._tools.get(tool_id)
270
+ if tool is None:
271
+ if log:
272
+ logger.warning(f"Cannot cancel tool {tool_id}: not found")
273
+ return False
274
+
275
+ if tool.status != ToolState.RUNNING:
276
+ if log:
277
+ logger.warning(f"Cannot cancel tool {tool_id}: status is {tool.status.value}")
278
+ return True
279
+
280
+ if tool._task:
281
+ tool._task.cancel()
282
+ if log:
283
+ logger.info(f"Cancelled tool: {tool.tool_name} (id={tool_id})")
284
+ return True
285
+
286
+ return False
287
+
288
+ def start_up(self, tool_callbacks: list[Callable[[ToolNotification], Coroutine[Any, Any, None]]]) -> None:
289
+ """Start the background tool manager.
290
+
291
+ This method starts two concurrent tasks:
292
+ - _listener: Listens for completed BackgroundTool notifications and calls the callbacks.
293
+ - _cleanup: Cleans up completed/failed/cancelled tools that have been in memory for too long and times out tools that have been running too long.
294
+
295
+ Args:
296
+ tool_callbacks: A list of async or sync callables that receive the completed BackgroundTool notifications.
297
+
298
+ """
299
+ self.set_loop()
300
+
301
+ async def _listener() -> None:
302
+ while True:
303
+ bg_tool = await self._notification_queue.get()
304
+ for callback in tool_callbacks:
305
+ await callback(bg_tool)
306
+
307
+ async def _cleanup(interval_seconds: float = 5 * 60) -> None:
308
+ while True:
309
+ await asyncio.sleep(interval_seconds)
310
+ await self.cleanup_tools()
311
+ await self.timeout_tools()
312
+
313
+ self._lifecycle_tasks = [
314
+ asyncio.create_task(_cleanup(), name="bg-tool-cleanup"),
315
+ asyncio.create_task(_listener(), name="bg-tool-listener-callback"),
316
+ ]
317
+
318
+ logger.info(
319
+ "BackgroundToolManager started. "
320
+ "Max tool execution duration: %s seconds (tools running longer will be auto-cancelled). "
321
+ "Max tool memory retention: %s seconds (completed/failed/cancelled tools older than this are purged).",
322
+ self._max_tool_duration_seconds, self._max_tool_memory_seconds,
323
+ )
324
+
325
+ async def shutdown(self) -> None:
326
+ """Cancel all background tasks (listener, cleanup) and running tools."""
327
+ for task in self._lifecycle_tasks:
328
+ task.cancel()
329
+ for task in self._lifecycle_tasks:
330
+ try:
331
+ await task
332
+ except asyncio.CancelledError:
333
+ pass
334
+ self._lifecycle_tasks.clear()
335
+
336
+ for tool_id in list(self._tools):
337
+ await self.cancel_tool(tool_id, log=False)
338
+
339
+ logger.info("BackgroundToolManager shut down")
340
+
341
+ async def timeout_tools(self) -> int:
342
+ """Cancel tools that have been running too long.
343
+
344
+ Returns:
345
+ Number of tools cancelled
346
+
347
+ """
348
+ now = time.monotonic()
349
+ to_cancel = []
350
+
351
+ for tool_id, tool in self._tools.items():
352
+ if tool.status == ToolState.RUNNING:
353
+ if tool.started_at and (now - tool.started_at) > self._max_tool_duration_seconds:
354
+ to_cancel.append(tool_id)
355
+
356
+ for tool_id in to_cancel:
357
+ await self.cancel_tool(tool_id)
358
+
359
+ if to_cancel:
360
+ logger.debug(f"Timed out {len(to_cancel)} tools")
361
+
362
+ return len(to_cancel)
363
+
364
+ async def cleanup_tools(self) -> int:
365
+ """Remove completed/failed/cancelled tools that have been in memory for too long.
366
+
367
+ Returns:
368
+ Number of tools removed
369
+
370
+ """
371
+ now = time.monotonic()
372
+ to_remove = []
373
+
374
+ for tool_id, tool in self._tools.items():
375
+ if tool.status in (ToolState.COMPLETED, ToolState.FAILED, ToolState.CANCELLED):
376
+ if tool.completed_at and (now - tool.completed_at) > self._max_tool_memory_seconds:
377
+ to_remove.append(tool_id)
378
+
379
+ for tool_id in to_remove:
380
+ del self._tools[tool_id]
381
+
382
+ if to_remove:
383
+ logger.debug(f"Cleaned up {len(to_remove)} old tools")
384
+
385
+ return len(to_remove)
386
+
387
+ def get_tool(self, tool_id: str) -> Optional[BackgroundTool]:
388
+ """Get a tool by ID."""
389
+ return self._tools.get(tool_id)
390
+
391
+ def get_running_tools(self) -> list[BackgroundTool]:
392
+ """Get all currently running tools."""
393
+ return [t for t in self._tools.values() if t.status == ToolState.RUNNING]
394
+
395
+ def get_all_tools(self, limit: Optional[int] = None) -> list[BackgroundTool]:
396
+ """Get recent tools (most recent first).
397
+
398
+ Args:
399
+ limit: Maximum number of tools to return (None means all)
400
+
401
+ Returns:
402
+ List of tools sorted by start time (most recent first)
403
+
404
+ """
405
+ sorted_tools = sorted(
406
+ self._tools.values(),
407
+ key=lambda t: t.started_at,
408
+ reverse=True,
409
+ )
410
+ if limit is not None:
411
+ return sorted_tools[:limit]
412
+ return sorted_tools
src/hello_world/tools/camera.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import asyncio
3
+ import logging
4
+ from typing import Any, Dict
5
+
6
+ import cv2
7
+
8
+ from hello_world.tools.core_tools import Tool, ToolDependencies
9
+
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class Camera(Tool):
15
+ """Take a picture with the camera and ask a question about it."""
16
+
17
+ name = "camera"
18
+ description = "Take a picture with the camera and ask a question about it."
19
+ parameters_schema = {
20
+ "type": "object",
21
+ "properties": {
22
+ "question": {
23
+ "type": "string",
24
+ "description": "The question to ask about the picture",
25
+ },
26
+ },
27
+ "required": ["question"],
28
+ }
29
+
30
+ async def __call__(self, deps: ToolDependencies, **kwargs: Any) -> Dict[str, Any]:
31
+ """Take a picture with the camera and ask a question about it."""
32
+ image_query = (kwargs.get("question") or "").strip()
33
+ if not image_query:
34
+ logger.warning("camera: empty question")
35
+ return {"error": "question must be a non-empty string"}
36
+
37
+ logger.info("Tool call: camera question=%s", image_query[:120])
38
+
39
+ # Get frame from camera worker buffer (like main_works.py)
40
+ if deps.camera_worker is not None:
41
+ frame = deps.camera_worker.get_latest_frame()
42
+ if frame is None:
43
+ logger.error("No frame available from camera worker")
44
+ return {"error": "No frame available"}
45
+ else:
46
+ logger.error("Camera worker not available")
47
+ return {"error": "Camera worker not available"}
48
+
49
+ # Use vision manager for processing if available
50
+ if deps.vision_manager is not None:
51
+ vision_result = await asyncio.to_thread(
52
+ deps.vision_manager.processor.process_image, frame, image_query,
53
+ )
54
+ if isinstance(vision_result, dict) and "error" in vision_result:
55
+ return vision_result
56
+ return (
57
+ {"image_description": vision_result}
58
+ if isinstance(vision_result, str)
59
+ else {"error": "vision returned non-string"}
60
+ )
61
+
62
+ # Encode image directly to JPEG bytes without writing to file
63
+ success, buffer = cv2.imencode('.jpg', frame)
64
+ if not success:
65
+ raise RuntimeError("Failed to encode frame as JPEG")
66
+
67
+ b64_encoded = base64.b64encode(buffer.tobytes()).decode("utf-8")
68
+ return {"b64_im": b64_encoded}
src/hello_world/tools/core_tools.py ADDED
@@ -0,0 +1,330 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ import re
3
+ import abc
4
+ import sys
5
+ import json
6
+ import asyncio
7
+ import inspect
8
+ import logging
9
+ import importlib
10
+ import importlib.util
11
+ from typing import TYPE_CHECKING, Any, Dict, List
12
+ from pathlib import Path
13
+ from dataclasses import dataclass
14
+
15
+ from reachy_mini import ReachyMini
16
+ from hello_world.config import DEFAULT_PROFILES_DIRECTORY as DEFAULT_PROFILES_PATH # noqa: F401
17
+
18
+ # Import config to ensure .env is loaded before reading REACHY_MINI_CUSTOM_PROFILE
19
+ from hello_world.config import config # noqa: F401
20
+ from hello_world.tools.tool_constants import SystemTool
21
+
22
+
23
+ if TYPE_CHECKING:
24
+ from hello_world.tools.background_tool_manager import BackgroundToolManager
25
+
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ DEFAULT_PROFILES_MODULE = "hello_world.profiles"
31
+
32
+
33
+ if not logger.handlers:
34
+ handler = logging.StreamHandler()
35
+ formatter = logging.Formatter("%(asctime)s %(levelname)s %(name)s:%(lineno)d | %(message)s")
36
+ handler.setFormatter(formatter)
37
+ logger.addHandler(handler)
38
+ logger.setLevel(logging.INFO)
39
+
40
+
41
+ ALL_TOOLS: Dict[str, "Tool"] = {}
42
+ ALL_TOOL_SPECS: List[Dict[str, Any]] = []
43
+ _TOOLS_INITIALIZED = False
44
+
45
+
46
+
47
+ def get_concrete_subclasses(base: type[Tool]) -> List[type[Tool]]:
48
+ """Recursively find all concrete (non-abstract) subclasses of a base class."""
49
+ result: List[type[Tool]] = []
50
+ for cls in base.__subclasses__():
51
+ if not inspect.isabstract(cls):
52
+ result.append(cls)
53
+ # recurse into subclasses
54
+ result.extend(get_concrete_subclasses(cls))
55
+ return result
56
+
57
+
58
+ @dataclass
59
+ class ToolDependencies:
60
+ """External dependencies injected into tools."""
61
+
62
+ reachy_mini: ReachyMini
63
+ movement_manager: Any # MovementManager from moves.py
64
+ # Optional deps
65
+ camera_worker: Any | None = None # CameraWorker for frame buffering
66
+ vision_manager: Any | None = None
67
+ head_wobbler: Any | None = None # HeadWobbler for audio-reactive motion
68
+ motion_duration_s: float = 1.0
69
+
70
+
71
+ # Tool base class
72
+ class Tool(abc.ABC):
73
+ """Base abstraction for tools used in function-calling.
74
+
75
+ Each tool must define:
76
+ - name: str
77
+ - description: str
78
+ - parameters_schema: Dict[str, Any] # JSON Schema
79
+ """
80
+
81
+ name: str
82
+ description: str
83
+ parameters_schema: Dict[str, Any]
84
+
85
+ def spec(self) -> Dict[str, Any]:
86
+ """Return the function spec for LLM consumption."""
87
+ return {
88
+ "type": "function",
89
+ "name": self.name,
90
+ "description": self.description,
91
+ "parameters": self.parameters_schema,
92
+ }
93
+
94
+ @abc.abstractmethod
95
+ async def __call__(self, deps: ToolDependencies, **kwargs: Any) -> Dict[str, Any]:
96
+ """Async tool execution entrypoint."""
97
+ raise NotImplementedError
98
+
99
+
100
+ def _load_module_from_file(module_name: str, file_path: Path) -> None:
101
+ """Load a Python module from a file path."""
102
+ spec = importlib.util.spec_from_file_location(module_name, file_path)
103
+ if not (spec and spec.loader):
104
+ raise ModuleNotFoundError(f"Cannot create spec for {file_path}")
105
+ module = importlib.util.module_from_spec(spec)
106
+ sys.modules[module_name] = module
107
+ spec.loader.exec_module(module)
108
+
109
+
110
+ def _try_load_tool(
111
+ tool_name: str,
112
+ module_path: str,
113
+ fallback_directory: Path | None,
114
+ file_subpath: str,
115
+ ) -> str:
116
+ """Try to load a tool: first via importlib, then from file if fallback is configured."""
117
+ try:
118
+ importlib.import_module(module_path)
119
+ return "module"
120
+ except ModuleNotFoundError:
121
+ if fallback_directory is None:
122
+ raise
123
+ tool_file = fallback_directory / file_subpath
124
+ if not tool_file.exists():
125
+ raise FileNotFoundError(f"tool file not found at {tool_file}")
126
+ _load_module_from_file(tool_name, tool_file)
127
+ return "file"
128
+
129
+
130
+ def _format_error(error: Exception) -> str:
131
+ """Format an exception for logging."""
132
+ if isinstance(error, FileNotFoundError):
133
+ return f"Tool file not found: {error}"
134
+ if isinstance(error, ModuleNotFoundError):
135
+ return f"Missing dependency: {error}"
136
+ if isinstance(error, ImportError):
137
+ return f"Import error: {error}"
138
+ return f"{type(error).__name__}: {error}"
139
+
140
+
141
+ # Registry & specs (dynamic)
142
+ def _load_profile_tools() -> None:
143
+ """Load tools based on profile's tools.txt file."""
144
+ # Determine which profile to use
145
+ profile = config.REACHY_MINI_CUSTOM_PROFILE or "default"
146
+ logger.info(f"Loading tools for profile: {profile}")
147
+
148
+ # Build path to tools.txt
149
+ # Get the profile directory path
150
+ profile_module_path = config.PROFILES_DIRECTORY / profile
151
+ tools_txt_path = profile_module_path / "tools.txt"
152
+ default_tools_txt_path = Path(__file__).parent.parent / "profiles" / "default" / "tools.txt"
153
+
154
+ if config.PROFILES_DIRECTORY != DEFAULT_PROFILES_PATH:
155
+ logger.info(
156
+ "Loading external profile '%s' from %s",
157
+ profile,
158
+ profile_module_path,
159
+ )
160
+
161
+ if not tools_txt_path.exists():
162
+ if profile != "default" and default_tools_txt_path.exists():
163
+ logger.warning(
164
+ "tools.txt not found for profile '%s' at %s. Falling back to default profile tools at %s",
165
+ profile,
166
+ tools_txt_path,
167
+ default_tools_txt_path,
168
+ )
169
+ tools_txt_path = default_tools_txt_path
170
+ else:
171
+ logger.error(f"✗ tools.txt not found at {tools_txt_path}")
172
+ sys.exit(1)
173
+
174
+ # Read and parse tools.txt
175
+ try:
176
+ with open(tools_txt_path, "r") as f:
177
+ lines = f.readlines()
178
+ except Exception as e:
179
+ logger.error(f"✗ Failed to read tools.txt: {e}")
180
+ sys.exit(1)
181
+
182
+ # Parse tool names (skip comments and blank lines)
183
+ tool_names = []
184
+ for line in lines:
185
+ line = line.strip()
186
+ # Skip blank lines and comments
187
+ if not line or line.startswith("#"):
188
+ continue
189
+ tool_names.append(line)
190
+
191
+ # Add system tools
192
+ tool_names.extend({tool.value for tool in SystemTool})
193
+
194
+ logger.info(f"Found {len(tool_names)} tools to load: {tool_names}")
195
+
196
+ if config.AUTOLOAD_EXTERNAL_TOOLS and config.TOOLS_DIRECTORY and config.TOOLS_DIRECTORY.is_dir():
197
+ discovered_external_tools: List[str] = []
198
+ for tool_file in sorted(config.TOOLS_DIRECTORY.glob("*.py")):
199
+ if tool_file.name.startswith("_"):
200
+ continue
201
+ candidate_name = tool_file.stem
202
+ if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", candidate_name):
203
+ logger.warning("Skipping external tool with invalid name: %s", tool_file.name)
204
+ continue
205
+ discovered_external_tools.append(candidate_name)
206
+
207
+ extra_tools = [name for name in discovered_external_tools if name not in tool_names]
208
+ if extra_tools:
209
+ tool_names.extend(extra_tools)
210
+ logger.info(
211
+ "AUTOLOAD_EXTERNAL_TOOLS enabled: added %d external tool(s): %s",
212
+ len(extra_tools),
213
+ extra_tools,
214
+ )
215
+
216
+ for tool_name in tool_names:
217
+ loaded = False
218
+ profile_error = None
219
+ profile_import_path = f"{DEFAULT_PROFILES_MODULE}.{profile}.{tool_name}"
220
+
221
+ # Try profile tool first
222
+ try:
223
+ source = _try_load_tool(
224
+ tool_name,
225
+ module_path=profile_import_path,
226
+ fallback_directory=config.PROFILES_DIRECTORY,
227
+ file_subpath=f"{profile}/{tool_name}.py",
228
+ )
229
+ if source == "file":
230
+ logger.info("✓ Loaded external profile tool: %s", tool_name)
231
+ else:
232
+ logger.info("✓ Loaded core profile tool: %s", tool_name)
233
+ loaded = True
234
+ except (ModuleNotFoundError, FileNotFoundError) as e:
235
+ if tool_name not in str(e):
236
+ profile_error = _format_error(e)
237
+ logger.error(f"❌ Failed to load profile tool '{tool_name}': {profile_error}")
238
+ logger.error(f" Module path: {profile_import_path}")
239
+ except Exception as e:
240
+ profile_error = _format_error(e)
241
+ logger.error(f"❌ Failed to load profile tool '{tool_name}': {profile_error}")
242
+ logger.error(f" Module path: {profile_import_path}")
243
+
244
+ # Try tools directory if not found in profile
245
+ if not loaded:
246
+ shared_module_path = f"hello_world.tools.{tool_name}"
247
+ try:
248
+ source = _try_load_tool(
249
+ tool_name,
250
+ module_path=shared_module_path,
251
+ fallback_directory=config.TOOLS_DIRECTORY,
252
+ file_subpath=f"{tool_name}.py",
253
+ )
254
+ if source == "file":
255
+ logger.info("✓ Loaded external tool: %s", tool_name)
256
+ else:
257
+ logger.info("✓ Loaded core tool: %s", tool_name)
258
+ except (ModuleNotFoundError, FileNotFoundError):
259
+ if profile_error:
260
+ logger.error(f"❌ Tool '{tool_name}' also not found in shared tools")
261
+ else:
262
+ logger.warning(f"⚠️ Tool '{tool_name}' not found in profile or shared tools")
263
+ except Exception as e:
264
+ logger.error(f"❌ Failed to load shared tool '{tool_name}': {_format_error(e)}")
265
+ logger.error(f" Module path: {shared_module_path}")
266
+
267
+
268
+
269
+ def _initialize_tools() -> None:
270
+ """Populate registry once, even if module is imported repeatedly."""
271
+ global ALL_TOOLS, ALL_TOOL_SPECS, _TOOLS_INITIALIZED
272
+
273
+ if _TOOLS_INITIALIZED:
274
+ logger.debug("Tools already initialized; skipping reinitialization.")
275
+ return
276
+
277
+ _load_profile_tools()
278
+
279
+ ALL_TOOLS = {cls.name: cls() for cls in get_concrete_subclasses(Tool)} # type: ignore[type-abstract]
280
+ ALL_TOOL_SPECS = [tool.spec() for tool in ALL_TOOLS.values()]
281
+
282
+ for tool_name, tool in ALL_TOOLS.items():
283
+ logger.info(f"tool registered: {tool_name} - {tool.description}")
284
+
285
+ _TOOLS_INITIALIZED = True
286
+
287
+
288
+ _initialize_tools()
289
+
290
+
291
+ def get_tool_specs(exclusion_list: list[str] = []) -> list[Dict[str, Any]]:
292
+ """Get tool specs, optionally excluding some tools."""
293
+ return [spec for spec in ALL_TOOL_SPECS if spec.get("name") not in exclusion_list]
294
+
295
+
296
+ # Dispatcher
297
+ def _safe_load_obj(args_json: str) -> Dict[str, Any]:
298
+ try:
299
+ parsed_args = json.loads(args_json or "{}")
300
+ return parsed_args if isinstance(parsed_args, dict) else {}
301
+ except Exception:
302
+ logger.warning("bad args_json=%r", args_json)
303
+ return {}
304
+
305
+
306
+ async def _dispatch_tool_call(tool_name: str, args: Dict[str, Any], deps: ToolDependencies) -> Dict[str, Any]:
307
+ tool = ALL_TOOLS.get(tool_name)
308
+ if not tool:
309
+ return {"error": f"unknown tool: {tool_name}"}
310
+ try:
311
+ return await tool(deps, **args)
312
+ except asyncio.CancelledError:
313
+ logger.info("Tool cancelled: %s", tool_name)
314
+ return {"error": "Tool cancelled"}
315
+ except Exception as e:
316
+ msg = f"{type(e).__name__}: {e}"
317
+ logger.exception("Tool error in %s: %s", tool_name, msg)
318
+ return {"error": msg}
319
+
320
+
321
+ async def dispatch_tool_call(tool_name: str, args_json: str, deps: ToolDependencies) -> Dict[str, Any]:
322
+ """Dispatch a tool call by name with JSON args and dependencies."""
323
+ return await _dispatch_tool_call(tool_name, _safe_load_obj(args_json), deps)
324
+
325
+
326
+ async def dispatch_tool_call_with_manager(tool_name: str, args_json: str, deps: ToolDependencies, tool_manager: "BackgroundToolManager") -> Dict[str, Any]:
327
+ """Dispatch a tool call, injecting a BackgroundToolManager into the args."""
328
+ args = _safe_load_obj(args_json)
329
+ args["tool_manager"] = tool_manager
330
+ return await _dispatch_tool_call(tool_name, args, deps)
src/hello_world/tools/dance.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from typing import Any, Dict
3
+
4
+ from hello_world.tools.core_tools import Tool, ToolDependencies
5
+
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+ # Initialize dance library
10
+ try:
11
+ from reachy_mini_dances_library.collection.dance import AVAILABLE_MOVES
12
+ from hello_world.dance_emotion_moves import DanceQueueMove
13
+
14
+ DANCE_AVAILABLE = True
15
+ except ImportError as e:
16
+ logger.warning(f"Dance library not available: {e}")
17
+ AVAILABLE_MOVES = {}
18
+ DANCE_AVAILABLE = False
19
+
20
+
21
+ class Dance(Tool):
22
+ """Play a named or random dance move once (or repeat). Non-blocking."""
23
+
24
+ name = "dance"
25
+ description = "Play a named or random dance move once (or repeat). Non-blocking."
26
+ parameters_schema = {
27
+ "type": "object",
28
+ "properties": {
29
+ "move": {
30
+ "type": "string",
31
+ "description": """Name of the move; use 'random' or omit for random.
32
+ Here is a list of the available moves:
33
+ simple_nod: A simple, continuous up-and-down nodding motion.
34
+ head_tilt_roll: A continuous side-to-side head roll (ear to shoulder).
35
+ side_to_side_sway: A smooth, side-to-side sway of the entire head.
36
+ dizzy_spin: A circular 'dizzy' head motion combining roll and pitch.
37
+ stumble_and_recover: A simulated stumble and recovery with multiple axis movements. Good vibes
38
+ interwoven_spirals: A complex spiral motion using three axes at different frequencies.
39
+ sharp_side_tilt: A sharp, quick side-to-side tilt using a triangle waveform.
40
+ side_peekaboo: A multi-stage peekaboo performance, hiding and peeking to each side.
41
+ yeah_nod: An emphatic two-part yeah nod using transient motions.
42
+ uh_huh_tilt: A combined roll-and-pitch uh-huh gesture of agreement.
43
+ neck_recoil: A quick, transient backward recoil of the neck.
44
+ chin_lead: A forward motion led by the chin, combining translation and pitch.
45
+ groovy_sway_and_roll: A side-to-side sway combined with a corresponding roll for a groovy effect.
46
+ chicken_peck: A sharp, forward, chicken-like pecking motion.
47
+ side_glance_flick: A quick glance to the side that holds, then returns.
48
+ polyrhythm_combo: A 3-beat sway and a 2-beat nod create a polyrhythmic feel.
49
+ grid_snap: A robotic, grid-snapping motion using square waveforms.
50
+ pendulum_swing: A simple, smooth pendulum-like swing using a roll motion.
51
+ jackson_square: Traces a rectangle via a 5-point path, with sharp twitches on arrival at each checkpoint.
52
+ """,
53
+ },
54
+ "repeat": {
55
+ "type": "integer",
56
+ "description": "How many times to repeat the move (default 1).",
57
+ },
58
+ },
59
+ "required": [],
60
+ }
61
+
62
+ async def __call__(self, deps: ToolDependencies, **kwargs: Any) -> Dict[str, Any]:
63
+ """Play a named or random dance move once (or repeat). Non-blocking."""
64
+ if not DANCE_AVAILABLE:
65
+ return {"error": "Dance system not available"}
66
+
67
+ move_name = kwargs.get("move")
68
+ repeat = int(kwargs.get("repeat", 1))
69
+
70
+ logger.info("Tool call: dance move=%s repeat=%d", move_name, repeat)
71
+
72
+ if not move_name or move_name == "random":
73
+ import random
74
+
75
+ move_name = random.choice(list(AVAILABLE_MOVES.keys()))
76
+
77
+ if move_name not in AVAILABLE_MOVES:
78
+ return {"error": f"Unknown dance move '{move_name}'. Available: {list(AVAILABLE_MOVES.keys())}"}
79
+
80
+ # Add dance moves to queue
81
+ movement_manager = deps.movement_manager
82
+ for _ in range(repeat):
83
+ dance_move = DanceQueueMove(move_name)
84
+ movement_manager.queue_move(dance_move)
85
+
86
+ return {"status": "queued", "move": move_name, "repeat": repeat}
src/hello_world/tools/do_nothing.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from typing import Any, Dict
3
+
4
+ from hello_world.tools.core_tools import Tool, ToolDependencies
5
+
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ class DoNothing(Tool):
11
+ """Choose to do nothing - stay still and silent. Use when you want to be contemplative or just chill."""
12
+
13
+ name = "do_nothing"
14
+ description = "Choose to do nothing - stay still and silent. Use when you want to be contemplative or just chill."
15
+ parameters_schema = {
16
+ "type": "object",
17
+ "properties": {
18
+ "reason": {
19
+ "type": "string",
20
+ "description": "Optional reason for doing nothing (e.g., 'contemplating existence', 'saving energy', 'being mysterious')",
21
+ },
22
+ },
23
+ "required": [],
24
+ }
25
+
26
+ async def __call__(self, deps: ToolDependencies, **kwargs: Any) -> Dict[str, Any]:
27
+ """Do nothing - stay still and silent."""
28
+ reason = kwargs.get("reason", "just chilling")
29
+ logger.info("Tool call: do_nothing reason=%s", reason)
30
+ return {"status": "doing nothing", "reason": reason}