/** * Loader + cache for the `pollen-robotics/reachy-mini-emotions-library` * dataset. * * Each emotion lives as a JSON file in the dataset's `main` branch: * https://huggingface.co/datasets//resolve/main/.json * * The HF CDN serves these with permissive CORS (verified manually * with `curl -i`), so we can fetch them straight from the browser * without a proxy. * * The optional companion sound is at: * https://huggingface.co/datasets//resolve/main/.wav * * Trajectories are 50-100 KB each (and audio blobs are typically * 50-300 KB when present). We cache them in memory once fetched so * the second playback of an emotion is upload-only with no extra * roundtrip to the HF CDN. We don't bother with localStorage / * IndexedDB - re-loading the page will always re-warm from the CDN, * which is fast enough. * * ─── Format note ──────────────────────────────────────────────── * * We deliberately keep `set_target_data` in the exact shape served * by the dataset (4×4 nested head matrices, `[r, l]` antennas, * `body_yaw` radians). The daemon's `playMove`/`upload_move_*` * pipeline understands that schema natively (it's the same one the * Python `reachy_mini.recorder` writes), so passing it through * verbatim is both more efficient (no per-frame transform) and more * robust (no chance of a re-encoding bug between us and the daemon). */ const DATASET_ID = 'pollen-robotics/reachy-mini-emotions-library'; const CDN_BASE = `https://huggingface.co/datasets/${DATASET_ID}/resolve/main`; /** * Parsed trajectory. We only validate that `time` and * `set_target_data` are present + aligned; the inner frame shape is * passed through to the daemon untouched (see file header). */ export interface Trajectory { id: string; description?: string; /** Per-frame timestamps, in seconds, monotonic, starting at 0 (or near 0). */ time: number[]; /** Raw per-frame target spec, daemon-native schema. Opaque to us. */ set_target_data: object[]; /** Total duration in seconds (last timestamp). */ durationSec: number; } const trajectoryCache = new Map>(); /** * Sidecar synchronous cache of resolved trajectory durations. * * Promises can't expose their resolved value synchronously, but the * UI sometimes needs the duration on the very first render of a * component (e.g. the family panel wants to paint duration badges * with no fade-in flicker if the trajectory is already warm). We * populate this Map from the same async path as `trajectoryCache`, * right after a successful parse, and expose `getCachedDurationSec` * for components that want to seed `useState` with already-known * values. Never populated speculatively - if it's here, the full * trajectory has been fetched and parsed at least once this session. */ const durationsByIdSync = new Map(); /** * Synchronously read a previously-resolved trajectory duration in * seconds. Returns `undefined` if the trajectory hasn't been * fetched + parsed yet in this session. Safe to call during render. */ export function getCachedDurationSec(id: string): number | undefined { return durationsByIdSync.get(id); } /** * Build the URL of an emotion's trajectory JSON. */ export function trajectoryUrl(id: string): string { return `${CDN_BASE}/${encodeURIComponent(id)}.json`; } /** * Build the URL of an emotion's companion sound. Some emotions don't * have a `.wav` (the daemon plays nothing for those); the consumer is * expected to handle 404s gracefully. */ export function soundUrl(id: string): string { return `${CDN_BASE}/${encodeURIComponent(id)}.wav`; } /** * Fetch + parse a trajectory. Cached per-id; concurrent calls share * the same in-flight promise. */ export function loadTrajectory(id: string): Promise { const cached = trajectoryCache.get(id); if (cached) return cached; const promise = (async () => { const res = await fetch(trajectoryUrl(id)); if (!res.ok) { throw new Error( `Failed to fetch trajectory '${id}': ${res.status} ${res.statusText}`, ); } const raw = await res.json(); const traj = parseTrajectory(id, raw); durationsByIdSync.set(id, traj.durationSec); return traj; })(); // If the fetch fails, evict the cached rejected promise so the next // call gets a fresh attempt instead of always seeing the old error. promise.catch(() => trajectoryCache.delete(id)); trajectoryCache.set(id, promise); return promise; } /** * Audio-blob cache (parallel to `trajectoryCache`). Each entry * resolves to either the audio `Blob` (passed to `robot.playMove`'s * `audioBlob` option for daemon-side lock-step playback) or `null` * when the emotion has no companion sound. Resolved promises are * cached; rejected ones evict so the next `play` retries from the * CDN. We DO `await audioBlob.arrayBuffer()` inside the SDK upload * path so the blob itself stays cheap to hold in memory. */ const audioBlobCache = new Map>(); /** * Fetch the companion WAV (if any). Resolves with the `Blob` on a * 2xx response, `null` on 404 / non-OK / network error. Promise * cached per-id, so a second play of the same emotion is upload-only. * * We expose this separately from `loadTrajectory` because the * trajectory and the audio share a single upload step in the SDK's * `playMove`, but the trajectory is also useful on its own (e.g. * for the duration badge in the family grid, which doesn't need the * audio). */ export function loadAudioBlob(id: string): Promise { const cached = audioBlobCache.get(id); if (cached) return cached; const promise = (async (): Promise => { try { const res = await fetch(soundUrl(id)); if (!res.ok) return null; return await res.blob(); } catch { return null; } })(); audioBlobCache.set(id, promise); return promise; } /** * Translate the raw dataset JSON into our internal `Trajectory` * shape. The dataset schema we observed in the wild: * * { * "description": "loving emotion", * "time": [0.0, 0.01, 0.02, …], * "set_target_data": [ * { "head": [[…4×4…]], "antennas": [r, l], "body_yaw": 0.1 }, * … * ] * } * * We pass `set_target_data` through verbatim (see file header). The * only normalisation is on `time`: some recorder builds emit an * off-by-one length, which we trim/pad to match the frame count so * the daemon's strict length check in `playMove` doesn't reject. */ function parseTrajectory(id: string, raw: unknown): Trajectory { if (!raw || typeof raw !== 'object') { throw new Error(`Trajectory '${id}' is not a JSON object`); } const obj = raw as Record; const description = typeof obj.description === 'string' ? obj.description : undefined; const time = Array.isArray(obj.time) ? (obj.time as unknown[]).filter((v): v is number => typeof v === 'number') : []; // Accept the canonical `set_target_data` plus the legacy `frames` // alias the older recorder used. Either is passed through as-is // to the daemon's upload path. const rawFrames: unknown[] = (Array.isArray(obj.set_target_data) && (obj.set_target_data as unknown[])) || (Array.isArray(obj.frames) && (obj.frames as unknown[])) || []; const setTargetData = rawFrames.filter( (f): f is object => typeof f === 'object' && f !== null, ); if (setTargetData.length === 0) { throw new Error( `Trajectory '${id}' has no frames (expected 'set_target_data' or 'frames')`, ); } if (time.length !== setTargetData.length) { // Some recorder builds emit `time` as an array of length // `frames.length + 1` (start + per-frame). Trim/pad as needed // rather than failing - the daemon's strict length check in // `playMove` will reject otherwise. if (time.length > setTargetData.length) time.length = setTargetData.length; while (time.length < setTargetData.length) { const last = time[time.length - 1] ?? 0; time.push(last + 0.01); } } const durationSec = time[time.length - 1] ?? 0; return { id, description, time, set_target_data: setTargetData, durationSec, }; }