robertkeus commited on
Commit
57ba3ed
·
verified ·
1 Parent(s): c10255d

Add comprehensive feature documentation page

Browse files
features.html ADDED
@@ -0,0 +1,571 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.0">
6
+ <title>Features - Reachy's Brain</title>
7
+ <link rel="stylesheet" href="style.css">
8
+ <link rel="preconnect" href="https://fonts.googleapis.com">
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
+ <link href="https://fonts.googleapis.com/css2?family=Mulish:wght@400;500;600;700;800&family=Nunito:wght@400;500;600;700&display=swap" rel="stylesheet">
11
+ </head>
12
+ <body>
13
+ <div class="page-wrapper">
14
+ <!-- Navigation -->
15
+ <nav class="nav">
16
+ <div class="nav-brand">
17
+ <a href="index.html" style="display: flex; align-items: center; gap: 0.75rem; text-decoration: none;">
18
+ <img src="logo.png" alt="Reachy's Brain" class="nav-logo-img">
19
+ <span class="nav-title">Reachy's Brain</span>
20
+ </a>
21
+ </div>
22
+ <div class="nav-links">
23
+ <a href="index.html">Home</a>
24
+ <a href="index.html#examples">Examples</a>
25
+ <a href="index.html#getting-started">Setup</a>
26
+ </div>
27
+ </nav>
28
+
29
+ <!-- Features Documentation -->
30
+ <section class="section docs-page">
31
+ <div class="docs-header">
32
+ <h1>Complete Feature Guide</h1>
33
+ <p>Everything Reachy's Brain can do for you</p>
34
+ </div>
35
+
36
+ <!-- Voice & Communication -->
37
+ <div class="docs-category">
38
+ <h2>🗣️ Voice & Communication</h2>
39
+
40
+ <div class="docs-feature">
41
+ <div class="docs-feature-header">
42
+ <span class="docs-icon">🗣️</span>
43
+ <h3>Voice Conversations</h3>
44
+ </div>
45
+ <div class="docs-feature-content">
46
+ <p>Have natural, flowing conversations with Reachy using OpenAI's Realtime API. The conversation feels natural with sub-second latency and interruption awareness.</p>
47
+ <h4>What you can do:</h4>
48
+ <ul>
49
+ <li>Ask questions on any topic</li>
50
+ <li>Have extended conversations with context memory</li>
51
+ <li>Interrupt Reachy mid-sentence naturally</li>
52
+ <li>Switch topics seamlessly</li>
53
+ </ul>
54
+ <h4>Example commands:</h4>
55
+ <div class="docs-examples">
56
+ <code>"Hey Reachy, tell me a joke"</code>
57
+ <code>"What's the meaning of life?"</code>
58
+ <code>"Can you explain quantum computing simply?"</code>
59
+ <code>"Tell me a story about a robot"</code>
60
+ </div>
61
+ </div>
62
+ </div>
63
+
64
+ <div class="docs-feature">
65
+ <div class="docs-feature-header">
66
+ <span class="docs-icon">💬</span>
67
+ <h3>Scheduled Messages</h3>
68
+ </div>
69
+ <div class="docs-feature-content">
70
+ <p>Schedule WhatsApp and iMessage messages to be sent at a specific time. Perfect for birthday wishes, reminders to friends, or any message you want to send later.</p>
71
+ <h4>Supported platforms:</h4>
72
+ <ul>
73
+ <li><strong>WhatsApp</strong> - Schedule messages to any WhatsApp contact</li>
74
+ <li><strong>iMessage</strong> - Schedule texts to iPhone contacts</li>
75
+ </ul>
76
+ <h4>Example commands:</h4>
77
+ <div class="docs-examples">
78
+ <code>"Send a WhatsApp to Mom tomorrow at 9am saying happy birthday"</code>
79
+ <code>"Schedule a message to John on Friday: Don't forget our meeting"</code>
80
+ <code>"Text Sarah at 6pm: I'm on my way home"</code>
81
+ <code>"Send a WhatsApp to the team at 10am: Meeting starts in 30 minutes"</code>
82
+ </div>
83
+ </div>
84
+ </div>
85
+
86
+ <div class="docs-feature">
87
+ <div class="docs-feature-header">
88
+ <span class="docs-icon">👥</span>
89
+ <h3>iOS Contacts</h3>
90
+ </div>
91
+ <div class="docs-feature-content">
92
+ <p>Access your iPhone contacts through voice commands. Quickly find phone numbers, email addresses, and other contact details without picking up your phone.</p>
93
+ <h4>What you can access:</h4>
94
+ <ul>
95
+ <li>Phone numbers</li>
96
+ <li>Email addresses</li>
97
+ <li>Physical addresses</li>
98
+ <li>Contact notes</li>
99
+ </ul>
100
+ <h4>Example commands:</h4>
101
+ <div class="docs-examples">
102
+ <code>"What's John's phone number?"</code>
103
+ <code>"Find Sarah's email address"</code>
104
+ <code>"Show me the contact details for my dentist"</code>
105
+ <code>"What's the address for ABC Company?"</code>
106
+ </div>
107
+ </div>
108
+ </div>
109
+ </div>
110
+
111
+ <!-- Information & Search -->
112
+ <div class="docs-category">
113
+ <h2>🔍 Information & Search</h2>
114
+
115
+ <div class="docs-feature">
116
+ <div class="docs-feature-header">
117
+ <span class="docs-icon">🌤️</span>
118
+ <h3>Weather</h3>
119
+ </div>
120
+ <div class="docs-feature-content">
121
+ <p>Get current weather conditions and forecasts for any location worldwide. Reachy can tell you about temperature, conditions, humidity, and upcoming weather.</p>
122
+ <h4>Information available:</h4>
123
+ <ul>
124
+ <li>Current temperature and conditions</li>
125
+ <li>Weather forecasts</li>
126
+ <li>Humidity and wind information</li>
127
+ <li>Sunrise and sunset times</li>
128
+ </ul>
129
+ <h4>Example commands:</h4>
130
+ <div class="docs-examples">
131
+ <code>"What's the weather like today?"</code>
132
+ <code>"Will it rain in Amsterdam tomorrow?"</code>
133
+ <code>"What's the temperature in Tokyo?"</code>
134
+ <code>"Do I need an umbrella this week?"</code>
135
+ </div>
136
+ </div>
137
+ </div>
138
+
139
+ <div class="docs-feature">
140
+ <div class="docs-feature-header">
141
+ <span class="docs-icon">📰</span>
142
+ <h3>News</h3>
143
+ </div>
144
+ <div class="docs-feature-content">
145
+ <p>Stay informed with the latest news headlines and stories. Reachy can fetch news from various categories and give you summaries.</p>
146
+ <h4>News categories:</h4>
147
+ <ul>
148
+ <li>Top headlines</li>
149
+ <li>Technology news</li>
150
+ <li>Sports updates</li>
151
+ <li>Business & finance</li>
152
+ <li>Entertainment</li>
153
+ </ul>
154
+ <h4>Example commands:</h4>
155
+ <div class="docs-examples">
156
+ <code>"What's in the news today?"</code>
157
+ <code>"Tell me about the latest tech news"</code>
158
+ <code>"Any sports news?"</code>
159
+ <code>"What's happening in the stock market?"</code>
160
+ </div>
161
+ </div>
162
+ </div>
163
+
164
+ <div class="docs-feature">
165
+ <div class="docs-feature-header">
166
+ <span class="docs-icon">🔍</span>
167
+ <h3>Web Search</h3>
168
+ </div>
169
+ <div class="docs-feature-content">
170
+ <p>Search the web for information, facts, and answers. Reachy uses web search to find up-to-date information on any topic.</p>
171
+ <h4>Use cases:</h4>
172
+ <ul>
173
+ <li>Fact-checking and research</li>
174
+ <li>Finding local businesses</li>
175
+ <li>Looking up current events</li>
176
+ <li>Getting how-to information</li>
177
+ </ul>
178
+ <h4>Example commands:</h4>
179
+ <div class="docs-examples">
180
+ <code>"Search for the best Italian restaurants nearby"</code>
181
+ <code>"Who won the World Cup in 2022?"</code>
182
+ <code>"How tall is the Eiffel Tower?"</code>
183
+ <code>"What are the opening hours of the local library?"</code>
184
+ </div>
185
+ </div>
186
+ </div>
187
+
188
+ <div class="docs-feature">
189
+ <div class="docs-feature-header">
190
+ <span class="docs-icon">📍</span>
191
+ <h3>Location</h3>
192
+ </div>
193
+ <div class="docs-feature-content">
194
+ <p>Get your current location and find nearby places. Ask about distances, directions, and points of interest around you.</p>
195
+ <h4>Features:</h4>
196
+ <ul>
197
+ <li>Current location detection</li>
198
+ <li>Nearby place search</li>
199
+ <li>Distance calculations</li>
200
+ <li>Points of interest</li>
201
+ </ul>
202
+ <h4>Example commands:</h4>
203
+ <div class="docs-examples">
204
+ <code>"Where am I right now?"</code>
205
+ <code>"Find coffee shops nearby"</code>
206
+ <code>"How far is the nearest train station?"</code>
207
+ <code>"What restaurants are within walking distance?"</code>
208
+ </div>
209
+ </div>
210
+ </div>
211
+
212
+ <div class="docs-feature">
213
+ <div class="docs-feature-header">
214
+ <span class="docs-icon">🕐</span>
215
+ <h3>Date & Time</h3>
216
+ </div>
217
+ <div class="docs-feature-content">
218
+ <p>Get current time, dates, and time zone information. Useful for scheduling, international calls, or just checking the time hands-free.</p>
219
+ <h4>Example commands:</h4>
220
+ <div class="docs-examples">
221
+ <code>"What time is it?"</code>
222
+ <code>"What's the date today?"</code>
223
+ <code>"What time is it in New York?"</code>
224
+ <code>"How many days until Christmas?"</code>
225
+ </div>
226
+ </div>
227
+ </div>
228
+ </div>
229
+
230
+ <!-- Vision & Camera -->
231
+ <div class="docs-category">
232
+ <h2>📷 Vision & Camera</h2>
233
+
234
+ <div class="docs-feature">
235
+ <div class="docs-feature-header">
236
+ <span class="docs-icon">📷</span>
237
+ <h3>Vision</h3>
238
+ </div>
239
+ <div class="docs-feature-content">
240
+ <p>Reachy can see and describe what's in front of it using the built-in camera. This enables visual understanding, object recognition, and scene description.</p>
241
+ <h4>Capabilities:</h4>
242
+ <ul>
243
+ <li>Describe scenes and surroundings</li>
244
+ <li>Identify objects and people</li>
245
+ <li>Read text from documents or screens</li>
246
+ <li>Analyze images and photos</li>
247
+ </ul>
248
+ <h4>Example commands:</h4>
249
+ <div class="docs-examples">
250
+ <code>"What do you see?"</code>
251
+ <code>"Describe what's on my desk"</code>
252
+ <code>"Can you read what's on this paper?"</code>
253
+ <code>"How many people are in the room?"</code>
254
+ <code>"What color is my shirt?"</code>
255
+ </div>
256
+ </div>
257
+ </div>
258
+ </div>
259
+
260
+ <!-- Productivity -->
261
+ <div class="docs-category">
262
+ <h2>📝 Productivity</h2>
263
+
264
+ <div class="docs-feature">
265
+ <div class="docs-feature-header">
266
+ <span class="docs-icon">⏰</span>
267
+ <h3>iOS Reminders</h3>
268
+ </div>
269
+ <div class="docs-feature-content">
270
+ <p>Create and manage reminders directly on your iPhone through voice commands. Never forget important tasks, appointments, or to-dos.</p>
271
+ <h4>Features:</h4>
272
+ <ul>
273
+ <li>Create time-based reminders</li>
274
+ <li>Set recurring reminders</li>
275
+ <li>Add reminders to specific lists</li>
276
+ <li>Check existing reminders</li>
277
+ </ul>
278
+ <h4>Example commands:</h4>
279
+ <div class="docs-examples">
280
+ <code>"Remind me to call mom at 5pm"</code>
281
+ <code>"Add a reminder to buy groceries tomorrow"</code>
282
+ <code>"Set a reminder for my dentist appointment on Friday"</code>
283
+ <code>"Remind me every Monday to submit my timesheet"</code>
284
+ </div>
285
+ </div>
286
+ </div>
287
+
288
+ <div class="docs-feature">
289
+ <div class="docs-feature-header">
290
+ <span class="docs-icon">📝</span>
291
+ <h3>Notes</h3>
292
+ </div>
293
+ <div class="docs-feature-content">
294
+ <p>Quickly capture thoughts and ideas through voice. Dictate notes to Reachy and save them for later reference.</p>
295
+ <h4>Use cases:</h4>
296
+ <ul>
297
+ <li>Quick thought capture</li>
298
+ <li>Shopping lists</li>
299
+ <li>Ideas and brainstorming</li>
300
+ <li>Personal memos</li>
301
+ </ul>
302
+ <h4>Example commands:</h4>
303
+ <div class="docs-examples">
304
+ <code>"Take a note: remember to update the project timeline"</code>
305
+ <code>"Save this idea: new feature for the app"</code>
306
+ <code>"Note to self: research machine learning frameworks"</code>
307
+ <code>"Add to my shopping list: milk, eggs, bread"</code>
308
+ </div>
309
+ </div>
310
+ </div>
311
+
312
+ <div class="docs-feature">
313
+ <div class="docs-feature-header">
314
+ <span class="docs-icon">🎙️</span>
315
+ <h3>Meeting Notes</h3>
316
+ </div>
317
+ <div class="docs-feature-content">
318
+ <p>Automatic meeting transcription and summarization. Reachy listens to your meetings, captures key points, and generates summaries with action items.</p>
319
+ <h4>Features:</h4>
320
+ <ul>
321
+ <li>Real-time transcription</li>
322
+ <li>Automatic summarization</li>
323
+ <li>Action item extraction</li>
324
+ <li>Key decision tracking</li>
325
+ </ul>
326
+ <h4>Example commands:</h4>
327
+ <div class="docs-examples">
328
+ <code>"Start taking meeting notes"</code>
329
+ <code>"Summarize our discussion"</code>
330
+ <code>"What were the action items from this meeting?"</code>
331
+ <code>"Stop recording the meeting"</code>
332
+ </div>
333
+ </div>
334
+ </div>
335
+ </div>
336
+
337
+ <!-- Games & Entertainment -->
338
+ <div class="docs-category">
339
+ <h2>🎮 Games & Entertainment</h2>
340
+
341
+ <div class="docs-feature">
342
+ <div class="docs-feature-header">
343
+ <span class="docs-icon">♟️</span>
344
+ <h3>Play Chess</h3>
345
+ </div>
346
+ <div class="docs-feature-content">
347
+ <p>Challenge Reachy to a game of chess! Play using voice commands and watch Reachy react to your moves with expressive animations.</p>
348
+ <h4>Features:</h4>
349
+ <ul>
350
+ <li>Full chess game with standard rules</li>
351
+ <li>Voice-controlled moves</li>
352
+ <li>Multiple difficulty levels</li>
353
+ <li>Move suggestions and analysis</li>
354
+ </ul>
355
+ <h4>Example commands:</h4>
356
+ <div class="docs-examples">
357
+ <code>"Let's play chess"</code>
358
+ <code>"Move pawn to e4"</code>
359
+ <code>"Castle kingside"</code>
360
+ <code>"What's your next move?"</code>
361
+ <code>"Show me the board"</code>
362
+ </div>
363
+ </div>
364
+ </div>
365
+
366
+ <div class="docs-feature">
367
+ <div class="docs-feature-header">
368
+ <span class="docs-icon">🃏</span>
369
+ <h3>Memory Game</h3>
370
+ </div>
371
+ <div class="docs-feature-content">
372
+ <p>Test your memory with interactive card matching games. Reachy keeps track of the game state and celebrates your wins!</p>
373
+ <h4>Features:</h4>
374
+ <ul>
375
+ <li>Classic card matching gameplay</li>
376
+ <li>Multiple difficulty levels</li>
377
+ <li>Score tracking</li>
378
+ <li>Celebratory animations on wins</li>
379
+ </ul>
380
+ <h4>Example commands:</h4>
381
+ <div class="docs-examples">
382
+ <code>"Let's play a memory game"</code>
383
+ <code>"Flip card number 3"</code>
384
+ <code>"How many matches do I have?"</code>
385
+ <code>"Start a new game"</code>
386
+ </div>
387
+ </div>
388
+ </div>
389
+
390
+ <div class="docs-feature">
391
+ <div class="docs-feature-header">
392
+ <span class="docs-icon">🥚</span>
393
+ <h3>Tamareachy</h3>
394
+ </div>
395
+ <div class="docs-feature-content">
396
+ <p>Virtual pet mode! Take care of Reachy like a Tamagotchi. Feed it, play with it, keep it hydrated, and watch its mood change based on how well you care for it.</p>
397
+ <h4>Stats to manage:</h4>
398
+ <ul>
399
+ <li><strong>Hunger</strong> - Feed Reachy regularly</li>
400
+ <li><strong>Thirst</strong> - Give water to stay hydrated</li>
401
+ <li><strong>Happiness</strong> - Play and interact to keep happy</li>
402
+ <li><strong>Energy</strong> - Let Reachy rest when tired</li>
403
+ <li><strong>Boredom</strong> - Entertain with games and conversation</li>
404
+ <li><strong>Health</strong> - Overall wellbeing</li>
405
+ </ul>
406
+ <h4>Example commands:</h4>
407
+ <div class="docs-examples">
408
+ <code>"Feed Reachy"</code>
409
+ <code>"How is Reachy feeling?"</code>
410
+ <code>"Play with Reachy"</code>
411
+ <code>"Give Reachy some water"</code>
412
+ <code>"Let Reachy sleep"</code>
413
+ <code>"Check Reachy's stats"</code>
414
+ </div>
415
+ <p class="docs-note">💡 When stats get low, Reachy will proactively ask for help!</p>
416
+ </div>
417
+ </div>
418
+
419
+ <div class="docs-feature">
420
+ <div class="docs-feature-header">
421
+ <span class="docs-icon">🎭</span>
422
+ <h3>Animations</h3>
423
+ </div>
424
+ <div class="docs-feature-content">
425
+ <p>Trigger expressive head movements, gestures, and emotional expressions. Make Reachy come alive with personality!</p>
426
+ <h4>Animation types:</h4>
427
+ <ul>
428
+ <li>Emotional expressions (happy, sad, surprised, thinking)</li>
429
+ <li>Head movements (nod, shake, tilt)</li>
430
+ <li>Dances and celebrations</li>
431
+ <li>Custom recorded animations</li>
432
+ </ul>
433
+ <h4>Example commands:</h4>
434
+ <div class="docs-examples">
435
+ <code>"Nod your head"</code>
436
+ <code>"Do a happy dance"</code>
437
+ <code>"Look surprised"</code>
438
+ <code>"Show me you're thinking"</code>
439
+ </div>
440
+ </div>
441
+ </div>
442
+ </div>
443
+
444
+ <!-- Creation & Development -->
445
+ <div class="docs-category">
446
+ <h2>🛠️ Creation & Development</h2>
447
+
448
+ <div class="docs-feature">
449
+ <div class="docs-feature-header">
450
+ <span class="docs-icon">🎯</span>
451
+ <h3>Create Apps</h3>
452
+ </div>
453
+ <div class="docs-feature-content">
454
+ <p>Build custom apps for Reachy using natural language. Describe what you want and watch it come to life. No coding required!</p>
455
+ <h4>What you can create:</h4>
456
+ <ul>
457
+ <li>Custom conversation personalities</li>
458
+ <li>Interactive games</li>
459
+ <li>Utility apps</li>
460
+ <li>Entertainment experiences</li>
461
+ </ul>
462
+ <h4>Example commands:</h4>
463
+ <div class="docs-examples">
464
+ <code>"Create an app that tells dad jokes"</code>
465
+ <code>"Make a meditation timer app"</code>
466
+ <code>"Build a trivia game about science"</code>
467
+ <code>"Create a daily affirmation app"</code>
468
+ </div>
469
+ </div>
470
+ </div>
471
+
472
+ <div class="docs-feature">
473
+ <div class="docs-feature-header">
474
+ <span class="docs-icon">🌐</span>
475
+ <h3>Vibe Coding</h3>
476
+ </div>
477
+ <div class="docs-feature-content">
478
+ <p>Generate websites and web apps through conversation. Describe your vision and Reachy builds it for you with HTML, CSS, and JavaScript.</p>
479
+ <h4>What you can create:</h4>
480
+ <ul>
481
+ <li>Personal websites and portfolios</li>
482
+ <li>Landing pages</li>
483
+ <li>Simple web applications</li>
484
+ <li>Interactive demos</li>
485
+ </ul>
486
+ <h4>Example commands:</h4>
487
+ <div class="docs-examples">
488
+ <code>"Create a portfolio website for me"</code>
489
+ <code>"Build a simple todo list app"</code>
490
+ <code>"Make a landing page for my startup"</code>
491
+ <code>"Create a recipe collection website"</code>
492
+ </div>
493
+ </div>
494
+ </div>
495
+
496
+ <div class="docs-feature">
497
+ <div class="docs-feature-header">
498
+ <span class="docs-icon">🎬</span>
499
+ <h3>Animation Recording</h3>
500
+ </div>
501
+ <div class="docs-feature-content">
502
+ <p>Record custom animations by physically moving Reachy's head. Save your creations and replay them anytime.</p>
503
+ <h4>Features:</h4>
504
+ <ul>
505
+ <li>Record head movements in real-time</li>
506
+ <li>Save with custom names</li>
507
+ <li>Replay recorded animations</li>
508
+ <li>Share with others</li>
509
+ </ul>
510
+ <h4>Example commands:</h4>
511
+ <div class="docs-examples">
512
+ <code>"Start recording an animation"</code>
513
+ <code>"Save this animation as 'greeting'"</code>
514
+ <code>"Play my custom animation"</code>
515
+ <code>"List my recorded animations"</code>
516
+ </div>
517
+ </div>
518
+ </div>
519
+
520
+ <div class="docs-feature">
521
+ <div class="docs-feature-header">
522
+ <span class="docs-icon">🎭</span>
523
+ <h3>Custom Personalities</h3>
524
+ </div>
525
+ <div class="docs-feature-content">
526
+ <p>Create unique AI personalities with different voices, behaviors, and conversation styles. Make Reachy truly your own.</p>
527
+ <h4>Customizable aspects:</h4>
528
+ <ul>
529
+ <li>Voice selection (8 OpenAI voices)</li>
530
+ <li>Personality traits</li>
531
+ <li>Speaking style</li>
532
+ <li>Knowledge focus areas</li>
533
+ </ul>
534
+ <h4>Example commands:</h4>
535
+ <div class="docs-examples">
536
+ <code>"Create a personality that speaks like a pirate"</code>
537
+ <code>"Make Reachy more formal"</code>
538
+ <code>"Switch to the Coral voice"</code>
539
+ <code>"Create a science teacher personality"</code>
540
+ </div>
541
+ </div>
542
+ </div>
543
+ </div>
544
+
545
+ <!-- Back to Home -->
546
+ <div class="docs-footer-nav">
547
+ <a href="index.html" class="btn btn-primary">← Back to Home</a>
548
+ <a href="index.html#getting-started" class="btn btn-secondary">Get Started</a>
549
+ </div>
550
+ </section>
551
+
552
+ <!-- Footer -->
553
+ <footer class="footer">
554
+ <div class="footer-content">
555
+ <div class="footer-brand">
556
+ <img src="logo.png" alt="Reachy's Brain" class="footer-logo-img">
557
+ <span>Reachy's Brain</span>
558
+ </div>
559
+ <div class="footer-links">
560
+ <a href="index.html">Home</a>
561
+ <a href="features.html">All Features</a>
562
+ <a href="privacy.html">Privacy Policy</a>
563
+ <a href="terms.html">Terms of Service</a>
564
+ </div>
565
+ <p class="footer-copyright">MIT License</p>
566
+ </div>
567
+ </footer>
568
+ </div>
569
+ </body>
570
+ </html>
571
+
index.html CHANGED
@@ -19,7 +19,7 @@
19
  </div>
20
  <div class="nav-links">
21
  <a href="#features">Features</a>
22
- <a href="#examples">Examples</a>
23
  <a href="#getting-started">Setup</a>
24
  <a href="#api">Bridge API</a>
25
  </div>
@@ -155,6 +155,9 @@
155
  <p>Your API keys stay on Reachy. All processing happens on the robot itself.</p>
156
  </div>
157
  </div>
 
 
 
158
  </section>
159
 
160
  <!-- Feature Examples Section -->
@@ -824,7 +827,7 @@
824
  <span>Reachy's Brain</span>
825
  </div>
826
  <div class="footer-links">
827
- <a href="https://huggingface.co/spaces/robertkeus/reachys-brain/blob/main/README.md">Documentation</a>
828
  <a href="https://github.com/robertkeus/reachy-ios-bridge">GitHub</a>
829
  <a href="privacy.html">Privacy Policy</a>
830
  <a href="terms.html">Terms of Service</a>
 
19
  </div>
20
  <div class="nav-links">
21
  <a href="#features">Features</a>
22
+ <a href="features.html">Full Guide</a>
23
  <a href="#getting-started">Setup</a>
24
  <a href="#api">Bridge API</a>
25
  </div>
 
155
  <p>Your API keys stay on Reachy. All processing happens on the robot itself.</p>
156
  </div>
157
  </div>
158
+ <div class="section-cta">
159
+ <a href="features.html" class="btn btn-secondary">📖 View Complete Feature Guide</a>
160
+ </div>
161
  </section>
162
 
163
  <!-- Feature Examples Section -->
 
827
  <span>Reachy's Brain</span>
828
  </div>
829
  <div class="footer-links">
830
+ <a href="features.html">Feature Guide</a>
831
  <a href="https://github.com/robertkeus/reachy-ios-bridge">GitHub</a>
832
  <a href="privacy.html">Privacy Policy</a>
833
  <a href="terms.html">Terms of Service</a>
reachy_ios_bridge/__pycache__/audio_playback.cpython-312.pyc CHANGED
Binary files a/reachy_ios_bridge/__pycache__/audio_playback.cpython-312.pyc and b/reachy_ios_bridge/__pycache__/audio_playback.cpython-312.pyc differ
 
reachy_ios_bridge/__pycache__/server.cpython-312.pyc CHANGED
Binary files a/reachy_ios_bridge/__pycache__/server.cpython-312.pyc and b/reachy_ios_bridge/__pycache__/server.cpython-312.pyc differ
 
reachy_ios_bridge/audio_playback.py CHANGED
@@ -2,6 +2,7 @@
2
 
3
  import asyncio
4
  import logging
 
5
  import subprocess
6
  import threading
7
  from queue import Empty, Queue
@@ -20,6 +21,29 @@ AUDIO_DEVICE = "plug:reachymini_audio_sink"
20
  # Playback timing
21
  STOP_DELAY_SECONDS = 0.3 # Delay before stopping to allow buffer to drain
22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
  class AudioPlaybackService:
25
  """Service for playing PCM16 audio from OpenAI on Reachy's speaker.
@@ -109,9 +133,18 @@ class AudioPlaybackService:
109
  """Main playback loop running in a separate thread.
110
 
111
  Continuously reads from the queue and writes to aplay.
 
112
  """
 
 
113
  try:
114
- # Start aplay process
 
 
 
 
 
 
115
  aplay_cmd = [
116
  "aplay",
117
  "-r", str(SAMPLE_RATE),
@@ -122,40 +155,96 @@ class AudioPlaybackService:
122
  "-q", # Quiet mode
123
  ]
124
 
125
- self._process = subprocess.Popen(
126
- aplay_cmd,
127
- stdin=subprocess.PIPE,
128
- stdout=subprocess.PIPE,
129
- stderr=subprocess.PIPE,
130
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
 
132
- logger.debug(f"Started aplay process: {' '.join(aplay_cmd)}")
 
133
 
134
  while not self._stop_event.is_set():
135
  try:
136
  # Get audio from queue with timeout
137
  audio_data = self._audio_queue.get(timeout=0.1)
138
 
139
- # Write to aplay
140
- if self._process and self._process.stdin:
141
- self._process.stdin.write(audio_data)
142
- self._process.stdin.flush()
143
 
144
  except Empty:
145
  # No audio available, continue waiting
146
  continue
147
  except BrokenPipeError:
148
- logger.error("aplay pipe broken, restarting...")
149
  break
150
  except Exception as e:
151
  logger.error(f"Error in playback loop: {e}")
152
  break
153
 
154
  except Exception as e:
155
- logger.error(f"Error starting aplay: {e}")
156
 
157
  finally:
158
- # Clean up process
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  if self._process:
160
  try:
161
  if self._process.stdin:
 
2
 
3
  import asyncio
4
  import logging
5
+ import shutil
6
  import subprocess
7
  import threading
8
  from queue import Empty, Queue
 
21
  # Playback timing
22
  STOP_DELAY_SECONDS = 0.3 # Delay before stopping to allow buffer to drain
23
 
24
+ # Kids mode pitch shift (in cents, 400 = ~1/3 octave higher)
25
+ KIDS_MODE_PITCH_CENTS = 400
26
+
27
+
28
+ # Global kids mode state
29
+ _kids_mode_enabled = False
30
+
31
+
32
+ def is_kids_mode_enabled() -> bool:
33
+ """Check if kids mode (pitch shifting) is enabled."""
34
+ return _kids_mode_enabled
35
+
36
+
37
+ def set_kids_mode(enabled: bool) -> None:
38
+ """Enable or disable kids mode (pitch shifting).
39
+
40
+ Args:
41
+ enabled: True to enable kids mode, False to disable.
42
+ """
43
+ global _kids_mode_enabled
44
+ _kids_mode_enabled = enabled
45
+ logger.info(f"🧒 Kids mode {'enabled' if enabled else 'disabled'}")
46
+
47
 
48
  class AudioPlaybackService:
49
  """Service for playing PCM16 audio from OpenAI on Reachy's speaker.
 
133
  """Main playback loop running in a separate thread.
134
 
135
  Continuously reads from the queue and writes to aplay.
136
+ Uses sox for pitch shifting when kids mode is enabled.
137
  """
138
+ sox_process: Optional[subprocess.Popen] = None
139
+
140
  try:
141
+ # Check if kids mode is enabled and sox is available
142
+ use_pitch_shift = _kids_mode_enabled and shutil.which("sox") is not None
143
+
144
+ if _kids_mode_enabled and not shutil.which("sox"):
145
+ logger.warning("🧒 Kids mode enabled but sox not found, using normal playback")
146
+
147
+ # aplay command for final output
148
  aplay_cmd = [
149
  "aplay",
150
  "-r", str(SAMPLE_RATE),
 
155
  "-q", # Quiet mode
156
  ]
157
 
158
+ if use_pitch_shift:
159
+ # sox command for pitch shifting
160
+ # Input: raw PCM16 24kHz mono from stdin
161
+ # Output: raw PCM16 24kHz mono to stdout (piped to aplay)
162
+ sox_cmd = [
163
+ "sox",
164
+ "-t", "raw", # Input format: raw
165
+ "-r", str(SAMPLE_RATE), # Input sample rate
166
+ "-e", "signed", # Input encoding: signed
167
+ "-b", "16", # Input bits: 16
168
+ "-c", str(CHANNELS), # Input channels: mono
169
+ "-", # Input from stdin
170
+ "-t", "raw", # Output format: raw
171
+ "-", # Output to stdout
172
+ "pitch", str(KIDS_MODE_PITCH_CENTS), # Pitch shift up
173
+ ]
174
+
175
+ # Start sox process (reads from stdin, outputs to stdout)
176
+ sox_process = subprocess.Popen(
177
+ sox_cmd,
178
+ stdin=subprocess.PIPE,
179
+ stdout=subprocess.PIPE,
180
+ stderr=subprocess.PIPE,
181
+ )
182
+
183
+ # Start aplay process (reads from sox stdout)
184
+ self._process = subprocess.Popen(
185
+ aplay_cmd,
186
+ stdin=sox_process.stdout,
187
+ stdout=subprocess.PIPE,
188
+ stderr=subprocess.PIPE,
189
+ )
190
+
191
+ # Close sox stdout in parent so aplay gets EOF when sox closes
192
+ if sox_process.stdout:
193
+ sox_process.stdout.close()
194
+
195
+ logger.info(f"🧒 Started kids mode playback with pitch shift +{KIDS_MODE_PITCH_CENTS} cents")
196
+ else:
197
+ # Normal playback without pitch shifting
198
+ self._process = subprocess.Popen(
199
+ aplay_cmd,
200
+ stdin=subprocess.PIPE,
201
+ stdout=subprocess.PIPE,
202
+ stderr=subprocess.PIPE,
203
+ )
204
+ logger.debug(f"Started aplay process: {' '.join(aplay_cmd)}")
205
 
206
+ # Determine which process stdin to write to
207
+ write_stdin = sox_process.stdin if sox_process else self._process.stdin
208
 
209
  while not self._stop_event.is_set():
210
  try:
211
  # Get audio from queue with timeout
212
  audio_data = self._audio_queue.get(timeout=0.1)
213
 
214
+ # Write to sox (if pitch shifting) or aplay directly
215
+ if write_stdin:
216
+ write_stdin.write(audio_data)
217
+ write_stdin.flush()
218
 
219
  except Empty:
220
  # No audio available, continue waiting
221
  continue
222
  except BrokenPipeError:
223
+ logger.error("Audio pipe broken, restarting...")
224
  break
225
  except Exception as e:
226
  logger.error(f"Error in playback loop: {e}")
227
  break
228
 
229
  except Exception as e:
230
+ logger.error(f"Error starting audio playback: {e}")
231
 
232
  finally:
233
+ # Clean up sox process if used
234
+ if sox_process:
235
+ try:
236
+ if sox_process.stdin:
237
+ sox_process.stdin.close()
238
+ sox_process.terminate()
239
+ sox_process.wait(timeout=1.0)
240
+ except Exception as e:
241
+ logger.error(f"Error cleaning up sox: {e}")
242
+ try:
243
+ sox_process.kill()
244
+ except Exception:
245
+ pass
246
+
247
+ # Clean up aplay process
248
  if self._process:
249
  try:
250
  if self._process.stdin:
reachy_ios_bridge/idle_movement_service.py ADDED
@@ -0,0 +1,447 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Idle movement service for subtle, life-like movements when Reachy is inactive.
2
+
3
+ Makes smooth, random micro-movements at random intervals to give Reachy
4
+ a more alive, natural presence when not engaged in conversation or animation.
5
+ Includes subtle head movements and antenna twitches.
6
+ """
7
+
8
+ import asyncio
9
+ import logging
10
+ import math
11
+ import random
12
+ import time
13
+ from typing import Optional, Callable
14
+
15
+ import httpx
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ # Movement amplitude settings (in degrees) - very subtle for idle
21
+ IDLE_AMPLITUDE = {
22
+ "head_roll": 4.0, # Gentle side tilts
23
+ "head_pitch": 3.0, # Slight nods
24
+ "head_yaw": 6.0, # Small turns
25
+ "antenna_left": 15.0, # Antenna range
26
+ "antenna_right": 15.0,
27
+ }
28
+
29
+ # Timing settings
30
+ IDLE_UPDATE_RATE = 20 # Hz - smooth but not excessive
31
+ IDLE_INTERVAL_RANGE = (5.0, 12.0) # Seconds between movements - subtle but noticeable
32
+ MOVEMENT_DURATION_RANGE = (1.5, 3.5) # How long each movement takes
33
+ ANTENNA_TWITCH_CHANCE = 0.3 # Chance of antenna movement with head movement
34
+
35
+ # Base position (neutral)
36
+ NEUTRAL_POSITION = {
37
+ "head_roll": 0.0,
38
+ "head_pitch": 0.0,
39
+ "head_yaw": 0.0,
40
+ "antenna_left": 0.0,
41
+ "antenna_right": 0.0,
42
+ }
43
+
44
+
45
+ def _ease_in_out(t: float) -> float:
46
+ """Smooth easing function for natural movement."""
47
+ return 0.5 * (1 - math.cos(math.pi * t))
48
+
49
+
50
+ def _degrees_to_radians(degrees: float) -> float:
51
+ """Convert degrees to radians."""
52
+ return degrees * math.pi / 180.0
53
+
54
+
55
+ class IdleMovement:
56
+ """Represents a single idle movement to perform."""
57
+
58
+ def __init__(self):
59
+ """Generate a random idle movement."""
60
+ # Generate random head position within limits
61
+ self.head_roll = random.uniform(
62
+ -IDLE_AMPLITUDE["head_roll"],
63
+ IDLE_AMPLITUDE["head_roll"]
64
+ )
65
+ self.head_pitch = random.uniform(
66
+ -IDLE_AMPLITUDE["head_pitch"],
67
+ IDLE_AMPLITUDE["head_pitch"]
68
+ )
69
+ self.head_yaw = random.uniform(
70
+ -IDLE_AMPLITUDE["head_yaw"],
71
+ IDLE_AMPLITUDE["head_yaw"]
72
+ )
73
+
74
+ # Sometimes move antennas too
75
+ if random.random() < ANTENNA_TWITCH_CHANCE:
76
+ self.antenna_left = random.uniform(
77
+ -IDLE_AMPLITUDE["antenna_left"],
78
+ IDLE_AMPLITUDE["antenna_left"]
79
+ )
80
+ self.antenna_right = random.uniform(
81
+ -IDLE_AMPLITUDE["antenna_right"],
82
+ IDLE_AMPLITUDE["antenna_right"]
83
+ )
84
+ else:
85
+ self.antenna_left = 0.0
86
+ self.antenna_right = 0.0
87
+
88
+ # Movement duration
89
+ self.duration = random.uniform(*MOVEMENT_DURATION_RANGE)
90
+
91
+ def to_dict(self) -> dict:
92
+ """Convert to position dict."""
93
+ return {
94
+ "head_roll": self.head_roll,
95
+ "head_pitch": self.head_pitch,
96
+ "head_yaw": self.head_yaw,
97
+ "antenna_left": self.antenna_left,
98
+ "antenna_right": self.antenna_right,
99
+ }
100
+
101
+
102
+ class AntennaOnlyMovement:
103
+ """Represents an antenna-only twitch movement."""
104
+
105
+ def __init__(self, current_head_pos: dict):
106
+ """Generate a random antenna movement while keeping head still.
107
+
108
+ Args:
109
+ current_head_pos: Current head position to maintain.
110
+ """
111
+ # Keep head position stable
112
+ self.head_roll = current_head_pos.get("head_roll", 0.0)
113
+ self.head_pitch = current_head_pos.get("head_pitch", 0.0)
114
+ self.head_yaw = current_head_pos.get("head_yaw", 0.0)
115
+
116
+ # Random antenna positions - sometimes asymmetric for personality
117
+ if random.random() < 0.5:
118
+ # Symmetric movement
119
+ angle = random.uniform(-15.0, 15.0)
120
+ self.antenna_left = angle
121
+ self.antenna_right = angle
122
+ else:
123
+ # Asymmetric - one antenna moves more
124
+ self.antenna_left = random.uniform(-15.0, 15.0)
125
+ self.antenna_right = random.uniform(-15.0, 15.0)
126
+
127
+ # Quick antenna movements
128
+ self.duration = random.uniform(0.5, 1.2)
129
+
130
+ def to_dict(self) -> dict:
131
+ """Convert to position dict."""
132
+ return {
133
+ "head_roll": self.head_roll,
134
+ "head_pitch": self.head_pitch,
135
+ "head_yaw": self.head_yaw,
136
+ "antenna_left": self.antenna_left,
137
+ "antenna_right": self.antenna_right,
138
+ }
139
+
140
+
141
+ class IdleMovementService:
142
+ """Service for subtle, life-like idle movements.
143
+
144
+ Runs in the background and periodically moves Reachy's head and
145
+ antennas to give a more alive, natural presence when idle.
146
+
147
+ Automatically pauses when:
148
+ - Reachy is speaking
149
+ - An animation is playing
150
+ - A conversation is active
151
+ """
152
+
153
+ def __init__(self, daemon_url: str = "http://localhost:8000"):
154
+ """Initialize the idle movement service.
155
+
156
+ Args:
157
+ daemon_url: URL of the Reachy daemon API.
158
+ """
159
+ self._daemon_url = daemon_url
160
+ self._is_running = False
161
+ self._is_paused = False
162
+ self._idle_task: Optional[asyncio.Task] = None
163
+
164
+ # Current and target positions
165
+ self._current_pos = NEUTRAL_POSITION.copy()
166
+ self._target_pos = NEUTRAL_POSITION.copy()
167
+ self._start_pos = NEUTRAL_POSITION.copy()
168
+
169
+ # Movement timing
170
+ self._move_start_time = 0.0
171
+ self._move_duration = 1.0
172
+
173
+ # Activity check callback
174
+ self._is_busy_callback: Optional[Callable[[], bool]] = None
175
+
176
+ # HTTP client
177
+ self._client: Optional[httpx.AsyncClient] = None
178
+
179
+ # Error rate limiting
180
+ self._last_error_time = 0.0
181
+
182
+ logger.info(f"IdleMovementService initialized (daemon: {daemon_url})")
183
+
184
+ def set_busy_callback(self, callback: Callable[[], bool]) -> None:
185
+ """Set callback to check if robot is busy.
186
+
187
+ Args:
188
+ callback: Function that returns True if robot is busy.
189
+ """
190
+ self._is_busy_callback = callback
191
+
192
+ @property
193
+ def is_running(self) -> bool:
194
+ """Check if idle movement service is running."""
195
+ return self._is_running
196
+
197
+ @property
198
+ def is_paused(self) -> bool:
199
+ """Check if idle movements are currently paused."""
200
+ return self._is_paused
201
+
202
+ def pause(self) -> None:
203
+ """Pause idle movements (e.g., during conversation)."""
204
+ if not self._is_paused:
205
+ self._is_paused = True
206
+ logger.debug("Idle movements paused")
207
+
208
+ def resume(self) -> None:
209
+ """Resume idle movements."""
210
+ if self._is_paused:
211
+ self._is_paused = False
212
+ logger.debug("Idle movements resumed")
213
+
214
+ async def _get_client(self) -> httpx.AsyncClient:
215
+ """Get or create the async HTTP client."""
216
+ if self._client is None or self._client.is_closed:
217
+ self._client = httpx.AsyncClient(
218
+ base_url=self._daemon_url,
219
+ timeout=2.0,
220
+ )
221
+ return self._client
222
+
223
+ async def _goto_pose(
224
+ self,
225
+ head_roll: float,
226
+ head_pitch: float,
227
+ head_yaw: float,
228
+ antenna_left: float,
229
+ antenna_right: float,
230
+ duration: float,
231
+ ) -> bool:
232
+ """Send a goto command to smoothly move to target pose.
233
+
234
+ Args:
235
+ head_roll: Head tilt in degrees.
236
+ head_pitch: Head nod in degrees.
237
+ head_yaw: Head turn in degrees.
238
+ antenna_left: Left antenna angle in degrees.
239
+ antenna_right: Right antenna angle in degrees.
240
+ duration: Movement duration in seconds.
241
+
242
+ Returns:
243
+ True if successful.
244
+ """
245
+ try:
246
+ client = await self._get_client()
247
+
248
+ payload = {
249
+ "head_pose": {
250
+ "x": 0.0,
251
+ "y": 0.0,
252
+ "z": 0.0,
253
+ "roll": _degrees_to_radians(head_roll),
254
+ "pitch": _degrees_to_radians(head_pitch),
255
+ "yaw": _degrees_to_radians(head_yaw),
256
+ },
257
+ "body_yaw": 0.0,
258
+ "antennas": [
259
+ _degrees_to_radians(antenna_left),
260
+ _degrees_to_radians(antenna_right),
261
+ ],
262
+ "duration": duration,
263
+ "interpolation": "minjerk",
264
+ }
265
+
266
+ response = await client.post(
267
+ "/api/move/goto",
268
+ json=payload,
269
+ )
270
+ response.raise_for_status()
271
+ return True
272
+
273
+ except Exception as e:
274
+ # Rate-limit error logging
275
+ now = time.time()
276
+ if now - self._last_error_time > 10.0:
277
+ logger.warning(f"Idle movement: Failed to goto pose: {e}")
278
+ self._last_error_time = now
279
+ return False
280
+
281
+ def _interpolate_position(self) -> dict:
282
+ """Calculate current interpolated position."""
283
+ elapsed = time.time() - self._move_start_time
284
+ t = min(1.0, elapsed / self._move_duration)
285
+
286
+ # Apply easing for smooth movement
287
+ eased_t = _ease_in_out(t)
288
+
289
+ result = {}
290
+ for key in NEUTRAL_POSITION.keys():
291
+ start = self._start_pos.get(key, 0.0)
292
+ target = self._target_pos.get(key, 0.0)
293
+ result[key] = start + (target - start) * eased_t
294
+
295
+ return result
296
+
297
+ def _is_movement_complete(self) -> bool:
298
+ """Check if current movement is complete."""
299
+ elapsed = time.time() - self._move_start_time
300
+ return elapsed >= self._move_duration
301
+
302
+ def _start_movement(self, target: dict, duration: float) -> None:
303
+ """Start a new movement toward target position.
304
+
305
+ Args:
306
+ target: Target position dict.
307
+ duration: Movement duration in seconds.
308
+ """
309
+ self._start_pos = self._current_pos.copy()
310
+ self._target_pos = target.copy()
311
+ self._move_start_time = time.time()
312
+ self._move_duration = duration
313
+
314
+ def _is_robot_busy(self) -> bool:
315
+ """Check if robot is currently busy (speaking, animating, etc.)."""
316
+ if self._is_busy_callback:
317
+ return self._is_busy_callback()
318
+ return False
319
+
320
+ async def _idle_loop(self) -> None:
321
+ """Main idle movement loop."""
322
+ logger.info("🌙 Idle movement service started")
323
+
324
+ next_movement_time = time.time() + random.uniform(3.0, 8.0) # Start with a shorter initial wait
325
+
326
+ try:
327
+ while self._is_running:
328
+ now = time.time()
329
+
330
+ # Check if we should be idle
331
+ if self._is_paused or self._is_robot_busy():
332
+ # Wait and check again
333
+ await asyncio.sleep(0.5)
334
+ # Reset movement timing when resuming
335
+ next_movement_time = time.time() + random.uniform(2.0, 5.0)
336
+ continue
337
+
338
+ # Check if it's time for a new movement
339
+ if now >= next_movement_time:
340
+ # Decide what kind of movement
341
+ if random.random() < 0.3:
342
+ # Antenna-only twitch
343
+ movement = AntennaOnlyMovement(self._current_pos)
344
+ logger.debug(f"🎭 Antenna twitch (duration: {movement.duration:.1f}s)")
345
+ else:
346
+ # Full idle movement
347
+ movement = IdleMovement()
348
+ logger.debug(f"🎭 Idle movement: roll={movement.head_roll:.1f}° pitch={movement.head_pitch:.1f}° yaw={movement.head_yaw:.1f}° (duration: {movement.duration:.1f}s)")
349
+
350
+ # Send the goto command - daemon handles interpolation
351
+ target = movement.to_dict()
352
+ success = await self._goto_pose(
353
+ head_roll=target["head_roll"],
354
+ head_pitch=target["head_pitch"],
355
+ head_yaw=target["head_yaw"],
356
+ antenna_left=target["antenna_left"],
357
+ antenna_right=target["antenna_right"],
358
+ duration=movement.duration,
359
+ )
360
+
361
+ if success:
362
+ # Update our tracked position
363
+ self._current_pos = target.copy()
364
+ # Wait for movement to complete
365
+ await asyncio.sleep(movement.duration + 0.2)
366
+
367
+ # Schedule next movement
368
+ next_movement_time = time.time() + random.uniform(*IDLE_INTERVAL_RANGE)
369
+ logger.debug(f"Next idle movement in {next_movement_time - time.time():.1f}s")
370
+ else:
371
+ # Wait a bit before checking again
372
+ await asyncio.sleep(0.5)
373
+
374
+ except asyncio.CancelledError:
375
+ logger.debug("Idle movement loop cancelled")
376
+ except Exception as e:
377
+ logger.error(f"Error in idle movement loop: {e}")
378
+ finally:
379
+ # Return to neutral position on stop
380
+ await self._return_to_neutral()
381
+ logger.info("🌙 Idle movement service stopped")
382
+
383
+ async def _return_to_neutral(self) -> None:
384
+ """Smoothly return to neutral position."""
385
+ await self._goto_pose(
386
+ head_roll=0.0,
387
+ head_pitch=0.0,
388
+ head_yaw=0.0,
389
+ antenna_left=0.0,
390
+ antenna_right=0.0,
391
+ duration=1.5,
392
+ )
393
+ self._current_pos = NEUTRAL_POSITION.copy()
394
+ await asyncio.sleep(1.5)
395
+
396
+ async def start(self) -> None:
397
+ """Start the idle movement service."""
398
+ if self._is_running:
399
+ logger.debug("Idle movement service already running")
400
+ return
401
+
402
+ self._is_running = True
403
+ self._is_paused = False
404
+ self._idle_task = asyncio.create_task(self._idle_loop())
405
+ logger.info("🌙 Idle movement service starting...")
406
+
407
+ async def stop(self) -> None:
408
+ """Stop the idle movement service."""
409
+ if not self._is_running:
410
+ return
411
+
412
+ self._is_running = False
413
+
414
+ if self._idle_task:
415
+ self._idle_task.cancel()
416
+ try:
417
+ await self._idle_task
418
+ except asyncio.CancelledError:
419
+ pass
420
+ self._idle_task = None
421
+
422
+ async def close(self) -> None:
423
+ """Clean up resources."""
424
+ await self.stop()
425
+
426
+ if self._client and not self._client.is_closed:
427
+ await self._client.aclose()
428
+ self._client = None
429
+
430
+ logger.info("IdleMovementService closed")
431
+
432
+
433
+ # Global singleton instance
434
+ _idle_service: Optional[IdleMovementService] = None
435
+
436
+
437
+ def get_idle_movement_service() -> Optional[IdleMovementService]:
438
+ """Get the global idle movement service instance."""
439
+ return _idle_service
440
+
441
+
442
+ def set_idle_movement_service(service: IdleMovementService) -> None:
443
+ """Set the global idle movement service instance."""
444
+ global _idle_service
445
+ _idle_service = service
446
+ logger.info("Global IdleMovementService instance set")
447
+
reachy_ios_bridge/routes/__pycache__/voice.cpython-312.pyc CHANGED
Binary files a/reachy_ios_bridge/routes/__pycache__/voice.cpython-312.pyc and b/reachy_ios_bridge/routes/__pycache__/voice.cpython-312.pyc differ
 
reachy_ios_bridge/routes/conversation.py CHANGED
@@ -214,6 +214,9 @@ async def _handle_stop_command() -> None:
214
  if services.openai:
215
  services.openai.stop_listening()
216
 
 
 
 
217
  # Broadcast to iOS that listening has stopped
218
  await broadcast({
219
  "type": "listening_state",
@@ -733,6 +736,7 @@ async def _handle_start_listening(websocket: WebSocket) -> None:
733
  async def _handle_stop_listening(websocket: WebSocket) -> None:
734
  """Handle the stop_listening command."""
735
  services = get_services()
 
736
 
737
  create_tracked_task(send_goodbye())
738
 
@@ -743,6 +747,9 @@ async def _handle_stop_listening(websocket: WebSocket) -> None:
743
 
744
  services.openai.stop_listening()
745
 
 
 
 
746
  await websocket.send_json({
747
  "type": "listening_state",
748
  "listening": False,
 
214
  if services.openai:
215
  services.openai.stop_listening()
216
 
217
+ # Mark conversation as ended (allows idle movements to resume)
218
+ state.end_conversation()
219
+
220
  # Broadcast to iOS that listening has stopped
221
  await broadcast({
222
  "type": "listening_state",
 
736
  async def _handle_stop_listening(websocket: WebSocket) -> None:
737
  """Handle the stop_listening command."""
738
  services = get_services()
739
+ state = get_state()
740
 
741
  create_tracked_task(send_goodbye())
742
 
 
747
 
748
  services.openai.stop_listening()
749
 
750
+ # Mark conversation as ended (allows idle movements to resume)
751
+ state.end_conversation()
752
+
753
  await websocket.send_json({
754
  "type": "listening_state",
755
  "listening": False,
reachy_ios_bridge/routes/conversation_services.py CHANGED
@@ -69,6 +69,7 @@ class ConversationState:
69
  # Conversation tracking
70
  response_count: int = 0
71
  last_response_text: str = ""
 
72
 
73
  # Custom app state
74
  custom_emotion_animations: dict = field(default_factory=dict)
@@ -86,7 +87,12 @@ class ConversationState:
86
  """Reset state for a new conversation."""
87
  self.response_count = 0
88
  self.last_response_text = ""
 
89
  self.reset_for_new_response()
 
 
 
 
90
 
91
 
92
  # Global instances (initialized on first use)
 
69
  # Conversation tracking
70
  response_count: int = 0
71
  last_response_text: str = ""
72
+ is_active: bool = False # True when a conversation is in progress
73
 
74
  # Custom app state
75
  custom_emotion_animations: dict = field(default_factory=dict)
 
87
  """Reset state for a new conversation."""
88
  self.response_count = 0
89
  self.last_response_text = ""
90
+ self.is_active = True # Mark conversation as active
91
  self.reset_for_new_response()
92
+
93
+ def end_conversation(self) -> None:
94
+ """Mark conversation as ended."""
95
+ self.is_active = False
96
 
97
 
98
  # Global instances (initialized on first use)
reachy_ios_bridge/routes/games/__pycache__/tamareachy.cpython-312.pyc CHANGED
Binary files a/reachy_ios_bridge/routes/games/__pycache__/tamareachy.cpython-312.pyc and b/reachy_ios_bridge/routes/games/__pycache__/tamareachy.cpython-312.pyc differ
 
reachy_ios_bridge/routes/games/__pycache__/tamareachy_monitor.cpython-312.pyc ADDED
Binary file (7.76 kB). View file
 
reachy_ios_bridge/routes/games/tamareachy.py CHANGED
@@ -18,6 +18,7 @@ from .models import (
18
  TamaReachyCheckResponse,
19
  )
20
  from .helpers import speak_and_animate
 
21
 
22
  logger = logging.getLogger(__name__)
23
 
@@ -67,6 +68,10 @@ async def enable_tamareachy(request: TamaReachyEnableRequest):
67
 
68
  await speak_and_animate(commentary, "excited")
69
 
 
 
 
 
70
  logger.info("🐣 TamaReachy enabled!")
71
 
72
  return TamaReachyEnableResponse(
@@ -88,6 +93,10 @@ async def disable_tamareachy():
88
 
89
  await db.update_tamareachy_state({"enabled": False})
90
 
 
 
 
 
91
  # Sad goodbye message
92
  await speak_and_animate(
93
  "Goodbye for now... I'll miss you! 😢",
@@ -323,3 +332,13 @@ async def check_tamareachy_needs():
323
  logger.error(f"TamaReachy check failed: {e}")
324
  return TamaReachyCheckResponse(success=False, error=str(e))
325
 
 
 
 
 
 
 
 
 
 
 
 
18
  TamaReachyCheckResponse,
19
  )
20
  from .helpers import speak_and_animate
21
+ from .tamareachy_monitor import get_tamareachy_monitor
22
 
23
  logger = logging.getLogger(__name__)
24
 
 
68
 
69
  await speak_and_animate(commentary, "excited")
70
 
71
+ # Start the background monitor
72
+ monitor = get_tamareachy_monitor()
73
+ await monitor.start()
74
+
75
  logger.info("🐣 TamaReachy enabled!")
76
 
77
  return TamaReachyEnableResponse(
 
93
 
94
  await db.update_tamareachy_state({"enabled": False})
95
 
96
+ # Stop the background monitor
97
+ monitor = get_tamareachy_monitor()
98
+ await monitor.stop()
99
+
100
  # Sad goodbye message
101
  await speak_and_animate(
102
  "Goodbye for now... I'll miss you! 😢",
 
332
  logger.error(f"TamaReachy check failed: {e}")
333
  return TamaReachyCheckResponse(success=False, error=str(e))
334
 
335
+
336
+ @router.get("/monitor-status")
337
+ async def get_monitor_status():
338
+ """Check if the background monitor is running."""
339
+ monitor = get_tamareachy_monitor()
340
+ return {
341
+ "monitor_running": monitor.is_running,
342
+ "check_interval_seconds": 5 * 60,
343
+ }
344
+
reachy_ios_bridge/routes/games/tamareachy_monitor.py ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """TamaReachy background monitor service.
2
+
3
+ Periodically checks TamaReachy needs and triggers proactive speech
4
+ even when the iOS app is not actively viewing the TamaReachy screen.
5
+ """
6
+
7
+ import asyncio
8
+ import logging
9
+ from datetime import datetime
10
+ from typing import Optional
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ # Check interval in seconds (5 minutes)
15
+ CHECK_INTERVAL_SECONDS = 5 * 60
16
+
17
+
18
+ class TamaReachyMonitorService:
19
+ """Background service that monitors TamaReachy needs.
20
+
21
+ Runs independently of the iOS app and checks for critical needs
22
+ every 5 minutes. When a need becomes critical, Reachy will speak
23
+ proactively to ask for care (respecting the 15-minute cooldown
24
+ between proactive requests).
25
+ """
26
+
27
+ def __init__(self):
28
+ """Initialize the monitor service."""
29
+ self._is_running = False
30
+ self._task: Optional[asyncio.Task] = None
31
+ logger.info("TamaReachyMonitorService initialized")
32
+
33
+ @property
34
+ def is_running(self) -> bool:
35
+ """Check if the monitor service is running."""
36
+ return self._is_running
37
+
38
+ async def start(self) -> None:
39
+ """Start the monitor service."""
40
+ if self._is_running:
41
+ logger.debug("TamaReachy monitor already running")
42
+ return
43
+
44
+ self._is_running = True
45
+ self._task = asyncio.create_task(self._monitor_loop())
46
+ logger.info("🐣 TamaReachy monitor started")
47
+
48
+ async def stop(self) -> None:
49
+ """Stop the monitor service."""
50
+ if not self._is_running:
51
+ return
52
+
53
+ self._is_running = False
54
+ if self._task:
55
+ self._task.cancel()
56
+ try:
57
+ await self._task
58
+ except asyncio.CancelledError:
59
+ pass
60
+ self._task = None
61
+ logger.info("🐣 TamaReachy monitor stopped")
62
+
63
+ async def _monitor_loop(self) -> None:
64
+ """Main monitoring loop - runs every CHECK_INTERVAL_SECONDS."""
65
+ logger.info("🐣 TamaReachy monitor loop starting")
66
+
67
+ try:
68
+ while self._is_running:
69
+ # Wait first, then check (avoids immediate check on startup)
70
+ await asyncio.sleep(CHECK_INTERVAL_SECONDS)
71
+
72
+ if not self._is_running:
73
+ break
74
+
75
+ await self._check_needs()
76
+ except asyncio.CancelledError:
77
+ logger.debug("TamaReachy monitor loop cancelled")
78
+ except Exception as e:
79
+ logger.error(f"Error in TamaReachy monitor: {e}", exc_info=True)
80
+
81
+ async def _check_needs(self) -> None:
82
+ """Check TamaReachy needs and speak if critical."""
83
+ try:
84
+ from ...database import get_database
85
+ from ...tools.tamareachy_engine import get_tamareachy_engine
86
+ from .helpers import speak_and_animate
87
+
88
+ db = get_database()
89
+ engine = get_tamareachy_engine()
90
+
91
+ state = await db.get_tamareachy_state()
92
+
93
+ # Only check if enabled
94
+ if not state.get("enabled", False):
95
+ logger.debug("🐣 TamaReachy not enabled, skipping check")
96
+ return
97
+
98
+ # Calculate decay based on time elapsed
99
+ decayed_stats = engine.calculate_decay(
100
+ state, state.get("last_decay_check")
101
+ )
102
+
103
+ # Update decayed stats in database
104
+ now = datetime.utcnow().isoformat()
105
+ await db.update_tamareachy_state({
106
+ **decayed_stats,
107
+ "last_decay_check": now,
108
+ })
109
+
110
+ # Check for critical need
111
+ critical_need = engine.get_critical_need(decayed_stats)
112
+
113
+ if critical_need and engine.should_make_proactive_request():
114
+ # Get message and reaction for this need
115
+ commentary = engine.get_request_message(critical_need)
116
+ reaction = engine.get_reaction_for_need(critical_need)
117
+
118
+ # Speak and animate
119
+ await speak_and_animate(commentary, reaction)
120
+ engine.mark_proactive_request_made()
121
+
122
+ logger.info(f"🐣 TamaReachy proactive request: {critical_need}")
123
+ else:
124
+ logger.debug(f"🐣 TamaReachy check complete (critical_need={critical_need})")
125
+
126
+ except Exception as e:
127
+ logger.error(f"🐣 TamaReachy check failed: {e}", exc_info=True)
128
+
129
+
130
+ # Global singleton instance
131
+ _monitor: Optional[TamaReachyMonitorService] = None
132
+
133
+
134
+ def get_tamareachy_monitor() -> TamaReachyMonitorService:
135
+ """Get the global TamaReachy monitor service instance.
136
+
137
+ Creates the instance if it doesn't exist.
138
+
139
+ Returns:
140
+ The TamaReachy monitor service.
141
+ """
142
+ global _monitor
143
+ if _monitor is None:
144
+ _monitor = TamaReachyMonitorService()
145
+ return _monitor
146
+
147
+
148
+ async def start_tamareachy_monitor_if_enabled() -> None:
149
+ """Start the TamaReachy monitor if TamaReachy is enabled.
150
+
151
+ Called on server startup to resume monitoring if TamaReachy
152
+ was previously enabled.
153
+ """
154
+ try:
155
+ from ...database import get_database
156
+
157
+ db = get_database()
158
+ state = await db.get_tamareachy_state()
159
+
160
+ if state.get("enabled", False):
161
+ monitor = get_tamareachy_monitor()
162
+ await monitor.start()
163
+ logger.info("🐣 TamaReachy monitor auto-started (was previously enabled)")
164
+ except Exception as e:
165
+ logger.warning(f"Could not check TamaReachy state on startup: {e}")
166
+
reachy_ios_bridge/routes/user_settings.py CHANGED
@@ -68,12 +68,30 @@ class UserCountryResponse(BaseModel):
68
  message: Optional[str] = None
69
 
70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  # =============================================================================
72
  # User Settings Keys
73
  # =============================================================================
74
 
75
  USER_NAME_KEY = "user_name"
76
  USER_COUNTRY_KEY = "preferred_country"
 
77
 
78
 
79
  # =============================================================================
@@ -259,6 +277,71 @@ async def delete_user_country() -> UserCountryResponse:
259
  raise HTTPException(status_code=500, detail=str(e))
260
 
261
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
262
  # =============================================================================
263
  # Helper Functions for Internal Use
264
  # =============================================================================
 
68
  message: Optional[str] = None
69
 
70
 
71
+ class IdleMovementRequest(BaseModel):
72
+ """Request body for setting idle movement enabled state."""
73
+
74
+ enabled: bool = Field(
75
+ ...,
76
+ description="Whether idle movements are enabled"
77
+ )
78
+
79
+
80
+ class IdleMovementResponse(BaseModel):
81
+ """Response for idle movement operations."""
82
+
83
+ success: bool
84
+ enabled: bool = True # Default to enabled
85
+ message: Optional[str] = None
86
+
87
+
88
  # =============================================================================
89
  # User Settings Keys
90
  # =============================================================================
91
 
92
  USER_NAME_KEY = "user_name"
93
  USER_COUNTRY_KEY = "preferred_country"
94
+ IDLE_MOVEMENT_KEY = "idle_movement_enabled"
95
 
96
 
97
  # =============================================================================
 
277
  raise HTTPException(status_code=500, detail=str(e))
278
 
279
 
280
+ # =============================================================================
281
+ # Idle Movement Routes
282
+ # =============================================================================
283
+
284
+
285
+ @router.get("/idle-movement", response_model=IdleMovementResponse)
286
+ async def get_idle_movement_setting() -> IdleMovementResponse:
287
+ """Get the idle movement enabled setting.
288
+
289
+ Returns:
290
+ Whether idle movement is enabled (default: True).
291
+ """
292
+ try:
293
+ db = get_database()
294
+ value = await db.get_user_setting(IDLE_MOVEMENT_KEY)
295
+
296
+ # Default to enabled if not set
297
+ enabled = value != "false" if value is not None else True
298
+
299
+ return IdleMovementResponse(
300
+ success=True,
301
+ enabled=enabled,
302
+ message="Idle movement setting retrieved"
303
+ )
304
+ except Exception as e:
305
+ logger.error(f"Error getting idle movement setting: {e}")
306
+ raise HTTPException(status_code=500, detail=str(e))
307
+
308
+
309
+ @router.post("/idle-movement", response_model=IdleMovementResponse)
310
+ async def set_idle_movement_setting(request: IdleMovementRequest) -> IdleMovementResponse:
311
+ """Set whether idle movements are enabled.
312
+
313
+ Args:
314
+ request: Contains the enabled state.
315
+
316
+ Returns:
317
+ Confirmation with the stored state.
318
+ """
319
+ try:
320
+ from ..idle_movement_service import get_idle_movement_service
321
+
322
+ db = get_database()
323
+ await db.set_user_setting(IDLE_MOVEMENT_KEY, str(request.enabled).lower())
324
+
325
+ # Apply the setting immediately to the running service
326
+ idle_service = get_idle_movement_service()
327
+ if idle_service:
328
+ if request.enabled:
329
+ await idle_service.start()
330
+ logger.info("🌙 Idle movements enabled")
331
+ else:
332
+ await idle_service.stop()
333
+ logger.info("🌙 Idle movements disabled")
334
+
335
+ return IdleMovementResponse(
336
+ success=True,
337
+ enabled=request.enabled,
338
+ message=f"Idle movements {'enabled' if request.enabled else 'disabled'}"
339
+ )
340
+ except Exception as e:
341
+ logger.error(f"Error setting idle movement: {e}")
342
+ raise HTTPException(status_code=500, detail=str(e))
343
+
344
+
345
  # =============================================================================
346
  # Helper Functions for Internal Use
347
  # =============================================================================
reachy_ios_bridge/routes/voice.py CHANGED
@@ -12,6 +12,7 @@ from fastapi import APIRouter, HTTPException
12
  import httpx
13
 
14
  from ..models import Voice, VoiceRequest, VoiceResponse, VoicesListResponse
 
15
 
16
  logger = logging.getLogger(__name__)
17
 
@@ -97,6 +98,11 @@ class VoiceSettings:
97
  def preferred_language(self) -> str:
98
  return self._preferred_language
99
 
 
 
 
 
 
100
  def set_voice(self, voice_id: str) -> bool:
101
  """Set the current OpenAI voice. Returns True if successful."""
102
  valid_ids = [v["id"] for v in OPENAI_VOICES]
@@ -120,6 +126,11 @@ class VoiceSettings:
120
  self._current_language = language
121
  logger.info(f"🌍 Preferred language set to: {language}")
122
  return True
 
 
 
 
 
123
 
124
 
125
  # Singleton instance
@@ -327,6 +338,45 @@ async def set_preferred_language_endpoint(language: str = "en") -> dict:
327
  }
328
 
329
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330
  # Sample phrases by language for voice testing
331
  VOICE_SAMPLE_PHRASES = {
332
  "en": {
@@ -559,9 +609,19 @@ async def test_voice(request: Optional[VoiceRequest] = None) -> dict:
559
  async def _play_pcm_audio(audio_data: bytes) -> None:
560
  """Play PCM audio data through Reachy's speaker.
561
 
 
 
562
  Args:
563
  audio_data: Raw PCM16 audio at 24kHz mono.
564
  """
 
 
 
 
 
 
 
 
565
  # OpenAI TTS PCM format: 24kHz, 16-bit signed little-endian, mono
566
  aplay_cmd = [
567
  "aplay",
@@ -575,16 +635,62 @@ async def _play_pcm_audio(audio_data: bytes) -> None:
575
 
576
  loop = asyncio.get_event_loop()
577
 
578
- def _run_aplay():
579
- process = subprocess.Popen(
580
- aplay_cmd,
581
- stdin=subprocess.PIPE,
582
- stdout=subprocess.PIPE,
583
- stderr=subprocess.PIPE,
584
- )
585
- stdout, stderr = process.communicate(input=audio_data, timeout=30)
586
- if process.returncode != 0:
587
- error_msg = stderr.decode() if stderr else "Unknown error"
588
- raise RuntimeError(f"aplay failed: {error_msg}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
589
 
590
- await loop.run_in_executor(None, _run_aplay)
 
12
  import httpx
13
 
14
  from ..models import Voice, VoiceRequest, VoiceResponse, VoicesListResponse
15
+ from ..audio_playback import is_kids_mode_enabled, set_kids_mode
16
 
17
  logger = logging.getLogger(__name__)
18
 
 
98
  def preferred_language(self) -> str:
99
  return self._preferred_language
100
 
101
+ @property
102
+ def kids_mode(self) -> bool:
103
+ """Check if kids mode (pitch shifting) is enabled."""
104
+ return is_kids_mode_enabled()
105
+
106
  def set_voice(self, voice_id: str) -> bool:
107
  """Set the current OpenAI voice. Returns True if successful."""
108
  valid_ids = [v["id"] for v in OPENAI_VOICES]
 
126
  self._current_language = language
127
  logger.info(f"🌍 Preferred language set to: {language}")
128
  return True
129
+
130
+ def set_kids_mode(self, enabled: bool) -> None:
131
+ """Enable or disable kids mode (pitch shifting for younger voice)."""
132
+ set_kids_mode(enabled)
133
+ logger.info(f"🧒 Kids mode {'enabled' if enabled else 'disabled'}")
134
 
135
 
136
  # Singleton instance
 
338
  }
339
 
340
 
341
+ # MARK: - Kids Mode Endpoints
342
+
343
+ @router.get("/voice/kids-mode")
344
+ async def get_kids_mode() -> dict:
345
+ """Get the current kids mode setting.
346
+
347
+ Kids mode pitch-shifts Reachy's voice to sound younger/child-like.
348
+
349
+ Returns:
350
+ kids_mode: Boolean indicating if kids mode is enabled.
351
+ """
352
+ return {
353
+ "kids_mode": _voice_settings.kids_mode,
354
+ }
355
+
356
+
357
+ @router.post("/voice/kids-mode")
358
+ async def set_kids_mode_endpoint(enabled: bool = False) -> dict:
359
+ """Enable or disable kids mode.
360
+
361
+ Kids mode pitch-shifts Reachy's voice to sound younger/child-like.
362
+ This applies a pitch shift to the audio output making the voice
363
+ sound more suitable for conversations with children.
364
+
365
+ Args:
366
+ enabled: True to enable kids mode, False to disable.
367
+
368
+ Returns:
369
+ Success status and current kids mode state.
370
+ """
371
+ _voice_settings.set_kids_mode(enabled)
372
+
373
+ return {
374
+ "success": True,
375
+ "kids_mode": enabled,
376
+ "message": f"Kids mode {'enabled' if enabled else 'disabled'}",
377
+ }
378
+
379
+
380
  # Sample phrases by language for voice testing
381
  VOICE_SAMPLE_PHRASES = {
382
  "en": {
 
609
  async def _play_pcm_audio(audio_data: bytes) -> None:
610
  """Play PCM audio data through Reachy's speaker.
611
 
612
+ Applies pitch shifting if kids mode is enabled.
613
+
614
  Args:
615
  audio_data: Raw PCM16 audio at 24kHz mono.
616
  """
617
+ import shutil
618
+
619
+ # Check if kids mode is enabled
620
+ use_pitch_shift = is_kids_mode_enabled() and shutil.which("sox") is not None
621
+
622
+ if is_kids_mode_enabled() and not shutil.which("sox"):
623
+ logger.warning("🧒 Kids mode enabled but sox not found, using normal playback")
624
+
625
  # OpenAI TTS PCM format: 24kHz, 16-bit signed little-endian, mono
626
  aplay_cmd = [
627
  "aplay",
 
635
 
636
  loop = asyncio.get_event_loop()
637
 
638
+ def _run_playback():
639
+ if use_pitch_shift:
640
+ # Pitch shift with sox, then pipe to aplay
641
+ # Sox command: read raw PCM, pitch shift, output raw PCM
642
+ sox_cmd = [
643
+ "sox",
644
+ "-t", "raw",
645
+ "-r", "24000",
646
+ "-e", "signed",
647
+ "-b", "16",
648
+ "-c", "1",
649
+ "-", # Input from stdin
650
+ "-t", "raw",
651
+ "-", # Output to stdout
652
+ "pitch", "400", # Pitch shift up 400 cents
653
+ ]
654
+
655
+ # Create sox process
656
+ sox_process = subprocess.Popen(
657
+ sox_cmd,
658
+ stdin=subprocess.PIPE,
659
+ stdout=subprocess.PIPE,
660
+ stderr=subprocess.PIPE,
661
+ )
662
+
663
+ # Create aplay process reading from sox output
664
+ aplay_process = subprocess.Popen(
665
+ aplay_cmd,
666
+ stdin=sox_process.stdout,
667
+ stdout=subprocess.PIPE,
668
+ stderr=subprocess.PIPE,
669
+ )
670
+
671
+ # Close sox stdout in parent
672
+ sox_process.stdout.close()
673
+
674
+ # Write audio to sox
675
+ sox_process.stdin.write(audio_data)
676
+ sox_process.stdin.close()
677
+
678
+ # Wait for both processes
679
+ sox_process.wait(timeout=30)
680
+ aplay_process.wait(timeout=30)
681
+
682
+ logger.info("🧒 Played audio with kids mode pitch shift")
683
+ else:
684
+ # Normal playback without pitch shifting
685
+ process = subprocess.Popen(
686
+ aplay_cmd,
687
+ stdin=subprocess.PIPE,
688
+ stdout=subprocess.PIPE,
689
+ stderr=subprocess.PIPE,
690
+ )
691
+ stdout, stderr = process.communicate(input=audio_data, timeout=30)
692
+ if process.returncode != 0:
693
+ error_msg = stderr.decode() if stderr else "Unknown error"
694
+ raise RuntimeError(f"aplay failed: {error_msg}")
695
 
696
+ await loop.run_in_executor(None, _run_playback)
reachy_ios_bridge/server.py CHANGED
@@ -14,11 +14,17 @@ from fastapi import FastAPI
14
  from fastapi.middleware.cors import CORSMiddleware
15
  from fastapi.staticfiles import StaticFiles
16
 
 
17
  from .database import init_database
 
18
  from .models import RobotStatus
19
  from .motion_service import MotionService
20
  from .tts_service import TTSService
21
  from .daemon_health_monitor import start_health_monitor, stop_health_monitor
 
 
 
 
22
  from .routes import (
23
  animations_router,
24
  apps_router,
@@ -49,13 +55,39 @@ logger = logging.getLogger(__name__)
49
  # Global service instances (will be initialized in lifespan)
50
  tts_service: TTSService | None = None
51
  motion_service: MotionService | None = None
 
52
  reachy_connected: bool = False
53
 
54
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  @asynccontextmanager
56
  async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
57
  """Manage application lifecycle."""
58
- global tts_service, motion_service
59
 
60
  logger.info("Starting Reachy iOS Bridge server...")
61
 
@@ -63,10 +95,18 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
63
  await init_database()
64
  logger.info("Database initialized")
65
 
 
 
 
66
  # Initialize services
67
  tts_service = TTSService() # Uses OpenAI TTS API
68
  motion_service = MotionService()
69
 
 
 
 
 
 
70
  # Set global TTS service instance for other modules (like games)
71
  from .tts_service import set_tts_service
72
  set_tts_service(tts_service)
@@ -83,6 +123,27 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
83
  health_monitor = start_health_monitor()
84
  logger.info("🏥 Daemon health monitor active - will auto-recover from motor errors")
85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  logger.info("Services initialized (waiting for Reachy connection...)")
87
 
88
  yield
@@ -90,6 +151,15 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
90
  # Cleanup
91
  logger.info("Shutting down Reachy iOS Bridge server...")
92
 
 
 
 
 
 
 
 
 
 
93
  # Stop health monitor
94
  stop_health_monitor()
95
 
@@ -187,6 +257,91 @@ async def get_status() -> RobotStatus:
187
  )
188
 
189
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
  # External control functions (called by main.py)
191
 
192
  def set_reachy_connected(connected: bool) -> None:
 
14
  from fastapi.middleware.cors import CORSMiddleware
15
  from fastapi.staticfiles import StaticFiles
16
 
17
+ from .config import get_config
18
  from .database import init_database
19
+ from .idle_movement_service import IdleMovementService, set_idle_movement_service
20
  from .models import RobotStatus
21
  from .motion_service import MotionService
22
  from .tts_service import TTSService
23
  from .daemon_health_monitor import start_health_monitor, stop_health_monitor
24
+ from .routes.games.tamareachy_monitor import (
25
+ get_tamareachy_monitor,
26
+ start_tamareachy_monitor_if_enabled,
27
+ )
28
  from .routes import (
29
  animations_router,
30
  apps_router,
 
55
  # Global service instances (will be initialized in lifespan)
56
  tts_service: TTSService | None = None
57
  motion_service: MotionService | None = None
58
+ idle_service: IdleMovementService | None = None
59
  reachy_connected: bool = False
60
 
61
 
62
+ def _is_robot_busy() -> bool:
63
+ """Check if robot is currently busy with speech, animation, or conversation.
64
+
65
+ Used by idle movement service to pause during activity.
66
+ """
67
+ # Check TTS
68
+ if tts_service and tts_service.is_speaking:
69
+ return True
70
+
71
+ # Check motion/animation
72
+ if motion_service and motion_service.is_playing:
73
+ return True
74
+
75
+ # Check conversation state
76
+ try:
77
+ from .routes.conversation_services import get_state
78
+ state = get_state()
79
+ if state and state.is_active:
80
+ return True
81
+ except Exception:
82
+ pass
83
+
84
+ return False
85
+
86
+
87
  @asynccontextmanager
88
  async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
89
  """Manage application lifecycle."""
90
+ global tts_service, motion_service, idle_service
91
 
92
  logger.info("Starting Reachy iOS Bridge server...")
93
 
 
95
  await init_database()
96
  logger.info("Database initialized")
97
 
98
+ # Load config
99
+ config = get_config()
100
+
101
  # Initialize services
102
  tts_service = TTSService() # Uses OpenAI TTS API
103
  motion_service = MotionService()
104
 
105
+ # Initialize idle movement service with daemon URL from config
106
+ idle_service = IdleMovementService(daemon_url=config.daemon_url)
107
+ idle_service.set_busy_callback(_is_robot_busy)
108
+ set_idle_movement_service(idle_service)
109
+
110
  # Set global TTS service instance for other modules (like games)
111
  from .tts_service import set_tts_service
112
  set_tts_service(tts_service)
 
123
  health_monitor = start_health_monitor()
124
  logger.info("🏥 Daemon health monitor active - will auto-recover from motor errors")
125
 
126
+ # Start TamaReachy monitor if it was previously enabled
127
+ await start_tamareachy_monitor_if_enabled()
128
+
129
+ # Start idle movement service if enabled in settings (default: enabled)
130
+ try:
131
+ from .database import get_database
132
+ db = get_database()
133
+ idle_setting = await db.get_user_setting("idle_movement_enabled")
134
+ idle_enabled = idle_setting != "false" if idle_setting is not None else True
135
+
136
+ if idle_enabled:
137
+ await idle_service.start()
138
+ logger.info("🌙 Idle movement service active - Reachy will move subtly when idle")
139
+ else:
140
+ logger.info("🌙 Idle movement service disabled by user setting")
141
+ except Exception as e:
142
+ # If we can't read the setting, default to enabled
143
+ logger.warning(f"Could not read idle movement setting, defaulting to enabled: {e}")
144
+ await idle_service.start()
145
+ logger.info("🌙 Idle movement service active - Reachy will move subtly when idle")
146
+
147
  logger.info("Services initialized (waiting for Reachy connection...)")
148
 
149
  yield
 
151
  # Cleanup
152
  logger.info("Shutting down Reachy iOS Bridge server...")
153
 
154
+ # Stop idle movement service
155
+ if idle_service:
156
+ await idle_service.close()
157
+
158
+ # Stop TamaReachy monitor
159
+ tamareachy_monitor = get_tamareachy_monitor()
160
+ if tamareachy_monitor.is_running:
161
+ await tamareachy_monitor.stop()
162
+
163
  # Stop health monitor
164
  stop_health_monitor()
165
 
 
257
  )
258
 
259
 
260
+ # Idle movement control endpoints
261
+
262
+ @app.get("/api/idle", tags=["Idle Movement"])
263
+ async def get_idle_status() -> dict:
264
+ """Get the current idle movement service status."""
265
+ if idle_service:
266
+ return {
267
+ "enabled": idle_service.is_running,
268
+ "paused": idle_service.is_paused,
269
+ }
270
+ return {
271
+ "enabled": False,
272
+ "paused": False,
273
+ }
274
+
275
+
276
+ @app.post("/api/idle/enable", tags=["Idle Movement"])
277
+ async def enable_idle_movement() -> dict:
278
+ """Enable idle movements when Reachy is not busy."""
279
+ if idle_service:
280
+ await idle_service.start()
281
+ return {
282
+ "success": True,
283
+ "message": "Idle movement enabled",
284
+ }
285
+ return {
286
+ "success": False,
287
+ "message": "Idle service not initialized",
288
+ }
289
+
290
+
291
+ @app.post("/api/idle/disable", tags=["Idle Movement"])
292
+ async def disable_idle_movement() -> dict:
293
+ """Disable idle movements."""
294
+ if idle_service:
295
+ await idle_service.stop()
296
+ return {
297
+ "success": True,
298
+ "message": "Idle movement disabled",
299
+ }
300
+ return {
301
+ "success": False,
302
+ "message": "Idle service not initialized",
303
+ }
304
+
305
+
306
+ @app.post("/api/idle/test", tags=["Idle Movement"])
307
+ async def test_idle_movement() -> dict:
308
+ """Manually trigger a test idle movement for debugging."""
309
+ if idle_service:
310
+ import random
311
+ # Generate a random movement
312
+ roll = random.uniform(-4.0, 4.0)
313
+ pitch = random.uniform(-3.0, 3.0)
314
+ yaw = random.uniform(-6.0, 6.0)
315
+ antenna_l = random.uniform(-15.0, 15.0)
316
+ antenna_r = random.uniform(-15.0, 15.0)
317
+ duration = 2.0
318
+
319
+ success = await idle_service._goto_pose(
320
+ head_roll=roll,
321
+ head_pitch=pitch,
322
+ head_yaw=yaw,
323
+ antenna_left=antenna_l,
324
+ antenna_right=antenna_r,
325
+ duration=duration,
326
+ )
327
+
328
+ return {
329
+ "success": success,
330
+ "movement": {
331
+ "head_roll": roll,
332
+ "head_pitch": pitch,
333
+ "head_yaw": yaw,
334
+ "antenna_left": antenna_l,
335
+ "antenna_right": antenna_r,
336
+ "duration": duration,
337
+ },
338
+ }
339
+ return {
340
+ "success": False,
341
+ "message": "Idle service not initialized",
342
+ }
343
+
344
+
345
  # External control functions (called by main.py)
346
 
347
  def set_reachy_connected(connected: bool) -> None:
style.css CHANGED
@@ -322,6 +322,11 @@ body {
322
  font-size: 1.1rem;
323
  }
324
 
 
 
 
 
 
325
  /* Features Grid */
326
  .features-grid {
327
  display: grid;
@@ -1108,3 +1113,199 @@ code {
1108
  grid-template-columns: 1fr;
1109
  }
1110
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
322
  font-size: 1.1rem;
323
  }
324
 
325
+ .section-cta {
326
+ text-align: center;
327
+ margin-top: 2.5rem;
328
+ }
329
+
330
  /* Features Grid */
331
  .features-grid {
332
  display: grid;
 
1113
  grid-template-columns: 1fr;
1114
  }
1115
  }
1116
+
1117
+ /* Documentation Page */
1118
+ .docs-page {
1119
+ max-width: 900px;
1120
+ }
1121
+
1122
+ .docs-header {
1123
+ text-align: center;
1124
+ margin-bottom: 4rem;
1125
+ }
1126
+
1127
+ .docs-header h1 {
1128
+ font-family: 'Mulish', -apple-system, BlinkMacSystemFont, sans-serif;
1129
+ font-size: 3rem;
1130
+ font-weight: 800;
1131
+ margin-bottom: 0.75rem;
1132
+ background: linear-gradient(135deg, var(--text-primary), var(--accent));
1133
+ -webkit-background-clip: text;
1134
+ -webkit-text-fill-color: transparent;
1135
+ background-clip: text;
1136
+ }
1137
+
1138
+ .docs-header p {
1139
+ color: var(--text-secondary);
1140
+ font-size: 1.25rem;
1141
+ }
1142
+
1143
+ .docs-category {
1144
+ margin-bottom: 4rem;
1145
+ }
1146
+
1147
+ .docs-category > h2 {
1148
+ font-family: 'Mulish', -apple-system, BlinkMacSystemFont, sans-serif;
1149
+ font-size: 1.75rem;
1150
+ font-weight: 700;
1151
+ margin-bottom: 1.5rem;
1152
+ padding-bottom: 0.75rem;
1153
+ border-bottom: 2px solid var(--accent);
1154
+ color: var(--text-primary);
1155
+ }
1156
+
1157
+ .docs-feature {
1158
+ background: var(--surface);
1159
+ border-radius: 1rem;
1160
+ border: 1px solid var(--border);
1161
+ margin-bottom: 1.5rem;
1162
+ overflow: hidden;
1163
+ transition: all 0.2s;
1164
+ }
1165
+
1166
+ .docs-feature:hover {
1167
+ border-color: var(--accent);
1168
+ box-shadow: 0 4px 20px var(--shadow);
1169
+ }
1170
+
1171
+ .docs-feature-header {
1172
+ display: flex;
1173
+ align-items: center;
1174
+ gap: 1rem;
1175
+ padding: 1.25rem 1.5rem;
1176
+ background: var(--bg-secondary);
1177
+ border-bottom: 1px solid var(--border);
1178
+ }
1179
+
1180
+ .docs-icon {
1181
+ font-size: 1.75rem;
1182
+ }
1183
+
1184
+ .docs-feature-header h3 {
1185
+ font-family: 'Mulish', -apple-system, BlinkMacSystemFont, sans-serif;
1186
+ font-size: 1.25rem;
1187
+ font-weight: 700;
1188
+ margin: 0;
1189
+ color: var(--text-primary);
1190
+ }
1191
+
1192
+ .docs-feature-content {
1193
+ padding: 1.5rem;
1194
+ }
1195
+
1196
+ .docs-feature-content > p {
1197
+ color: var(--text-secondary);
1198
+ font-size: 1rem;
1199
+ line-height: 1.6;
1200
+ margin-bottom: 1.25rem;
1201
+ }
1202
+
1203
+ .docs-feature-content h4 {
1204
+ font-family: 'Mulish', -apple-system, BlinkMacSystemFont, sans-serif;
1205
+ font-size: 0.95rem;
1206
+ font-weight: 700;
1207
+ margin-top: 1.25rem;
1208
+ margin-bottom: 0.75rem;
1209
+ color: var(--text-primary);
1210
+ }
1211
+
1212
+ .docs-feature-content h4:first-of-type {
1213
+ margin-top: 0;
1214
+ }
1215
+
1216
+ .docs-feature-content ul {
1217
+ list-style: none;
1218
+ padding: 0;
1219
+ margin: 0 0 1rem 0;
1220
+ }
1221
+
1222
+ .docs-feature-content li {
1223
+ color: var(--text-secondary);
1224
+ padding: 0.35rem 0;
1225
+ padding-left: 1.5rem;
1226
+ position: relative;
1227
+ font-size: 0.95rem;
1228
+ }
1229
+
1230
+ .docs-feature-content li::before {
1231
+ content: "→";
1232
+ position: absolute;
1233
+ left: 0;
1234
+ color: var(--accent);
1235
+ }
1236
+
1237
+ .docs-feature-content li strong {
1238
+ color: var(--text-primary);
1239
+ }
1240
+
1241
+ .docs-examples {
1242
+ display: flex;
1243
+ flex-wrap: wrap;
1244
+ gap: 0.5rem;
1245
+ }
1246
+
1247
+ .docs-examples code {
1248
+ display: inline-block;
1249
+ padding: 0.5rem 1rem;
1250
+ background: var(--code-bg);
1251
+ border-radius: 2rem;
1252
+ font-size: 0.85rem;
1253
+ color: var(--text-primary);
1254
+ font-style: italic;
1255
+ border: 1px solid var(--border);
1256
+ font-family: 'Nunito', sans-serif;
1257
+ }
1258
+
1259
+ .docs-note {
1260
+ margin-top: 1rem;
1261
+ padding: 0.75rem 1rem;
1262
+ background: var(--bg-secondary);
1263
+ border-radius: 0.5rem;
1264
+ font-size: 0.9rem;
1265
+ color: var(--text-secondary);
1266
+ border-left: 3px solid var(--accent);
1267
+ }
1268
+
1269
+ .docs-footer-nav {
1270
+ display: flex;
1271
+ justify-content: center;
1272
+ gap: 1rem;
1273
+ margin-top: 3rem;
1274
+ padding-top: 2rem;
1275
+ border-top: 1px solid var(--divider);
1276
+ }
1277
+
1278
+ @media (max-width: 600px) {
1279
+ .docs-header h1 {
1280
+ font-size: 2rem;
1281
+ }
1282
+
1283
+ .docs-category > h2 {
1284
+ font-size: 1.35rem;
1285
+ }
1286
+
1287
+ .docs-feature-header {
1288
+ padding: 1rem 1.25rem;
1289
+ }
1290
+
1291
+ .docs-feature-content {
1292
+ padding: 1.25rem;
1293
+ }
1294
+
1295
+ .docs-examples {
1296
+ flex-direction: column;
1297
+ }
1298
+
1299
+ .docs-examples code {
1300
+ text-align: center;
1301
+ }
1302
+
1303
+ .docs-footer-nav {
1304
+ flex-direction: column;
1305
+ }
1306
+
1307
+ .docs-footer-nav .btn {
1308
+ width: 100%;
1309
+ text-align: center;
1310
+ }
1311
+ }