Spaces:
Running
Running
Commit ·
bdced15
0
Parent(s):
feat: howdy friend static Space
Browse files- .gitignore +1 -0
- README.md +42 -0
- app.js +637 -0
- index.html +124 -0
- plan.md +82 -0
- 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
|
|