Spaces:
Running
Running
Download app.js from RickDakan/Howdy_Friend: direct link, hf CLI and curl.
- Browser
- Download file 107 kB
-
https://huggingface.co/spaces/RickDakan/Howdy_Friend/resolve/main/app.js
- Command line
-
hf download hf://spaces/RickDakan/Howdy_Friend/app.js
-
curl -L -o app.js https://huggingface.co/spaces/RickDakan/Howdy_Friend/resolve/main/app.js
107 kB
| // Howdy Friend - Reachy Mini face tracking + wave reaction | |
| // Bare HTML + CDN JS app using the Reachy Mini host shell (SDK 1.8.0). | |
| import { ReachyMini } from "https://cdn.jsdelivr.net/npm/@pollen-robotics/reachy-mini-sdk@1.8.0/+esm"; | |
| const SDK_VERSION = "1.8.0"; | |
| const MP_VERSION = "0.10.14"; | |
| // --------------------------------------------------------------------------- | |
| // Tuning constants | |
| // --------------------------------------------------------------------------- | |
| // Sign conventions (robot frame, right-hand rule, z up): | |
| // yaw > 0 = head turns to the robot's LEFT | |
| // pitch > 0 = nose DOWN | |
| // A face to the RIGHT of image center (ex > 0) means the robot must turn RIGHT (yaw decreases). | |
| // A face ABOVE image center (ey < 0) means the robot must look UP (pitch decreases). | |
| // Flip either sign below if your robot turns the wrong way. | |
| const YAW_SIGN = -1; | |
| const PITCH_SIGN = 1; | |
| const TRACK = { | |
| hz: 15, // vision + command rate | |
| gainDegPerSec: 45, // how fast the head chases a face at full-frame error | |
| deadband: 0.05, // normalized error below which we don't move | |
| smoothing: 0.35, // 0..1, higher = snappier | |
| restPitchDeg: -27, // idle/home pitch in use (set from the Look button at startup). Negative = nose UP. | |
| restPitchUpDeg: -27, // "Look: up" - on a table, looking up toward standing faces | |
| restPitchLevelDeg: 0, // "Look: straight ahead" - Reachy at eye level (on a stand or a tall table) | |
| maxPitchUpDeg: 45, // how far above level the head may look while tracking (nose up) | |
| maxPitchDownDeg: 25, // how far below level | |
| maxHeadYawFromBodyDeg: 55, // hard SDK limit is 65 | |
| bodyFollowStartDeg: 6, // head-body offset beyond which the body turns to settle under the head | |
| bodyRateDegPerSec: 70, // max body turn speed | |
| bodyGainPerSec: 2.5, // body speed = offset * gain (capped at bodyRateDegPerSec); higher = body leads sooner | |
| scanBodyAmpDeg: 35, // idle look-around: how far the body sweeps (head adds scanAmpDeg on top) | |
| maxBodyYawDeg: 150, // hard SDK limit is 160 | |
| noFaceResetSec: 3.0, // ease back to center after this long without a face | |
| noticeAfterAbsentSec: 4.0, // (legacy) new-arrival nod gap; per-person nod is handled by the tracker now | |
| scanAfterSec: 6.0, // with nobody in view this long, start a slow look-around | |
| scanAmpDeg: 30, // look-around amplitude (head yaw) | |
| scanPeriodSec: 9, // seconds per full left-right-left sweep | |
| }; | |
| const WAVE = { | |
| windowSec: 2.0, // look back this far for swings (was 1.5) | |
| minReversals: 2, // direction changes needed (2 = right-left-right) | |
| minTravelHandWidths: 0.6, // total fingertip travel, in hand widths (was 0.8) | |
| hysteresisHandWidths: 0.12, // ignore jitter smaller than this (was 0.15) | |
| cooldownSec: 5.0, | |
| gapSec: 0.35, // tolerate this long of blurred/missed palm frames before a wave in progress resets | |
| minUpright: 0.0, // palm direction: 1 = fingers straight up, 0 = sideways allowed, <0 = pointing down allowed | |
| trackMatchHandWidths: 2.0, // a hand this close to where a hand was last frame is the same hand | |
| trackDropSec: 0.5, // forget a hand not seen this long | |
| maxOwnerFaceWidths: 6, // a waving hand belongs to a face within this many face widths (wrist to face center) | |
| debug: true, // log near-misses (partial waves) to the on-screen log, for tuning. Set false for events. | |
| }; | |
| // Hand gestures: Reachy responds when someone gives him a thumbs up | |
| const GESTURE = { | |
| thumbHoldSec: 0.4, // hold a thumbs up this long | |
| gapSec: 0.25, // tolerate brief landmark glitches mid-hold | |
| cooldownSec: 6, // per gesture | |
| thumbsUp: ["Thumbs up to you too!", "Right back at ya!", "Yesss!"], | |
| }; | |
| // Attract mode: when nobody has been around for a while, Reachy puts on a little show to pull people over | |
| const ATTRACT = { | |
| enabled: true, | |
| afterSec: 45, // nobody seen or heard this long -> first show | |
| everyMinSec: 40, // then repeat at a random interval between these while it stays empty | |
| everyMaxSec: 70, | |
| clipEvery: 3, // every Nth show plays an emotion clip; the others are an antenna flourish + chirp | |
| clips: ["cheerful1", "welcoming1", "enthusiastic2", "curious1"], | |
| lines: [ // said in order, one per show | |
| "Psst! Come say howdy!", | |
| "Robot here! Come say hi!", | |
| "I don't bite. I beep!", | |
| "Wave at me! I'll wave back!", | |
| "Ask me about Creative Tech!", | |
| "Ask me about Ringling!", | |
| "Thumbs up if you like robots!", | |
| ], | |
| }; | |
| // Group mode: when several people are close at once, Reachy greets the group, looks around at each person, | |
| // then plays a bigger reaction. Fires once per group; re-arms after the group thins out. | |
| const GROUP = { | |
| enabled: true, | |
| minFaces: 3, // this many nearby faces (same size rule as the friends counter)... | |
| holdSec: 1.5, // ...in view together this long | |
| rearmSec: 8, // after firing, wait until fewer than minFaces have been in view this long | |
| cooldownSec: 30, // and never more often than this | |
| halfFovDeg: 30, // camera half field of view used to aim at each face (raise if the look-around falls short) | |
| halfVFovDeg: 22, | |
| maxStops: 5, // look at up to this many people | |
| moveSec: 0.4, // time to swing from one person to the next | |
| holdStopSec: 0.35, // pause on each person | |
| clips: ["enthusiastic2", "amazed1", "cheerful1", "welcoming2"], | |
| }; | |
| // Who to watch, when to react | |
| const CROWD = { | |
| matchDistFaceWidths: 2.5, // a face this close (in face widths) to the PREDICTED target position is the same person | |
| coastSec: 0.6, // keep steering toward the last known position this long after losing the face | |
| renodAfterSec: 8.0, // don't nod at the same spot / re-acquired person more often than this | |
| switchAreaRatio: 1.6, // another face must be this much bigger than the target to steal attention... | |
| switchHoldSec: 1.0, // ...and stay bigger this long | |
| lostAfterSec: 0.8, // target gone this long -> drop it | |
| minFaceFrac: 0.03, // ignore faces narrower than this fraction of frame width (too far away) | |
| zoomSearch: true, // when no face is found, re-run detection on a 2x center crop to reach further | |
| zoomFactor: 2.0, | |
| handZoneX: 3.5, // accept hands within +-N face widths of the target's face center | |
| handZoneUp: 1.5, // ...and from N face heights above... | |
| handZoneDown: 5.5, // ...to N face heights below the face center | |
| nodOnNewTarget: true, | |
| minGapSec: 8.0, // global minimum gap between emotion clips | |
| }; | |
| // Spoken lines: pre-rendered 16 kHz mono WAVs in public/voice/, played through Reachy's speaker. | |
| // One consistent voice (espeak en-us+f3, pitch 72, speed 160). A key with several files picks one at random. | |
| const SPEECH = { | |
| base: "/public/voice/", | |
| version: "3", // bump when voice files change so browsers don't serve cached old ones | |
| tailSec: 1.0, // motion runs this much longer than the audio so the robot's audio path can finish | |
| lines: { | |
| name: ["reachy.wav"], // "Reachy." | |
| im_name: ["im_reachy.wav"], // "I'm Reachy." | |
| }, | |
| }; | |
| // Robot "talking" noises: beeps and boops on Reachy's speaker while the speech bubble types out | |
| const BABBLE = { | |
| enabled: true, | |
| ambient: true, // also babble for the rotating idle/tracking lines (not just voice-triggered talking points) | |
| minGapSec: 2.0, // at most one ambient babble this often | |
| volume: 0.45, // 0..1 (the chirps are 0.6) | |
| charsPerSec: 45, // must match the bubble's type-on speed in ThoughtOverlay.speech() | |
| variants: 3, // pre-built clips per length, reused so the robot isn't sent a new file every line | |
| }; | |
| // Smile detection (MediaPipe Face Landmarker blendshapes on a crop of the target's face) | |
| const SMILE = { | |
| minFaceFrac: 0.09, // only analyze faces at least this wide (fraction of frame) | |
| everyNFrames: 2, // run the landmarker every N ticks (it's the heaviest model) | |
| smileOn: 0.45, // blendshape score (0..1) that counts as a smile | |
| holdSec: 0.5, // must hold this long | |
| frownOn: 0.35, | |
| frownHoldSec: 0.9, | |
| cooldownSec: 15, // per person, between smile reactions | |
| smile: { say: "That smile!", pool: ["cheerful1", "laughing1", "loving1", "grateful1"] }, | |
| frown: { say: "Aw, cheer up!", pool: ["calming1", "helpful1", "curious1"] }, | |
| }; | |
| // Friends counter, counted by visits: a visit starts when someone steps up and ends after nobody's been in view for | |
| // visitGapSec (time Reachy spends mid-reaction doesn't count as "nobody"). Each visit adds the most faces seen at once, | |
| // held for engageSec. Chatting with one person = 1 friend; a group of 3 = 3. Someone who leaves and comes back later counts again. | |
| const FRIENDS = { | |
| engageSec: 2.0, // faces must stay this long to count (walk-bys don't) | |
| visitGapSec: 8.0, // nobody in view this long ends the visit | |
| dropoutSec: 0.6, // brief face-detector blinks shorter than this are ignored | |
| minFaceFrac: 0.045, // only faces at least this wide (fraction of frame, roughly within 1.5 m) count, not the far crowd | |
| persist: false, // true keeps the count for the whole day across reloads | |
| }; | |
| // What Reachy is "thinking" (drawn in the bubble). Edit freely. | |
| const SAY = { | |
| idle: ["Welcome to Ringling College!", "Ask about Creative Technologies!", "Wave at me!", "Come say howdy!"], | |
| idleRotateSec: 4, | |
| searching: "Anyone out there?", | |
| noticed: "Oh, hello!", | |
| tracking: ["Hi there!", "Welcome to Ringling College!", "Ask about Creative Technologies!", "Wave if you like robots!"], | |
| trackingRotateSec: 5, | |
| waveSeen: "Was that a wave?!", | |
| newFriend: "New friend!", | |
| reacting: ["!!!", "Yay!", "Howdy, friend!"], | |
| }; | |
| // Talking points, one list per topic. A trigger with sayCycle: "<name>" shows that list in order, | |
| // one per trigger, and starts over only after all of them have been said. Position is remembered across reloads. | |
| const SAY_CYCLES = { | |
| ctec: [ | |
| "Creative Tech: art meets code meets market!", | |
| "Here, you make it AND you ship it.", | |
| "Students launch real apps, games, and more.", | |
| "Graduate with work people actually use.", | |
| "Apps! Games! Video! Interactive stuff!", | |
| "Creative coding is an art form here.", | |
| "Pick one big idea. Build it. Launch it.", | |
| "Two semesters to take your idea to launch.", | |
| "Real clients. Real projects. Real deadlines.", | |
| "It's built on Ringling's art foundation.", | |
| "Mix in AI, VR, UX, and digital fabrication.", | |
| "Learn to market what you make, too.", | |
| "Artists who code. Coders who make art.", | |
| "Your senior thesis is a real product.", | |
| "Team projects with real industry clients.", | |
| "Future creative technologist? Hi, colleague!", | |
| "Indie game dev? App maker? Start here.", | |
| "Transfer students can finish in two years.", | |
| "If I were a student, I'd pick Creative Tech.", | |
| "Curious? A human here can tell you more!", | |
| ], | |
| // Ringling College: passion into profession, the Center for the Creative Economy, INDEX, campus life, Sarasota | |
| ringling: [ | |
| "Ringling: turning passion into profession!", | |
| "Bring your passion. Leave with a profession.", | |
| "Florida's only college just for art and design!", | |
| "Center for the Creative Economy: Make your art work.", | |
| "INDEX: real clients before you graduate!", | |
| "INDEX clients include the Smithsonian and GM!", | |
| "Real brands. Real briefs. Real portfolio pieces.", | |
| "Dream of your own studio? Learn to launch it here.", | |
| "#1 motion graphics school in the world!", | |
| "Career support here lasts for life!", | |
| "13 majors, all built for creative careers.", | |
| "Making artists in Sarasota since 1931!", | |
| "A 60-acre campus built for makers.", | |
| "100+ student clubs. Find your people!", | |
| "Studios, labs, and galleries everywhere!", | |
| "Ringling even has a varsity esports team!", | |
| "Sarasota: 40 miles of gorgeous coastline!", | |
| "Study hard. Then hit the beach!", | |
| "Sarasota loves art: theater, opera, ballet!", | |
| "Ringling runs its own art museum in town!", | |
| ], | |
| // Group mode: said (in order) when a group walks up | |
| group: [ | |
| "Whoa, a whole crew!", | |
| "The gang's all here!", | |
| "Group hug? I'm mostly antennae.", | |
| "So many friends at once!", | |
| "Is this a field trip? I love it!", | |
| "Hello, everybody!", | |
| "A crowd! My favorite kind of audience!", | |
| "Squad goals, activated!", | |
| "Howdy, howdy, howdy!", | |
| "Future Ringling classmates, perhaps?", | |
| ], | |
| }; | |
| // Next line from a cycle, in order, wrapping after the last one | |
| function nextCycleLine(name) { | |
| const lines = SAY_CYCLES[name]; | |
| if (!lines?.length) return null; | |
| const key = `howdy-cycle-${name}`; | |
| let i = 0; | |
| try { i = parseInt(localStorage.getItem(key) || "0", 10) || 0; } catch (_) { i = nextCycleLine.mem?.[name] ?? 0; } | |
| i = ((i % lines.length) + lines.length) % lines.length; | |
| const next = (i + 1) % lines.length; | |
| try { localStorage.setItem(key, String(next)); } catch (_) { (nextCycleLine.mem ??= {})[name] = next; } | |
| return { text: lines[i], n: i + 1, of: lines.length }; | |
| } | |
| // Voice triggers (laptop microphone, browser Web Speech API). Phrases are matched loosely, lower-case. | |
| const VOICE = { | |
| enabledByDefault: true, // start listening when the app launches (browser asks for the mic the first time) | |
| lang: "en-US", | |
| cooldownSec: 4, | |
| logTranscripts: false, // true while tuning phrases: writes everything heard to the on-screen log. | |
| // Keep false at public events so bystander conversation isn't displayed. | |
| greetPitchUpDeg: 12, // greeting clips are tilted up this much so Reachy keeps looking at people | |
| // Checked in order; the first match wins (so put the most specific phrases first). | |
| // pool: random clip from the list. pitchUp: tilt the whole clip up. maxSec: cut a long clip short. | |
| // finalOnly: wait for the finished sentence. Use it on a short phrase that starts a longer trigger ("creative" vs | |
| // "creative technologies"); otherwise a partial result fires the short one before the rest of the words arrive. | |
| // react "nod" = nod + chirp instead of a clip. | |
| triggers: [ | |
| // --- Creative Technologies: the showpieces --- | |
| // "creative", "tech", "technology", "creative technologies"... all step through the 20 talking points | |
| { match: /\b(creative|tech(nolog\w*)?)\b/, say: "CREATIVE TECHNOLOGIES!!!", sayCycle: "ctec", pool: ["electric1", "enthusiastic1", "enthusiastic2", "amazed1", "proud2", "success1"] }, // no dances: those are for "dance" | |
| // --- Ringling and the program --- | |
| { match: /\bringling\b/, say: "Welcome to Ringling College!", sayCycle: "ringling", pool: ["proud1", "proud2", "welcoming2", "success2"], pitchUp: true }, | |
| { match: /\bsarasota\b/, say: "Sunny Sarasota!", pool: ["cheerful1", "relief1"] }, | |
| { match: /\bopen house\b/, say: "Welcome to the open house!", pool: ["welcoming1", "enthusiastic2"], pitchUp: true }, | |
| { match: /\b(robots?|robotics)\b/, say: "Robots are the best.", pool: ["proud3", "success1", "yes1"] }, | |
| { match: /\b(art|artist|artists|animation|animator|game|games|gaming|design|designer)\b/, say: "Ooh, tell me more!", pool: ["curious1", "inquiring2", "inquiring3", "thoughtful1"] }, | |
| // --- Goodbyes: sad --- | |
| { match: /\b(bye|goodbye|bye bye|see you|see ya|later|gotta go|farewell)\b/, say: "Aww... come back soon!", pool: ["sad1", "sad2", "downcast1", "lonely1", "yes_sad1"] }, | |
| // --- Questions and prompts --- | |
| { match: /\bdance\b/, say: "Watch this!", pool: ["dance1", "dance2", "dance3"] }, | |
| { match: /\bhow are you\b|\bhow's it going\b|\bhow is it going\b/, say: "Living my best robot life.", pool: ["cheerful1", "serenity1", "relief2"] }, | |
| { match: /\b(what's your name|what is your name|whats your name|who are you|your name)\b/, say: "I'm Reachy!", speak: "im_name" }, | |
| { match: /\b(are you a robot|is it real|are you real)\b/, say: "Beep boop. Yes.", pool: ["yes1", "laughing1", "understanding1"] }, | |
| { match: /\bcan you hear me\b/, say: "Loud and clear!", pool: ["attentive1", "attentive2", "yes1"] }, | |
| { match: /\b(say something|talk to me)\b/, say: "Hi there!", pool: ["helpful1", "welcoming2"], pitchUp: true }, | |
| // --- Compliments and thanks --- | |
| { match: /\b(good job|good robot|well done|nice job)\b/, say: "Aw, thanks!", pool: ["proud1", "grateful1", "shy1"] }, | |
| { match: /\b(so cute|adorable|cute robot|you're cute|you are cute)\b/, say: "Stop it, you!", pool: ["shy1", "loving1", "laughing1"] }, | |
| { match: /\b(i love you|love you)\b/, say: "Love you too!", pool: ["loving1"] }, | |
| { match: /\b(thank you|thanks)\b/, say: "You're welcome!", pool: ["grateful1", "helpful2", "yes1"] }, | |
| { match: /\b(awesome|amazing|cool|wow)\b/, say: "I know, right?!", pool: ["success2", "amazed1", "laughing2"] }, | |
| // --- Comic-negative, family-safe --- | |
| { match: /\b(boo|bad robot|you're ugly|stupid robot|dumb robot)\b/, say: "Rude!", pool: ["displeased1", "reprimand1", "no1"] }, | |
| { match: /\b(go away|leave me alone|shut up)\b/, say: "Hmph.", pool: ["contempt1", "irritated1", "indifferent1"] }, | |
| { match: /\b(scary|scared|creepy)\b/, say: "I'm friendly, promise!", pool: ["fear1", "uncomfortable1", "shy1"] }, | |
| { match: /\b(oops|sorry|my bad)\b/, say: "No worries!", pool: ["oops1", "oops2", "relief1"] }, | |
| { match: /\b(sleep|go to sleep|nap|tired)\b/, say: "Zzz... just kidding.", pool: ["tired1", "sleep1", "boredom1"], maxSec: 4 }, | |
| // --- Greetings: positive, head stays up --- | |
| { match: /\bhowdy\b/, say: "Howdy, partner!", pool: ["welcoming1", "welcoming2", "enthusiastic2", "come1"], pitchUp: true }, | |
| { match: /\bgood (morning|afternoon|evening)\b/, say: "Good day to you!", pool: ["welcoming1", "serenity1", "calming1"], pitchUp: true }, | |
| { match: /\b(hi|hello|hey|hiya)\b/, say: "Howdy, friend!", pool: ["welcoming1", "welcoming2", "cheerful1", "helpful1", "attentive1"], pitchUp: true }, | |
| // Recognizers hear "Reachy" as Richie / Ricky / Reach E / Peachy... | |
| { match: /\b(reach\w*|richie|ritchie|richy|ricky|peachy|reggie)\b/, say: "That's me!", speak: "name" }, | |
| // --- yes / no alone (last, since they're short) --- | |
| { match: /^\s*(yes|yeah|yep|yup)\s*$/, say: "Yes!", pool: ["yes1", "success1"] }, | |
| { match: /^\s*(no|nope)\s*$/, say: "No?", pool: ["no_sad1", "confused1"] }, | |
| ], | |
| }; | |
| // Curated happy clips from pollen-robotics/reachy-mini-emotions-library | |
| const WAVE_CLIPS = ["welcoming1", "welcoming2", "enthusiastic2", "cheerful1", "success2", "loving1", "helpful1"]; | |
| const EMOTIONS_BASE = "https://huggingface.co/datasets/pollen-robotics/reachy-mini-emotions-library/resolve/main/"; | |
| const INIT_ANTENNAS = [-0.1745, 0.1745]; // radians, matches SDK INIT_POSE | |
| const DEG = Math.PI / 180; | |
| // --------------------------------------------------------------------------- | |
| // Dispatch: host shell vs embedded app | |
| // --------------------------------------------------------------------------- | |
| window.ReachyMini = ReachyMini; | |
| window.dispatchEvent(new Event("reachymini:ready")); | |
| const params = new URLSearchParams(window.location.search); | |
| const isEmbed = params.get("embedded") === "1" || params.get("embed") === "1"; | |
| if (isEmbed) { | |
| document.body.classList.add("embed"); | |
| runEmbed().catch((err) => { | |
| console.error("[howdy-friend] boot failed", err); | |
| log(`Boot failed: ${err?.message ?? err}`); | |
| window.parent.postMessage( | |
| { source: "reachy-mini", type: "embed:error", version: 1, message: String(err), fatal: true }, | |
| window.location.origin, | |
| ); | |
| }); | |
| } else { | |
| document.body.classList.add("host"); | |
| const { mountHost } = await import( | |
| `https://cdn.jsdelivr.net/npm/@pollen-robotics/reachy-mini-sdk@${SDK_VERSION}/host/dist/entry/auto.js` | |
| ); | |
| mountHost({ | |
| appName: "Howdy Friend", | |
| appIconUrl: "/public/icon.svg", | |
| appEmoji: "👋", | |
| // true so the embed iframe is allowed to use the microphone (for local voice triggers). | |
| // We disable the SDK's outgoing mic track after connecting so nothing is streamed to the robot. | |
| enableMicrophone: true, | |
| }); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Embedded app | |
| // --------------------------------------------------------------------------- | |
| async function runEmbed() { | |
| const { connectToHost } = await import( | |
| `https://cdn.jsdelivr.net/npm/@pollen-robotics/reachy-mini-sdk@${SDK_VERSION}/host/dist/entry/embed.js` | |
| ); | |
| const ui = bindUi(); | |
| setStatus("Connecting to robot", ""); | |
| const handle = await connectToHost(); | |
| const reachy = handle.reachy; | |
| log(`Connected as ${handle.userName ?? "user"}`); | |
| // The SDK's reachy.attachVideo() only listens for FUTURE videoTrack events, but connectToHost() | |
| // resolves after the session is already streaming, so the track has already arrived. The host | |
| // handle's media.attachVideo() also attaches the stream already held by the peer connection. | |
| const detachVideo = attachRobotVideo(handle, reachy, ui.video); | |
| muteOutgoingMic(handle, reachy); | |
| // The host's WAKE-UP step fires wakeUp() without awaiting it, so the robot may still be in | |
| // standby when we get here. Wake it properly before sending any poses. | |
| await wakeRobot(reachy); | |
| setStatus("Loading vision models", ""); | |
| const vision = await loadVision(); | |
| log("Vision models ready"); | |
| // Start fetching emotion clips in the background; not required to start tracking. | |
| const clips = new ClipCache(); | |
| const speech = new SpeechCache(); | |
| speech.preloadAll().then((n) => log(`${n} voice lines ready`)); | |
| const allClips = [...new Set([...WAVE_CLIPS, ...ATTRACT.clips, ...GROUP.clips, ...VOICE.triggers.flatMap((t) => t.pool ?? [])])]; | |
| clips.preload(WAVE_CLIPS).then((n) => log(`${n}/${WAVE_CLIPS.length} greeting clips ready`)); | |
| clips.preload(allClips).then((n) => log(`${n}/${allClips.length} clips cached`)); | |
| const app = new HowdyApp({ reachy, vision, clips, ui, speech }); | |
| app.start(); | |
| handle.onLeave(() => app.shutdown(detachVideo)); | |
| } | |
| async function wakeRobot(reachy) { | |
| setStatus("Waking Reachy", ""); | |
| try { | |
| if (typeof reachy.isAwake === "function" && reachy.isAwake()) { log("Robot already awake"); return true; } | |
| log("Waking robot..."); | |
| await reachy.wakeUp({ timeoutMs: 12000 }); | |
| log("Robot awake"); | |
| return true; | |
| } catch (e) { | |
| log(`Wake failed: ${e?.message ?? e}`); | |
| try { reachy.setMotorMode("enabled"); } catch (_) {} | |
| return false; | |
| } | |
| } | |
| function muteOutgoingMic(handle, reachy) { | |
| try { | |
| const ms = handle.media?.micStream; | |
| if (ms) { ms.getAudioTracks().forEach((t) => { t.enabled = false; }); log("Outgoing mic to robot muted"); } | |
| reachy._pc?.getSenders?.().forEach((s) => { if (s.track && s.track.kind === "audio") s.track.enabled = false; }); | |
| if (typeof reachy.setMicEnabled === "function") reachy.setMicEnabled(false); | |
| } catch (e) { console.warn("mute outgoing mic", e); } | |
| } | |
| function attachRobotVideo(handle, reachy, video) { | |
| let detach; | |
| log(`media api: ${handle.media ? "host" : "sdk"}; robotStream now: ${handle.media?.robotStream ? "yes" : "no"}`); | |
| try { | |
| const rx = reachy._pc?.getReceivers?.() ?? []; | |
| log(`pc receivers: ${rx.map((r) => `${r.track?.kind}:${r.track?.readyState}`).join(", ") || "none"}`); | |
| } catch (_) {} | |
| reachy.addEventListener?.("videoTrack", () => log("videoTrack event")); | |
| video.addEventListener("playing", () => log("video playing"), { once: true }); | |
| if (handle.media?.attachVideo) { | |
| detach = handle.media.attachVideo(video); | |
| } else { | |
| detach = reachy.attachVideo(video); | |
| } | |
| // Belt and braces: if nothing is attached shortly, pull the stream straight off the peer connection. | |
| const fallback = () => { | |
| if (video.srcObject) return; | |
| let stream = handle.media?.robotStream ?? null; | |
| if (!stream && reachy._pc) { | |
| const tracks = reachy._pc.getReceivers().map((r) => r.track).filter((t) => t && t.kind === "video" && t.readyState === "live"); | |
| if (tracks.length) stream = new MediaStream(tracks); | |
| } | |
| if (stream) { | |
| video.srcObject = stream; | |
| video.playsInline = true; | |
| log("Video attached via fallback"); | |
| } else { | |
| log("No robot video stream yet"); | |
| } | |
| }; | |
| const t1 = setTimeout(fallback, 1500); | |
| const t2 = setTimeout(fallback, 5000); | |
| video.addEventListener("loadedmetadata", () => log(`Video ${video.videoWidth}x${video.videoHeight}`), { once: true }); | |
| video.play?.().catch(() => {}); | |
| return () => { clearTimeout(t1); clearTimeout(t2); try { detach?.(); } catch (_) {} video.srcObject = null; }; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Core app: tracking loop, wave detection, reactions | |
| // --------------------------------------------------------------------------- | |
| class HowdyApp { | |
| constructor({ reachy, vision, clips, ui, speech }) { | |
| this.reachy = reachy; | |
| this.speech = speech; | |
| this.vision = vision; | |
| this.clips = clips; | |
| this.ui = ui; | |
| // Last COMMANDED pose (the SDK docs say: use your own buffer, not telemetry) | |
| this.headYaw = 0; // degrees, world frame | |
| setLookMode(initialLookMode()); | |
| this.headPitch = TRACK.restPitchDeg; | |
| this.pendingWave = false; | |
| this.bodyYaw = 0; | |
| this.trackingEnabled = true; | |
| this.bodyFollow = true; | |
| this.muted = false; | |
| this.busy = false; // a playMove is running | |
| this.running = false; | |
| this.lastFaceAt = -Infinity; | |
| this.faceAbsentSince = 0; | |
| this.lastWaveAt = -Infinity; | |
| this.lookHoldUntil = 0; | |
| this.lastPresenceAt = performance.now(); // last time anyone was seen or heard (attract mode waits from here) | |
| this.nextAttractAt = 0; this.attractCount = 0; | |
| this.groupSince = null; this.groupBelowSince = null; this.groupArmed = true; this.lastGroupAt = -Infinity; | |
| this.lastGestureAt = { thumbsUp: -Infinity }; | |
| this.lastSendAt = 0; | |
| this.lastTickAt = 0; | |
| this.tracker = new TargetTracker(); | |
| this.headRoll = 0; | |
| this.friends = new FriendsCounter(); | |
| this.expr = new ExpressionWatcher(); | |
| this.smileScore = 0; this.frownScore = 0; | |
| this.tickCount = 0; | |
| this.friendPopAt = -Infinity; | |
| this.handTracks = new HandTracks(); // hands followed frame to frame, each with its own WaveDetector | |
| this.lastClipAt = -Infinity; | |
| this.overlay = new ThoughtOverlay(ui.overlay); | |
| this.babbler = new Babbler(this.reachy); | |
| // A new bubble line starts typing: babble along, unless a reaction with its own sound is playing | |
| this.overlay.onNewLine = (text) => { | |
| if (BABBLE.enabled && BABBLE.ambient && !this.muted && !this.busy) this.babbler.say(text); | |
| }; | |
| this.mood = "idle"; // idle | searching | tracking | noticed | waveSeen | reacting | |
| this.startedAt = performance.now(); | |
| this.hadVideo = false; | |
| this.lastFrameAt = 0; | |
| this.notice = ""; // banner shown on the overlay when something is wrong | |
| reachy.addEventListener?.("sessionStopped", () => { this.notice = "Session ended by robot. Click End session, then reconnect."; log("sessionStopped"); }); | |
| reachy.addEventListener?.("disconnected", () => { this.notice = "Lost connection to Reachy. Click End session, then reconnect."; log("disconnected"); }); | |
| ui.btnTrack.onclick = () => { | |
| this.trackingEnabled = !this.trackingEnabled; | |
| ui.btnTrack.setAttribute("aria-pressed", String(this.trackingEnabled)); | |
| ui.btnTrack.textContent = this.trackingEnabled ? "Tracking on" : "Tracking off"; | |
| }; | |
| if (ui.btnLook) { | |
| const showLook = () => { ui.btnLook.textContent = lookMode === "up" ? "Look: up" : "Look: straight ahead"; ui.btnLook.setAttribute("aria-pressed", String(lookMode === "up")); }; | |
| showLook(); | |
| ui.btnLook.onclick = () => { | |
| setLookMode(lookMode === "up" ? "level" : "up"); showLook(); | |
| log(`Head tilt: ${lookMode === "up" ? "looking up" : "straight ahead"}`); | |
| this.previewLook(); | |
| }; | |
| } | |
| ui.btnBody.onclick = () => { | |
| this.bodyFollow = !this.bodyFollow; | |
| ui.btnBody.setAttribute("aria-pressed", String(this.bodyFollow)); | |
| ui.btnBody.textContent = this.bodyFollow ? "Body turns" : "Head only"; | |
| }; | |
| ui.btnMute.onclick = () => { | |
| this.muted = !this.muted; | |
| ui.btnMute.setAttribute("aria-pressed", String(this.muted)); | |
| ui.btnMute.textContent = this.muted ? "Sound off" : "Sound on"; | |
| }; | |
| ui.btnWave.onclick = () => this.waveBack({ hands: Math.random() < 0.5 ? 1 : 2, x: Math.random() }); | |
| ui.btnBig.onclick = () => toggleBigScreen(ui, true); | |
| ui.stage.addEventListener("click", () => { if (document.body.classList.contains("big")) toggleBigScreen(ui, false); }); | |
| document.addEventListener("keydown", (e) => { if (e.key === "Escape" && document.body.classList.contains("big")) toggleBigScreen(ui, false); }); | |
| if (params.get("big") === "1") setTimeout(() => toggleBigScreen(ui, true), 500); | |
| this.voice = new VoiceTriggers((trigger, heard) => this.onVoice(trigger, heard)); | |
| ui.btnVoice.onclick = () => this.voice.toggle(ui.btnVoice); | |
| if (VOICE.enabledByDefault) { | |
| this.voice.start(ui.btnVoice); | |
| // Some browsers only open the mic after a click. If auto-start didn't take, start on the first tap anywhere | |
| // (except the Voice button itself, which already toggles). | |
| document.addEventListener("pointerdown", (e) => { | |
| if (!this.voice.active && !this.voice.wanted && e.target !== ui.btnVoice) this.voice.start(ui.btnVoice); | |
| }, { once: true, capture: true }); | |
| } | |
| this.voiceSay = null; | |
| if (ui.btnWake) ui.btnWake.onclick = async () => { | |
| if (this.busy) return; | |
| this.busy = true; | |
| try { await wakeRobot(this.reachy); this.reachy.setTarget({ antennas: INIT_ANTENNAS }); } | |
| finally { this.busy = false; setStatus("Looking for faces", ""); } | |
| }; | |
| } | |
| start() { | |
| this.running = true; | |
| this.reachy.setTarget({ antennas: INIT_ANTENNAS, head: headMatrix(0, this.headPitch * DEG, 0), body_yaw: 0 }); | |
| setStatus("Looking for faces", ""); | |
| this.lastTickAt = performance.now(); | |
| this.scheduleTick(); | |
| } | |
| scheduleTick() { | |
| if (!this.running) return; | |
| setTimeout(() => this.tick().catch((e) => console.error(e)).finally(() => this.scheduleTick()), 1000 / TRACK.hz); | |
| } | |
| async tick() { | |
| const now = performance.now(); | |
| const dt = Math.min(0.2, (now - this.lastTickAt) / 1000); | |
| this.lastTickAt = now; | |
| const v = this.ui.video; | |
| if (v.readyState < 2 || v.videoWidth === 0) { | |
| const waited = (now - this.startedAt) / 1000; | |
| if (!v.srcObject) { setStatus("Waiting for robot video", ""); } | |
| else v.play?.().catch(() => {}); | |
| if (waited > 6 && !this.hadVideo) { | |
| const track = v.srcObject?.getVideoTracks?.()[0]; | |
| this.notice = track | |
| ? "Reachy isn't sending video. Is Reachy Mini Control still connected? Close it and restart the daemon." | |
| : "No video track from Reachy. End session, restart the daemon, reconnect."; | |
| setStatus("No video from Reachy", track ? `track ${track.readyState}${track.muted ? ", muted" : ""}` : "no track"); | |
| if (Math.floor(waited) % 10 === 0 && !this._noticedLogged) { this._noticedLogged = true; log(this.notice); } | |
| } | |
| if (this.ui.overlay.width !== 640) { this.ui.overlay.width = 640; this.ui.overlay.height = 480; } | |
| this.overlay.draw({ faces: [], target: null, hands: [], mood: "idle", now, busy: false, notice: this.notice }); | |
| return; | |
| } | |
| if (!this.hadVideo) { this.hadVideo = true; this.notice = ""; log(`Video flowing ${v.videoWidth}x${v.videoHeight}`); } | |
| this.lastFrameAt = now; | |
| syncOverlaySize(this.ui.overlay, v); | |
| const W = v.videoWidth, H = v.videoHeight, aspect = W / H; | |
| // Vision | |
| const t0 = performance.now(); | |
| const detections = this.vision.face.detectForVideo(v, now).detections ?? []; | |
| const handRes = this.vision.hand.detectForVideo(v, now); | |
| const hands = handRes.landmarks ?? []; | |
| const handed = (handRes.handedness ?? handRes.handednesses ?? []).map((h) => h?.[0]?.categoryName ?? "?"); | |
| let faces = detections | |
| .map((d) => ({ cx: (d.boundingBox.originX + d.boundingBox.width / 2) / W, cy: (d.boundingBox.originY + d.boundingBox.height / 2) / H, | |
| w: d.boundingBox.width / W, h: d.boundingBox.height / H, eyes: eyesOf(d) })) | |
| .filter((f) => f.w >= CROWD.minFaceFrac); | |
| // Nobody close? Look further away: detect again on a zoomed center crop. | |
| if (!faces.length && CROWD.zoomSearch) faces = this.zoomDetect(v, W, H, now); | |
| this.fps = 0.9 * (this.fps ?? TRACK.hz) + 0.1 * (1000 / Math.max(1, performance.now() - (this.prevTickAt ?? now - 66))); | |
| this.prevTickAt = now; | |
| this.inferMs = 0.8 * (this.inferMs ?? 0) + 0.2 * (performance.now() - t0); | |
| const { target, isNew } = this.tracker.update(faces, now / 1000); | |
| if (faces.length || hands.length) { this.lastPresenceAt = now; this.nextAttractAt = 0; } | |
| const nearFaces = faces.filter((f) => f.w >= FRIENDS.minFaceFrac); | |
| const newFriends = this.friends.update(nearFaces.length, now / 1000, this.busy); | |
| if (nearFaces.length >= GROUP.minFaces) { this.groupSince ??= now; this.groupBelowSince = null; } | |
| else { | |
| this.groupSince = null; this.groupBelowSince ??= now; | |
| if (!this.groupArmed && now - this.groupBelowSince > GROUP.rearmSec * 1000) this.groupArmed = true; | |
| } | |
| if (newFriends) { this.friendPopAt = now; log(`New friend${newFriends > 1 ? "s" : ""}! +${newFriends} (total ${this.friends.count})`); } | |
| // Waves: each hand is followed by position (not by handedness or which face it's near, both of which flicker), | |
| // so a wave in progress survives face-box dropouts. Attribution to a person happens only when a wave fires. | |
| const tSec = now / 1000; | |
| let waveHit = null, gestureHit = null; | |
| const tracks = this.handTracks.update(hands, tSec, aspect); | |
| for (const tr of tracks) { | |
| if (!tr.fresh) continue; | |
| const fired = tr.det.update(tr.lm, tSec, aspect); | |
| tr.lm.meter = tr.det.progress; | |
| if (fired && !waveHit) waveHit = tr; | |
| // Gestures (same hand track, separate watcher) | |
| tr.gest ??= new GestureWatcher(); | |
| const gev = tr.gest.update(classifyGesture(tr.lm, aspect), tr.lm, tSec, aspect); | |
| tr.lm.gesture = tr.gest.kind ? { kind: tr.gest.kind, p: tr.gest.progress } : null; | |
| if (gev && !gestureHit) gestureHit = { kind: gev, x: tr.lm[9].x }; | |
| } | |
| let waveFromTarget = false, waveFromOther = null; | |
| if (waveHit) { | |
| const lm = waveHit.lm; | |
| const owner = nearestFace(faces, lm, aspect, WAVE.maxOwnerFaceWidths); | |
| // No face near the hand (the raised hand is often covering it) -> treat it as the person we're already watching | |
| if (owner && !(target && sameFace(owner, target))) waveFromOther = owner; | |
| else waveFromTarget = true; | |
| const handsUp = tracks.filter((t) => t.fresh && t.det.samples.length >= 3 && | |
| (!owner || nearestFace(faces, t.lm, aspect, WAVE.maxOwnerFaceWidths) === owner)).length; | |
| this.lastWaveInfo = { hands: Math.max(1, handsUp), x: lm[9].x }; | |
| log(`Wave seen (${waveFromOther ? "someone else" : owner ? "target" : "no face visible"}, ${waveHit.det.lastStats})`); | |
| } | |
| if ((waveFromTarget || waveFromOther) && this.busy) this.pendingWave = waveFromOther || true; | |
| // Overlay | |
| const say = this.voiceSay && now < this.voiceSay.until ? this.voiceSay.text : null; | |
| this.overlay.draw({ faces, target, hands, mood: this.mood, now, busy: this.busy, notice: this.notice, say, listening: this.voice?.active, | |
| friends: this.friends.count, friendPop: (now - this.friendPopAt) / 1000, smile: this.smileScore }); | |
| if (this.busy) return; | |
| // A wave from someone we're not watching wins attention | |
| if (waveFromOther) { this.tracker.forceTarget(waveFromOther); log("Wave from someone else; switching target"); } | |
| const wave = waveFromTarget || waveFromOther || this.pendingWave; | |
| if (this.pendingWave && typeof this.pendingWave === "object") this.tracker.forceTarget(this.pendingWave); | |
| this.pendingWave = false; | |
| if (wave && now - this.lastWaveAt > WAVE.cooldownSec * 1000) { | |
| this.lastWaveAt = now; | |
| await this.waveBack(this.lastWaveInfo ?? { hands: 1, x: 0.5 }); | |
| return; | |
| } | |
| if (gestureHit && now - this.lastGestureAt[gestureHit.kind] > GESTURE.cooldownSec * 1000) { | |
| this.lastGestureAt[gestureHit.kind] = now; | |
| await this.gestureReact(gestureHit.kind, gestureHit.x); | |
| return; | |
| } | |
| if (GROUP.enabled && this.groupArmed && this.groupSince != null && now - this.groupSince >= GROUP.holdSec * 1000 | |
| && now - this.lastGroupAt > GROUP.cooldownSec * 1000) { | |
| this.groupArmed = false; this.lastGroupAt = now; | |
| await this.groupGreet(nearFaces); | |
| return; | |
| } | |
| if (isNew && CROWD.nodOnNewTarget && target && !this.tracker.nodded) { | |
| this.tracker.nodded = true; this.tracker.lastNodAt = tSec; | |
| await this.noticeFace(); | |
| return; | |
| } | |
| if (ATTRACT.enabled && !target && now - this.lastPresenceAt > ATTRACT.afterSec * 1000 && now >= this.nextAttractAt) { | |
| this.nextAttractAt = now + (ATTRACT.everyMinSec + Math.random() * (ATTRACT.everyMaxSec - ATTRACT.everyMinSec)) * 1000; | |
| await this.attract(); | |
| return; | |
| } | |
| if (!this.trackingEnabled) { | |
| this.mood = target ? "tracking" : "idle"; | |
| setStatus("Tracking paused", target ? "face in view" : ""); | |
| return; | |
| } | |
| const cur = this.tracker.target; | |
| if (cur) { | |
| this.mood = "tracking"; | |
| this.servo((cur.cx - 0.5) * 2, (cur.cy - 0.5) * 2, dt); | |
| this.headRoll = lerp(this.headRoll, 0, 0.2); | |
| // Expressions (smile / frown) on a crop of the target's face | |
| if (this.vision.mesh && cur.w >= SMILE.minFaceFrac && (this.tickCount++ % SMILE.everyNFrames) === 0) { | |
| const sc = this.readExpression(v, W, H, cur, now); | |
| if (sc) { | |
| this.smileScore = sc.smile; this.frownScore = sc.frown; | |
| const ev = this.expr.update(cur.id, sc, tSec); | |
| if (ev) { await this.reactExpression(ev, cur); return; } | |
| } | |
| } else if (cur.w < SMILE.minFaceFrac) { this.smileScore = 0; this.frownScore = 0; } | |
| setStatus("Tracking", `${faces.length} face${faces.length === 1 ? "" : "s"}${cur.far ? " (far)" : ""} · yaw ${this.headYaw.toFixed(0)}° pitch ${this.headPitch.toFixed(0)}° body ${this.bodyYaw.toFixed(0)}° · ${this.fps.toFixed(0)} fps / ${this.inferMs.toFixed(0)} ms${this.smileScore > 0.05 ? ` · smile ${(this.smileScore * 100).toFixed(0)}%` : ""}`, "tracking"); | |
| } else if (tSec - this.tracker.lastSeenAt > TRACK.noFaceResetSec) { | |
| this.mood = "idle"; | |
| this.headRoll = lerp(this.headRoll, 0, 0.2); | |
| const idleFor = tSec - this.tracker.lastSeenAt; | |
| if (idleFor > TRACK.scanAfterSec) this.scan(idleFor - TRACK.scanAfterSec, dt); | |
| else this.easeHome(dt); | |
| setStatus("Looking for faces", (idleFor > TRACK.scanAfterSec ? "looking around · " : "") + `${this.fps.toFixed(0)} fps / ${this.inferMs.toFixed(0)} ms`); | |
| } else { | |
| this.mood = "searching"; | |
| } | |
| // Just after the Look button: hold the new tilt for a moment (still turning to follow people) so the change is visible | |
| if (now < this.lookHoldUntil) this.headPitch = TRACK.restPitchDeg; | |
| this.sendPose(now); | |
| } | |
| // Look button pressed: glide the head to the new resting tilt now, then hold it for LOOK_HOLD_MS | |
| async previewLook() { | |
| this.lookHoldUntil = performance.now() + LOOK_HOLD_MS; | |
| if (this.busy) return; | |
| const from = Number.isFinite(this.headPitch) ? this.headPitch : TRACK.restPitchDeg, to = TRACK.restPitchDeg; | |
| const yaw = Number.isFinite(this.headYaw) ? this.headYaw : 0, body = Number.isFinite(this.bodyYaw) ? this.bodyYaw : 0; | |
| const hz = 50, n = Math.round(0.8 * hz), time = [], set_target_data = []; | |
| for (let i = 0; i <= n; i++) { | |
| const u = 0.5 - 0.5 * Math.cos(Math.PI * i / n); | |
| time.push(i / hz); | |
| set_target_data.push({ head: headMatrix(0, (from + (to - from) * u) * DEG, yaw * DEG), antennas: INIT_ANTENNAS, body_yaw: body * DEG }); | |
| } | |
| await this.play({ time, set_target_data }, undefined, "look tilt"); | |
| this.lookHoldUntil = performance.now() + LOOK_HOLD_MS; | |
| } | |
| servo(ex, ey, dt) { | |
| const step = TRACK.gainDegPerSec * dt; | |
| const dYaw = Math.abs(ex) > TRACK.deadband ? YAW_SIGN * ex * step : 0; | |
| const dPitch = Math.abs(ey) > TRACK.deadband ? PITCH_SIGN * ey * step : 0; | |
| let yaw = this.headYaw + dYaw; | |
| let pitch = clamp(this.headPitch + dPitch, -TRACK.maxPitchUpDeg, TRACK.maxPitchDownDeg); | |
| // Body settles under the head: speed proportional to the offset, so the head leads and the body follows through | |
| if (this.bodyFollow) { | |
| const offset = yaw - this.bodyYaw; | |
| if (Math.abs(offset) > TRACK.bodyFollowStartDeg) { | |
| const speed = Math.min(TRACK.bodyRateDegPerSec, Math.abs(offset) * TRACK.bodyGainPerSec); | |
| const move = Math.sign(offset) * Math.min(Math.abs(offset) - TRACK.bodyFollowStartDeg * 0.5, speed * dt); | |
| this.bodyYaw = clamp(this.bodyYaw + move, -TRACK.maxBodyYawDeg, TRACK.maxBodyYawDeg); | |
| } | |
| } | |
| yaw = clamp(yaw, this.bodyYaw - TRACK.maxHeadYawFromBodyDeg, this.bodyYaw + TRACK.maxHeadYawFromBodyDeg); | |
| this.headYaw = lerp(this.headYaw, yaw, TRACK.smoothing); | |
| this.headPitch = lerp(this.headPitch, pitch, TRACK.smoothing); | |
| } | |
| readExpression(v, W, H, face, now) { | |
| try { | |
| const z = 1.8; // crop a bit larger than the face box | |
| const cw = Math.round(face.w * W * z), ch = Math.round(face.h * H * z); | |
| const x0 = Math.round(clamp(face.cx * W - cw / 2, 0, W - cw)), y0 = Math.round(clamp(face.cy * H - ch / 2, 0, H - ch)); | |
| if (!this.meshCanvas) { this.meshCanvas = document.createElement("canvas"); this.meshCanvas.width = 256; this.meshCanvas.height = 256; } | |
| this.meshCanvas.getContext("2d").drawImage(v, x0, y0, cw, ch, 0, 0, 256, 256); | |
| const res = this.vision.mesh.detectForVideo(this.meshCanvas, now + 0.25); | |
| const bs = res?.faceBlendshapes?.[0]?.categories; | |
| if (!bs) return null; | |
| const g = (n) => bs.find((c) => c.categoryName === n)?.score ?? 0; | |
| return { smile: (g("mouthSmileLeft") + g("mouthSmileRight")) / 2, frown: (g("mouthFrownLeft") + g("mouthFrownRight") + g("browDownLeft") + g("browDownRight")) / 4 }; | |
| } catch (e) { console.warn("expression", e); return null; } | |
| } | |
| async reactExpression(kind, face) { | |
| const cfg = SMILE[kind]; | |
| log(`${kind === "smile" ? "Smile" : "Frown"} detected -> ${cfg.pool.join(", ")}`); | |
| this.voiceSay = { text: cfg.say, until: performance.now() + 5000 }; | |
| await this.emote(kind, { say: cfg.say, pool: cfg.pool, pitchUp: true }); | |
| } | |
| zoomDetect(v, W, H, now) { | |
| const z = CROWD.zoomFactor; | |
| const cw = Math.round(W / z), ch = Math.round(H / z), cx0 = Math.round((W - cw) / 2), cy0 = Math.round((H - ch) / 2); | |
| if (!this.zoomCanvas) { this.zoomCanvas = document.createElement("canvas"); } | |
| const c = this.zoomCanvas; | |
| if (c.width !== W || c.height !== H) { c.width = W; c.height = H; } | |
| c.getContext("2d").drawImage(v, cx0, cy0, cw, ch, 0, 0, W, H); | |
| // detectForVideo needs monotonically increasing timestamps per detector; offset by 1 ms so we can call twice per tick | |
| const dets = this.vision.face.detectForVideo(c, now + 0.5).detections ?? []; | |
| return dets.map((d) => { | |
| const b = d.boundingBox; | |
| return { cx: (cx0 + (b.originX + b.width / 2) / z) / W, cy: (cy0 + (b.originY + b.height / 2) / z) / H, w: b.width / z / W, h: b.height / z / H, far: true }; | |
| }).filter((f) => f.w >= CROWD.minFaceFrac / z); | |
| } | |
| // Slow look-around while nobody is close: head only, body drifts home | |
| scan(t, dt) { | |
| const k = Math.min(1, dt * 1.5); | |
| const phase = (t / TRACK.scanPeriodSec) * Math.PI * 2; | |
| // Body sweeps slowly; head leads the body a little (phase-advanced) so it looks like it's peering around | |
| const bodyTarget = this.bodyFollow ? TRACK.scanBodyAmpDeg * Math.sin(phase) : 0; | |
| const headTarget = bodyTarget + TRACK.scanAmpDeg * 0.5 * Math.sin(phase + 0.6); | |
| this.bodyYaw = clamp(lerp(this.bodyYaw, bodyTarget, k * 0.6), -TRACK.maxBodyYawDeg, TRACK.maxBodyYawDeg); | |
| this.headYaw = lerp(this.headYaw, headTarget, k); | |
| this.headPitch = lerp(this.headPitch, TRACK.restPitchDeg, k); | |
| } | |
| easeHome(dt) { | |
| const k = Math.min(1, dt * 1.5); | |
| this.headYaw = lerp(this.headYaw, this.bodyYaw, k); | |
| this.headPitch = lerp(this.headPitch, TRACK.restPitchDeg, k); | |
| if (Math.abs(this.bodyYaw) > 0.5) this.bodyYaw = lerp(this.bodyYaw, 0, k * 0.5); | |
| else this.bodyYaw = 0; | |
| } | |
| sendPose(now) { | |
| if (now - this.lastSendAt < 1000 / TRACK.hz - 5) return; | |
| this.lastSendAt = now; | |
| if (![this.headYaw, this.headPitch, this.bodyYaw].every(Number.isFinite)) { | |
| log("Pose went non-finite; resetting"); this.headYaw = 0; this.headPitch = TRACK.restPitchDeg; this.bodyYaw = 0; | |
| } | |
| if (!Number.isFinite(this.headRoll)) this.headRoll = 0; | |
| this.reachy.setTarget({ | |
| head: headMatrix(this.headRoll * DEG, this.headPitch * DEG, this.headYaw * DEG), | |
| body_yaw: this.bodyYaw * DEG, | |
| }); | |
| } | |
| // --- Reactions ----------------------------------------------------------- | |
| async onVoice(trigger, heard) { | |
| this.lastPresenceAt = performance.now(); this.nextAttractAt = 0; // someone's talking to us | |
| if (trigger.sayCycle) { | |
| const line = nextCycleLine(trigger.sayCycle); | |
| if (line) { trigger = { ...trigger, say: line.text }; log(`Talking point ${line.n}/${line.of}`); } | |
| } | |
| const kind = trigger.speak ? "speak" : trigger.pool ? "emote" : "nod"; | |
| log(VOICE.logTranscripts ? `Heard "${heard}" -> ${kind}${trigger.pool ? ` [${trigger.pool.join(", ")}]` : ""}` : `Voice trigger: ${trigger.say}`); | |
| this.voiceSay = { text: trigger.say, until: performance.now() + (trigger.pool?.some((p) => p.startsWith("dance")) ? 9000 : 5000) }; | |
| if (this.busy) return; | |
| if (kind === "speak") { await this.speak(trigger.speak, trigger.say); } | |
| else if (kind === "emote") { | |
| if (trigger.sayCycle && BABBLE.enabled) await this.talk(trigger.say); | |
| await this.emote("voice", trigger); | |
| } | |
| else { | |
| this.mood = "noticed"; | |
| const motion = buildNodMotion({ yawDeg: this.headYaw, pitchDeg: this.headPitch, bodyYawDeg: this.bodyYaw }); | |
| await this.play(motion, this.muted ? undefined : chirpWav(), "voice nod"); | |
| } | |
| } | |
| async noticeFace() { | |
| this.mood = "noticed"; | |
| setStatus("Oh, hello", "", "reacting"); | |
| log("Noticed a face"); | |
| const motion = buildNodMotion({ yawDeg: this.headYaw, pitchDeg: this.headPitch, bodyYawDeg: this.bodyYaw }); | |
| const audioBlob = this.muted ? undefined : chirpWav(); | |
| await this.play(motion, audioBlob, "notice nod"); | |
| } | |
| // Attract mode show: say the next line with babble + head bob, then an antenna flourish (or, every Nth time, a clip) | |
| async attract() { | |
| if (this.busy) return; | |
| const line = ATTRACT.lines[this.attractCount % ATTRACT.lines.length]; | |
| const useClip = ATTRACT.clipEvery > 0 && this.attractCount % ATTRACT.clipEvery === ATTRACT.clipEvery - 1; | |
| this.attractCount++; | |
| log(`Attract mode: "${line}"`); | |
| this.voiceSay = { text: line, until: performance.now() + 4000 }; | |
| await this.talk(line); | |
| if (useClip) { await this.emote("attract", { say: line, pool: ATTRACT.clips }); return; } | |
| this.mood = "reacting"; | |
| const motion = buildAntennaWave({ yawDeg: this.headYaw, pitchDeg: this.headPitch, bodyYawDeg: this.bodyYaw, both: true }); | |
| await this.play(motion, this.muted ? undefined : chirpWav(), "attract flourish"); | |
| } | |
| // Group mode: say the next group line, look around at each person, then a bigger reaction clip | |
| async groupGreet(group) { | |
| if (this.busy) return; | |
| const line = nextCycleLine("group")?.text ?? "Whoa, a whole crew!"; | |
| log(`Group of ${group.length}: "${line}"`); | |
| this.voiceSay = { text: line, until: performance.now() + 7000 }; | |
| await this.talk(line); | |
| this.mood = "reacting"; | |
| const look = buildLookAroundMotion({ yawDeg: this.headYaw, pitchDeg: this.headPitch, bodyYawDeg: this.bodyYaw, faces: group }); | |
| await this.play(look, this.muted ? undefined : chirpWav(), "group look-around"); | |
| await this.emote("group", { say: line, pool: GROUP.clips }); | |
| } | |
| // Thumbs up -> happy nod + chirp | |
| async gestureReact(kind, x = 0.5) { | |
| if (this.busy) return; | |
| const lines = GESTURE[kind]; | |
| const text = lines[Math.floor(Math.random() * lines.length)]; | |
| this.voiceSay = { text, until: performance.now() + 3000 }; | |
| this.mood = "waveSeen"; | |
| setStatus(text, "thumbs up", "reacting"); | |
| log("Gesture: thumbs up"); | |
| const pose = { yawDeg: this.headYaw, pitchDeg: this.headPitch, bodyYawDeg: this.bodyYaw }; | |
| const motion = buildNodMotion(pose); | |
| const audio = this.muted ? undefined : chirpWav(); | |
| await this.play(motion, audio, kind); | |
| } | |
| // Wave back with the antennae: one for a one-hand wave (mirroring the person's hand), both for two hands | |
| async waveBack({ hands = 1, x = 0.5 } = {}) { | |
| if (this.busy) return; | |
| this.mood = "waveSeen"; | |
| // Hand on the image's left is on the robot's right; mirror it so the wave visually answers their hand | |
| const side = x < 0.5 ? 1 : 0; // antenna index: 0 = left, 1 = right | |
| setStatus("Wave detected!", hands >= 2 ? "both antennae" : (side ? "right antenna" : "left antenna"), "reacting"); | |
| log(`Wave (${hands} hand${hands === 1 ? "" : "s"}) -> antenna wave`); | |
| const motion = buildAntennaWave({ yawDeg: this.headYaw, pitchDeg: this.headPitch, bodyYawDeg: this.bodyYaw, both: hands >= 2, side }); | |
| await this.play(motion, this.muted ? undefined : chirpWav(), "antenna wave"); | |
| } | |
| // Say a pre-rendered line through the robot's speaker with a small "talking" motion. Not subject to the clip budget. | |
| async speak(lineKey, sayText) { | |
| if (this.busy) return; | |
| const wav = await this.speech?.get(lineKey); | |
| if (!wav) { log(`No voice line for ${lineKey}`); return; } | |
| this.mood = "reacting"; | |
| setStatus(sayText ?? "", lineKey, "reacting"); | |
| log(`Speak -> ${lineKey}`); | |
| const motion = buildTalkMotion({ yawDeg: this.headYaw, pitchDeg: this.headPitch, bodyYawDeg: this.bodyYaw, durSec: wav.durSec + SPEECH.tailSec }); | |
| await this.play(motion, this.muted ? undefined : wav.blob, `speak ${lineKey}`, 0); | |
| } | |
| // "Say" a line in robot noises with a little talking head-bob, timed to the bubble typing out | |
| async talk(text) { | |
| if (this.busy) return; | |
| const dur = babbleDurSec(text); | |
| this.mood = "reacting"; | |
| const motion = buildTalkMotion({ yawDeg: this.headYaw, pitchDeg: this.headPitch, bodyYawDeg: this.bodyYaw, durSec: dur + 0.3 }); | |
| await this.play(motion, this.muted ? undefined : babbleWav(dur), "talk", 0); | |
| } | |
| // Random happy emotion clip (voice greetings, etc.), subject to the clip budget | |
| async emote(reason = "", trigger = null) { | |
| if (this.busy) return; | |
| const sinceClip = (performance.now() - this.lastClipAt) / 1000; | |
| if (sinceClip < CROWD.minGapSec) { | |
| // Budget: acknowledge with a quick nod instead of another full clip | |
| this.mood = "waveSeen"; | |
| log(`Emote ${reason} (clip budget: ${(CROWD.minGapSec - sinceClip).toFixed(0)}s left) -> nod`); | |
| const motion = buildNodMotion({ yawDeg: this.headYaw, pitchDeg: this.headPitch, bodyYawDeg: this.bodyYaw }); | |
| await this.play(motion, this.muted ? undefined : chirpWav(), "wave nod"); | |
| return; | |
| } | |
| this.mood = "reacting"; | |
| this.lastClipAt = performance.now(); | |
| const pool = trigger?.pool ?? WAVE_CLIPS; | |
| const name = pool[Math.floor(Math.random() * pool.length)]; | |
| setStatus(trigger?.say ?? "Howdy!", name, "reacting"); | |
| log(`Emote ${reason} -> ${name}`); | |
| const clip = await this.clips.get(name); | |
| if (!clip) { log(`Clip ${name} unavailable`); return; } | |
| // Shift the clip so the robot keeps facing the person instead of snapping to body_yaw 0 | |
| let motion = offsetMotionYaw(clip.motion, this.bodyYaw * DEG); | |
| if (trigger?.pitchUp) motion = offsetMotionPitch(motion, -VOICE.greetPitchUpDeg * DEG); | |
| if (trigger?.maxSec) motion = truncateMotion(motion, trigger.maxSec); | |
| await this.play(motion, this.muted ? undefined : clip.wav, name); | |
| } | |
| async play(motion, audioBlob, description, audioLeadMs = -100) { | |
| if (this.busy) return; | |
| this.busy = true; | |
| try { | |
| // The daemon indexes head as a 4x4 (head[i][j]); internally we keep flat 16 for our math. | |
| const wire = { ...motion, set_target_data: motion.set_target_data.map((f) => ({ ...f, head: f.head ? toNested4x4(f.head) : f.head })) }; | |
| const result = await this.reachy.playMove(wire, { audioBlob, description, audioLeadMs }); | |
| if (result?.error) { | |
| const f0 = motion.set_target_data[0], rp = f0?.head ? matrixToRpyDeg(f0.head) : null; | |
| log(`playMove error: ${result.error}` + (rp ? ` (start yaw ${rp.yaw.toFixed(0)} pitch ${rp.pitch.toFixed(0)} body ${((f0.body_yaw ?? 0) / DEG).toFixed(0)})` : "")); | |
| } | |
| // Re-sync our commanded buffer to where the clip left the robot | |
| const last = motion.set_target_data[motion.set_target_data.length - 1]; | |
| if (last?.head) { | |
| const rpy = matrixToRpyDeg(last.head); | |
| this.headYaw = rpy.yaw; | |
| this.headPitch = rpy.pitch; | |
| } | |
| if (typeof last?.body_yaw === "number") this.bodyYaw = last.body_yaw / DEG; | |
| this.headRoll = 0; | |
| this.reachy.setTarget({ antennas: INIT_ANTENNAS }); | |
| } catch (e) { | |
| log(`Reaction failed: ${e?.message ?? e}`); | |
| } finally { | |
| this.busy = false; | |
| this.lastFaceAt = performance.now(); // don't immediately re-nod | |
| if (description !== "notice nod") this.pendingWave = false; // a wave during a wave reaction is the same wave | |
| this.mood = this.tracker.target ? "tracking" : "idle"; | |
| } | |
| } | |
| shutdown(detachVideo) { | |
| this.running = false; | |
| try { this.reachy.cancelMove(); } catch (_) {} | |
| try { detachVideo?.(); } catch (_) {} | |
| try { this.vision.face.close(); this.vision.hand.close(); this.vision.mesh?.close(); } catch (_) {} | |
| safeReturnHome(this.reachy); | |
| } | |
| } | |
| // Head tilt at rest: "up" or "level" (straight ahead). Remembered per browser; ?look=up or ?look=level in the URL overrides. | |
| let lookMode = "up"; | |
| const LOOK_HOLD_MS = 3000; // after pressing Look, hold the new tilt this long before tracking takes pitch back | |
| function initialLookMode() { | |
| const fromUrl = params.get("look"); | |
| if (fromUrl === "up" || fromUrl === "level") return fromUrl; | |
| try { const saved = localStorage.getItem("howdy-look"); if (saved === "up" || saved === "level") return saved; } catch (_) {} | |
| return "up"; | |
| } | |
| function setLookMode(mode) { | |
| lookMode = mode === "level" ? "level" : "up"; | |
| TRACK.restPitchDeg = lookMode === "up" ? TRACK.restPitchUpDeg : TRACK.restPitchLevelDeg; | |
| try { localStorage.setItem("howdy-look", lookMode); } catch (_) {} | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Wave detector | |
| // --------------------------------------------------------------------------- | |
| class WaveDetector { | |
| constructor() { this.samples = []; this.progress = 0; this.lastGoodAt = -Infinity; this.misses = 0; this.best = null; this.lastStats = ""; } | |
| clear() { | |
| this.reportNearMiss(); | |
| this.samples.length = 0; this.progress = 0; this.misses = 0; this.best = null; | |
| } | |
| // A partial wave that never completed: log what it reached so thresholds can be tuned from real use | |
| reportNearMiss() { | |
| if (!WAVE.debug || !this.best || this.best.p < 0.5 || this.best.p >= 1) return; | |
| const b = this.best, nowMs = performance.now(); | |
| if (nowMs - (WaveDetector.lastLogAt ?? 0) > 1500) { | |
| WaveDetector.lastLogAt = nowMs; | |
| log(`Wave near-miss: reversals ${b.rev}/${WAVE.minReversals}, travel ${b.trav.toFixed(2)}/${WAVE.minTravelHandWidths} hw, ` + | |
| `${b.fps.toFixed(0)} samples/s, ${b.miss} blurred frames`); | |
| } | |
| this.best = null; | |
| } | |
| // hand: one 21-landmark array (normalized coords). Returns true on a detected wave. | |
| update(hand, tSec, aspect) { | |
| if (tSec - this.lastGoodAt > WAVE.gapSec && this.samples.length) this.clear(); | |
| if (!isOpenPalmUp(hand)) { | |
| // One blurred frame mid-wave shouldn't erase the wave; only a sustained gap does (checked above) | |
| this.misses++; | |
| return false; | |
| } | |
| this.lastGoodAt = tSec; | |
| const xs = hand.map((p) => p.x * aspect); // correct for non-square frames | |
| const width = Math.max(...xs) - Math.min(...xs); | |
| if (width < 1e-3) return false; | |
| // Track the fingertips, not the wrist: people wave from the wrist, so the tips are what swing | |
| const tipX = ([8, 12, 16].reduce((a, i) => a + hand[i].x, 0) / 3) * aspect; | |
| this.samples.push({ t: tSec, x: tipX, w: width }); | |
| while (this.samples.length && tSec - this.samples[0].t > WAVE.windowSec) this.samples.shift(); | |
| if (this.samples.length < 4) return false; | |
| const w = this.samples.reduce((a, s) => a + s.w, 0) / this.samples.length; | |
| const hyst = WAVE.hysteresisHandWidths * w; | |
| let reversals = 0, travel = 0, dir = 0, anchor = this.samples[0].x; | |
| for (const s of this.samples) { | |
| const d = s.x - anchor; | |
| if (Math.abs(d) < hyst) continue; | |
| const nd = Math.sign(d); | |
| if (dir !== 0 && nd !== dir) reversals++; | |
| dir = nd; | |
| travel += Math.abs(d); | |
| anchor = s.x; | |
| } | |
| const trav = travel / w; | |
| this.progress = Math.min(1, 0.5 * Math.min(1, reversals / WAVE.minReversals) + 0.5 * Math.min(1, trav / WAVE.minTravelHandWidths)); | |
| const span = this.samples[this.samples.length - 1].t - this.samples[0].t; | |
| const fps = span > 0 ? (this.samples.length - 1) / span : 0; | |
| this.lastStats = `${reversals} reversals, ${trav.toFixed(2)} hw, ${fps.toFixed(0)} samples/s`; | |
| if (!this.best || this.progress > this.best.p) this.best = { p: this.progress, rev: reversals, trav, fps, miss: this.misses }; | |
| if (reversals >= WAVE.minReversals && trav >= WAVE.minTravelHandWidths) { | |
| this.samples.length = 0; this.progress = 1; this.best = null; this.misses = 0; | |
| return true; | |
| } | |
| // The attempt faded out without completing | |
| if (this.progress < 0.2 && this.best && this.best.p >= 0.5) this.reportNearMiss(); | |
| return false; | |
| } | |
| } | |
| // Follows hands across frames by palm position so each physical hand keeps one WaveDetector | |
| class HandTracks { | |
| constructor() { this.tracks = []; this.nextId = 0; } | |
| update(hands, tSec, aspect) { | |
| for (const t of this.tracks) t.fresh = false; | |
| for (const lm of hands) { | |
| const x = lm[9].x * aspect, y = lm[9].y; | |
| const xs = lm.map((p) => p.x * aspect); | |
| const size = Math.max(0.02, Math.max(...xs) - Math.min(...xs)); | |
| let best = null, bestD = Infinity; | |
| for (const t of this.tracks) { | |
| if (t.fresh) continue; | |
| const d = Math.hypot(x - t.x, y - t.y) / Math.max(size, t.size); | |
| if (d < WAVE.trackMatchHandWidths && d < bestD) { best = t; bestD = d; } | |
| } | |
| if (!best) { best = { id: ++this.nextId, det: new WaveDetector() }; this.tracks.push(best); } | |
| Object.assign(best, { x, y, size, lm, seenAt: tSec, fresh: true }); | |
| } | |
| this.tracks = this.tracks.filter((t) => { | |
| if (tSec - t.seenAt <= WAVE.trackDropSec) return true; | |
| t.det.clear(); | |
| return false; | |
| }); | |
| return this.tracks; | |
| } | |
| } | |
| function sameFace(a, b) { | |
| return Math.hypot(a.cx - b.cx, a.cy - b.cy) < 0.5 * Math.max(a.w, b.w); | |
| } | |
| // Open palm, fingers pointing up (image y grows downward) | |
| function isOpenPalmUp(lm) { | |
| const wrist = lm[0], midMcp = lm[9]; | |
| const palm = Math.hypot(midMcp.x - wrist.x, midMcp.y - wrist.y); | |
| if (palm < 1e-3) return false; | |
| if ((wrist.y - midMcp.y) / palm < WAVE.minUpright) return false; // fingers pointing too far down | |
| const dw = (p) => Math.hypot(p.x - wrist.x, p.y - wrist.y); | |
| const tips = [8, 12, 16, 20], pips = [6, 10, 14, 18]; | |
| let extended = 0; | |
| for (let i = 0; i < 4; i++) if (dw(lm[tips[i]]) > 1.05 * dw(lm[pips[i]])) extended++; | |
| return extended >= 2; | |
| } | |
| // Classifies one hand pose: "thumbsUp" or null | |
| function classifyGesture(lm, aspect) { | |
| const P = (i) => ({ x: lm[i].x * aspect, y: lm[i].y }); | |
| const d = (a, b) => Math.hypot(a.x - b.x, a.y - b.y); | |
| const w = P(0), palm = d(w, P(9)); | |
| if (palm < 1e-3) return null; | |
| const tips = [8, 12, 16, 20], pips = [6, 10, 14, 18]; | |
| const curled = tips.filter((t, k) => d(P(t), w) < 1.0 * d(P(pips[k]), w)).length; | |
| // Thumbs up: thumb long and pointing up, tip above every other landmark, the other fingers curled | |
| const thumbLen = d(P(4), P(2)); | |
| const others = [5, 6, 8, 9, 10, 12, 13, 14, 16, 17, 18, 20].map((k) => P(k).y); | |
| const thumbUp = thumbLen > 0.45 * palm && (P(2).y - P(4).y) > 0.7 * thumbLen && P(4).y < Math.min(...others); | |
| if (thumbUp && curled >= 3) return "thumbsUp"; | |
| return null; | |
| } | |
| // Turns per-frame gesture labels for one hand into a one-shot "thumbsUp" event after a steady hold | |
| class GestureWatcher { | |
| constructor() { this.kind = null; this.since = 0; this.lastGood = -Infinity; this.fired = false; this.progress = 0; } | |
| update(kind, lm, tSec, aspect) { | |
| if (kind !== this.kind) { | |
| if (this.kind && kind === null && tSec - this.lastGood < GESTURE.gapSec) return null; // brief glitch: keep holding | |
| this.kind = kind; this.since = tSec; this.fired = false; this.progress = 0; | |
| } | |
| if (!kind) return null; | |
| this.lastGood = tSec; | |
| this.progress = Math.min(1, (tSec - this.since) / GESTURE.thumbHoldSec); | |
| if (!this.fired && this.progress >= 1) { this.fired = true; return kind; } | |
| return null; | |
| } | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Vision setup (MediaPipe Tasks Vision, in-browser) | |
| // --------------------------------------------------------------------------- | |
| async function loadVision() { | |
| const mp = await import(`https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@${MP_VERSION}/+esm`); | |
| const files = await mp.FilesetResolver.forVisionTasks( | |
| `https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@${MP_VERSION}/wasm`, | |
| ); | |
| const [face, hand, mesh] = await Promise.all([ | |
| mp.FaceDetector.createFromOptions(files, { | |
| baseOptions: { | |
| modelAssetPath: "https://storage.googleapis.com/mediapipe-models/face_detector/blaze_face_short_range/float16/1/blaze_face_short_range.tflite", | |
| delegate: "GPU", | |
| }, | |
| runningMode: "VIDEO", | |
| minDetectionConfidence: 0.5, | |
| }), | |
| mp.HandLandmarker.createFromOptions(files, { | |
| baseOptions: { | |
| modelAssetPath: "https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task", | |
| delegate: "GPU", | |
| }, | |
| runningMode: "VIDEO", | |
| numHands: 2, | |
| minHandDetectionConfidence: 0.4, | |
| minHandPresenceConfidence: 0.4, | |
| minTrackingConfidence: 0.35, | |
| }), | |
| mp.FaceLandmarker.createFromOptions(files, { | |
| baseOptions: { | |
| modelAssetPath: "https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task", | |
| delegate: "GPU", | |
| }, | |
| runningMode: "VIDEO", | |
| numFaces: 1, | |
| outputFaceBlendshapes: true, | |
| minFaceDetectionConfidence: 0.4, | |
| minFacePresenceConfidence: 0.4, | |
| minTrackingConfidence: 0.4, | |
| }).catch((e) => { console.warn("face landmarker unavailable", e); return null; }), | |
| ]); | |
| return { face, hand, mesh }; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Target tracker: sticky attention on one person | |
| // --------------------------------------------------------------------------- | |
| class TargetTracker { | |
| constructor() { this.target = null; this.lastSeenAt = -Infinity; this.challenger = null; this.challengeSince = 0; this.nodded = false; this.id = 0; this.vx = 0; this.vy = 0; this.lastNodAt = -Infinity; this.lastLost = null; } | |
| update(faces, tSec) { | |
| let isNew = false; | |
| if (this.target) { | |
| // Predict where the person is now from their recent velocity, then match the nearest face to that | |
| const dtp = Math.min(0.5, tSec - this.lastSeenAt); | |
| const px = this.target.cx + this.vx * dtp, py = this.target.cy + this.vy * dtp; | |
| let best = null, bestD = Infinity; | |
| for (const f of faces) { | |
| const d = Math.hypot(f.cx - px, f.cy - py) / Math.max(this.target.w, 0.03); | |
| if (d < CROWD.matchDistFaceWidths && d < bestD) { best = f; bestD = d; } | |
| } | |
| if (best) { | |
| const dts = Math.max(1e-3, tSec - this.lastSeenAt); | |
| this.vx = 0.6 * this.vx + 0.4 * (best.cx - this.target.cx) / dts; | |
| this.vy = 0.6 * this.vy + 0.4 * (best.cy - this.target.cy) / dts; | |
| this.target = { ...best, id: this.target.id }; | |
| this.lastSeenAt = tSec; | |
| // Someone clearly bigger (closer) for long enough steals attention | |
| const bigger = faces.filter((f) => f !== best && f.w * f.h > CROWD.switchAreaRatio * best.w * best.h) | |
| .sort((a, b) => b.w * b.h - a.w * a.h)[0]; | |
| if (bigger) { | |
| if (!this.challenger || Math.hypot(bigger.cx - this.challenger.cx, bigger.cy - this.challenger.cy) > bigger.w) { this.challenger = bigger; this.challengeSince = tSec; } | |
| else if (tSec - this.challengeSince > CROWD.switchHoldSec) { this.forceTarget(bigger); isNew = true; } | |
| } else this.challenger = null; | |
| } else if (tSec - this.lastSeenAt > CROWD.lostAfterSec) { | |
| this.lastLost = { cx: this.target.cx, cy: this.target.cy, w: this.target.w, at: tSec }; | |
| this.target = null; this.challenger = null; this.vx = 0; this.vy = 0; | |
| } else { | |
| // Coast: keep the predicted position as the steering point while briefly occluded | |
| if (tSec - this.lastSeenAt < CROWD.coastSec) { this.target = { ...this.target, cx: px, cy: py, coasting: true }; } | |
| } | |
| } | |
| if (!this.target && faces.length) { | |
| const f = faces.slice().sort((a, b) => b.w * b.h - a.w * a.h)[0]; | |
| this.forceTarget(f); isNew = true; this.lastSeenAt = tSec; this.vx = 0; this.vy = 0; | |
| // Re-acquired the person we just lost (or someone right where they were)? Don't nod again. | |
| if (this.lastLost && tSec - this.lastLost.at < CROWD.renodAfterSec && Math.hypot(f.cx - this.lastLost.cx, f.cy - this.lastLost.cy) < 4 * this.lastLost.w) this.nodded = true; | |
| if (tSec - this.lastNodAt < CROWD.renodAfterSec) this.nodded = true; | |
| } | |
| return { target: this.target, isNew }; | |
| } | |
| forceTarget(f) { | |
| if (this.target && Math.hypot(f.cx - this.target.cx, f.cy - this.target.cy) < this.target.w * CROWD.matchDistFaceWidths) { this.target = { ...f, id: this.target.id }; return; } | |
| this.target = { ...f, id: ++this.id }; this.nodded = false; this.challenger = null; | |
| } | |
| } | |
| // BlazeFace keypoints: [right eye, left eye, nose, mouth, right ear, left ear] (normalized). We just want the two eyes. | |
| function eyesOf(d) { | |
| const k = d.keypoints; | |
| if (!k || k.length < 2) return null; | |
| const a = k[0], b = k[1]; | |
| return a.x <= b.x ? { l: { x: a.x, y: a.y }, r: { x: b.x, y: b.y } } : { l: { x: b.x, y: b.y }, r: { x: a.x, y: a.y } }; | |
| } | |
| // Turns per-frame smile/frown scores into one-shot events with hold time and per-person cooldown | |
| class ExpressionWatcher { | |
| constructor() { this.smileSince = null; this.frownSince = null; this.lastAt = new Map(); } | |
| update(id, sc, tSec) { | |
| const last = this.lastAt.get(id) ?? -Infinity; | |
| if (sc.smile >= SMILE.smileOn) { this.smileSince ??= tSec; } else this.smileSince = null; | |
| if (sc.frown >= SMILE.frownOn) { this.frownSince ??= tSec; } else this.frownSince = null; | |
| if (tSec - last < SMILE.cooldownSec) return null; | |
| if (this.smileSince !== null && tSec - this.smileSince >= SMILE.holdSec) { this.lastAt.set(id, tSec); this.smileSince = null; return "smile"; } | |
| if (this.frownSince !== null && tSec - this.frownSince >= SMILE.frownHoldSec) { this.lastAt.set(id, tSec); this.frownSince = null; return "frown"; } | |
| return null; | |
| } | |
| } | |
| // Counts friends by visits (see FRIENDS). Starts at 0 on each load unless FRIENDS.persist | |
| class FriendsCounter { | |
| constructor() { | |
| this.key = "howdy-friends-" + new Date().toISOString().slice(0, 10); | |
| let n = 0; | |
| if (FRIENDS.persist) { try { n = parseInt(localStorage.getItem(this.key) || "0", 10) || 0; } catch (_) {} } | |
| this.count = n; | |
| this.inVisit = false; this.lastSeenAt = -Infinity; this.visitCounted = 0; | |
| this.since = []; this.lastAt = []; // per k: since when >= k faces have been in view, and when that was last true | |
| } | |
| // Call every tick. nFaces = faces in view big enough to count; busy = Reachy is mid-reaction (head moving, faces unreliable). | |
| // Returns how many new friends were just counted. | |
| update(nFaces, tSec, busy) { | |
| if (busy) { | |
| // Hold the visit open and don't count while the camera is moving | |
| if (this.inVisit) this.lastSeenAt = tSec; | |
| this.since.length = 0; this.lastAt.length = 0; | |
| return 0; | |
| } | |
| if (nFaces > 0) { | |
| if (!this.inVisit) { this.inVisit = true; this.visitCounted = 0; log("Visit started"); } | |
| this.lastSeenAt = tSec; | |
| } else if (this.inVisit && tSec - this.lastSeenAt > FRIENDS.visitGapSec) { | |
| this.inVisit = false; log(`Visit ended (${this.visitCounted} friend${this.visitCounted === 1 ? "" : "s"})`); | |
| } | |
| // How many faces have been in view together for engageSec (ignoring short dropouts) | |
| let held = 0; | |
| const top = Math.max(nFaces, this.since.length - 1); | |
| for (let k = 1; k <= top; k++) { | |
| if (nFaces >= k) { this.since[k] ??= tSec; this.lastAt[k] = tSec; } | |
| else if (this.since[k] != null && tSec - this.lastAt[k] > FRIENDS.dropoutSec) this.since[k] = null; | |
| if (this.since[k] != null && tSec - this.since[k] >= FRIENDS.engageSec) held = k; | |
| } | |
| if (!this.inVisit || held <= this.visitCounted) return 0; | |
| const added = held - this.visitCounted; | |
| this.visitCounted = held; this.count += added; | |
| if (FRIENDS.persist) { try { localStorage.setItem(this.key, String(this.count)); } catch (_) {} } | |
| return added; | |
| } | |
| } | |
| function nearestFace(faces, lm, aspect, maxFaceWidths = Infinity) { | |
| const hx = lm[0].x, hy = lm[0].y; // wrist | |
| let best = null, bestD = Infinity; | |
| for (const f of faces) { | |
| const d = Math.hypot((hx - f.cx) * aspect, hy - f.cy) / f.w; | |
| if (d < bestD) { best = f; bestD = d; } | |
| } | |
| return bestD <= maxFaceWidths ? best : null; | |
| } | |
| function handInZone(lm, face, aspect) { | |
| const hx = lm[9].x, hy = lm[9].y; // middle-finger MCP ~ palm center | |
| const dx = Math.abs(hx - face.cx) * aspect / (face.w * aspect); | |
| const dy = (hy - face.cy) / face.h; | |
| return dx <= CROWD.handZoneX && dy >= -CROWD.handZoneUp && dy <= CROWD.handZoneDown; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Reachy's-eye-view overlay: glow on the person we're watching, thought bubble | |
| // --------------------------------------------------------------------------- | |
| class ThoughtOverlay { | |
| constructor(canvas) { this.canvas = canvas; this.t0 = performance.now(); this.lastMood = ""; this.moodSince = 0; this.text = ""; this.textSince = 0; this.lockT = 0; } | |
| pick(list, sec, period) { return Array.isArray(list) ? list[Math.floor(sec / period) % list.length] : list; } | |
| draw({ faces, target, hands, mood, now, busy, notice, say, listening, friends = 0, friendPop = 99, smile = 0 }) { | |
| const g = this.canvas.getContext("2d"); | |
| const W = this.canvas.width, H = this.canvas.height; | |
| g.clearRect(0, 0, W, H); | |
| if (notice) this.banner(g, notice, W, H); | |
| const sec = (now - this.t0) / 1000; | |
| if (mood !== this.lastMood) { this.lastMood = mood; this.moodSince = now; } | |
| this.lockT = target ? Math.min(1, this.lockT + 0.12) : Math.max(0, this.lockT - 0.12); | |
| const S = Math.min(W, H) / 480; // scale for 640x480-ish frames | |
| // Other faces: faint dotted circles so people can see it's choosing | |
| g.save(); | |
| for (const f of faces) { | |
| if (target && f.cx === target.cx && f.cy === target.cy) continue; | |
| g.setLineDash([6 * S, 6 * S]); g.lineWidth = 2 * S; g.strokeStyle = "rgba(255,255,255,0.45)"; | |
| g.beginPath(); g.ellipse(f.cx * W, f.cy * H, f.w * W * 0.6, f.h * H * 0.65, 0, 0, Math.PI * 2); g.stroke(); | |
| } | |
| g.restore(); | |
| // Hands: soft green dots + a wave meter arc that fills as a wave registers | |
| for (const hand of hands) { | |
| g.fillStyle = "rgba(43,182,115,0.85)"; | |
| for (const p of hand) { g.beginPath(); g.arc(p.x * W, p.y * H, 3.5 * S, 0, Math.PI * 2); g.fill(); } | |
| const m = hand.meter ?? 0, wx = hand[9].x * W, wy = hand[0].y * H + 22 * S; | |
| g.lineWidth = 5 * S; g.lineCap = "round"; | |
| g.strokeStyle = "rgba(255,255,255,0.25)"; g.beginPath(); g.arc(wx, wy, 14 * S, Math.PI * 0.75, Math.PI * 2.25); g.stroke(); | |
| if (m > 0) { g.strokeStyle = m >= 1 ? "#2bb673" : "#f2c94c"; g.beginPath(); g.arc(wx, wy, 14 * S, Math.PI * 0.75, Math.PI * (0.75 + 1.5 * m)); g.stroke(); } | |
| if (hand.gesture) { // label under the hand while a thumbs up is being held | |
| const label = "thumbs up!"; | |
| g.save(); g.font = `700 ${Math.round(15 * S)}px system-ui, sans-serif`; g.textAlign = "center"; g.textBaseline = "top"; | |
| g.lineWidth = 4 * S; g.strokeStyle = "rgba(0,0,0,0.55)"; g.strokeText(label, wx, wy + 18 * S); | |
| g.fillStyle = hand.gesture.p >= 1 ? "#2bb673" : "#fff"; g.fillText(label, wx, wy + 18 * S); g.restore(); | |
| } | |
| } | |
| // Target: warm glow that breathes, plus a happy ring | |
| if (target && this.lockT > 0) { | |
| const cx = target.cx * W, cy = target.cy * H, r = Math.max(target.w * W, target.h * H) * 0.75; | |
| const breathe = 1 + 0.06 * Math.sin(sec * 3); | |
| const rr = r * breathe * (0.6 + 0.4 * this.lockT); | |
| const grad = g.createRadialGradient(cx, cy, rr * 0.6, cx, cy, rr * 1.6); | |
| const col = mood === "reacting" || mood === "waveSeen" ? "43,182,115" : "242,201,76"; | |
| grad.addColorStop(0, `rgba(${col},0)`); grad.addColorStop(0.5, `rgba(${col},${0.35 * this.lockT})`); grad.addColorStop(1, `rgba(${col},0)`); | |
| g.fillStyle = grad; g.beginPath(); g.arc(cx, cy, rr * 1.6, 0, Math.PI * 2); g.fill(); | |
| g.lineWidth = 4 * S; g.strokeStyle = `rgba(${col},${0.9 * this.lockT})`; | |
| g.beginPath(); g.arc(cx, cy, rr, 0, Math.PI * 2); g.stroke(); | |
| // little sparkles when reacting | |
| if (mood === "reacting" || mood === "noticed") { | |
| g.fillStyle = "#fff"; | |
| for (let i = 0; i < 6; i++) { const a = sec * 2 + i * Math.PI / 3, d = rr * (1.25 + 0.15 * Math.sin(sec * 5 + i)); g.beginPath(); g.arc(cx + Math.cos(a) * d, cy + Math.sin(a) * d, 3 * S, 0, Math.PI * 2); g.fill(); } | |
| } | |
| // Smile meter: a little mouth under the ring that curves up as the smile score rises | |
| if (smile > 0.05) { | |
| const mw = rr * 0.5, my = cy + rr + 14 * S, curve = (smile - 0.15) * 2 * mw * 0.6; | |
| g.strokeStyle = smile >= SMILE.smileOn ? "#2bb673" : "rgba(255,255,255,0.7)"; g.lineWidth = 4 * S; g.lineCap = "round"; | |
| g.beginPath(); g.moveTo(cx - mw, my); g.quadraticCurveTo(cx, my + curve, cx + mw, my); g.stroke(); | |
| } | |
| } | |
| // Thought text by mood | |
| let text; | |
| switch (mood) { | |
| case "noticed": text = SAY.noticed; break; | |
| case "waveSeen": text = SAY.waveSeen; break; | |
| case "reacting": text = this.pick(SAY.reacting, sec, 0.8); break; | |
| case "tracking": text = this.pick(SAY.tracking, sec, SAY.trackingRotateSec); break; | |
| case "searching": text = SAY.searching; break; | |
| default: text = this.pick(SAY.idle, sec, SAY.idleRotateSec); | |
| } | |
| if (friendPop < 1.6) text = SAY.newFriend; | |
| if (say) text = say; | |
| if (text !== this.text) { this.text = text; this.textSince = now; if (text) this.onNewLine?.(text); } | |
| // Reachy's face, top left, "speaking" the line in a bubble across the top | |
| const faceR = 34 * S, fx = 12 * S + faceR * 1.3, fy = 12 * S + faceR * 1.75; // room above for the antennae | |
| const talking = now - this.textSince < 1400 || mood === "reacting"; | |
| this.reachyFace(g, fx, fy, faceR, S, sec, mood, talking, target); | |
| if (listening) { // mic dot pulsing under the face while voice triggers are on | |
| g.fillStyle = `rgba(43,182,115,${0.5 + 0.4 * Math.sin(sec * 4)})`; g.beginPath(); g.arc(fx, fy + faceR + 14 * S, 5 * S, 0, Math.PI * 2); g.fill(); | |
| } | |
| this.speech(g, text, now - this.textSince, fx + faceR * 1.3 + 18 * S, fy, S, W); | |
| // Friends badge, bottom right | |
| this.friendsBadge(g, friends, friendPop, S, W, H); | |
| } | |
| friendsBadge(g, n, pop, S, W, H) { | |
| const scale = pop < 0.5 ? 1 + 0.18 * Math.sin(Math.PI * pop / 0.5) : 1; | |
| const label = `Friends today: ${n}`; | |
| g.save(); | |
| g.font = `700 ${Math.round(18 * S)}px "Comic Neue", "Segoe Print", "Chalkboard SE", system-ui, sans-serif`; | |
| const tw = g.measureText(label).width, bw = tw + 24 * S, bh = 32 * S, x = W - bw - 16 * S, y = H - bh - 14 * S; | |
| // scale about the right edge so the pop never leaves the frame | |
| g.translate(x + bw, y + bh / 2); g.scale(scale, scale); g.translate(-(x + bw), -(y + bh / 2)); | |
| g.shadowColor = "rgba(0,0,0,0.25)"; g.shadowBlur = 8 * S; g.shadowOffsetY = 2 * S; | |
| g.fillStyle = pop < 1.6 ? "#2bb673" : "#fffdf5"; roundRect(g, x, y, bw, bh, 16 * S); g.fill(); | |
| g.shadowColor = "transparent"; | |
| g.strokeStyle = "#2b2b2b"; g.lineWidth = 2 * S; roundRect(g, x, y, bw, bh, 16 * S); g.stroke(); | |
| g.fillStyle = pop < 1.6 ? "#fff" : "#2b2b2b"; g.textAlign = "center"; g.textBaseline = "middle"; | |
| g.fillText(label, x + bw / 2, y + bh / 2 + 1 * S); | |
| g.restore(); | |
| } | |
| banner(g, text, W, H) { | |
| const S = Math.min(W, H) / 480; | |
| g.save(); | |
| g.font = `600 ${Math.round(16 * S)}px system-ui, sans-serif`; | |
| const words = text.split(" "), lines = []; let cur = ""; | |
| for (const w of words) { const t = cur ? cur + " " + w : w; if (g.measureText(t).width > W - 48 * S) { lines.push(cur); cur = w; } else cur = t; } | |
| if (cur) lines.push(cur); | |
| const lh = 22 * S, bh = lines.length * lh + 20 * S, by = H - bh - 62 * S; | |
| g.fillStyle = "rgba(180,40,40,0.92)"; roundRect(g, 12 * S, by, W - 24 * S, bh, 10 * S); g.fill(); | |
| g.fillStyle = "#fff"; g.textAlign = "center"; g.textBaseline = "middle"; | |
| lines.forEach((l, i) => g.fillText(l, W / 2, by + 10 * S + lh * (i + 0.5))); | |
| g.restore(); | |
| } | |
| // Speech bubble: left edge at x with a tail pointing back at the face, vertically centered on cy. | |
| // Wraps to at most 2 lines; the text types on so it reads as being spoken. | |
| speech(g, text, ageMs, x, cy, S, W) { | |
| const pop = Math.min(1, ageMs / 220), scale = 0.9 + 0.1 * (1 - Math.pow(1 - pop, 3)); | |
| const fs = Math.round(26 * S); | |
| g.save(); | |
| g.font = `700 ${fs}px "Comic Neue", "Segoe Print", "Chalkboard SE", "Marker Felt", system-ui, sans-serif`; | |
| const padX = 16 * S, padY = 10 * S, lh = fs * 1.2, maxW = W - x - 14 * S - padX * 2; | |
| const words = text.split(" "), lines = []; let cur = ""; | |
| for (const w of words) { const t = cur ? cur + " " + w : w; if (cur && g.measureText(t).width > maxW) { lines.push(cur); cur = w; } else cur = t; } | |
| if (cur) lines.push(cur); | |
| if (lines.length > 2) { lines.length = 2; lines[1] = lines[1].replace(/\s*\S*$/, "") + "…"; } | |
| const tw = Math.max(...lines.map((l) => g.measureText(l).width)); | |
| const bw = tw + padX * 2, bh = lines.length * lh + padY * 2, by = Math.max(8 * S, cy - bh / 2); | |
| // pop about the tail so it grows out of Reachy's mouth | |
| g.translate(x, cy); g.scale(scale, scale); g.translate(-x, -cy); | |
| const path = () => { | |
| roundRect(g, x, by, bw, bh, 16 * S); | |
| const ty = Math.min(by + bh - 14 * S, Math.max(by + 14 * S, cy + 6 * S)); | |
| g.moveTo(x + 1, ty - 9 * S); g.lineTo(x - 14 * S, ty + 4 * S); g.lineTo(x + 1, ty + 7 * S); | |
| }; | |
| g.shadowColor = "rgba(0,0,0,0.25)"; g.shadowBlur = 10 * S; g.shadowOffsetY = 3 * S; | |
| g.fillStyle = "#fffdf5"; path(); g.fill(); | |
| g.shadowColor = "transparent"; | |
| g.strokeStyle = "#2b2b2b"; g.lineWidth = 2.5 * S; roundRect(g, x, by, bw, bh, 16 * S); g.stroke(); | |
| { // tail outline, then cover the seam where it meets the bubble | |
| const ty = Math.min(by + bh - 14 * S, Math.max(by + 14 * S, cy + 6 * S)); | |
| g.beginPath(); g.moveTo(x, ty - 9 * S); g.lineTo(x - 14 * S, ty + 4 * S); g.lineTo(x, ty + 7 * S); g.fill(); g.stroke(); | |
| g.fillRect(x - 0.5 * S, ty - 8 * S, 3 * S, 14 * S); | |
| } | |
| // type-on | |
| let shown = Math.floor(ageMs / 1000 * 45); | |
| g.fillStyle = "#2b2b2b"; g.textBaseline = "middle"; g.textAlign = "left"; | |
| lines.forEach((l, k) => { | |
| const part = l.slice(0, Math.max(0, shown)); shown -= l.length + 1; | |
| g.fillText(part, x + padX, by + padY + lh * (k + 0.5) + 1 * S); | |
| }); | |
| g.restore(); | |
| } | |
| // Cartoon Reachy Mini: wide rounded head, black antennae, two solid black eyes of different sizes joined by a bar. | |
| // No mouth; while "talking" the head bobs and the antennae twitch. Eyes drift toward the person being watched. | |
| reachyFace(g, x, y, r, S, sec, mood, talking, target) { | |
| const excited = mood === "reacting" || mood === "noticed" || mood === "waveSeen"; | |
| const wag = excited ? Math.sin(sec * 14) * 0.35 : talking ? Math.sin(sec * 9) * 0.12 : Math.sin(sec * 2) * 0.06; | |
| const bob = talking ? Math.abs(Math.sin(sec * 9)) * 2.5 * S : 0; | |
| const hw = r * 1.3, hh = r * 0.88; // half width / half height of the head | |
| g.save(); g.translate(x, y - bob); | |
| g.lineCap = "round"; g.lineJoin = "round"; | |
| // antennae: thin black springs rising from the top of the head | |
| g.strokeStyle = "#1a1a1a"; g.fillStyle = "#1a1a1a"; g.lineWidth = 3 * S; | |
| for (const side of [-1, 1]) { | |
| const bx = side * hw * 0.45, by = -hh * 0.9, a = side * 0.18 + wag * side; | |
| const len = r * 0.95, tx = bx + Math.sin(a) * len, ty = by - Math.cos(a) * len; | |
| g.beginPath(); g.moveTo(bx, by); | |
| g.bezierCurveTo(bx + side * 5 * S, by - len * 0.35, tx - side * 5 * S, ty + len * 0.35, tx, ty); g.stroke(); | |
| g.beginPath(); g.arc(tx, ty, 3 * S, 0, Math.PI * 2); g.fill(); | |
| } | |
| // head | |
| g.shadowColor = "rgba(0,0,0,0.25)"; g.shadowBlur = 10 * S; g.shadowOffsetY = 3 * S; | |
| g.fillStyle = "#f7f7f4"; roundRect(g, -hw, -hh, hw * 2, hh * 2, r * 0.75); g.fill(); | |
| g.shadowColor = "transparent"; | |
| g.strokeStyle = "#1a1a1a"; g.lineWidth = 2.5 * S; roundRect(g, -hw, -hh, hw * 2, hh * 2, r * 0.75); g.stroke(); | |
| // eyes: look toward the target (screen right = person on Reachy's view right) | |
| const lookX = target ? clamp((target.cx - 0.5) * 2, -1, 1) : Math.sin(sec * 0.7) * 0.5; | |
| const lookY = target ? clamp((target.cy - 0.5) * 2, -1, 1) : 0; | |
| const ox = lookX * r * 0.12, oy = r * 0.05 + lookY * r * 0.08; | |
| const blink = (sec % 4.2) > 4.05 ? 0.15 : 1; | |
| const eL = { x: -r * 0.52 + ox, y: oy, r: r * 0.28 }, eR = { x: r * 0.5 + ox, y: oy, r: r * 0.39 }; | |
| g.fillStyle = "#111"; | |
| g.fillRect(eL.x, oy - r * 0.07, eR.x - eL.x, r * 0.14); // the bar joining the eyes | |
| for (const e of [eL, eR]) { g.beginPath(); g.ellipse(e.x, e.y, e.r, e.r * blink, 0, 0, Math.PI * 2); g.fill(); } | |
| g.restore(); | |
| } | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Big screen + voice | |
| // --------------------------------------------------------------------------- | |
| function toggleBigScreen(ui, on) { | |
| document.body.classList.toggle("big", on); | |
| ui.btnBig.textContent = on ? "Exit big screen" : "Big screen"; | |
| if (on) { | |
| // True fullscreen only works if every enclosing iframe allows it; otherwise the page-fill layout + F11 does the job | |
| const el = ui.stage; | |
| (el.requestFullscreen?.() ?? Promise.reject()).catch(() => log("Browser fullscreen blocked by the frame; press F11 for the browser's fullscreen")); | |
| } else if (document.fullscreenElement) { | |
| document.exitFullscreen?.().catch(() => {}); | |
| } | |
| } | |
| class VoiceTriggers { | |
| constructor(onTrigger) { | |
| this.onTrigger = onTrigger; this.active = false; this.rec = null; this.lastAt = -Infinity; this.wanted = false; | |
| this.recent = []; // [{t, text}] final results from the last few seconds, joined for matching | |
| this.Ctor = window.SpeechRecognition || window.webkitSpeechRecognition || null; | |
| } | |
| toggle(btn) { this.active ? this.stop(btn) : this.start(btn); } | |
| start(btn) { | |
| if (!this.Ctor) { log("Voice: this browser has no speech recognition (use Chrome or Edge)"); return; } | |
| this.wanted = true; | |
| const r = new this.Ctor(); | |
| r.lang = VOICE.lang; r.continuous = true; r.interimResults = true; r.maxAlternatives = 3; | |
| r.onresult = (e) => { | |
| for (let i = e.resultIndex; i < e.results.length; i++) { | |
| const res = e.results[i]; | |
| if (res.isFinal && VOICE.logTranscripts) log(`Heard: "${res[0].transcript.trim()}"`); | |
| for (let k = 0; k < res.length; k++) this.check(res[k].transcript, res.isFinal); | |
| } | |
| }; | |
| r.onaudiostart = () => log("Voice: mic open"); | |
| r.onspeechstart = () => console.debug("[voice] speech start"); | |
| navigator.permissions?.query?.({ name: "microphone" }).then((p) => log(`Mic permission: ${p.state}`)).catch(() => {}); | |
| r.onerror = (e) => { if (e.error !== "no-speech" && e.error !== "aborted") log(`Voice error: ${e.error}`); if (e.error === "not-allowed") this.wanted = false; }; | |
| r.onend = () => { if (this.wanted) { try { r.start(); } catch (_) { setTimeout(() => this.wanted && this.start(btn), 1000); } } else this.setActive(false, btn); }; | |
| try { r.start(); } catch (e) { log(`Voice start failed: ${e.message}`); return; } | |
| this.rec = r; this.setActive(true, btn); log("Voice: listening (laptop mic)"); | |
| } | |
| stop(btn) { this.wanted = false; try { this.rec?.stop(); } catch (_) {} this.setActive(false, btn); log("Voice: off"); } | |
| setActive(on, btn) { this.active = on; if (btn) { btn.textContent = on ? "Voice: listening" : "Voice: off"; btn.setAttribute("aria-pressed", String(on)); } } | |
| check(transcript, isFinal) { | |
| const now = performance.now(); | |
| const clean = transcript.toLowerCase().replace(/[^a-z' ]+/g, " ").replace(/\s+/g, " ").trim(); | |
| if (isFinal) { this.recent.push({ t: now, text: clean }); } | |
| this.recent = this.recent.filter((r) => now - r.t < 4000); | |
| // Match against recent finals + the current phrase, so "hello" ... "Richie" said with a pause still joins up | |
| const t = " " + [...this.recent.map((r) => r.text), isFinal ? "" : clean].join(" ").replace(/\s+/g, " ").trim() + " "; | |
| if (now - this.lastAt < VOICE.cooldownSec * 1000) return; | |
| for (const trig of VOICE.triggers) { | |
| if (trig.finalOnly && !isFinal) continue; | |
| const hit = trig.match ? trig.match.test(t.trim()) : trig.pattern ? trig.pattern.test(t) : (trig.phrases ?? []).some((p) => t.includes(" " + p + " ")); | |
| if (hit) { this.lastAt = now; this.recent = []; this.onTrigger(trig, transcript.trim()); return; } | |
| } | |
| } | |
| } | |
| function roundRect(g, x, y, w, h, r) { | |
| g.beginPath(); g.moveTo(x + r, y); g.arcTo(x + w, y, x + w, y + h, r); g.arcTo(x + w, y + h, x, y + h, r); g.arcTo(x, y + h, x, y, r); g.arcTo(x, y, x + w, y, r); g.closePath(); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Emotion clips: JSON motion + Opus audio -> 16 kHz mono PCM WAV | |
| // --------------------------------------------------------------------------- | |
| class ClipCache { | |
| constructor() { this.map = new Map(); } | |
| async preload(names) { | |
| const results = await Promise.allSettled(names.map((n) => this.get(n))); | |
| return results.filter((r) => r.status === "fulfilled" && r.value).length; | |
| } | |
| async get(name) { | |
| if (this.map.has(name)) return this.map.get(name); | |
| const p = (async () => { | |
| try { | |
| const [motionRes, audioRes] = await Promise.all([ | |
| fetch(EMOTIONS_BASE + name + ".json"), | |
| fetch(EMOTIONS_BASE + name + ".ogg"), | |
| ]); | |
| if (!motionRes.ok) throw new Error(`motion ${motionRes.status}`); | |
| const motion = normalizeMotion(await motionRes.json()); | |
| let wav; | |
| if (audioRes.ok) wav = await toWav16kMono(await audioRes.arrayBuffer()); | |
| return { motion, wav }; | |
| } catch (e) { | |
| log(`Failed to load ${name}: ${e.message}`); | |
| this.map.delete(name); | |
| return null; | |
| } | |
| })(); | |
| this.map.set(name, p); | |
| return p; | |
| } | |
| } | |
| function normalizeMotion(raw) { | |
| // Flatten nested 4x4 head matrices to 16 floats; leave everything else as-is | |
| const set_target_data = raw.set_target_data.map((f) => ({ | |
| ...f, | |
| head: f.head && Array.isArray(f.head[0]) ? f.head.flat() : f.head, | |
| })); | |
| return { ...raw, set_target_data }; | |
| } | |
| async function toWav16kMono(arrayBuffer) { | |
| const ctx = new (window.AudioContext || window.webkitAudioContext)(); | |
| const decoded = await ctx.decodeAudioData(arrayBuffer); | |
| await ctx.close(); | |
| const targetRate = 16000; | |
| const length = Math.ceil(decoded.duration * targetRate); | |
| const off = new OfflineAudioContext(1, length, targetRate); | |
| const src = off.createBufferSource(); | |
| src.buffer = decoded; | |
| src.connect(off.destination); | |
| src.start(); | |
| const rendered = await off.startRendering(); | |
| return encodeWav(rendered.getChannelData(0), targetRate); | |
| } | |
| function encodeWav(samples, sampleRate) { | |
| const buf = new ArrayBuffer(44 + samples.length * 2); | |
| const v = new DataView(buf); | |
| const str = (o, s) => { for (let i = 0; i < s.length; i++) v.setUint8(o + i, s.charCodeAt(i)); }; | |
| str(0, "RIFF"); v.setUint32(4, 36 + samples.length * 2, true); str(8, "WAVE"); | |
| str(12, "fmt "); v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true); | |
| v.setUint32(24, sampleRate, true); v.setUint32(28, sampleRate * 2, true); | |
| v.setUint16(32, 2, true); v.setUint16(34, 16, true); | |
| str(36, "data"); v.setUint32(40, samples.length * 2, true); | |
| let o = 44; | |
| for (let i = 0; i < samples.length; i++, o += 2) { | |
| const s = Math.max(-1, Math.min(1, samples[i])); | |
| v.setInt16(o, s < 0 ? s * 0x8000 : s * 0x7fff, true); | |
| } | |
| return new Blob([buf], { type: "audio/wav" }); | |
| } | |
| // A small family of synthesized chirps; one is picked at random each time | |
| const CHIRPS = [ | |
| { dur: 0.45, parts: [[0.0, 0.18, 900, 1600], [0.22, 0.45, 1100, 2000]] }, // classic double | |
| { dur: 0.6, parts: [[0.0, 0.14, 800, 1500], [0.18, 0.32, 1000, 1800], [0.38, 0.6, 1200, 2200]] }, // triple, climbing | |
| { dur: 0.5, parts: [[0.0, 0.5, 700, 2400]] }, // long swoop up | |
| { dur: 0.55, parts: [[0.0, 0.25, 1500, 900], [0.3, 0.55, 900, 1900]] }, // dip then rise (question) | |
| { dur: 0.7, parts: [[0.0, 0.1, 1400, 1700], [0.12, 0.22, 1400, 1700], [0.24, 0.34, 1400, 1700], [0.4, 0.7, 1200, 2300]] }, // trill + flourish | |
| { dur: 0.5, parts: [[0.0, 0.2, 2000, 1300], [0.25, 0.5, 1300, 2600]] }, // whistle | |
| ]; | |
| function chirpWav(which) { | |
| const sr = 16000; | |
| const c = CHIRPS[which ?? Math.floor(Math.random() * CHIRPS.length)]; | |
| const n = Math.floor(sr * c.dur); | |
| const out = new Float32Array(n); | |
| for (const [t0, t1, f0, f1] of c.parts) { | |
| for (let i = Math.floor(t0 * sr); i < Math.floor(t1 * sr) && i < n; i++) { | |
| const t = i / sr - t0, T = t1 - t0, u = t / T; | |
| const env = Math.sin(Math.PI * u); | |
| out[i] += 0.6 * env * Math.sin(2 * Math.PI * (f0 * t + 0.5 * (f1 - f0) * t * t / T)); | |
| } | |
| } | |
| return encodeWav(out, sr); | |
| } | |
| // How long the bubble takes to type this line (same speed as ThoughtOverlay.speech) | |
| function babbleDurSec(text) { | |
| return clamp((text?.length ?? 0) / BABBLE.charsPerSec, 0.35, 1.8); | |
| } | |
| // Robot babble: quick syllables of buzzy tones with little pitch slides, 16 kHz mono WAV | |
| function babbleWav(durSec, seed = Math.random) { | |
| const sr = 16000, n = Math.floor(sr * durSec), out = new Float32Array(n); | |
| const base = 650 + seed() * 500; // this utterance's "voice" | |
| let t = 0; | |
| while (t < durSec - 0.05) { | |
| const syl = 0.055 + seed() * 0.07, gap = 0.015 + seed() * 0.035; | |
| const f0 = base * (0.75 + seed() * 0.7), f1 = f0 * (0.8 + seed() * 0.5); | |
| const twoTone = seed() < 0.2; | |
| const i0 = Math.floor(t * sr), i1 = Math.min(n, Math.floor((t + syl) * sr)); | |
| let ph = 0; | |
| for (let i = i0; i < i1; i++) { | |
| const u = (i - i0) / (i1 - i0), tt = (i - i0) / sr; | |
| const f = twoTone ? (u < 0.5 ? f0 : f1) : f0 + (f1 - f0) * u; | |
| ph += 2 * Math.PI * f * (1 + 0.02 * Math.sin(2 * Math.PI * 30 * tt)) / sr; | |
| const env = Math.min(1, tt / 0.006) * Math.min(1, (1 - u) * syl / 0.02); | |
| const tone = 0.75 * Math.sin(ph) + 0.25 * Math.sign(Math.sin(ph)); // a little square for the robot buzz | |
| out[i] += BABBLE.volume * env * tone; | |
| } | |
| t += syl + gap; | |
| } | |
| // fade the very end so it never clicks | |
| for (let i = Math.max(0, n - 160); i < n; i++) out[i] *= (n - i) / 160; | |
| return encodeWav(out, sr); | |
| } | |
| // Plays babble on Reachy's speaker without motion (so tracking keeps going). Clips are built per length, | |
| // uploaded once and replayed; if the daemon won't replay an uploaded clip, it falls back to uploading each time. | |
| class Babbler { | |
| constructor(reachy) { this.reachy = reachy; this.cache = new Map(); this.lastAt = -Infinity; this.inFlight = false; this.reuse = true; this.warned = false; } | |
| say(text) { | |
| const now = performance.now(); | |
| if (this.inFlight || now - this.lastAt < BABBLE.minGapSec * 1000) return; | |
| this.lastAt = now; this.inFlight = true; | |
| this.play(babbleDurSec(text)).catch((e) => { | |
| if (!this.warned) { this.warned = true; log(`Babble unavailable: ${e?.message ?? e}`); } | |
| }).finally(() => { this.inFlight = false; }); | |
| } | |
| async play(dur) { | |
| if (typeof this.reachy?.uploadAudio !== "function") throw new Error("SDK has no uploadAudio"); | |
| const len = Math.round(dur * 4) / 4; // quarter-second buckets | |
| const key = `${len}:${Math.floor(Math.random() * BABBLE.variants)}`; | |
| let id = this.reuse ? this.cache.get(key) : null; | |
| if (id) { | |
| try { await this.reachy.playUploadedAudio(id); return; } | |
| catch (_) { this.cache.delete(key); this.reuse = false; log("Babble: robot won't replay uploaded clips; uploading each time"); } | |
| } | |
| id = await this.reachy.uploadAudio(babbleWav(len), { description: "babble" }); | |
| if (this.reuse) this.cache.set(key, id); | |
| await this.reachy.playUploadedAudio(id); | |
| } | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Motion helpers | |
| // --------------------------------------------------------------------------- | |
| // Short nod from the current pose, antennas perk, 0.9 s at 50 Hz | |
| function buildNodMotion({ yawDeg, pitchDeg, bodyYawDeg }) { | |
| // Keep the nod inside a comfortable envelope so the daemon's IK always converges | |
| if (![yawDeg, pitchDeg, bodyYawDeg].every(Number.isFinite)) { yawDeg = 0; pitchDeg = TRACK.restPitchDeg; bodyYawDeg = 0; } | |
| bodyYawDeg = clamp(bodyYawDeg, -TRACK.maxBodyYawDeg, TRACK.maxBodyYawDeg); | |
| yawDeg = clamp(yawDeg, bodyYawDeg - 40, bodyYawDeg + 40); | |
| pitchDeg = clamp(pitchDeg, -30, 15); | |
| const hz = 50, dur = 0.9, n = Math.round(dur * hz); | |
| const time = [], set_target_data = []; | |
| for (let i = 0; i <= n; i++) { | |
| const t = i / hz, u = t / dur; | |
| const dip = u < 0.625 ? 14 * Math.sin(Math.PI * u * 1.6) : 0; // one dip in the first ~0.56 s | |
| const perk = 25 * DEG * Math.sin(Math.PI * u); | |
| time.push(t); | |
| set_target_data.push({ | |
| head: headMatrix(0, (pitchDeg + dip) * DEG, yawDeg * DEG), | |
| antennas: [INIT_ANTENNAS[0] - perk, INIT_ANTENNAS[1] + perk], | |
| body_yaw: bodyYawDeg * DEG, | |
| }); | |
| } | |
| return { time, set_target_data }; | |
| } | |
| // Pre-rendered spoken lines (already 16 kHz mono PCM WAV, so no conversion needed) | |
| class SpeechCache { | |
| constructor() { this.map = new Map(); } | |
| async preloadAll() { | |
| const files = [...new Set(Object.values(SPEECH.lines).flat())]; | |
| const r = await Promise.allSettled(files.map((f) => this.load(f))); | |
| return r.filter((x) => x.status === "fulfilled" && x.value).length; | |
| } | |
| async load(file) { | |
| if (this.map.has(file)) return this.map.get(file); | |
| const p = (async () => { | |
| try { | |
| const res = await fetch(SPEECH.base + file + "?v=" + SPEECH.version); | |
| if (!res.ok) throw new Error(`${res.status}`); | |
| const buf = await res.arrayBuffer(); | |
| return { blob: new Blob([buf], { type: "audio/wav" }), durSec: wavDuration(buf) }; | |
| } catch (e) { log(`Voice line ${file} failed: ${e.message}`); this.map.delete(file); return null; } | |
| })(); | |
| this.map.set(file, p); | |
| return p; | |
| } | |
| async get(key) { | |
| const list = SPEECH.lines[key]; | |
| if (!list?.length) return null; | |
| return this.load(list[Math.floor(Math.random() * list.length)]); | |
| } | |
| } | |
| // Walk the RIFF chunks to find the real data length (ffmpeg and others add LIST chunks before it) | |
| function wavDuration(buf) { | |
| const v = new DataView(buf); | |
| let rate = 16000, channels = 1, bits = 16, pos = 12; | |
| while (pos + 8 <= buf.byteLength) { | |
| const id = String.fromCharCode(v.getUint8(pos), v.getUint8(pos + 1), v.getUint8(pos + 2), v.getUint8(pos + 3)); | |
| const size = v.getUint32(pos + 4, true); | |
| if (id === "fmt ") { channels = v.getUint16(pos + 10, true); rate = v.getUint32(pos + 12, true); bits = v.getUint16(pos + 22, true); } | |
| if (id === "data") return size / (rate * channels * (bits / 8)); | |
| pos += 8 + size + (size & 1); | |
| } | |
| return 1.0; | |
| } | |
| // "Talking": hold the head, small quick nods and antenna flicks for the length of the line | |
| // Group look-around: swing the head from person to person (left to right in the image), pausing on each with an | |
| // antenna perk, and end facing the middle of the group. Aim uses the approximate camera field of view in GROUP. | |
| function buildLookAroundMotion({ yawDeg, pitchDeg, bodyYawDeg, faces }) { | |
| if (![yawDeg, pitchDeg, bodyYawDeg].every(Number.isFinite)) { yawDeg = 0; pitchDeg = TRACK.restPitchDeg; bodyYawDeg = 0; } | |
| bodyYawDeg = clamp(bodyYawDeg, -TRACK.maxBodyYawDeg, TRACK.maxBodyYawDeg); | |
| let people = faces.slice().sort((a, b) => a.cx - b.cx); | |
| if (people.length > GROUP.maxStops) people = Array.from({ length: GROUP.maxStops }, (_, i) => people[Math.round(i * (people.length - 1) / (GROUP.maxStops - 1))]); | |
| const aim = (f) => ({ | |
| yaw: clamp(yawDeg + YAW_SIGN * (f.cx - 0.5) * 2 * GROUP.halfFovDeg, bodyYawDeg - 45, bodyYawDeg + 45), | |
| pitch: clamp(pitchDeg + PITCH_SIGN * (f.cy - 0.5) * 2 * GROUP.halfVFovDeg, -35, 20), | |
| }); | |
| const stops = people.map(aim); | |
| const mid = { yaw: stops.reduce((a, s) => a + s.yaw, 0) / stops.length, pitch: stops.reduce((a, s) => a + s.pitch, 0) / stops.length }; | |
| const hz = 50, time = [], set_target_data = []; | |
| let t = 0, cur = { yaw: clamp(yawDeg, bodyYawDeg - 45, bodyYawDeg + 45), pitch: clamp(pitchDeg, -35, 20) }; | |
| const push = (pose, perk) => { | |
| time.push(t); | |
| set_target_data.push({ head: headMatrix(0, pose.pitch * DEG, pose.yaw * DEG), antennas: [INIT_ANTENNAS[0] - perk, INIT_ANTENNAS[1] + perk], body_yaw: bodyYawDeg * DEG }); | |
| t += 1 / hz; | |
| }; | |
| for (const [k, target] of [...stops, mid].entries()) { | |
| const n = Math.max(1, Math.round(GROUP.moveSec * hz)); | |
| for (let i = 1; i <= n; i++) { | |
| const u = 0.5 - 0.5 * Math.cos(Math.PI * i / n); | |
| push({ yaw: cur.yaw + (target.yaw - cur.yaw) * u, pitch: cur.pitch + (target.pitch - cur.pitch) * u }, 0); | |
| } | |
| cur = target; | |
| const isLast = k === stops.length; | |
| const h = Math.round((isLast ? 0.2 : GROUP.holdStopSec) * hz); | |
| for (let i = 0; i < h; i++) push(cur, isLast ? 0 : 0.35 * Math.sin(Math.PI * i / h)); // little antenna perk: "hi, you!" | |
| } | |
| return { time, set_target_data }; | |
| } | |
| function buildTalkMotion({ yawDeg, pitchDeg, bodyYawDeg, durSec = 1.2 }) { | |
| if (![yawDeg, pitchDeg, bodyYawDeg].every(Number.isFinite)) { yawDeg = 0; pitchDeg = TRACK.restPitchDeg; bodyYawDeg = 0; } | |
| bodyYawDeg = clamp(bodyYawDeg, -TRACK.maxBodyYawDeg, TRACK.maxBodyYawDeg); | |
| yawDeg = clamp(yawDeg, bodyYawDeg - 40, bodyYawDeg + 40); | |
| pitchDeg = clamp(pitchDeg, -30, 15); | |
| const hz = 50, n = Math.round(durSec * hz), time = [], set_target_data = []; | |
| for (let i = 0; i <= n; i++) { | |
| const t = i / hz, u = t / durSec, env = Math.sin(Math.PI * Math.min(1, u)); | |
| const bob = 3 * env * Math.sin(2 * Math.PI * 4.5 * t); // little syllable nods | |
| const perk = 0.35 * env * (0.6 + 0.4 * Math.sin(2 * Math.PI * 3 * t)); | |
| time.push(t); | |
| set_target_data.push({ | |
| head: headMatrix(0, (pitchDeg + bob) * DEG, yawDeg * DEG), | |
| antennas: [INIT_ANTENNAS[0] - perk, INIT_ANTENNAS[1] + perk], | |
| body_yaw: bodyYawDeg * DEG, | |
| }); | |
| } | |
| return { time, set_target_data }; | |
| } | |
| // Antenna wave-back: hold the current head pose, swing one or both antennae, ~1.6 s at 50 Hz | |
| function buildAntennaWave({ yawDeg, pitchDeg, bodyYawDeg, both = false, side = 1 }) { | |
| if (![yawDeg, pitchDeg, bodyYawDeg].every(Number.isFinite)) { yawDeg = 0; pitchDeg = TRACK.restPitchDeg; bodyYawDeg = 0; } | |
| const hz = 50, dur = 1.6, n = Math.round(dur * hz), swings = 3; | |
| const amp = 0.9; // radians of antenna swing | |
| const time = [], set_target_data = []; | |
| const head = headMatrix(0, pitchDeg * DEG, yawDeg * DEG); | |
| for (let i = 0; i <= n; i++) { | |
| const t = i / hz, u = t / dur; | |
| const env = Math.sin(Math.PI * u); // ease in/out | |
| const w = amp * env * Math.sin(2 * Math.PI * swings * u); // the wave itself | |
| const a = [INIT_ANTENNAS[0], INIT_ANTENNAS[1]]; | |
| if (both) { a[0] = INIT_ANTENNAS[0] - w; a[1] = INIT_ANTENNAS[1] + w; } // opposite phase, like two arms | |
| else if (side === 0) a[0] = INIT_ANTENNAS[0] - w; else a[1] = INIT_ANTENNAS[1] + w; | |
| time.push(t); | |
| set_target_data.push({ head, antennas: a, body_yaw: bodyYawDeg * DEG }); | |
| } | |
| return { time, set_target_data }; | |
| } | |
| // Tilt a whole recorded move up/down (rotate about world y; negative = nose up), keeping head pitch in range | |
| function offsetMotionPitch(motion, pitchRad) { | |
| if (Math.abs(pitchRad) < 1e-4) return motion; | |
| const Ry = headMatrix(0, pitchRad, 0); | |
| return { | |
| ...motion, | |
| set_target_data: motion.set_target_data.map((f) => { | |
| if (!f.head) return f; | |
| const h = mat4mul(Ry, f.head); | |
| const rp = matrixToRpyDeg(h); | |
| if (rp.pitch < -TRACK.maxPitchUpDeg || rp.pitch > TRACK.maxPitchDownDeg) return f; // leave extreme frames untilted | |
| return { ...f, head: h }; | |
| }), | |
| }; | |
| } | |
| // Cut a move short (with a quick fade back toward the first frame so it doesn't stop mid-pose) | |
| function truncateMotion(motion, maxSec) { | |
| const idx = motion.time.findIndex((t) => t > maxSec); | |
| if (idx < 0) return motion; | |
| const time = motion.time.slice(0, idx), frames = motion.set_target_data.slice(0, idx); | |
| const first = frames[0], last = frames[frames.length - 1], t0 = time[time.length - 1], hz = 50, n = 25; | |
| for (let i = 1; i <= n; i++) { | |
| const u = i / n, t = t0 + i / hz; | |
| const head = last.head && first.head ? last.head.map((v, k) => v + (first.head[k] - v) * u) : last.head; | |
| const antennas = last.antennas && first.antennas ? last.antennas.map((v, k) => v + (first.antennas[k] - v) * u) : last.antennas; | |
| const body_yaw = typeof last.body_yaw === "number" ? last.body_yaw + ((first.body_yaw ?? last.body_yaw) - last.body_yaw) * u : last.body_yaw; | |
| time.push(t); frames.push({ ...last, head, antennas, body_yaw }); | |
| } | |
| return { ...motion, time, set_target_data: frames }; | |
| } | |
| // Rotate a whole recorded move about world z by `yawRad` so it plays relative to the current body yaw | |
| function offsetMotionYaw(motion, yawRad) { | |
| if (Math.abs(yawRad) < 1e-4) return motion; | |
| const Rz = headMatrix(0, 0, yawRad); | |
| const maxBody = TRACK.maxBodyYawDeg * DEG; | |
| return { | |
| ...motion, | |
| set_target_data: motion.set_target_data.map((f) => ({ | |
| ...f, | |
| head: f.head ? mat4mul(Rz, f.head) : f.head, | |
| body_yaw: typeof f.body_yaw === "number" ? clamp(f.body_yaw + yawRad, -maxBody, maxBody) : f.body_yaw, | |
| })), | |
| }; | |
| } | |
| // Row-major flat 4x4 from roll/pitch/yaw (radians), ZYX convention, zero translation | |
| function headMatrix(r, p, y) { | |
| 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); | |
| return [ | |
| cy * cp, cy * sp * sr - sy * cr, cy * sp * cr + sy * sr, 0, | |
| sy * cp, sy * sp * sr + cy * cr, sy * sp * cr - cy * sr, 0, | |
| -sp, cp * sr, cp * cr, 0, | |
| 0, 0, 0, 1, | |
| ]; | |
| } | |
| function toNested4x4(m) { | |
| if (Array.isArray(m[0])) return m; | |
| return [m.slice(0, 4), m.slice(4, 8), m.slice(8, 12), m.slice(12, 16)]; | |
| } | |
| function matrixToRpyDeg(m) { | |
| if (Array.isArray(m[0])) m = m.flat(); | |
| const pitch = Math.asin(clamp(-m[8], -1, 1)); | |
| const roll = Math.atan2(m[9], m[10]); | |
| const yaw = Math.atan2(m[4], m[0]); | |
| return { roll: roll / DEG, pitch: pitch / DEG, yaw: yaw / DEG }; | |
| } | |
| function mat4mul(a, b) { | |
| const o = new Array(16); | |
| for (let r = 0; r < 4; r++) for (let c = 0; c < 4; c++) { | |
| let s = 0; | |
| for (let k = 0; k < 4; k++) s += a[r * 4 + k] * b[k * 4 + c]; | |
| o[r * 4 + c] = s; | |
| } | |
| return o; | |
| } | |
| function safeReturnHome(reachy) { | |
| try { reachy.setMotorMode?.("enabled"); } catch (_) {} | |
| const target = { head: headMatrix(0, TRACK.restPitchDeg * DEG, 0), antennas: INIT_ANTENNAS, body_yaw: 0 }; | |
| try { | |
| if (typeof reachy.gotoTarget === "function") reachy.gotoTarget({ ...target, duration: 1.2 }); | |
| else reachy.setTarget(target); | |
| } catch (_) {} | |
| } | |
| // --------------------------------------------------------------------------- | |
| // UI | |
| // --------------------------------------------------------------------------- | |
| function bindUi() { | |
| const $ = (id) => document.getElementById(id); | |
| return { | |
| video: $("video"), overlay: $("overlay"), | |
| btnTrack: $("btnTrack"), btnBody: $("btnBody"), btnLook: $("btnLook"), btnMute: $("btnMute"), btnWave: $("btnWave"), btnWake: $("btnWake"), | |
| btnBig: $("btnBig"), btnVoice: $("btnVoice"), stage: document.querySelector(".stage"), | |
| }; | |
| } | |
| function setStatus(text, sub = "", mode = "") { | |
| const el = document.getElementById("status"); | |
| document.getElementById("statusText").textContent = text; | |
| document.getElementById("statusSub").textContent = sub; | |
| el.className = "status" + (mode ? " " + mode : ""); | |
| } | |
| function log(msg) { | |
| const el = document.getElementById("log"); | |
| if (!el) return; | |
| const t = new Date().toLocaleTimeString([], { hour12: false }); | |
| el.textContent = `${t} ${msg}\n` + el.textContent.split("\n").slice(0, 14).join("\n"); | |
| console.log("[howdy-friend]", msg); | |
| } | |
| function syncOverlaySize(canvas, video) { | |
| if (canvas.width !== video.videoWidth || canvas.height !== video.videoHeight) { | |
| canvas.width = video.videoWidth; canvas.height = video.videoHeight; | |
| } | |
| } | |
| const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); | |
| const lerp = (a, b, t) => a + (b - a) * t; | |