RickDakan commited on
Commit
bdced15
·
0 Parent(s):

feat: howdy friend static Space

Browse files
Files changed (6) hide show
  1. .gitignore +1 -0
  2. README.md +42 -0
  3. app.js +637 -0
  4. index.html +124 -0
  5. plan.md +82 -0
  6. public/icon.svg +11 -0
.gitignore ADDED
@@ -0,0 +1 @@
 
 
1
+ agents.local.md
README.md ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Howdy Friend
3
+ emoji: 👋
4
+ colorFrom: yellow
5
+ colorTo: green
6
+ sdk: static
7
+ pinned: false
8
+ hf_oauth: true
9
+ short_description: Reachy tracks your face and gets happy when you wave.
10
+ tags:
11
+ - reachy_mini
12
+ - reachy_mini_js_app
13
+ ---
14
+
15
+ # Howdy Friend
16
+
17
+ Reachy Mini watches its camera, keeps its head (and body) pointed at the nearest face, gives a quick nod and chirp when it first notices you, and plays a happy clip from the Reachy Mini emotions library when you wave at it.
18
+
19
+ Everything runs in the browser. The robot only streams video and receives motion commands over WebRTC, so this works on a Wireless Reachy Mini without loading the onboard computer.
20
+
21
+ ## How it works
22
+
23
+ - **Face tracking**: MediaPipe Face Detector runs on the robot's video stream in the browser. The largest face drives a smoothed proportional controller on head yaw and pitch, sent with `setTarget()` at ~15 Hz. If the face drifts far to the side, the body turns to follow so the head never hits its yaw limit.
24
+ - **Wave detection**: MediaPipe Hand Landmarker finds an open hand. The wrist's horizontal position is tracked over a 1.2 s window; two or more direction reversals with enough travel counts as a wave. A 5 s cooldown prevents double triggers.
25
+ - **Reactions**: On first sight of a face, a short synthesized nod + chirp. On a wave, a random clip from a curated happy set in `pollen-robotics/reachy-mini-emotions-library`, played daemon-side with `playMove()` so motion and audio stay in sync. The clip is shifted by the current body yaw so the robot keeps facing you during the reaction.
26
+
27
+ ## Deploying
28
+
29
+ No build step. Push these files to the Space:
30
+
31
+ ```
32
+ git init -b main
33
+ git remote add space https://huggingface.co/spaces/RickDakan/howdy_friend
34
+ git add -A && git commit -m "feat: howdy friend"
35
+ git push -u space main
36
+ ```
37
+
38
+ Open the Space URL, sign in with Hugging Face, pick your robot.
39
+
40
+ ## Tuning
41
+
42
+ Constants at the top of `app.js`: tracking gain, deadband, smoothing, body-follow threshold, wave sensitivity, cooldowns, and the list of reaction clips. Sign conventions for yaw/pitch are noted there in case your camera mount differs.
app.js ADDED
@@ -0,0 +1,637 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Howdy Friend - Reachy Mini face tracking + wave reaction
2
+ // Bare HTML + CDN JS app using the Reachy Mini host shell (SDK 1.8.0).
3
+
4
+ import { ReachyMini } from "https://cdn.jsdelivr.net/npm/@pollen-robotics/reachy-mini-sdk@1.8.0/+esm";
5
+
6
+ const SDK_VERSION = "1.8.0";
7
+ const MP_VERSION = "0.10.14";
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Tuning constants
11
+ // ---------------------------------------------------------------------------
12
+
13
+ // Sign conventions (robot frame, right-hand rule, z up):
14
+ // yaw > 0 = head turns to the robot's LEFT
15
+ // pitch > 0 = nose DOWN
16
+ // A face to the RIGHT of image center (ex > 0) means the robot must turn RIGHT (yaw decreases).
17
+ // A face ABOVE image center (ey < 0) means the robot must look UP (pitch decreases).
18
+ // Flip either sign below if your robot turns the wrong way.
19
+ const YAW_SIGN = -1;
20
+ const PITCH_SIGN = 1;
21
+
22
+ const TRACK = {
23
+ hz: 15, // vision + command rate
24
+ gainDegPerSec: 45, // how fast the head chases a face at full-frame error
25
+ deadband: 0.05, // normalized error below which we don't move
26
+ smoothing: 0.35, // 0..1, higher = snappier
27
+ maxPitchDeg: 30,
28
+ maxHeadYawFromBodyDeg: 55, // hard SDK limit is 65
29
+ bodyFollowStartDeg: 22, // head-body offset that starts the body turning
30
+ bodyRateDegPerSec: 40,
31
+ maxBodyYawDeg: 150, // hard SDK limit is 160
32
+ noFaceResetSec: 3.0, // ease back to center after this long without a face
33
+ noticeAfterAbsentSec: 4.0, // "new arrival" nod only if no face was seen for this long
34
+ };
35
+
36
+ const WAVE = {
37
+ windowSec: 1.2,
38
+ minReversals: 2,
39
+ minTravelHandWidths: 1.0,
40
+ hysteresisHandWidths: 0.15,
41
+ cooldownSec: 5.0,
42
+ };
43
+
44
+ // Curated happy clips from pollen-robotics/reachy-mini-emotions-library
45
+ const WAVE_CLIPS = ["welcoming1", "welcoming2", "enthusiastic2", "cheerful1", "success2", "loving1", "helpful1"];
46
+ const EMOTIONS_BASE = "https://huggingface.co/datasets/pollen-robotics/reachy-mini-emotions-library/resolve/main/";
47
+
48
+ const INIT_ANTENNAS = [-0.1745, 0.1745]; // radians, matches SDK INIT_POSE
49
+ const DEG = Math.PI / 180;
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Dispatch: host shell vs embedded app
53
+ // ---------------------------------------------------------------------------
54
+
55
+ window.ReachyMini = ReachyMini;
56
+ window.dispatchEvent(new Event("reachymini:ready"));
57
+
58
+ const params = new URLSearchParams(window.location.search);
59
+ const isEmbed = params.get("embedded") === "1" || params.get("embed") === "1";
60
+
61
+ if (isEmbed) {
62
+ document.body.classList.add("embed");
63
+ runEmbed().catch((err) => {
64
+ console.error("[howdy-friend] boot failed", err);
65
+ log(`Boot failed: ${err?.message ?? err}`);
66
+ window.parent.postMessage(
67
+ { source: "reachy-mini", type: "embed:error", version: 1, message: String(err), fatal: true },
68
+ window.location.origin,
69
+ );
70
+ });
71
+ } else {
72
+ document.body.classList.add("host");
73
+ const { mountHost } = await import(
74
+ `https://cdn.jsdelivr.net/npm/@pollen-robotics/reachy-mini-sdk@${SDK_VERSION}/host/dist/entry/auto.js`
75
+ );
76
+ mountHost({
77
+ appName: "Howdy Friend",
78
+ appIconUrl: "/public/icon.svg",
79
+ appEmoji: "👋",
80
+ enableMicrophone: false,
81
+ });
82
+ }
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Embedded app
86
+ // ---------------------------------------------------------------------------
87
+
88
+ async function runEmbed() {
89
+ const { connectToHost } = await import(
90
+ `https://cdn.jsdelivr.net/npm/@pollen-robotics/reachy-mini-sdk@${SDK_VERSION}/host/dist/entry/embed.js`
91
+ );
92
+
93
+ const ui = bindUi();
94
+ setStatus("Connecting to robot", "");
95
+
96
+ const handle = await connectToHost();
97
+ const reachy = handle.reachy;
98
+ log(`Connected as ${handle.userName ?? "user"}`);
99
+
100
+ const detachVideo = reachy.attachVideo(ui.video);
101
+
102
+ setStatus("Loading vision models", "");
103
+ const vision = await loadVision();
104
+ log("Vision models ready");
105
+
106
+ // Start fetching emotion clips in the background; not required to start tracking.
107
+ const clips = new ClipCache();
108
+ clips.preload(WAVE_CLIPS).then((n) => log(`${n}/${WAVE_CLIPS.length} reaction clips ready`));
109
+
110
+ const app = new HowdyApp({ reachy, vision, clips, ui });
111
+ app.start();
112
+
113
+ handle.onLeave(() => app.shutdown(detachVideo));
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Core app: tracking loop, wave detection, reactions
118
+ // ---------------------------------------------------------------------------
119
+
120
+ class HowdyApp {
121
+ constructor({ reachy, vision, clips, ui }) {
122
+ this.reachy = reachy;
123
+ this.vision = vision;
124
+ this.clips = clips;
125
+ this.ui = ui;
126
+
127
+ // Last COMMANDED pose (the SDK docs say: use your own buffer, not telemetry)
128
+ this.headYaw = 0; // degrees, world frame
129
+ this.headPitch = 0;
130
+ this.bodyYaw = 0;
131
+
132
+ this.trackingEnabled = true;
133
+ this.bodyFollow = true;
134
+ this.muted = false;
135
+ this.busy = false; // a playMove is running
136
+ this.running = false;
137
+
138
+ this.lastFaceAt = -Infinity;
139
+ this.faceAbsentSince = 0;
140
+ this.lastWaveAt = -Infinity;
141
+ this.lastSendAt = 0;
142
+ this.lastTickAt = 0;
143
+
144
+ this.wave = new WaveDetector();
145
+
146
+ ui.btnTrack.onclick = () => {
147
+ this.trackingEnabled = !this.trackingEnabled;
148
+ ui.btnTrack.setAttribute("aria-pressed", String(this.trackingEnabled));
149
+ ui.btnTrack.textContent = this.trackingEnabled ? "Tracking on" : "Tracking off";
150
+ };
151
+ ui.btnBody.onclick = () => {
152
+ this.bodyFollow = !this.bodyFollow;
153
+ ui.btnBody.setAttribute("aria-pressed", String(this.bodyFollow));
154
+ ui.btnBody.textContent = this.bodyFollow ? "Body turns" : "Head only";
155
+ };
156
+ ui.btnMute.onclick = () => {
157
+ this.muted = !this.muted;
158
+ ui.btnMute.setAttribute("aria-pressed", String(this.muted));
159
+ ui.btnMute.textContent = this.muted ? "Sound off" : "Sound on";
160
+ };
161
+ ui.btnWave.onclick = () => this.reactToWave();
162
+ }
163
+
164
+ start() {
165
+ this.running = true;
166
+ this.reachy.setTarget({ antennas: INIT_ANTENNAS });
167
+ setStatus("Looking for faces", "");
168
+ this.lastTickAt = performance.now();
169
+ this.scheduleTick();
170
+ }
171
+
172
+ scheduleTick() {
173
+ if (!this.running) return;
174
+ setTimeout(() => this.tick().catch((e) => console.error(e)).finally(() => this.scheduleTick()), 1000 / TRACK.hz);
175
+ }
176
+
177
+ async tick() {
178
+ const now = performance.now();
179
+ const dt = Math.min(0.2, (now - this.lastTickAt) / 1000);
180
+ this.lastTickAt = now;
181
+
182
+ const v = this.ui.video;
183
+ if (v.readyState < 2 || v.videoWidth === 0) return;
184
+ syncOverlaySize(this.ui.overlay, v);
185
+
186
+ // Vision
187
+ const faces = this.vision.face.detectForVideo(v, now).detections ?? [];
188
+ const hands = this.vision.hand.detectForVideo(v, now).landmarks ?? [];
189
+ const face = pickLargestFace(faces);
190
+ drawOverlay(this.ui.overlay, v, face, hands);
191
+
192
+ // Wave detection runs even while busy so we don't miss one mid-reaction
193
+ const waved = this.wave.update(hands, now / 1000, v.videoWidth / v.videoHeight);
194
+
195
+ if (this.busy) return;
196
+
197
+ // Face bookkeeping
198
+ if (face) {
199
+ const absentFor = (now - this.lastFaceAt) / 1000;
200
+ const isNewArrival = absentFor > TRACK.noticeAfterAbsentSec;
201
+ this.lastFaceAt = now;
202
+ if (isNewArrival) {
203
+ await this.noticeFace();
204
+ return;
205
+ }
206
+ }
207
+
208
+ if (waved && now - this.lastWaveAt > WAVE.cooldownSec * 1000) {
209
+ this.lastWaveAt = now;
210
+ await this.reactToWave();
211
+ return;
212
+ }
213
+
214
+ // Tracking
215
+ if (!this.trackingEnabled) {
216
+ setStatus("Tracking paused", face ? "face in view" : "");
217
+ return;
218
+ }
219
+
220
+ if (face) {
221
+ const cx = (face.boundingBox.originX + face.boundingBox.width / 2) / v.videoWidth;
222
+ const cy = (face.boundingBox.originY + face.boundingBox.height / 2) / v.videoHeight;
223
+ const ex = (cx - 0.5) * 2; // -1..1
224
+ const ey = (cy - 0.5) * 2;
225
+ this.servo(ex, ey, dt);
226
+ setStatus("Tracking", `yaw ${this.headYaw.toFixed(0)}° body ${this.bodyYaw.toFixed(0)}°`, "tracking");
227
+ } else if ((now - this.lastFaceAt) / 1000 > TRACK.noFaceResetSec) {
228
+ this.easeHome(dt);
229
+ setStatus("Looking for faces", "");
230
+ }
231
+
232
+ this.sendPose(now);
233
+ }
234
+
235
+ servo(ex, ey, dt) {
236
+ const step = TRACK.gainDegPerSec * dt;
237
+ const dYaw = Math.abs(ex) > TRACK.deadband ? YAW_SIGN * ex * step : 0;
238
+ const dPitch = Math.abs(ey) > TRACK.deadband ? PITCH_SIGN * ey * step : 0;
239
+
240
+ let yaw = this.headYaw + dYaw;
241
+ let pitch = clamp(this.headPitch + dPitch, -TRACK.maxPitchDeg, TRACK.maxPitchDeg);
242
+
243
+ // Body follows when the head is far off the body's axis
244
+ if (this.bodyFollow) {
245
+ const offset = yaw - this.bodyYaw;
246
+ if (Math.abs(offset) > TRACK.bodyFollowStartDeg) {
247
+ const move = Math.sign(offset) * Math.min(Math.abs(offset), TRACK.bodyRateDegPerSec * dt);
248
+ this.bodyYaw = clamp(this.bodyYaw + move, -TRACK.maxBodyYawDeg, TRACK.maxBodyYawDeg);
249
+ }
250
+ }
251
+ yaw = clamp(yaw, this.bodyYaw - TRACK.maxHeadYawFromBodyDeg, this.bodyYaw + TRACK.maxHeadYawFromBodyDeg);
252
+
253
+ this.headYaw = lerp(this.headYaw, yaw, TRACK.smoothing);
254
+ this.headPitch = lerp(this.headPitch, pitch, TRACK.smoothing);
255
+ }
256
+
257
+ easeHome(dt) {
258
+ const k = Math.min(1, dt * 1.5);
259
+ this.headYaw = lerp(this.headYaw, this.bodyYaw, k);
260
+ this.headPitch = lerp(this.headPitch, 0, k);
261
+ if (Math.abs(this.bodyYaw) > 0.5) this.bodyYaw = lerp(this.bodyYaw, 0, k * 0.5);
262
+ else this.bodyYaw = 0;
263
+ }
264
+
265
+ sendPose(now) {
266
+ if (now - this.lastSendAt < 1000 / TRACK.hz - 5) return;
267
+ this.lastSendAt = now;
268
+ this.reachy.setTarget({
269
+ head: headMatrix(0, this.headPitch * DEG, this.headYaw * DEG),
270
+ body_yaw: this.bodyYaw * DEG,
271
+ });
272
+ }
273
+
274
+ // --- Reactions -----------------------------------------------------------
275
+
276
+ async noticeFace() {
277
+ setStatus("Oh, hello", "", "reacting");
278
+ log("Noticed a face");
279
+ const motion = buildNodMotion({ yawDeg: this.headYaw, pitchDeg: this.headPitch, bodyYawDeg: this.bodyYaw });
280
+ const audioBlob = this.muted ? undefined : chirpWav();
281
+ await this.play(motion, audioBlob, "notice nod");
282
+ }
283
+
284
+ async reactToWave() {
285
+ if (this.busy) return;
286
+ const name = WAVE_CLIPS[Math.floor(Math.random() * WAVE_CLIPS.length)];
287
+ setStatus("Wave detected!", name, "reacting");
288
+ log(`Wave -> ${name}`);
289
+ const clip = await this.clips.get(name);
290
+ if (!clip) { log(`Clip ${name} unavailable`); return; }
291
+ // Shift the clip so the robot keeps facing the person instead of snapping to body_yaw 0
292
+ const motion = offsetMotionYaw(clip.motion, this.bodyYaw * DEG);
293
+ await this.play(motion, this.muted ? undefined : clip.wav, name);
294
+ }
295
+
296
+ async play(motion, audioBlob, description) {
297
+ if (this.busy) return;
298
+ this.busy = true;
299
+ try {
300
+ const result = await this.reachy.playMove(motion, { audioBlob, description, audioLeadMs: -100 });
301
+ if (result?.error) log(`playMove error: ${result.error}`);
302
+ // Re-sync our commanded buffer to where the clip left the robot
303
+ const last = motion.set_target_data[motion.set_target_data.length - 1];
304
+ if (last?.head) {
305
+ const rpy = matrixToRpyDeg(last.head);
306
+ this.headYaw = rpy.yaw;
307
+ this.headPitch = rpy.pitch;
308
+ }
309
+ if (typeof last?.body_yaw === "number") this.bodyYaw = last.body_yaw / DEG;
310
+ this.reachy.setTarget({ antennas: INIT_ANTENNAS });
311
+ } catch (e) {
312
+ log(`Reaction failed: ${e?.message ?? e}`);
313
+ } finally {
314
+ this.busy = false;
315
+ this.lastFaceAt = performance.now(); // don't immediately re-nod
316
+ }
317
+ }
318
+
319
+ shutdown(detachVideo) {
320
+ this.running = false;
321
+ try { this.reachy.cancelMove(); } catch (_) {}
322
+ try { detachVideo?.(); } catch (_) {}
323
+ try { this.vision.face.close(); this.vision.hand.close(); } catch (_) {}
324
+ safeReturnHome(this.reachy);
325
+ }
326
+ }
327
+
328
+ // ---------------------------------------------------------------------------
329
+ // Wave detector
330
+ // ---------------------------------------------------------------------------
331
+
332
+ class WaveDetector {
333
+ constructor() { this.samples = []; }
334
+
335
+ // hands: array of 21-landmark arrays (normalized coords). Returns true on a detected wave.
336
+ update(hands, tSec, aspect) {
337
+ const hand = hands.find(isOpenPalmUp);
338
+ if (!hand) { this.samples.length = 0; return false; }
339
+
340
+ const xs = hand.map((p) => p.x * aspect); // correct for non-square frames
341
+ const width = Math.max(...xs) - Math.min(...xs);
342
+ if (width < 1e-3) return false;
343
+ const wristX = hand[0].x * aspect;
344
+
345
+ this.samples.push({ t: tSec, x: wristX, w: width });
346
+ while (this.samples.length && tSec - this.samples[0].t > WAVE.windowSec) this.samples.shift();
347
+ if (this.samples.length < 6) return false;
348
+
349
+ const w = this.samples.reduce((a, s) => a + s.w, 0) / this.samples.length;
350
+ const hyst = WAVE.hysteresisHandWidths * w;
351
+
352
+ let reversals = 0, travel = 0, dir = 0, anchor = this.samples[0].x;
353
+ for (const s of this.samples) {
354
+ const d = s.x - anchor;
355
+ if (Math.abs(d) < hyst) continue;
356
+ const nd = Math.sign(d);
357
+ if (dir !== 0 && nd !== dir) reversals++;
358
+ dir = nd;
359
+ travel += Math.abs(d);
360
+ anchor = s.x;
361
+ }
362
+ if (reversals >= WAVE.minReversals && travel >= WAVE.minTravelHandWidths * w) {
363
+ this.samples.length = 0;
364
+ return true;
365
+ }
366
+ return false;
367
+ }
368
+ }
369
+
370
+ // Open palm, fingers pointing up (image y grows downward)
371
+ function isOpenPalmUp(lm) {
372
+ const wrist = lm[0], midMcp = lm[9];
373
+ if (wrist.y < midMcp.y) return false; // hand not upright
374
+ const tips = [8, 12, 16, 20], pips = [6, 10, 14, 18];
375
+ let extended = 0;
376
+ for (let i = 0; i < 4; i++) if (lm[tips[i]].y < lm[pips[i]].y) extended++;
377
+ return extended >= 3;
378
+ }
379
+
380
+ // ---------------------------------------------------------------------------
381
+ // Vision setup (MediaPipe Tasks Vision, in-browser)
382
+ // ---------------------------------------------------------------------------
383
+
384
+ async function loadVision() {
385
+ const mp = await import(`https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@${MP_VERSION}/+esm`);
386
+ const files = await mp.FilesetResolver.forVisionTasks(
387
+ `https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@${MP_VERSION}/wasm`,
388
+ );
389
+ const [face, hand] = await Promise.all([
390
+ mp.FaceDetector.createFromOptions(files, {
391
+ baseOptions: {
392
+ modelAssetPath: "https://storage.googleapis.com/mediapipe-models/face_detector/blaze_face_short_range/float16/1/blaze_face_short_range.tflite",
393
+ delegate: "GPU",
394
+ },
395
+ runningMode: "VIDEO",
396
+ minDetectionConfidence: 0.6,
397
+ }),
398
+ mp.HandLandmarker.createFromOptions(files, {
399
+ baseOptions: {
400
+ modelAssetPath: "https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task",
401
+ delegate: "GPU",
402
+ },
403
+ runningMode: "VIDEO",
404
+ numHands: 1,
405
+ minHandDetectionConfidence: 0.6,
406
+ minTrackingConfidence: 0.5,
407
+ }),
408
+ ]);
409
+ return { face, hand };
410
+ }
411
+
412
+ function pickLargestFace(detections) {
413
+ let best = null, bestArea = 0;
414
+ for (const d of detections) {
415
+ const a = d.boundingBox.width * d.boundingBox.height;
416
+ if (a > bestArea) { best = d; bestArea = a; }
417
+ }
418
+ return best;
419
+ }
420
+
421
+ // ---------------------------------------------------------------------------
422
+ // Emotion clips: JSON motion + Opus audio -> 16 kHz mono PCM WAV
423
+ // ---------------------------------------------------------------------------
424
+
425
+ class ClipCache {
426
+ constructor() { this.map = new Map(); }
427
+
428
+ async preload(names) {
429
+ const results = await Promise.allSettled(names.map((n) => this.get(n)));
430
+ return results.filter((r) => r.status === "fulfilled" && r.value).length;
431
+ }
432
+
433
+ async get(name) {
434
+ if (this.map.has(name)) return this.map.get(name);
435
+ const p = (async () => {
436
+ try {
437
+ const [motionRes, audioRes] = await Promise.all([
438
+ fetch(EMOTIONS_BASE + name + ".json"),
439
+ fetch(EMOTIONS_BASE + name + ".ogg"),
440
+ ]);
441
+ if (!motionRes.ok) throw new Error(`motion ${motionRes.status}`);
442
+ const motion = normalizeMotion(await motionRes.json());
443
+ let wav;
444
+ if (audioRes.ok) wav = await toWav16kMono(await audioRes.arrayBuffer());
445
+ return { motion, wav };
446
+ } catch (e) {
447
+ log(`Failed to load ${name}: ${e.message}`);
448
+ this.map.delete(name);
449
+ return null;
450
+ }
451
+ })();
452
+ this.map.set(name, p);
453
+ return p;
454
+ }
455
+ }
456
+
457
+ function normalizeMotion(raw) {
458
+ // Flatten nested 4x4 head matrices to 16 floats; leave everything else as-is
459
+ const set_target_data = raw.set_target_data.map((f) => ({
460
+ ...f,
461
+ head: f.head && Array.isArray(f.head[0]) ? f.head.flat() : f.head,
462
+ }));
463
+ return { ...raw, set_target_data };
464
+ }
465
+
466
+ async function toWav16kMono(arrayBuffer) {
467
+ const ctx = new (window.AudioContext || window.webkitAudioContext)();
468
+ const decoded = await ctx.decodeAudioData(arrayBuffer);
469
+ await ctx.close();
470
+ const targetRate = 16000;
471
+ const length = Math.ceil(decoded.duration * targetRate);
472
+ const off = new OfflineAudioContext(1, length, targetRate);
473
+ const src = off.createBufferSource();
474
+ src.buffer = decoded;
475
+ src.connect(off.destination);
476
+ src.start();
477
+ const rendered = await off.startRendering();
478
+ return encodeWav(rendered.getChannelData(0), targetRate);
479
+ }
480
+
481
+ function encodeWav(samples, sampleRate) {
482
+ const buf = new ArrayBuffer(44 + samples.length * 2);
483
+ const v = new DataView(buf);
484
+ const str = (o, s) => { for (let i = 0; i < s.length; i++) v.setUint8(o + i, s.charCodeAt(i)); };
485
+ str(0, "RIFF"); v.setUint32(4, 36 + samples.length * 2, true); str(8, "WAVE");
486
+ str(12, "fmt "); v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true);
487
+ v.setUint32(24, sampleRate, true); v.setUint32(28, sampleRate * 2, true);
488
+ v.setUint16(32, 2, true); v.setUint16(34, 16, true);
489
+ str(36, "data"); v.setUint32(40, samples.length * 2, true);
490
+ let o = 44;
491
+ for (let i = 0; i < samples.length; i++, o += 2) {
492
+ const s = Math.max(-1, Math.min(1, samples[i]));
493
+ v.setInt16(o, s < 0 ? s * 0x8000 : s * 0x7fff, true);
494
+ }
495
+ return new Blob([buf], { type: "audio/wav" });
496
+ }
497
+
498
+ // Two quick rising chirps, ~0.45 s total
499
+ function chirpWav() {
500
+ const sr = 16000, dur = 0.45, n = Math.floor(sr * dur);
501
+ const out = new Float32Array(n);
502
+ const chirp = (t0, t1, f0, f1) => {
503
+ for (let i = Math.floor(t0 * sr); i < Math.floor(t1 * sr) && i < n; i++) {
504
+ const t = i / sr - t0, T = t1 - t0, u = t / T;
505
+ const f = f0 + (f1 - f0) * u;
506
+ const env = Math.sin(Math.PI * u);
507
+ out[i] += 0.6 * env * Math.sin(2 * Math.PI * (f0 * t + 0.5 * (f1 - f0) * t * t / T));
508
+ }
509
+ };
510
+ chirp(0.0, 0.18, 900, 1600);
511
+ chirp(0.22, 0.45, 1100, 2000);
512
+ return encodeWav(out, sr);
513
+ }
514
+
515
+ // ---------------------------------------------------------------------------
516
+ // Motion helpers
517
+ // ---------------------------------------------------------------------------
518
+
519
+ // Short nod from the current pose, antennas perk, 0.9 s at 50 Hz
520
+ function buildNodMotion({ yawDeg, pitchDeg, bodyYawDeg }) {
521
+ const hz = 50, dur = 0.9, n = Math.round(dur * hz);
522
+ const time = [], set_target_data = [];
523
+ for (let i = 0; i <= n; i++) {
524
+ const t = i / hz, u = t / dur;
525
+ const dip = u < 0.625 ? 14 * Math.sin(Math.PI * u * 1.6) : 0; // one dip in the first ~0.56 s
526
+ const perk = 25 * DEG * Math.sin(Math.PI * u);
527
+ time.push(t);
528
+ set_target_data.push({
529
+ head: headMatrix(0, (pitchDeg + dip) * DEG, yawDeg * DEG),
530
+ antennas: [INIT_ANTENNAS[0] - perk, INIT_ANTENNAS[1] + perk],
531
+ body_yaw: bodyYawDeg * DEG,
532
+ });
533
+ }
534
+ return { time, set_target_data };
535
+ }
536
+
537
+ // Rotate a whole recorded move about world z by `yawRad` so it plays relative to the current body yaw
538
+ function offsetMotionYaw(motion, yawRad) {
539
+ if (Math.abs(yawRad) < 1e-4) return motion;
540
+ const Rz = headMatrix(0, 0, yawRad);
541
+ const maxBody = TRACK.maxBodyYawDeg * DEG;
542
+ return {
543
+ ...motion,
544
+ set_target_data: motion.set_target_data.map((f) => ({
545
+ ...f,
546
+ head: f.head ? mat4mul(Rz, f.head) : f.head,
547
+ body_yaw: typeof f.body_yaw === "number" ? clamp(f.body_yaw + yawRad, -maxBody, maxBody) : f.body_yaw,
548
+ })),
549
+ };
550
+ }
551
+
552
+ // Row-major flat 4x4 from roll/pitch/yaw (radians), ZYX convention, zero translation
553
+ function headMatrix(r, p, y) {
554
+ const cr = Math.cos(r), sr = Math.sin(r), cp = Math.cos(p), sp = Math.sin(p), cy = Math.cos(y), sy = Math.sin(y);
555
+ return [
556
+ cy * cp, cy * sp * sr - sy * cr, cy * sp * cr + sy * sr, 0,
557
+ sy * cp, sy * sp * sr + cy * cr, sy * sp * cr - cy * sr, 0,
558
+ -sp, cp * sr, cp * cr, 0,
559
+ 0, 0, 0, 1,
560
+ ];
561
+ }
562
+
563
+ function matrixToRpyDeg(m) {
564
+ const pitch = Math.asin(clamp(-m[8], -1, 1));
565
+ const roll = Math.atan2(m[9], m[10]);
566
+ const yaw = Math.atan2(m[4], m[0]);
567
+ return { roll: roll / DEG, pitch: pitch / DEG, yaw: yaw / DEG };
568
+ }
569
+
570
+ function mat4mul(a, b) {
571
+ const o = new Array(16);
572
+ for (let r = 0; r < 4; r++) for (let c = 0; c < 4; c++) {
573
+ let s = 0;
574
+ for (let k = 0; k < 4; k++) s += a[r * 4 + k] * b[k * 4 + c];
575
+ o[r * 4 + c] = s;
576
+ }
577
+ return o;
578
+ }
579
+
580
+ function safeReturnHome(reachy) {
581
+ try { reachy.setMotorMode?.("enabled"); } catch (_) {}
582
+ const target = { head: headMatrix(0, 0, 0), antennas: INIT_ANTENNAS, body_yaw: 0 };
583
+ try {
584
+ if (typeof reachy.gotoTarget === "function") reachy.gotoTarget({ ...target, duration: 1.2 });
585
+ else reachy.setTarget(target);
586
+ } catch (_) {}
587
+ }
588
+
589
+ // ---------------------------------------------------------------------------
590
+ // UI
591
+ // ---------------------------------------------------------------------------
592
+
593
+ function bindUi() {
594
+ const $ = (id) => document.getElementById(id);
595
+ return {
596
+ video: $("video"), overlay: $("overlay"),
597
+ btnTrack: $("btnTrack"), btnBody: $("btnBody"), btnMute: $("btnMute"), btnWave: $("btnWave"),
598
+ };
599
+ }
600
+
601
+ function setStatus(text, sub = "", mode = "") {
602
+ const el = document.getElementById("status");
603
+ document.getElementById("statusText").textContent = text;
604
+ document.getElementById("statusSub").textContent = sub;
605
+ el.className = "status" + (mode ? " " + mode : "");
606
+ }
607
+
608
+ function log(msg) {
609
+ const el = document.getElementById("log");
610
+ if (!el) return;
611
+ const t = new Date().toLocaleTimeString([], { hour12: false });
612
+ el.textContent = `${t} ${msg}\n` + el.textContent.split("\n").slice(0, 5).join("\n");
613
+ console.log("[howdy-friend]", msg);
614
+ }
615
+
616
+ function syncOverlaySize(canvas, video) {
617
+ if (canvas.width !== video.videoWidth || canvas.height !== video.videoHeight) {
618
+ canvas.width = video.videoWidth; canvas.height = video.videoHeight;
619
+ }
620
+ }
621
+
622
+ function drawOverlay(canvas, video, face, hands) {
623
+ const g = canvas.getContext("2d");
624
+ g.clearRect(0, 0, canvas.width, canvas.height);
625
+ if (face) {
626
+ const b = face.boundingBox;
627
+ g.strokeStyle = "#f2c94c"; g.lineWidth = 3;
628
+ g.strokeRect(b.originX, b.originY, b.width, b.height);
629
+ }
630
+ for (const hand of hands) {
631
+ g.fillStyle = "#2bb673";
632
+ for (const p of hand) { g.beginPath(); g.arc(p.x * canvas.width, p.y * canvas.height, 4, 0, Math.PI * 2); g.fill(); }
633
+ }
634
+ }
635
+
636
+ const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
637
+ const lerp = (a, b, t) => a + (b - a) * t;
index.html ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, viewport-fit=cover" />
6
+ <title>Howdy Friend</title>
7
+ <link rel="icon" href="/public/icon.svg" type="image/svg+xml" />
8
+
9
+ <link rel="modulepreload" href="https://cdn.jsdelivr.net/npm/@pollen-robotics/reachy-mini-sdk@1.8.0/+esm" />
10
+ <link rel="modulepreload" href="https://cdn.jsdelivr.net/npm/@pollen-robotics/reachy-mini-sdk@1.8.0/host/dist/entry/auto.js" />
11
+ <link rel="modulepreload" href="https://cdn.jsdelivr.net/npm/@pollen-robotics/reachy-mini-sdk@1.8.0/host/dist/entry/embed.js" />
12
+
13
+ <!-- Theme bootstrap (host passes ?theme=dark|light when iframing us) -->
14
+ <script>
15
+ (function () {
16
+ try {
17
+ var params = new URLSearchParams(window.location.search);
18
+ var raw = params.get("theme");
19
+ var mode;
20
+ if (raw === "dark" || raw === "light") mode = raw;
21
+ else if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches === false) mode = "light";
22
+ else mode = "dark";
23
+ document.documentElement.setAttribute("data-theme", mode);
24
+ } catch (_) {
25
+ document.documentElement.setAttribute("data-theme", "dark");
26
+ }
27
+ })();
28
+ </script>
29
+
30
+ <!-- HF Spaces OAuth placeholders. Substituted by HF at serve time because hf_oauth: true. -->
31
+ <script>
32
+ (function () {
33
+ var clientId = "__OAUTH_CLIENT_ID__";
34
+ var scopes = "__OAUTH_SCOPES__";
35
+ var spaceHost = "__SPACE_HOST__";
36
+ var spaceId = "__SPACE_ID__";
37
+ var looksSubstituted = clientId && clientId.indexOf("__") !== 0;
38
+ if (looksSubstituted) {
39
+ window.huggingface = window.huggingface || {};
40
+ window.huggingface.variables = {
41
+ OAUTH_CLIENT_ID: clientId,
42
+ OAUTH_SCOPES: scopes && scopes.indexOf("__") !== 0 ? scopes : "openid profile",
43
+ SPACE_HOST: spaceHost && spaceHost.indexOf("__") !== 0 ? spaceHost : "",
44
+ SPACE_ID: spaceId && spaceId.indexOf("__") !== 0 ? spaceId : "",
45
+ };
46
+ }
47
+ })();
48
+ </script>
49
+
50
+ <style>
51
+ :root {
52
+ --bg: #101214;
53
+ --fg: #f3f1ea;
54
+ --muted: #9aa0a6;
55
+ --accent: #f2c94c;
56
+ --ok: #2bb673;
57
+ --panel: #1a1d20;
58
+ }
59
+ [data-theme="light"] {
60
+ --bg: #f7f5ef;
61
+ --fg: #16181a;
62
+ --muted: #5d6368;
63
+ --panel: #ffffff;
64
+ }
65
+ * { box-sizing: border-box; }
66
+ html, body { margin: 0; height: 100%; background: var(--bg); color: var(--fg);
67
+ font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
68
+ #root { display: none; }
69
+ #app { display: none; min-height: 100dvh; flex-direction: column; padding: 0.75rem;
70
+ padding-bottom: calc(0.75rem + env(safe-area-inset-bottom)); gap: 0.75rem; }
71
+ body.embed #app { display: flex; }
72
+ body.host #root { display: block; }
73
+
74
+ .stage { position: relative; width: 100%; aspect-ratio: 4 / 3; background: #000;
75
+ border-radius: 12px; overflow: hidden; }
76
+ .stage video, .stage canvas { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; }
77
+ .stage canvas { pointer-events: none; }
78
+
79
+ .status { display: flex; align-items: center; gap: 0.6rem; font-size: 1.05rem; min-height: 1.6rem; }
80
+ .status .dot { width: 0.7rem; height: 0.7rem; border-radius: 50%; background: var(--muted); flex: none;
81
+ transition: background 0.2s; }
82
+ .status.tracking .dot { background: var(--accent); }
83
+ .status.reacting .dot { background: var(--ok); }
84
+ .status small { color: var(--muted); }
85
+
86
+ .controls { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; }
87
+ button { min-height: 48px; border: 1px solid transparent; border-radius: 10px; background: var(--panel);
88
+ color: var(--fg); font: inherit; font-size: 1rem; padding: 0.6rem 0.9rem; cursor: pointer; }
89
+ button[aria-pressed="true"] { border-color: var(--accent); }
90
+ button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
91
+ button.wide { grid-column: 1 / -1; }
92
+
93
+ .log { font-size: 0.85rem; color: var(--muted); white-space: pre-wrap; max-height: 6rem; overflow: auto; }
94
+ @media (prefers-reduced-motion: reduce) { .status .dot { transition: none; } }
95
+ </style>
96
+ </head>
97
+ <body>
98
+ <div id="root"></div>
99
+
100
+ <main id="app">
101
+ <div class="stage">
102
+ <video id="video" autoplay playsinline muted></video>
103
+ <canvas id="overlay"></canvas>
104
+ </div>
105
+
106
+ <div id="status" class="status">
107
+ <span class="dot"></span>
108
+ <span id="statusText">Loading vision models</span>
109
+ <small id="statusSub"></small>
110
+ </div>
111
+
112
+ <div class="controls">
113
+ <button id="btnTrack" aria-pressed="true">Tracking on</button>
114
+ <button id="btnBody" aria-pressed="true">Body turns</button>
115
+ <button id="btnMute" aria-pressed="false">Sound on</button>
116
+ <button id="btnWave">Test wave reaction</button>
117
+ </div>
118
+
119
+ <div id="log" class="log"></div>
120
+ </main>
121
+
122
+ <script type="module" src="/app.js"></script>
123
+ </body>
124
+ </html>
plan.md ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # plan.md — Reachy Mini "Wave Hello" app
2
+
3
+ ## What you want
4
+
5
+ A Reachy Mini app that:
6
+
7
+ 1. Watches the robot's camera for faces and keeps the head pointed at the nearest/largest face.
8
+ 2. Detects when a person waves at the robot.
9
+ 3. Responds to a wave by wagging its antennae and making happy noises.
10
+
11
+ Robot: **Wireless** (onboard CM4). Reaction source: **prebuilt emotions library** (`pollen-robotics/reachy-mini-emotions-library`).
12
+
13
+ ## App flavor: JS / Hugging Face Space
14
+
15
+ Chosen over Python because the Wireless CM4 is CPU- and memory-constrained. In the JS flavor all vision runs in the browser on your laptop or phone, the robot only streams video and receives motion commands. This matches the pattern used by the official `hand_tracker_v2` and `reachy_mini_telepresence` reference apps.
16
+
17
+ Cost of this choice: WebRTC adds roughly 100-300 ms of latency between what the camera sees and the head command. Face tracking will feel slightly laggy compared to an on-robot loop. This is acceptable for social tracking; it would not be for fast target following. Graduating to Python later is easy if you decide you want tighter tracking.
18
+
19
+ ## Technical approach
20
+
21
+ **Scaffold**
22
+ - Clone `pollen-robotics/reachy_mini_telepresence` (React + Vite, already handles the video stream) and trim it. Alternative: `reachy_mini_minimal_conversation` (vanilla TS) if you prefer no React. I lean telepresence because the camera plumbing is already correct.
23
+ - Pin `@pollen-robotics/reachy-mini-sdk@1.8.0`.
24
+ - Standard host shell: `mountHost()` for OAuth, robot picker, top bar; `connectToHost()` in the embed.
25
+ - `enableMicrophone: false` (no user-to-robot audio needed).
26
+ - Mobile-first layout, per the guide.
27
+
28
+ **Vision (in browser)**
29
+ - `@mediapipe/tasks-vision` from npm: Face Detector (short-range, lightweight) and Hand Landmarker (1 hand). Both run in the browser via WebGL/WASM. Frames come from the `<video>` element attached with `reachy.attachVideo()`. Never `getUserMedia()`; that would grab your own laptop camera.
30
+ - Run detection at ~15 fps in a `requestVideoFrameCallback` loop; skip frames if the previous inference hasn't finished.
31
+
32
+ **Face tracking loop**
33
+ - Pick the largest detected face. Compute its center offset from frame center, normalized to [-1, 1].
34
+ - Proportional control on head yaw and pitch, with a small deadband (~4% of frame) and exponential smoothing so the head doesn't twitch.
35
+ - Command via `reachy.setTarget()` at 10-20 Hz using a locally kept "last commanded pose" as the baseline (the guide warns against using telemetry as the baseline because it lags one RTT).
36
+ - Clamp: pitch/roll within ±40°, keep yaw well inside limits. Body yaw stays fixed at 0 in v1; if the face drifts past ~35° head yaw, optionally rotate the body to follow (v2).
37
+ - If no face is seen for ~3 s, ease back to a neutral pose.
38
+
39
+ **Wave detection**
40
+ - From Hand Landmarker: require an open hand (fingers extended, palm roughly facing camera) with the wrist above the hand's midpoint.
41
+ - Track wrist x over a 1.2 s sliding window. A wave is: at least 2 direction reversals with horizontal travel above a threshold proportional to hand size.
42
+ - Debounce: after a wave triggers, ignore new waves for ~4 s so one wave produces one reaction.
43
+
44
+ **Happy reaction**
45
+ - At startup, fetch a small set of "happy" moves from the emotions dataset (each is `<name>.json` + `<name>.ogg` at the dataset root) and cache them in memory.
46
+ - On wave: pause the tracking loop, call `reachy.playMove(motionJson, { audioBlob })`, await `finished`, then resume tracking. `playMove` runs daemon-side with a single clock, so motion and sound stay in sync.
47
+ - The recorded emotions already include antenna motion. If the library's "happy" clips are not antenna-heavy enough, I'll layer a short custom antenna wag via `setAntennasDeg` before or after the clip.
48
+
49
+ **Cleanup**
50
+ - `handle.onLeave()` → `safelyReturnToPose(reachy)`, cancel any in-flight `playMove`, stop the vision loop, close MediaPipe.
51
+
52
+ **UI (minimal)**
53
+ - Robot camera feed with face box and hand landmarks overlaid.
54
+ - Status line: "Looking for faces" / "Tracking" / "Wave detected!"
55
+ - Toggles: tracking on/off, reaction sensitivity, mute.
56
+
57
+ ## Files I will produce
58
+
59
+ Per the guide's 3-file contract: `index.html`, `src/dispatch.ts`, `src/embed.tsx`, plus `src/vision/` helpers, `public/icon.svg`, `README.md` frontmatter with `sdk: static`, `hf_oauth: true`, and the `reachy_mini_js_app` tag.
60
+
61
+ ## Questions (answer inline)
62
+
63
+ 1. Which emotion clips should play on a wave? Options: (a) just `happy`, (b) a random pick from a short list I curate (happy, amazed, excited-type clips) so it doesn't feel identical every time.
64
+ Answer: random pick from a curated list (welcoming1, welcoming2, enthusiastic2, cheerful1, success2, loving1, helpful1)
65
+
66
+ 2. Should the robot also react when it first sees a face, before any wave (e.g. a small "noticed you" head tilt)? Or stay quiet until waved at?
67
+ Answer: yes, a short nod and a chirp when a face first appears
68
+
69
+ 3. Body rotation: keep the body fixed and only move the head (simpler, v1), or let the body turn to follow a person walking around it?
70
+ Answer: body turns to follow
71
+
72
+ 4. Your Hugging Face username and the Space name you want (e.g. `yourname/reachy_wave_hello`).
73
+ Answer: RickDakan/howdy_friend
74
+
75
+ 5. Do you have Node.js + npm on your machine? If not, I can switch to the bare-HTML + CDN path, but MediaPipe is easier via npm.
76
+ Answer: unknown, so use bare HTML + CDN path (no Node needed)
77
+
78
+ ## Not doing in v1
79
+
80
+ - Multi-person tracking or identity recognition.
81
+ - Custom recorded sounds.
82
+ - Python fallback.
public/icon.svg ADDED