drenayaz commited on
Commit
569067e
·
0 Parent(s):

Initial commit

Browse files
.gitattributes ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ *.mp4 filter=xet diff=xet merge=xet -text
2
+ *.png filter=xet diff=xet merge=xet -text
3
+ *.jpg filter=xet diff=xet merge=xet -text
4
+ *.jpeg filter=xet diff=xet merge=xet -text
5
+ *.svg filter=xet diff=xet merge=xet -text
.gitignore ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ __pycache__/
2
+ *.egg-info/
3
+ build/
README.md ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Wake Me Up
3
+ emoji: 👋
4
+ colorFrom: red
5
+ colorTo: blue
6
+ sdk: static
7
+ pinned: false
8
+ short_description: Your first app to test Reachy Mini features
9
+ tags:
10
+ - reachy_mini
11
+ - reachy_mini_python_app
12
+ ---
assets/.gitkeep ADDED
File without changes
index.html ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>Wake Me Up - Reachy Mini</title>
7
+ <meta name="description" content="Your first app with Reachy Mini - Test all the robot's features in simple steps.">
8
+ <link rel="stylesheet" href="style.css">
9
+ </head>
10
+ <body>
11
+
12
+ <!-- Hero Section -->
13
+ <section class="hero">
14
+ <div class="container">
15
+ <div class="hero-grid">
16
+ <div>
17
+ <video class="hero-video" autoplay loop muted playsinline>
18
+ <source src="assets/touch_demo.mp4" type="video/mp4">
19
+ <div class="hero-placeholder">🤖⚡</div>
20
+ </video>
21
+ </div>
22
+
23
+ <div class="hero-content">
24
+ <div class="hero-header">
25
+ <div class="hero-icon">👋</div>
26
+ <h1 class="hero-title">Wake Me Up</h1>
27
+ </div>
28
+
29
+ <div class="tags">
30
+ <span class="tag">first app</span>
31
+ <span class="tag">testing</span>
32
+ <span class="tag">beginner-friendly</span>
33
+ </div>
34
+
35
+ <p class="hero-description">
36
+ Welcome to your <b>first app with Reachy Mini</b>! This simple and friendly application helps you test all the robot's features in a few easy steps.
37
+ <br><br>
38
+ Test the microphone, motors, speaker, and camera to make sure everything works perfectly.
39
+ </p>
40
+ </div>
41
+ </div>
42
+ </div>
43
+ </section>
44
+
45
+ <!-- Technical Section -->
46
+ <section class="technical">
47
+ <div class="container">
48
+ <div class="technical-grid">
49
+ <div>
50
+ <h2 class="section-title">How to install</h2>
51
+
52
+ <ol class="install-steps">
53
+ <li>
54
+ <span>
55
+ Make sure you already have the
56
+ <a href="https://github.com/pollen-robotics/reachy-mini-desktop-app/releases/latest"
57
+ class="link-tertiary">dashboard</a>
58
+ installed.
59
+ </span>
60
+ </li>
61
+ <li>
62
+ <span>Connect to your Reachy Mini using the dashboard.</span>
63
+ </li>
64
+ <li>
65
+ <span>Navigate to the "Applications" tab in the dashboard.</span>
66
+ </li>
67
+ <li>
68
+ <span>Find "Wake Me Up" in the list of available applications.</span>
69
+ </li>
70
+ <li>
71
+ <span>Click "Install" and the app will be ready to use!</span>
72
+ </li>
73
+ </ol>
74
+ </div>
75
+
76
+ <div>
77
+ <div class="demo-placeholder">Video coming soon</div>
78
+ </div>
79
+ </div>
80
+ </div>
81
+ </section>
82
+
83
+ <!-- Footer -->
84
+ <footer class="footer">
85
+ <div class="container">
86
+ <div class="footer-content">
87
+ <div class="footer-links">
88
+ <a href="https://github.com/pollen-robotics/reachy_mini" target="_blank" rel="noopener">> Documentation</a>
89
+ <a href="https://huggingface.co/spaces/pollen-robotics/Reachy-Mini_Best_Spaces" target="_blank" rel="noopener">> Browse other apps</a>
90
+ <a href="https://huggingface.co/blog/pollen-robotics/make-and-publish-your-reachy-mini-apps" target="_blank" rel="noopener">> Create your own app</a>
91
+ </div>
92
+ <p class="footer-help">
93
+ Need help? Contact us on <a href="https://discord.gg/u3QtUBhy" target="_blank" rel="noopener">Discord</a> and join the community.
94
+ Proudly brought by <a href="https://www.pollen-robotics.com/" target="_blank">Pollen Robotics</a> x 🤗 <a href="https://huggingface.co" target="_blank">Hugging Face</a>
95
+ </p>
96
+ </div>
97
+ </div>
98
+ </footer>
99
+
100
+ </body>
101
+ </html>
pyproject.toml ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+
6
+ [project]
7
+ name = "wake_me_up"
8
+ version = "0.1.0"
9
+ description = "Your first app with Reachy Mini - Test all the robot's features"
10
+ readme = "README.md"
11
+ requires-python = ">=3.10"
12
+ dependencies = [
13
+ "reachy-mini"
14
+ ]
15
+ keywords = ["reachy-mini-app"]
16
+
17
+ [project.entry-points."reachy_mini_apps"]
18
+ wake_me_up = "wake_me_up.main:WakeMeUp"
19
+
20
+ [tool.setuptools]
21
+ package-dir = { "" = "." }
22
+ include-package-data = true
23
+
24
+ [tool.setuptools.packages.find]
25
+ where = ["."]
26
+
27
+ [tool.setuptools.package-data]
28
+ wake_me_up = ["**/*"] # Also include all non-.py files
style.css ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
2
+
3
+ * {
4
+ margin: 0;
5
+ padding: 0;
6
+ box-sizing: border-box;
7
+ }
8
+
9
+ :root {
10
+ --background: #F6F6F8;
11
+ --foreground: #333333;
12
+ --primary: #FF9900;
13
+ --primary-hover: #FFB333;
14
+ --muted: #E8E8EB;
15
+ --muted-foreground: #878789;
16
+ --border: #E0E0E0;
17
+ --card: #FFFFFF;
18
+ --radius: 0.5rem;
19
+ }
20
+
21
+ body {
22
+ font-family: 'Inter', sans-serif;
23
+ background-color: var(--background);
24
+ color: var(--foreground);
25
+ line-height: 1.6;
26
+ -webkit-font-smoothing: antialiased;
27
+ }
28
+
29
+ .container {
30
+ max-width: 1200px;
31
+ margin: 0 auto;
32
+ padding: 0 1.5rem;
33
+ }
34
+
35
+ /* Hero Section */
36
+ .hero {
37
+ padding: 4rem 0;
38
+ }
39
+
40
+ .hero-grid {
41
+ display: grid;
42
+ grid-template-columns: 1fr 1fr;
43
+ gap: 3rem;
44
+ align-items: center;
45
+ }
46
+
47
+ .hero-video {
48
+ width: 100%;
49
+ height: auto;
50
+ border-radius: var(--radius);
51
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
52
+ background: #000;
53
+ }
54
+
55
+ @media (max-width: 768px) {
56
+ .hero-grid {
57
+ grid-template-columns: 1fr;
58
+ }
59
+ }
60
+
61
+ .hero-content {
62
+ text-align: left;
63
+ }
64
+
65
+ .hero-header {
66
+ display: flex;
67
+ align-items: center;
68
+ justify-content: center;
69
+ gap: 1rem;
70
+ margin-bottom: 1.5rem;
71
+ }
72
+
73
+ .hero-icon {
74
+ font-size: 4rem;
75
+ }
76
+
77
+ .hero-title {
78
+ font-size: 3rem;
79
+ font-weight: 700;
80
+ color: var(--foreground);
81
+ }
82
+
83
+ .tags {
84
+ display: flex;
85
+ flex-wrap: wrap;
86
+ justify-content: center;
87
+ gap: 0.5rem;
88
+ margin-bottom: 1.5rem;
89
+ }
90
+
91
+ .tag {
92
+ display: inline-flex;
93
+ align-items: center;
94
+ padding: 0.25rem 0.75rem;
95
+ font-size: 0.75rem;
96
+ font-weight: 500;
97
+ border-radius: 9999px;
98
+ background-color: var(--muted);
99
+ color: var(--muted-foreground);
100
+ }
101
+
102
+ .hero-description {
103
+ font-size: 1.125rem;
104
+ color: var(--muted-foreground);
105
+ max-width: 600px;
106
+ margin: 0 auto 2rem;
107
+ }
108
+
109
+ .hero-placeholder {
110
+ width: 100%;
111
+ max-width: 700px;
112
+ height: 400px;
113
+ border-radius: var(--radius);
114
+ box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.15);
115
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
116
+ display: flex;
117
+ align-items: center;
118
+ justify-content: center;
119
+ color: white;
120
+ font-size: 3rem;
121
+ }
122
+
123
+ /* Technical Section */
124
+ .technical {
125
+ padding: 4rem 0;
126
+ background-color: var(--card);
127
+ }
128
+
129
+ .technical-grid {
130
+ display: grid;
131
+ grid-template-columns: 1fr 1fr;
132
+ gap: 4rem;
133
+ align-items: start;
134
+ }
135
+
136
+ @media (max-width: 768px) {
137
+ .technical-grid {
138
+ grid-template-columns: 1fr;
139
+ gap: 2rem;
140
+ }
141
+ }
142
+
143
+ .section-title {
144
+ font-size: 1.5rem;
145
+ font-weight: 700;
146
+ color: var(--foreground);
147
+ margin-bottom: 1rem;
148
+ }
149
+
150
+ .section-text {
151
+ color: var(--muted-foreground);
152
+ margin-bottom: 1.5rem;
153
+ }
154
+
155
+ .install-steps {
156
+ list-style: none;
157
+ counter-reset: step;
158
+ }
159
+
160
+ .install-steps li {
161
+ counter-increment: step;
162
+ display: flex;
163
+ align-items: flex-start;
164
+ gap: 1rem;
165
+ margin-bottom: 1rem;
166
+ color: var(--muted-foreground);
167
+ }
168
+
169
+ .install-steps li::before {
170
+ content: counter(step);
171
+ display: flex;
172
+ align-items: center;
173
+ justify-content: center;
174
+ min-width: 1.75rem;
175
+ height: 1.75rem;
176
+ border-radius: 50%;
177
+ background-color: var(--primary);
178
+ color: white;
179
+ font-size: 0.875rem;
180
+ font-weight: 600;
181
+ }
182
+
183
+ .link-tertiary {
184
+ color: var(--muted-foreground);
185
+ text-decoration: underline;
186
+ transition: color 0.2s;
187
+ }
188
+
189
+ .link-tertiary:hover {
190
+ color: var(--primary);
191
+ }
192
+
193
+ .demo-placeholder {
194
+ width: 100%;
195
+ height: 400px;
196
+ border-radius: var(--radius);
197
+ box-shadow: 0 10px 30px -10px rgba(0, 0, 0, 0.1);
198
+ background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
199
+ display: flex;
200
+ align-items: center;
201
+ justify-content: center;
202
+ color: white;
203
+ font-size: 2rem;
204
+ font-weight: 600;
205
+ }
206
+
207
+ /* Footer */
208
+ .footer {
209
+ background-color: #F6F6F8;
210
+ border-top: 1px solid var(--border);
211
+ padding: 2rem 0;
212
+ }
213
+
214
+ .footer-content {
215
+ display: flex;
216
+ flex-direction: column;
217
+ align-items: center;
218
+ gap: 1rem;
219
+ text-align: center;
220
+ }
221
+
222
+ .footer-links {
223
+ display: flex;
224
+ flex-wrap: wrap;
225
+ justify-content: center;
226
+ gap: 1.5rem;
227
+ font-size: 0.875rem;
228
+ }
229
+
230
+ .footer-links a {
231
+ color: var(--muted-foreground);
232
+ text-decoration: none;
233
+ transition: color 0.2s;
234
+ }
235
+
236
+ .footer-links a:hover {
237
+ color: var(--primary);
238
+ }
239
+
240
+ .footer-help {
241
+ font-size: 0.875rem;
242
+ color: var(--muted-foreground);
243
+ }
244
+
245
+ .footer-help a {
246
+ color: var(--primary);
247
+ text-decoration: underline;
248
+ }
249
+
250
+ .footer-help a:hover {
251
+ color: var(--primary-hover);
252
+ }
wake_me_up/__init__.py ADDED
File without changes
wake_me_up/main.py ADDED
@@ -0,0 +1,662 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import threading
2
+ import time
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import numpy as np
7
+ import cv2
8
+ from fastapi.responses import StreamingResponse
9
+ from pydantic import BaseModel
10
+
11
+ from reachy_mini import ReachyMini, ReachyMiniApp
12
+ from reachy_mini.utils import create_head_pose
13
+ from reachy_mini.motion.recorded_move import RecordedMove, RecordedMoves
14
+
15
+
16
+ class WakeMeUp(ReachyMiniApp):
17
+ """
18
+ Wake Me Up - Your first app with Reachy Mini.
19
+ Tests: audio input, motors, audio output, camera.
20
+ """
21
+ custom_app_url: str | None = "http://0.0.0.0:8042"
22
+ request_media_backend: str | None = None
23
+
24
+ # Touch detection constants
25
+ TOUCH_THRESHOLD = 10 # Audio level threshold for touch detection
26
+ TOUCH_DURATION_REQUIRED = 1.5 # Seconds of continuous touch required
27
+ TOUCH_GRACE_PERIOD = 0.4 # Allow 0.4s breaks without resetting
28
+
29
+ # Loop timing constants
30
+ MAIN_LOOP_FREQUENCY = 50 # Hz - frequency of main loop
31
+ STATE_POLL_INTERVAL = 500 # ms - frontend polling interval
32
+ AUDIO_POLL_INTERVAL = 100 # ms - audio level polling interval
33
+
34
+ def __init__(self):
35
+ super().__init__()
36
+ self._state_lock = threading.Lock()
37
+ self._current_step = 0 # Steps 0-5 (0=init, 1-4=tests, 5=success)
38
+ self._is_sleeping = False
39
+ self._audio_level = 0.0
40
+ self._audio_stream = None
41
+ self._last_frame = None
42
+ self._volume = self._get_current_volume() # Read actual system volume
43
+ self._movement_playing = False
44
+ self._movement_busy = False # Tracks entire process (including go to sleep)
45
+ self._cancel_movement = threading.Event()
46
+ self._is_first_movement = True # Track if this is the first play (auto-play)
47
+ self._is_first_emotion = True # Track if this is the first emotion play
48
+ self._emotion_playing = False # Track if emotion is currently playing
49
+ self._auto_play_emotion = False # Flag to auto-play emotion in step 3
50
+ self._auto_play_curious = False # Flag to auto-play curious emotion before step 4
51
+ self._auto_play_enthusiastic = False # Flag to auto-play enthusiastic emotion on step 5
52
+ self._reachy_mini = None
53
+ self._emotions = None # Pre-loaded emotions
54
+
55
+ # Touch detection for audio test
56
+ self._touch_detected = False
57
+ self._touch_start_time = None
58
+ self._touch_accumulated_time = 0.0 # Total time above threshold
59
+ self._last_audio_check = None
60
+ self._auto_play_movement = False # Flag to auto-play movement in step 2
61
+
62
+ self._register_routes()
63
+
64
+ def run(self, reachy_mini: ReachyMini, stop_event: threading.Event):
65
+ self._reachy_mini = reachy_mini
66
+
67
+ # Pre-load emotions library (so no wait during diagnostic)
68
+ print("Pre-loading emotions library...")
69
+ try:
70
+ self._emotions = RecordedMoves("pollen-robotics/reachy-mini-emotions-library")
71
+ print("Emotions loaded successfully")
72
+ except Exception as e:
73
+ print(f"Failed to pre-load emotions: {e}")
74
+
75
+ # Put robot in sleeping pose at startup
76
+ print("Setting sleeping pose...")
77
+ try:
78
+ reachy_mini.goto_sleep()
79
+ with self._state_lock:
80
+ self._is_sleeping = True
81
+ # Auto-advance to step 1 when sleeping
82
+ if self._current_step == 0:
83
+ self._current_step = 1
84
+ print("Sleeping pose set, advancing to step 1")
85
+ except Exception as e:
86
+ print(f"Failed to set sleeping pose: {e}")
87
+
88
+ # Start audio input stream (always listening)
89
+ self._start_audio_input()
90
+
91
+ # Helper function to check and execute auto-play actions
92
+ def check_auto_play(flag_attr, step, delay, target_func, args, description):
93
+ with self._state_lock:
94
+ should_play = getattr(self, flag_attr)
95
+ current = self._current_step
96
+
97
+ if should_play and current == step:
98
+ with self._state_lock:
99
+ setattr(self, flag_attr, False)
100
+ time.sleep(delay)
101
+ print(f"Auto-playing {description}...")
102
+ threading.Thread(target=target_func, args=args, daemon=True).start()
103
+ return True
104
+ return False
105
+
106
+ # Main loop
107
+ while not stop_event.is_set():
108
+ # Check and execute auto-play actions (one per iteration)
109
+ check_auto_play('_auto_play_movement', 2, 0.5, self._play_test_movement, (reachy_mini,), "test movement") or \
110
+ check_auto_play('_auto_play_emotion', 3, 0.5, self._play_emotion, (reachy_mini, "success2"), "happy emotion") or \
111
+ check_auto_play('_auto_play_curious', 4, 1.0, self._play_emotion, (reachy_mini, "curious1"), "curious emotion") or \
112
+ check_auto_play('_auto_play_enthusiastic', 5, 0.5, self._play_emotion, (reachy_mini, "enthusiastic1"), "enthusiastic emotion")
113
+
114
+ # Capture camera frames only during step 4
115
+ with self._state_lock:
116
+ current = self._current_step
117
+
118
+ if current == 4:
119
+ frame = reachy_mini.media.get_frame()
120
+ if frame is not None:
121
+ with self._state_lock:
122
+ self._last_frame = cv2.resize(frame, (640, 480))
123
+
124
+ time.sleep(1.0 / self.MAIN_LOOP_FREQUENCY)
125
+
126
+ # Cleanup
127
+ self._stop_audio_input()
128
+
129
+ # ========== Audio Input ==========
130
+ def _start_audio_input(self):
131
+ """Start continuous audio monitoring"""
132
+ try:
133
+ import sounddevice as sd
134
+ except ImportError:
135
+ print("sounddevice not available - audio monitoring disabled")
136
+ return
137
+
138
+ def audio_callback(indata, frames, time_info, status):
139
+ if status:
140
+ print(f"Audio status: {status}")
141
+ rms = np.sqrt(np.mean(indata**2))
142
+ audio_level = float(rms * 100) # Scale to 0-100
143
+
144
+ with self._state_lock:
145
+ self._audio_level = audio_level
146
+
147
+ # Touch detection logic (only during step 1)
148
+ if self._current_step == 1 and not self._touch_detected:
149
+ current_time = time.time()
150
+
151
+ if audio_level > self.TOUCH_THRESHOLD:
152
+ # High audio level - touching/rubbing
153
+ if self._last_audio_check is not None:
154
+ # Accumulate the time since last check
155
+ delta = current_time - self._last_audio_check
156
+ self._touch_accumulated_time += delta
157
+
158
+ # Check if accumulated time reaches required duration
159
+ if self._touch_accumulated_time >= self.TOUCH_DURATION_REQUIRED:
160
+ self._touch_detected = True
161
+ print("Touch detected! Auto-advancing to step 2")
162
+ # Auto-advance to step 2
163
+ self._current_step = 2
164
+ self._auto_play_movement = True
165
+
166
+ if self._touch_start_time is None:
167
+ self._touch_start_time = current_time
168
+
169
+ self._last_audio_check = current_time
170
+ else:
171
+ # Audio level dropped
172
+ if self._last_audio_check is not None:
173
+ # Check if break is within grace period
174
+ break_duration = current_time - self._last_audio_check
175
+ if break_duration > self.TOUCH_GRACE_PERIOD:
176
+ # Break too long - reset progress
177
+ self._touch_start_time = None
178
+ self._touch_accumulated_time = 0.0
179
+ self._last_audio_check = None
180
+
181
+ try:
182
+ self._audio_stream = sd.InputStream(
183
+ samplerate=44100,
184
+ channels=1,
185
+ dtype='float32',
186
+ callback=audio_callback
187
+ )
188
+ self._audio_stream.start()
189
+ print("Audio monitoring started")
190
+ except Exception as e:
191
+ print(f"Failed to start audio monitoring: {e}")
192
+
193
+ def _stop_audio_input(self):
194
+ if self._audio_stream:
195
+ try:
196
+ self._audio_stream.stop()
197
+ self._audio_stream.close()
198
+ print("Audio monitoring stopped")
199
+ except Exception as e:
200
+ print(f"Error stopping audio: {e}")
201
+
202
+ def _get_current_volume(self) -> float:
203
+ """Read current system volume (same method as dashboard)"""
204
+ try:
205
+ import subprocess
206
+ import re
207
+
208
+ card_name = "Audio" # Reachy Mini Audio card
209
+
210
+ # Get current volume using amixer
211
+ result = subprocess.run(
212
+ ["amixer", "-c", card_name, "sget", "PCM"],
213
+ capture_output=True,
214
+ text=True,
215
+ timeout=2
216
+ )
217
+
218
+ if result.returncode == 0:
219
+ # Parse output to extract volume percentage
220
+ # Example: [75%] [on]
221
+ match = re.search(r'\[(\d+)%\]', result.stdout)
222
+ if match:
223
+ volume_percent = int(match.group(1))
224
+ volume = volume_percent / 100.0
225
+ print(f"Current system volume: {volume_percent}%")
226
+ return volume
227
+
228
+ except Exception as e:
229
+ print(f"Failed to read system volume: {e}")
230
+
231
+ # Default to 50% if we can't read
232
+ print("Using default volume: 50%")
233
+ return 0.5
234
+
235
+ # ========== Motor Movement ==========
236
+ def _play_test_movement(self, reachy_mini):
237
+ """Play movement from moves/test_movement.json"""
238
+ with self._state_lock:
239
+ if self._movement_busy:
240
+ return # Already busy
241
+ self._movement_busy = True
242
+ self._cancel_movement.clear()
243
+ is_first = self._is_first_movement
244
+ is_sleeping = self._is_sleeping
245
+
246
+ try:
247
+ # Only go to sleep if:
248
+ # - This is NOT the first movement (auto-play)
249
+ # - Robot is NOT already in sleep position (after stop)
250
+ if not is_first and not is_sleeping:
251
+ print("Going to sleep before replay...")
252
+ reachy_mini.goto_sleep()
253
+ with self._state_lock:
254
+ self._is_sleeping = True
255
+ time.sleep(0.3) # Brief pause after reaching sleep position
256
+ print("Sleep position reached, starting movement...")
257
+ elif is_first:
258
+ print("First movement play, starting directly...")
259
+ else:
260
+ print("Already in sleep position, starting movement...")
261
+
262
+ # Mark that first movement has been played
263
+ with self._state_lock:
264
+ self._is_first_movement = False
265
+
266
+ # Now set movement_playing to trigger video sync
267
+ with self._state_lock:
268
+ self._movement_playing = True
269
+
270
+ # Load movement
271
+ move_path = Path(__file__).parent / "moves" / "hello-world.json"
272
+ if not move_path.exists():
273
+ print(f"Movement file not found: {move_path}")
274
+ # Fallback: simple head movement
275
+ self._play_simple_movement(reachy_mini)
276
+ else:
277
+ move = self._load_move(move_path)
278
+ reachy_mini.enable_motors()
279
+
280
+ # Check for cancellation before playing
281
+ if self._cancel_movement.is_set():
282
+ print("Movement cancelled before starting")
283
+ return
284
+
285
+ # Use streaming playback instead of play_move to allow cancellation
286
+ cancelled = not self._stream_playback(reachy_mini, move)
287
+
288
+ if cancelled:
289
+ print("Movement was cancelled, going to sleep...")
290
+ # Stop robot at current position by going to sleep (blocking)
291
+ reachy_mini.goto_sleep()
292
+ with self._state_lock:
293
+ self._is_sleeping = True
294
+ print("Robot in sleep position")
295
+ else:
296
+ # Movement completed successfully, return to neutral position
297
+ print("Test movement completed, returning to neutral position...")
298
+ reachy_mini.goto_target(
299
+ head=create_head_pose(pitch=0.0, yaw=0.0, roll=0.0),
300
+ antennas=[0, 0],
301
+ duration=0.8
302
+ )
303
+ with self._state_lock:
304
+ self._is_sleeping = False
305
+ print("Robot in neutral position (motors still enabled)")
306
+
307
+ except Exception as e:
308
+ print(f"Failed to play movement: {e}")
309
+ # Fallback
310
+ try:
311
+ if not self._cancel_movement.is_set():
312
+ self._play_simple_movement(reachy_mini)
313
+ except Exception as e2:
314
+ print(f"Fallback movement also failed: {e2}")
315
+ finally:
316
+ with self._state_lock:
317
+ self._movement_playing = False
318
+ self._movement_busy = False
319
+
320
+ def _stream_playback(self, reachy_mini, move: RecordedMove) -> bool:
321
+ """Stream movement playback with cancellation support (from marionette pattern)"""
322
+ try:
323
+ start_head_pose, start_antennas, start_body_yaw = move.evaluate(0.0)
324
+ except Exception:
325
+ return False
326
+
327
+ # Go to starting position
328
+ reachy_mini.goto_target(
329
+ head=start_head_pose,
330
+ antennas=list(start_antennas) if start_antennas is not None else None,
331
+ duration=0.8,
332
+ )
333
+ if start_body_yaw is not None:
334
+ reachy_mini.set_target_body_yaw(float(start_body_yaw))
335
+
336
+ playback_freq = 100.0
337
+ sleep_period = 1.0 / playback_freq
338
+ t0 = time.time()
339
+
340
+ while True:
341
+ if self._cancel_movement.is_set():
342
+ return False
343
+
344
+ elapsed = time.time() - t0
345
+ if elapsed >= move.duration:
346
+ break
347
+
348
+ t = min(max(elapsed, 0.0), max(move.duration - 1e-3, 0.0))
349
+ head, antennas, body_yaw = move.evaluate(t)
350
+ reachy_mini.set_target_head_pose(head)
351
+
352
+ if body_yaw is not None:
353
+ reachy_mini.set_target_body_yaw(float(body_yaw))
354
+ if antennas is not None:
355
+ reachy_mini.set_target_antenna_joint_positions(list(antennas))
356
+
357
+ remaining = move.duration - elapsed
358
+ wait_time = max(0.001, min(sleep_period, remaining))
359
+ if self._cancel_movement.wait(wait_time):
360
+ return False
361
+
362
+ return True
363
+
364
+ def _play_simple_movement(self, reachy_mini):
365
+ """Fallback: simple head movement sequence"""
366
+ print("Playing simple fallback movement")
367
+ reachy_mini.enable_motors()
368
+ try:
369
+ # Look up
370
+ if self._cancel_movement.is_set():
371
+ return
372
+ reachy_mini.goto_target(
373
+ head=create_head_pose(pitch=15, degrees=True),
374
+ antennas=[0, 0],
375
+ duration=1.0
376
+ )
377
+ time.sleep(1.0)
378
+
379
+ # Look left
380
+ if self._cancel_movement.is_set():
381
+ return
382
+ reachy_mini.goto_target(
383
+ head=create_head_pose(yaw=30, degrees=True),
384
+ antennas=[0, 0],
385
+ duration=1.0
386
+ )
387
+ time.sleep(1.0)
388
+
389
+ # Look right
390
+ if self._cancel_movement.is_set():
391
+ return
392
+ reachy_mini.goto_target(
393
+ head=create_head_pose(yaw=-30, degrees=True),
394
+ antennas=[0, 0],
395
+ duration=1.0
396
+ )
397
+ time.sleep(1.0)
398
+
399
+ # Center
400
+ if self._cancel_movement.is_set():
401
+ return
402
+ reachy_mini.goto_target(
403
+ head=create_head_pose(),
404
+ antennas=[0, 0],
405
+ duration=1.0
406
+ )
407
+ time.sleep(1.0)
408
+ # Keep motors enabled after movement
409
+ except Exception as e:
410
+ print(f"Error in simple movement: {e}")
411
+
412
+ def _load_move(self, json_path: Path) -> RecordedMove:
413
+ """Load a recorded movement from JSON file"""
414
+ move_data = json.loads(json_path.read_text(encoding='utf-8'))
415
+ return RecordedMove(move_data, sound_path=None)
416
+
417
+ # ========== Emotion Playback ==========
418
+ def _play_emotion(self, reachy_mini, emotion_name):
419
+ """Play an emotion from the library with sound"""
420
+ with self._state_lock:
421
+ is_first = self._is_first_emotion
422
+ is_sleeping = self._is_sleeping
423
+ self._emotion_playing = True
424
+
425
+ try:
426
+ # Check if robot is sleeping and wake it up if needed
427
+ if is_sleeping:
428
+ print(f"Robot is sleeping, waking up before playing {emotion_name}...")
429
+ reachy_mini.enable_motors()
430
+ reachy_mini.goto_target(
431
+ head=create_head_pose(pitch=0.0, yaw=0.0, roll=0.0),
432
+ duration=1.0
433
+ )
434
+ with self._state_lock:
435
+ self._is_sleeping = False
436
+ time.sleep(0.3)
437
+ print(f"Robot awake, playing {emotion_name}...")
438
+ elif is_first:
439
+ print(f"First emotion play, starting {emotion_name} directly...")
440
+ else:
441
+ print(f"Playing {emotion_name}...")
442
+
443
+ # Mark that first emotion has been played
444
+ with self._state_lock:
445
+ self._is_first_emotion = False
446
+
447
+ # Use pre-loaded emotions (no wait time!)
448
+ if self._emotions is None:
449
+ print("Emotions not loaded, loading now...")
450
+ self._emotions = RecordedMoves("pollen-robotics/reachy-mini-emotions-library")
451
+
452
+ emotion_move = self._emotions.get(emotion_name)
453
+
454
+ reachy_mini.enable_motors()
455
+ reachy_mini.play_move(emotion_move, initial_goto_duration=0.8, sound=True)
456
+
457
+ # Return to neutral position after emotion
458
+ print(f"{emotion_name} completed, returning to neutral position...")
459
+ reachy_mini.goto_target(
460
+ head=create_head_pose(pitch=0.0, yaw=0.0, roll=0.0),
461
+ antennas=[0, 0],
462
+ duration=0.8
463
+ )
464
+ with self._state_lock:
465
+ self._is_sleeping = False
466
+ print("Robot in neutral position (motors still enabled)")
467
+
468
+ except Exception as e:
469
+ print(f"Failed to play {emotion_name}: {e}")
470
+ # Fallback: just play sound (only for happy/success2)
471
+ if emotion_name == "success2":
472
+ try:
473
+ reachy_mini.media.play_sound("wake_up.wav")
474
+ print("Played fallback sound")
475
+ except Exception as e2:
476
+ print(f"Failed to play fallback sound: {e2}")
477
+ finally:
478
+ with self._state_lock:
479
+ self._emotion_playing = False
480
+
481
+ # ========== Camera Streaming ==========
482
+ def _frame_generator(self):
483
+ """MJPEG streaming generator"""
484
+ while True:
485
+ with self._state_lock:
486
+ frame = self._last_frame
487
+
488
+ if frame is None:
489
+ time.sleep(0.01)
490
+ continue
491
+
492
+ try:
493
+ ret, jpeg = cv2.imencode('.jpg', frame)
494
+ if not ret:
495
+ time.sleep(0.01)
496
+ continue
497
+
498
+ frame_bytes = jpeg.tobytes()
499
+ yield (
500
+ b'--frame\r\n'
501
+ b'Content-Type: image/jpeg\r\n\r\n' + frame_bytes + b'\r\n'
502
+ )
503
+ except Exception as e:
504
+ print(f"Frame encoding error: {e}")
505
+
506
+ time.sleep(0.05) # ~20 FPS
507
+
508
+ # ========== FastAPI Routes ==========
509
+ def _register_routes(self):
510
+ @self.settings_app.get("/api/state")
511
+ def get_state():
512
+ return self._serialize_state()
513
+
514
+ @self.settings_app.get("/api/audio_level")
515
+ def get_audio_level():
516
+ """Fast endpoint for real-time audio visualization"""
517
+ with self._state_lock:
518
+ # Calculate touch progress (0.0 to 1.0) based on accumulated time
519
+ touch_progress = 0.0
520
+ if not self._touch_detected:
521
+ touch_progress = min(self._touch_accumulated_time / self.TOUCH_DURATION_REQUIRED, 1.0)
522
+
523
+ return {
524
+ "audio_level": self._audio_level,
525
+ "touch_detected": self._touch_detected,
526
+ "touch_progress": touch_progress
527
+ }
528
+
529
+ @self.settings_app.post("/api/validate_step")
530
+ def validate_step(payload: dict):
531
+ """User validates current step with works/doesn't work"""
532
+ step = payload.get("step") # 1, 2, 3, or 4
533
+ works = payload.get("works") # True or False
534
+
535
+ with self._state_lock:
536
+ if step == 1:
537
+ self._current_step = 2
538
+ # Reset touch detection for next time
539
+ self._touch_detected = False
540
+ self._touch_start_time = None
541
+ self._touch_accumulated_time = 0.0
542
+ self._last_audio_check = None
543
+ elif step == 2:
544
+ self._current_step = 3
545
+ # Auto-play emotion when entering step 3
546
+ self._auto_play_emotion = True
547
+ elif step == 3:
548
+ self._current_step = 4
549
+ # Auto-play curious emotion after 1s on step 4
550
+ self._auto_play_curious = True
551
+ elif step == 4:
552
+ self._current_step = 5 # Done
553
+ # Auto-play enthusiastic emotion on step 5
554
+ self._auto_play_enthusiastic = True
555
+
556
+ next_step = self._current_step
557
+
558
+ return {"status": "ok", "next_step": next_step}
559
+
560
+ @self.settings_app.post("/api/play_movement")
561
+ def play_movement():
562
+ """Trigger motor test movement"""
563
+ if self._reachy_mini is None:
564
+ return {"status": "error", "message": "Robot not initialized"}
565
+
566
+ threading.Thread(
567
+ target=self._play_test_movement,
568
+ args=(self._reachy_mini,),
569
+ daemon=True
570
+ ).start()
571
+ return {"status": "playing"}
572
+
573
+ @self.settings_app.post("/api/stop_movement")
574
+ def stop_movement():
575
+ """Stop the current movement"""
576
+ if self._reachy_mini is None:
577
+ return {"status": "error", "message": "Robot not initialized"}
578
+
579
+ try:
580
+ # Signal the movement thread to stop
581
+ self._cancel_movement.set()
582
+ print("Movement cancellation requested")
583
+ return {"status": "stopping"}
584
+ except Exception as e:
585
+ print(f"Failed to stop movement: {e}")
586
+ return {"status": "error", "message": str(e)}
587
+
588
+ @self.settings_app.post("/api/play_happy")
589
+ def play_happy():
590
+ """Play happy emotion with sound"""
591
+ if self._reachy_mini is None:
592
+ return {"status": "error", "message": "Robot not initialized"}
593
+
594
+ threading.Thread(
595
+ target=self._play_emotion,
596
+ args=(self._reachy_mini, "success2"),
597
+ daemon=True
598
+ ).start()
599
+ return {"status": "playing"}
600
+
601
+ @self.settings_app.post("/api/set_volume")
602
+ def set_volume(payload: dict):
603
+ volume = payload.get("volume", 0.5)
604
+ with self._state_lock:
605
+ self._volume = max(0.0, min(1.0, float(volume)))
606
+
607
+ # Apply volume to system using amixer (same method as dashboard)
608
+ try:
609
+ import subprocess
610
+ volume_percent = int(self._volume * 100)
611
+
612
+ # Detect audio card (same as dashboard implementation)
613
+ card_name = "Audio" # Default for Reachy Mini Audio
614
+
615
+ # Set volume using amixer with card name
616
+ subprocess.run(
617
+ ["amixer", "-c", card_name, "sset", "PCM", f"{volume_percent}%"],
618
+ check=False,
619
+ capture_output=True,
620
+ timeout=2
621
+ )
622
+ # Also set PCM,1 to 100% (dashboard quirk for compatibility)
623
+ subprocess.run(
624
+ ["amixer", "-c", card_name, "sset", "PCM,1", "100%"],
625
+ check=False,
626
+ capture_output=True,
627
+ timeout=2
628
+ )
629
+ print(f"Volume set to {volume_percent}%")
630
+ except Exception as e:
631
+ print(f"Failed to set system volume: {e}")
632
+
633
+ return {"volume": self._volume}
634
+
635
+ @self.settings_app.get("/video_feed")
636
+ def video_feed():
637
+ return StreamingResponse(
638
+ self._frame_generator(),
639
+ media_type="multipart/x-mixed-replace; boundary=frame"
640
+ )
641
+
642
+ def _serialize_state(self):
643
+ """Serialize current state for API"""
644
+ with self._state_lock:
645
+ return {
646
+ "current_step": self._current_step,
647
+ "is_sleeping": self._is_sleeping,
648
+ "audio_level": self._audio_level,
649
+ "volume": self._volume,
650
+ "movement_playing": self._movement_playing,
651
+ "movement_busy": self._movement_busy,
652
+ "is_first_emotion": self._is_first_emotion,
653
+ "emotion_playing": self._emotion_playing,
654
+ }
655
+
656
+
657
+ if __name__ == "__main__":
658
+ app = WakeMeUp()
659
+ try:
660
+ app.wrapped_run()
661
+ except KeyboardInterrupt:
662
+ app.stop()
wake_me_up/moves/hello-world.json ADDED
The diff for this file is too large to render. See raw diff
 
wake_me_up/static/assets/.gitkeep ADDED
File without changes
wake_me_up/static/index.html ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>Reachy Mini Diagnostic</title>
7
+ <link rel="stylesheet" href="/static/style.css">
8
+ </head>
9
+ <body>
10
+ <header class="header">
11
+ <h1>First Steps with Reachy Mini</h1>
12
+ </header>
13
+
14
+ <main class="container">
15
+
16
+ <!-- Step 0: Scanning Motors -->
17
+ <section id="step-0" class="step-screen active">
18
+ <div class="card">
19
+ <h2>Initializing Reachy Mini</h2>
20
+ <p class="instruction">Scanning motors and setting sleeping pose...</p>
21
+ <div class="loading-spinner">
22
+ <div class="spinner"></div>
23
+ </div>
24
+ </div>
25
+ </section>
26
+
27
+ <!-- Step 1: Audio Input Test -->
28
+ <section id="step-1" class="step-screen">
29
+ <div class="card">
30
+ <div class="title-with-icon">
31
+ <img src="/static/assets/sleeping-reachy.svg" alt="Sleeping Reachy" class="sleeping-reachy-icon">
32
+ <h2>Time to Wake Up!</h2>
33
+ </div>
34
+ <video class="demo-video" autoplay loop muted playsinline>
35
+ <source src="/static/assets/touch_demo.mp4" type="video/mp4">
36
+ <p style="color: #999;">Demo video not available</p>
37
+ </video>
38
+
39
+ <p class="instruction">Wake up Reachy Mini</p>
40
+
41
+ <div class="touch-progress-container">
42
+ <div class="touch-progress-bar" id="touch-progress-bar"></div>
43
+ </div>
44
+
45
+ <div class="waveform-container" id="waveform"></div>
46
+
47
+ <div class="button-group">
48
+ <a href="https://huggingface.co/docs/reachy_mini/troubleshooting" target="_blank" class="btn-fail">Sound doesn't work </a>
49
+ </div>
50
+
51
+ <div class="step-indicator" id="step-indicator">Step <span id="current-step">1</span> of 4</div>
52
+ </div>
53
+ </section>
54
+
55
+ <!-- Step 2: Motor Test -->
56
+ <section id="step-2" class="step-screen">
57
+ <div class="card">
58
+ <div class="title-with-icon">
59
+ <img src="/static/assets/reachy-mini.png" alt="Reachy Mini" class="reachy-mini-icon">
60
+ <h2>Stretch Time!</h2>
61
+ </div>
62
+ <video class="demo-video" muted playsinline preload="auto">
63
+ <source src="/static/assets/movement_demo.mp4" type="video/mp4">
64
+ <p style="color: #999;">Demo video not available</p>
65
+ </video>
66
+ <p class="instruction">Watch the robot perform the movement</p>
67
+
68
+ <button class="btn-text" id="btn-play-movement" onclick="playMovement()">
69
+ Replay Movement
70
+ </button>
71
+
72
+ <div class="button-group" id="motor-validate" style="display: none;">
73
+ <a href="https://huggingface.co/docs/reachy_mini/troubleshooting" target="_blank" class="btn-fail">Robot didn't move correctly </a>
74
+ <button class="btn-success" onclick="validateStep(2, true)">Robot did the same </button>
75
+ </div>
76
+
77
+ <div class="step-indicator">Step 2 of 4</div>
78
+ </div>
79
+ </section>
80
+
81
+ <!-- Step 3: Audio Output Test -->
82
+ <section id="step-3" class="step-screen">
83
+ <div class="card">
84
+ <div class="title-with-icon">
85
+ <img src="/static/assets/reachy-mini-sav.png" alt="Reachy Mini" class="reachy-mini-icon">
86
+ <h2>Can You Hear Me?</h2>
87
+ </div>
88
+ <p class="instruction">Listen to the robot's happy sound</p>
89
+
90
+ <div class="volume-control">
91
+ <label for="volume-slider">Volume</label>
92
+ <input type="range" id="volume-slider" min="0" max="100" value="50">
93
+ <span id="volume-value">50%</span>
94
+ </div>
95
+
96
+ <button class="btn-text" id="btn-play-sound" onclick="playHappy()">Play Sound</button>
97
+
98
+ <div class="button-group">
99
+ <a href="https://huggingface.co/docs/reachy_mini/troubleshooting" target="_blank" class="btn-fail">No sound </a>
100
+ <button class="btn-success" onclick="validateStep(3, true)">I hear the sound </button>
101
+ </div>
102
+
103
+ <div class="step-indicator">Step 3 of 4</div>
104
+ </div>
105
+ </section>
106
+
107
+ <!-- Step 4: Camera Test -->
108
+ <section id="step-4" class="step-screen">
109
+ <div class="card">
110
+ <div class="title-with-icon">
111
+ <img src="/static/assets/reachy-mini-explorer.png" alt="Reachy Mini" class="reachy-mini-icon explorer-icon">
112
+ <h2>Let's Look Around!</h2>
113
+ </div>
114
+ <p class="instruction">Check if you can see the camera feed</p>
115
+
116
+ <div class="camera-container">
117
+ <img id="camera-feed" src="/video_feed" alt="Camera feed">
118
+ </div>
119
+
120
+ <div class="button-group">
121
+ <a href="https://huggingface.co/docs/reachy_mini/troubleshooting" target="_blank" class="btn-fail">Camera doesn't work </a>
122
+ <button class="btn-success" onclick="validateStep(4, true)">Camera works </button>
123
+ </div>
124
+
125
+ <div class="step-indicator">Step 4 of 4</div>
126
+ </div>
127
+ </section>
128
+
129
+ <!-- Step 5: Summary -->
130
+ <section id="step-5" class="step-screen">
131
+ <div class="card">
132
+ <div class="title-with-icon">
133
+ <img src="/static/assets/reachy-mini-party.png" alt="Reachy Mini" class="reachy-mini-icon">
134
+ <h2>We're Ready to Play!</h2>
135
+ </div>
136
+ <p class="instruction" style="font-size: 18px; margin: 30px 0;">Everything is working correctly</p>
137
+ </div>
138
+ </section>
139
+ </main>
140
+ <script src="/static/main.js"></script>
141
+ </body>
142
+ </html>
wake_me_up/static/main.js ADDED
@@ -0,0 +1,328 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ let waveformBars = [];
2
+ let audioPollingInterval = null;
3
+ let wasMovementPlaying = false;
4
+ let lastStep = null;
5
+
6
+ // Initialize
7
+ document.addEventListener('DOMContentLoaded', () => {
8
+ initWaveform();
9
+ startPolling();
10
+ setupVolumeSlider();
11
+ });
12
+
13
+ // Create waveform bars
14
+ function initWaveform() {
15
+ const container = document.getElementById('waveform');
16
+ if (!container) return;
17
+
18
+ const barCount = 50;
19
+ for (let i = 0; i < barCount; i++) {
20
+ const bar = document.createElement('div');
21
+ bar.className = 'waveform-bar';
22
+ bar.style.height = '4px';
23
+ container.appendChild(bar);
24
+ waveformBars.push(bar);
25
+ }
26
+ }
27
+
28
+ // State polling
29
+ async function fetchState() {
30
+ try {
31
+ const resp = await fetch('/api/state', { cache: 'no-store' });
32
+ const state = await resp.json();
33
+ updateUI(state);
34
+ } catch (err) {
35
+ console.error('State fetch failed:', err);
36
+ }
37
+ }
38
+
39
+ function startPolling() {
40
+ fetchState();
41
+ setInterval(fetchState, 500);
42
+ }
43
+
44
+ // Fast audio polling for live waveform
45
+ async function fetchAudioLevel() {
46
+ try {
47
+ const resp = await fetch('/api/audio_level', { cache: 'no-store' });
48
+ const data = await resp.json();
49
+ updateWaveform(data.audio_level);
50
+ updateTouchDetection(data.touch_progress, data.touch_detected);
51
+ } catch (err) {
52
+ console.error('Audio fetch failed:', err);
53
+ }
54
+ }
55
+
56
+ function updateTouchDetection(progress, detected) {
57
+ const progressBar = document.getElementById('touch-progress-bar');
58
+
59
+ if (!progressBar) return;
60
+
61
+ if (detected) {
62
+ progressBar.style.width = '100%';
63
+ } else {
64
+ progressBar.style.width = (progress * 100) + '%';
65
+ }
66
+ }
67
+
68
+ function startAudioPolling() {
69
+ if (audioPollingInterval) return; // Already running
70
+ fetchAudioLevel(); // Immediate first fetch
71
+ audioPollingInterval = setInterval(fetchAudioLevel, 100); // 10 Hz
72
+ }
73
+
74
+ function stopAudioPolling() {
75
+ if (audioPollingInterval) {
76
+ clearInterval(audioPollingInterval);
77
+ audioPollingInterval = null;
78
+ }
79
+ }
80
+
81
+ // Helper function to toggle button states
82
+ function setButtonsDisabled(selector, disabled) {
83
+ const buttons = document.querySelectorAll(selector);
84
+ buttons.forEach(button => {
85
+ button.disabled = disabled;
86
+ button.style.opacity = disabled ? '0.5' : '1';
87
+ button.style.cursor = disabled ? 'not-allowed' : 'pointer';
88
+ });
89
+ }
90
+
91
+ // UI Updates
92
+ function updateUI(state) {
93
+ // Update current step number in step 1 indicator
94
+ const currentStepSpan = document.getElementById('current-step');
95
+ if (currentStepSpan) {
96
+ currentStepSpan.textContent = state.current_step;
97
+ }
98
+
99
+ document.querySelectorAll('.step-screen').forEach(s => s.classList.remove('active'));
100
+ document.getElementById('step-' + state.current_step).classList.add('active');
101
+
102
+ // Start/stop fast audio polling based on step
103
+ if (state.current_step === 1) {
104
+ startAudioPolling();
105
+ } else {
106
+ stopAudioPolling();
107
+ }
108
+
109
+ if (state.current_step === 2) {
110
+ const btn = document.getElementById('btn-play-movement');
111
+ const validateDiv = document.getElementById('motor-validate');
112
+ const validateButtons = document.querySelectorAll('#motor-validate button');
113
+ const video = document.querySelector('#step-2 .demo-video');
114
+
115
+ // Detect when movement starts playing (transition from false to true)
116
+ if (state.movement_playing && !wasMovementPlaying) {
117
+ // Movement just started - sync video
118
+ if (video) {
119
+ video.currentTime = 0;
120
+ setTimeout(() => {
121
+ video.play().catch(err => console.log('Video play failed:', err));
122
+ }, 600);
123
+ }
124
+ }
125
+
126
+ // Use movement_busy for button state (covers entire process including sleep)
127
+ if (!state.movement_busy) {
128
+ // Process finished, reset button and enable validation
129
+ if (btn) {
130
+ btn.dataset.playing = 'false';
131
+ btn.textContent = 'Replay Movement';
132
+ btn.disabled = false;
133
+ // Switch back to text link style
134
+ btn.classList.remove('btn-primary');
135
+ btn.classList.add('btn-text');
136
+ }
137
+ if (validateDiv) validateDiv.style.display = 'flex';
138
+ // Enable validation buttons
139
+ validateButtons.forEach(b => b.disabled = false);
140
+ } else {
141
+ // Process is busy (sleeping or playing)
142
+ if (btn) {
143
+ btn.dataset.playing = 'true';
144
+ btn.textContent = 'Stop';
145
+ btn.disabled = false;
146
+ // Switch to solid button style
147
+ btn.classList.remove('btn-text');
148
+ btn.classList.add('btn-primary');
149
+ }
150
+ // Disable validation buttons
151
+ validateButtons.forEach(b => b.disabled = true);
152
+ }
153
+
154
+ wasMovementPlaying = state.movement_playing;
155
+ }
156
+
157
+ if (state.current_step === 3) {
158
+ // Update button text based on whether emotion has been played
159
+ const btn = document.getElementById('btn-play-sound');
160
+ if (btn && !state.is_first_emotion) {
161
+ btn.textContent = 'Replay Sound';
162
+ }
163
+
164
+ // Update volume slider to match current system volume
165
+ const slider = document.getElementById('volume-slider');
166
+ const volumeValue = document.getElementById('volume-value');
167
+ if (slider && volumeValue) {
168
+ const volumePercent = Math.round(state.volume * 100);
169
+ slider.value = volumePercent;
170
+ volumeValue.textContent = volumePercent + '%';
171
+ }
172
+
173
+ // Disable validation buttons while emotion is playing
174
+ setButtonsDisabled('#step-3 .button-group button, #step-3 .button-group a', state.emotion_playing);
175
+ }
176
+
177
+ if (state.current_step === 4 && lastStep !== 4) {
178
+ // Reload camera feed only once when entering step 4 to prevent frozen image
179
+ const cameraFeed = document.getElementById('camera-feed');
180
+ if (cameraFeed) {
181
+ // Force reload by adding timestamp to prevent caching
182
+ const timestamp = new Date().getTime();
183
+ cameraFeed.src = '/video_feed?' + timestamp;
184
+
185
+ // Remove any previous animation class and reset style
186
+ cameraFeed.classList.remove('camera-wake-up');
187
+ cameraFeed.style.clipPath = '';
188
+
189
+ // Add eye-opening animation
190
+ cameraFeed.classList.add('camera-wake-up');
191
+
192
+ // After animations complete, maintain eye shape (ellipse)
193
+ setTimeout(() => {
194
+ cameraFeed.classList.remove('camera-wake-up');
195
+ cameraFeed.style.clipPath = 'ellipse(100% 50% at 50% 50%)';
196
+ }, 2700);
197
+ }
198
+ }
199
+
200
+ if (state.current_step === 4) {
201
+ // Disable validation buttons while curious emotion is playing
202
+ setButtonsDisabled('#step-4 .button-group button, #step-4 .button-group a', state.emotion_playing);
203
+ }
204
+
205
+ lastStep = state.current_step;
206
+ }
207
+
208
+ async function validateStep(step, works) {
209
+ try {
210
+ await fetch('/api/validate_step', {
211
+ method: 'POST',
212
+ headers: { 'Content-Type': 'application/json' },
213
+ body: JSON.stringify({ step, works })
214
+ });
215
+ await fetchState();
216
+ } catch (err) {
217
+ console.error('Validation failed:', err);
218
+ alert('Failed to validate step');
219
+ }
220
+ }
221
+
222
+ async function playMovement() {
223
+ try {
224
+ const btn = document.getElementById('btn-play-movement');
225
+
226
+ // If currently playing, stop it
227
+ if (btn.dataset.playing === 'true') {
228
+ await stopMovement();
229
+ return;
230
+ }
231
+
232
+ btn.dataset.playing = 'true';
233
+ btn.textContent = 'Stop';
234
+ // Switch to solid button for Stop action
235
+ btn.classList.remove('btn-text');
236
+ btn.classList.add('btn-primary');
237
+
238
+ // Find the video element and pause it at frame 0
239
+ const video = document.querySelector('#step-2 .demo-video');
240
+ if (video) {
241
+ // Pause video and reset to start - keep it paused during sleep phase
242
+ video.pause();
243
+ video.currentTime = 0;
244
+ }
245
+
246
+ // Start the robot movement (video will auto-start when movement_playing becomes true)
247
+ await fetch('/api/play_movement', { method: 'POST' });
248
+ } catch (err) {
249
+ console.error('Play movement failed:', err);
250
+ alert('Failed to play movement');
251
+ }
252
+ }
253
+
254
+ async function stopMovement() {
255
+ try {
256
+ // Disable the button immediately to show it's processing
257
+ const btn = document.getElementById('btn-play-movement');
258
+ if (btn) {
259
+ btn.disabled = true;
260
+ }
261
+
262
+ // Stop the video
263
+ const video = document.querySelector('#step-2 .demo-video');
264
+ if (video) {
265
+ video.pause();
266
+ video.currentTime = 0;
267
+ }
268
+
269
+ // Call stop API (backend will handle stopping the robot)
270
+ await fetch('/api/stop_movement', { method: 'POST' });
271
+
272
+ // Don't reset button state here - let the polling update it
273
+ // when movement_busy becomes false (after robot goes to sleep)
274
+ } catch (err) {
275
+ console.error('Stop movement failed:', err);
276
+ }
277
+ }
278
+
279
+ async function playHappy() {
280
+ try {
281
+ await fetch('/api/play_happy', { method: 'POST' });
282
+ } catch (err) {
283
+ console.error('Play happy failed:', err);
284
+ alert('Failed to play sound');
285
+ }
286
+ }
287
+
288
+ // Update waveform visualization
289
+ function updateWaveform(audioLevel) {
290
+ if (waveformBars.length === 0) return;
291
+
292
+ const maxHeight = 120;
293
+ const minHeight = 4;
294
+ const baseLevel = Math.min(audioLevel / 100, 1);
295
+
296
+ waveformBars.forEach((bar, index) => {
297
+ const position = index / waveformBars.length;
298
+ const centerDistance = Math.abs(position - 0.5) * 2;
299
+ const envelope = 1 - Math.pow(centerDistance, 2);
300
+
301
+ const randomness = Math.random() * 0.4 + 0.8;
302
+ const height = minHeight + (maxHeight - minHeight) * baseLevel * envelope * randomness;
303
+
304
+ bar.style.height = height + 'px';
305
+ });
306
+ }
307
+
308
+ function setupVolumeSlider() {
309
+ const slider = document.getElementById('volume-slider');
310
+ if (!slider) return;
311
+
312
+ slider.addEventListener('input', async (e) => {
313
+ const value = parseInt(e.target.value);
314
+ document.getElementById('volume-value').textContent = value + '%';
315
+
316
+ try {
317
+ await fetch('/api/set_volume', {
318
+ method: 'POST',
319
+ headers: { 'Content-Type': 'application/json' },
320
+ body: JSON.stringify({ volume: value / 100 })
321
+ });
322
+ } catch (err) {
323
+ console.error('Set volume failed:', err);
324
+ }
325
+ });
326
+ }
327
+
328
+
wake_me_up/static/style.css ADDED
@@ -0,0 +1,397 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ body {
2
+ margin: 0;
3
+ padding: 0;
4
+ min-height: 100vh;
5
+ font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
6
+ color: #1e293b;
7
+
8
+ /* Clean light background */
9
+ background: #e5e7eb;
10
+ }
11
+
12
+ .header {
13
+ display: flex;
14
+ flex-direction: column;
15
+ align-items: center;
16
+ gap: 8px;
17
+ padding: 32px 0 24px;
18
+ margin-bottom: 8px;
19
+ }
20
+
21
+ .header h1 {
22
+ font-size: 36px;
23
+ font-weight: 600;
24
+ letter-spacing: -0.3px;
25
+ margin: 0;
26
+ color: #0f172a;
27
+ }
28
+
29
+ .step-indicator {
30
+ position: absolute;
31
+ bottom: 20px;
32
+ left: 20px;
33
+ font-size: 13px;
34
+ font-weight: 500;
35
+ color: #64748b;
36
+ background: transparent;
37
+ padding: 0;
38
+ border-radius: 0;
39
+ }
40
+
41
+ .container {
42
+ max-width: 900px;
43
+ margin: 0 auto;
44
+ padding: 20px;
45
+ }
46
+
47
+ .card {
48
+ background: #FFFFFF;
49
+ padding: 40px;
50
+ border-radius: 12px;
51
+ /* Material-UI elevation-2 inspired shadow */
52
+ box-shadow:
53
+ 0px 2px 4px rgba(0, 0, 0, 0.06),
54
+ 0px 4px 6px rgba(0, 0, 0, 0.1);
55
+ margin-bottom: 24px;
56
+ text-align: center;
57
+ position: relative;
58
+ border: 1px solid #e2e8f0;
59
+ }
60
+
61
+ .step-screen {
62
+ display: none;
63
+ }
64
+
65
+ .step-screen.active {
66
+ display: block;
67
+ }
68
+
69
+ .card h2 {
70
+ margin: 0 0 8px 0;
71
+ font-size: 28px;
72
+ font-weight: 600;
73
+ color: #0f172a;
74
+ letter-spacing: -0.2px;
75
+ }
76
+
77
+ .instruction {
78
+ font-size: 15px;
79
+ color: #64748b;
80
+ margin: 8px 0 28px;
81
+ line-height: 1.6;
82
+ }
83
+
84
+ .demo-video {
85
+ width: 100%;
86
+ max-width: 600px;
87
+ height: auto;
88
+ object-fit: contain;
89
+ border-radius: 12px;
90
+ margin: 20px 0;
91
+ background: #000;
92
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
93
+ }
94
+
95
+ /* Crop movement video in height */
96
+ #step-2 .demo-video {
97
+ max-height: 400px;
98
+ object-fit: cover;
99
+ }
100
+
101
+ /* Touch Detection Progress */
102
+ .touch-progress-container {
103
+ width: 100%;
104
+ max-width: 400px;
105
+ height: 8px;
106
+ background: #e2e8f0;
107
+ border-radius: 4px;
108
+ overflow: hidden;
109
+ margin: 20px auto;
110
+ }
111
+
112
+ .touch-progress-bar {
113
+ height: 100%;
114
+ width: 0%;
115
+ background: linear-gradient(90deg, #FFC107, #FF9900);
116
+ border-radius: 4px;
117
+ transition: width 0.1s linear;
118
+ }
119
+
120
+ /* Waveform */
121
+ .waveform-container {
122
+ display: flex;
123
+ align-items: center;
124
+ justify-content: center;
125
+ gap: 3px;
126
+ height: 120px;
127
+ margin: 28px 0;
128
+ padding: 24px;
129
+ background: #f8fafc;
130
+ border-radius: 10px;
131
+ border: 1px solid #e2e8f0;
132
+ }
133
+
134
+ .waveform-bar {
135
+ flex: 1;
136
+ background: #FF9900;
137
+ border-radius: 3px;
138
+ transition: height 0.1s ease;
139
+ min-height: 4px;
140
+ max-width: 8px;
141
+ }
142
+
143
+ /* Volume Control */
144
+ .volume-control {
145
+ display: flex;
146
+ align-items: center;
147
+ gap: 16px;
148
+ justify-content: center;
149
+ margin: 28px 0;
150
+ padding: 20px 24px;
151
+ background: #f8fafc;
152
+ border-radius: 10px;
153
+ border: 1px solid #e2e8f0;
154
+ }
155
+
156
+ .volume-control label {
157
+ font-weight: 500;
158
+ color: #475569;
159
+ font-size: 14px;
160
+ }
161
+
162
+ #volume-slider {
163
+ flex: 1;
164
+ max-width: 400px;
165
+ height: 4px;
166
+ border-radius: 2px;
167
+ background: #cbd5e1;
168
+ outline: none;
169
+ -webkit-appearance: none;
170
+ }
171
+
172
+ #volume-slider::-webkit-slider-thumb {
173
+ -webkit-appearance: none;
174
+ appearance: none;
175
+ width: 16px;
176
+ height: 16px;
177
+ border-radius: 50%;
178
+ background: #FF9900;
179
+ cursor: pointer;
180
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
181
+ }
182
+
183
+ #volume-slider::-moz-range-thumb {
184
+ width: 16px;
185
+ height: 16px;
186
+ border-radius: 50%;
187
+ background: #FF9900;
188
+ cursor: pointer;
189
+ border: none;
190
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
191
+ }
192
+
193
+ #volume-value {
194
+ font-weight: 600;
195
+ color: #475569;
196
+ min-width: 45px;
197
+ font-size: 14px;
198
+ }
199
+
200
+ /* Title with icon layout */
201
+ .title-with-icon {
202
+ display: flex;
203
+ align-items: center;
204
+ justify-content: center;
205
+ gap: 16px;
206
+ margin-bottom: 20px;
207
+ }
208
+
209
+ .title-with-icon h2 {
210
+ font-size: 28px;
211
+ margin: 0;
212
+ }
213
+
214
+ /* Reachy icons */
215
+ .sleeping-reachy-icon,
216
+ .reachy-mini-icon {
217
+ width: 100px;
218
+ height: auto;
219
+ }
220
+
221
+ /* Explorer icon is bigger */
222
+ .explorer-icon {
223
+ width: 130px;
224
+ height: auto;
225
+ }
226
+
227
+ /* Camera Feed */
228
+ .camera-container {
229
+ margin: 20px 0;
230
+ }
231
+
232
+ #camera-feed {
233
+ width: 100%;
234
+ max-width: 640px;
235
+ border-radius: 12px;
236
+ background: #000;
237
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
238
+ }
239
+
240
+ /* Buttons */
241
+ .button-group {
242
+ display: flex;
243
+ gap: 16px;
244
+ justify-content: center;
245
+ align-items: center;
246
+ margin: 30px 0 0;
247
+ flex-wrap: wrap;
248
+ }
249
+
250
+ .btn-primary,
251
+ .btn-success,
252
+ .btn-fail {
253
+ padding: 12px 24px;
254
+ border-radius: 8px;
255
+ font-size: 14px;
256
+ font-weight: 500;
257
+ cursor: pointer;
258
+ transition: all 0.2s ease;
259
+ border: none;
260
+ }
261
+
262
+ /* Orange buttons (primary actions and validation) */
263
+ .btn-primary,
264
+ .btn-success {
265
+ background: linear-gradient(135deg, #FF9900, #FFB333);
266
+ color: white;
267
+ }
268
+
269
+ .btn-primary:hover:not(:disabled),
270
+ .btn-success:hover:not(:disabled) {
271
+ background: linear-gradient(135deg, #FFB333, #FFCC66);
272
+ transform: translateY(-1px);
273
+ box-shadow: 0 4px 8px rgba(255, 153, 0, 0.3);
274
+ }
275
+
276
+ /* Text link style for "doesn't work" */
277
+ .btn-fail {
278
+ background: transparent;
279
+ color: #64748b;
280
+ border: none;
281
+ padding: 8px 12px;
282
+ font-size: 14px;
283
+ text-decoration: underline;
284
+ }
285
+
286
+ .btn-fail:hover:not(:disabled) {
287
+ color: #FF9900;
288
+ }
289
+
290
+ /* Text link style for secondary actions */
291
+ .btn-text {
292
+ background: transparent;
293
+ color: #FF9900;
294
+ border: none;
295
+ padding: 8px 12px;
296
+ font-size: 14px;
297
+ font-weight: 500;
298
+ cursor: pointer;
299
+ text-decoration: none;
300
+ transition: all 0.2s ease;
301
+ }
302
+
303
+ .btn-text:hover:not(:disabled) {
304
+ color: #FFB333;
305
+ text-decoration: underline;
306
+ }
307
+
308
+ .btn-primary:disabled,
309
+ .btn-success:disabled,
310
+ .btn-text:disabled {
311
+ background: #f3f4f6;
312
+ color: #9ca3af;
313
+ cursor: not-allowed;
314
+ opacity: 0.6;
315
+ }
316
+
317
+ /* Summary */
318
+ .summary-list {
319
+ list-style: none;
320
+ padding: 0;
321
+ text-align: left;
322
+ max-width: 400px;
323
+ margin: 30px auto;
324
+ font-size: 16px;
325
+ }
326
+
327
+ .summary-list li {
328
+ padding: 14px 18px;
329
+ margin: 10px 0;
330
+ border-radius: 10px;
331
+ font-weight: 500;
332
+ display: flex;
333
+ align-items: center;
334
+ gap: 10px;
335
+ }
336
+
337
+ .summary-list li.pass {
338
+ background: #d1fae5;
339
+ color: #065f46;
340
+ }
341
+
342
+ .summary-list li.fail {
343
+ background: #fee2e2;
344
+ color: #991b1b;
345
+ }
346
+
347
+ /* Loading Spinner */
348
+ .loading-spinner {
349
+ display: flex;
350
+ justify-content: center;
351
+ align-items: center;
352
+ height: 200px;
353
+ }
354
+
355
+ .spinner {
356
+ width: 48px;
357
+ height: 48px;
358
+ border: 4px solid rgba(255, 153, 0, 0.2);
359
+ border-top-color: #FF9900;
360
+ border-radius: 50%;
361
+ animation: spin 1s linear infinite;
362
+ }
363
+
364
+ @keyframes spin {
365
+ to {
366
+ transform: rotate(360deg);
367
+ }
368
+ }
369
+
370
+ /* Camera eyes blinking animation */
371
+ /* Camera eye-opening animation */
372
+ @keyframes eye-open {
373
+ 0% {
374
+ clip-path: ellipse(100% 0% at 50% 50%);
375
+ }
376
+ 100% {
377
+ clip-path: ellipse(100% 50% at 50% 50%);
378
+ }
379
+ }
380
+
381
+ @keyframes eye-blink {
382
+ 0%, 100% {
383
+ clip-path: ellipse(100% 50% at 50% 50%);
384
+ }
385
+ 50% {
386
+ clip-path: ellipse(100% 5% at 50% 50%);
387
+ }
388
+ }
389
+
390
+ .camera-wake-up {
391
+ animation:
392
+ eye-open 1s ease-out forwards,
393
+ eye-blink 0.25s ease-in-out 1.2s,
394
+ eye-blink 0.25s ease-in-out 1.5s,
395
+ eye-blink 0.3s ease-in-out 2.3s;
396
+ animation-fill-mode: forwards;
397
+ }