Spaces:
Running
Running
Nicolas Rabault Claude Sonnet 4.6 commited on
Commit ·
47e59b7
1
Parent(s): 166871c
feat(app): copy plumbing (openai-realtime, antennas, head-wobbler, move-player, robot-tools) from reference app
Browse filesRelax noUncheckedIndexedAccess in tsconfig.app.json only (upstream reference files
use array indexing without undefined guards; base tsconfig is unchanged).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- src/antennas.ts +106 -0
- src/globals.d.ts +75 -0
- src/head-wobbler.ts +256 -0
- src/move-player.ts +504 -0
- src/openai-realtime.ts +611 -0
- src/robot-tools.ts +71 -0
- tsconfig.app.json +2 -1
src/antennas.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Antennas oscillator - mirrors the `BreathingMove` antenna behaviour from
|
| 3 |
+
* `reachy_mini_conversation_app/moves.py`: a gentle sinusoid in opposition
|
| 4 |
+
* (right ↔ −left) at ~0.5 Hz with a ±15° amplitude. The two ears "breathe"
|
| 5 |
+
* asymmetrically which makes an idle robot feel alive.
|
| 6 |
+
*
|
| 7 |
+
* When the user starts speaking we freeze the antennas on their current
|
| 8 |
+
* position (just like the Python `set_listening(True)` path), and smoothly
|
| 9 |
+
* blend back into the oscillation once they stop.
|
| 10 |
+
*/
|
| 11 |
+
|
| 12 |
+
const FREQ_HZ = 0.5;
|
| 13 |
+
const AMPLITUDE_DEG = 15;
|
| 14 |
+
const BLEND_MS = 400; // time to smoothly ramp back after a freeze
|
| 15 |
+
|
| 16 |
+
export interface AntennasOscillatorOptions {
|
| 17 |
+
onAntennas: (right: number, left: number) => void;
|
| 18 |
+
/** Update rate in Hz. 30 is plenty for smooth motion. Defaults to 30. */
|
| 19 |
+
updateHz?: number;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
export class AntennasOscillator {
|
| 23 |
+
private readonly onAntennas: (right: number, left: number) => void;
|
| 24 |
+
private readonly periodMs: number;
|
| 25 |
+
|
| 26 |
+
private timer: number | null = null;
|
| 27 |
+
private t = 0;
|
| 28 |
+
private frozen = false;
|
| 29 |
+
private frozenRight = 0;
|
| 30 |
+
private frozenLeft = 0;
|
| 31 |
+
// 0..1 crossfader between frozen pose and live oscillation.
|
| 32 |
+
private blend = 1;
|
| 33 |
+
|
| 34 |
+
constructor(options: AntennasOscillatorOptions) {
|
| 35 |
+
this.onAntennas = options.onAntennas;
|
| 36 |
+
this.periodMs = 1000 / (options.updateHz ?? 30);
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
start(): void {
|
| 40 |
+
if (this.timer !== null) return;
|
| 41 |
+
this.t = 0;
|
| 42 |
+
this.blend = 1;
|
| 43 |
+
this.frozen = false;
|
| 44 |
+
this.timer = window.setInterval(() => this.tick(), this.periodMs);
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
stop(): void {
|
| 48 |
+
if (this.timer !== null) {
|
| 49 |
+
clearInterval(this.timer);
|
| 50 |
+
this.timer = null;
|
| 51 |
+
}
|
| 52 |
+
// Return to neutral so the ears don't stay stuck at a random angle.
|
| 53 |
+
this.onAntennas(0, 0);
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
/**
|
| 57 |
+
* Freeze the antennas on their current position. Used when the user is
|
| 58 |
+
* speaking: the robot "listens attentively" instead of keeping its
|
| 59 |
+
* idle animation.
|
| 60 |
+
*/
|
| 61 |
+
freeze(): void {
|
| 62 |
+
if (this.frozen) return;
|
| 63 |
+
this.frozen = true;
|
| 64 |
+
// Snapshot the current oscillation output so the freeze is seamless.
|
| 65 |
+
const { right, left } = this.currentOscillation();
|
| 66 |
+
this.frozenRight = right;
|
| 67 |
+
this.frozenLeft = left;
|
| 68 |
+
this.blend = 0; // fully on the frozen values
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
/**
|
| 72 |
+
* Release the freeze; antennas ramp back into the oscillation over
|
| 73 |
+
* ~BLEND_MS ms.
|
| 74 |
+
*/
|
| 75 |
+
resume(): void {
|
| 76 |
+
if (!this.frozen) return;
|
| 77 |
+
this.frozen = false;
|
| 78 |
+
// `blend` will rise back to 1 via tick().
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
// ─── Internals ───────────────────────────────────────────────────────
|
| 82 |
+
|
| 83 |
+
private tick(): void {
|
| 84 |
+
this.t += this.periodMs / 1000;
|
| 85 |
+
|
| 86 |
+
// Update blend towards its target (0 when frozen, 1 when live).
|
| 87 |
+
const target = this.frozen ? 0 : 1;
|
| 88 |
+
const step = this.periodMs / BLEND_MS;
|
| 89 |
+
if (this.blend < target) this.blend = Math.min(1, this.blend + step);
|
| 90 |
+
else if (this.blend > target) this.blend = Math.max(0, this.blend - step);
|
| 91 |
+
|
| 92 |
+
const { right: liveR, left: liveL } = this.currentOscillation();
|
| 93 |
+
const right = liveR * this.blend + this.frozenRight * (1 - this.blend);
|
| 94 |
+
const left = liveL * this.blend + this.frozenLeft * (1 - this.blend);
|
| 95 |
+
|
| 96 |
+
this.onAntennas(right, left);
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
private currentOscillation(): { right: number; left: number } {
|
| 100 |
+
const phase = 2 * Math.PI * FREQ_HZ * this.t;
|
| 101 |
+
const right = AMPLITUDE_DEG * Math.sin(phase);
|
| 102 |
+
// Opposition: when right antenna goes "up", left goes "down".
|
| 103 |
+
const left = -AMPLITUDE_DEG * Math.sin(phase);
|
| 104 |
+
return { right, left };
|
| 105 |
+
}
|
| 106 |
+
}
|
src/globals.d.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Type declarations for the ReachyMini SDK loaded from a CDN script tag in
|
| 3 |
+
* index.html. We only expose what we actually consume here.
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
export interface RobotInfo {
|
| 7 |
+
id: string;
|
| 8 |
+
meta?: { name?: string };
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export interface RobotState {
|
| 12 |
+
head: { roll: number; pitch: number; yaw: number };
|
| 13 |
+
antennas: { right: number; left: number };
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export interface ReachyMiniOptions {
|
| 17 |
+
signalingUrl?: string;
|
| 18 |
+
enableMicrophone?: boolean;
|
| 19 |
+
clientId?: string;
|
| 20 |
+
appName?: string;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
export interface ReachyMiniInstance extends EventTarget {
|
| 24 |
+
readonly state: "disconnected" | "connected" | "streaming";
|
| 25 |
+
readonly robots: RobotInfo[];
|
| 26 |
+
readonly username: string | null;
|
| 27 |
+
readonly isAuthenticated: boolean;
|
| 28 |
+
readonly micSupported: boolean;
|
| 29 |
+
readonly micMuted: boolean;
|
| 30 |
+
readonly audioMuted: boolean;
|
| 31 |
+
|
| 32 |
+
/**
|
| 33 |
+
* Exposed by the SDK as an internal field but used by the webrtc_example
|
| 34 |
+
* reference to pull stats; we read it to get the audio receiver/senders.
|
| 35 |
+
*/
|
| 36 |
+
_pc: RTCPeerConnection | null;
|
| 37 |
+
|
| 38 |
+
authenticate(): Promise<boolean>;
|
| 39 |
+
login(): Promise<void>;
|
| 40 |
+
logout(): void;
|
| 41 |
+
|
| 42 |
+
connect(token?: string): Promise<void>;
|
| 43 |
+
disconnect(): void;
|
| 44 |
+
|
| 45 |
+
startSession(robotId: string): Promise<void>;
|
| 46 |
+
stopSession(): Promise<void>;
|
| 47 |
+
|
| 48 |
+
attachVideo(el: HTMLVideoElement): () => void;
|
| 49 |
+
|
| 50 |
+
setHeadPose(roll: number, pitch: number, yaw: number): boolean;
|
| 51 |
+
setAntennas(right: number, left: number): boolean;
|
| 52 |
+
playSound(file: string): boolean;
|
| 53 |
+
|
| 54 |
+
/**
|
| 55 |
+
* Send an arbitrary JSON message on the robot data channel. Useful for
|
| 56 |
+
* wire-format commands not exposed by dedicated helpers (e.g.
|
| 57 |
+
* `set_full_target` to stream dance frames).
|
| 58 |
+
*/
|
| 59 |
+
sendRaw(data: unknown): boolean;
|
| 60 |
+
|
| 61 |
+
setAudioMuted(muted: boolean): void;
|
| 62 |
+
setMicMuted(muted: boolean): void;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
export type ReachyMiniConstructor = new (
|
| 66 |
+
options?: ReachyMiniOptions,
|
| 67 |
+
) => ReachyMiniInstance;
|
| 68 |
+
|
| 69 |
+
declare global {
|
| 70 |
+
interface Window {
|
| 71 |
+
ReachyMini: ReachyMiniConstructor;
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
export {};
|
src/head-wobbler.ts
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Head wobble generator - JS port of the Python
|
| 3 |
+
* `reachy_mini_conversation_app/audio/speech_tapper.py` + `head_wobbler.py`.
|
| 4 |
+
*
|
| 5 |
+
* Purpose: take the assistant's outgoing voice (the audio OpenAI plays back
|
| 6 |
+
* through the robot) and drive small, organic head sways + nods in sync with
|
| 7 |
+
* the speech, so Reachy "comes alive" while talking.
|
| 8 |
+
*
|
| 9 |
+
* The Python version sampled raw PCM base64 deltas over the OpenAI WebSocket
|
| 10 |
+
* API; we instead hook a Web Audio `AnalyserNode` to the remote
|
| 11 |
+
* `MediaStreamTrack` (which is how audio reaches us in WebRTC mode). Both
|
| 12 |
+
* yield the same loudness envelope we need to modulate the sine oscillators.
|
| 13 |
+
*
|
| 14 |
+
* The SDK only exposes rotational `setHeadPose(roll, pitch, yaw)` in degrees,
|
| 15 |
+
* so we drop the small (±mm-range) translations from the original Python
|
| 16 |
+
* pipeline - they're a cherry-on-top that isn't reachable through the public
|
| 17 |
+
* JS API anyway.
|
| 18 |
+
*/
|
| 19 |
+
|
| 20 |
+
// ─── Constants ported from speech_tapper.py ─────────────────────────────
|
| 21 |
+
// Frame / hop timing. Hop drives how often we update offsets.
|
| 22 |
+
const HOP_MS = 50; // produce one pose target every 50ms (20Hz)
|
| 23 |
+
const FRAME_MS = 20; // RMS window in ms
|
| 24 |
+
|
| 25 |
+
// Loudness mapping: RMS in dBFS is remapped to [0..1] with a slight gamma.
|
| 26 |
+
const SWAY_DB_LOW = -46;
|
| 27 |
+
const SWAY_DB_HIGH = -18;
|
| 28 |
+
const SWAY_DB_GAMMA = 0.9;
|
| 29 |
+
const SWAY_MASTER = 1.5;
|
| 30 |
+
const SENS_DB_OFFSET = 4;
|
| 31 |
+
|
| 32 |
+
// Hysteretic VAD thresholds on the sliding-frame dBFS envelope.
|
| 33 |
+
const VAD_DB_ON = -35;
|
| 34 |
+
const VAD_DB_OFF = -45;
|
| 35 |
+
// Attack / release for the 0→1 envelope follower that multiplies the sway.
|
| 36 |
+
const ENV_FOLLOW_GAIN = 0.65;
|
| 37 |
+
|
| 38 |
+
// Sinusoid frequencies (Hz) + peak amplitudes (degrees).
|
| 39 |
+
// All three axes use independent initial phases so the motion doesn't feel
|
| 40 |
+
// mechanical when they align.
|
| 41 |
+
const SWAY_F_PITCH = 2.2;
|
| 42 |
+
const SWAY_A_PITCH_DEG = 4.5;
|
| 43 |
+
|
| 44 |
+
const SWAY_F_YAW = 0.6;
|
| 45 |
+
const SWAY_A_YAW_DEG = 7.5;
|
| 46 |
+
|
| 47 |
+
const SWAY_F_ROLL = 1.3;
|
| 48 |
+
const SWAY_A_ROLL_DEG = 2.25;
|
| 49 |
+
|
| 50 |
+
// Deliberate delay between the audio we analyse and the matching head
|
| 51 |
+
// motion. A touch of latency makes the motion feel slaved to the voice
|
| 52 |
+
// (reactive, not predictive). Matches `MOVEMENT_LATENCY_S = 0.2` in Python.
|
| 53 |
+
const MOVEMENT_LATENCY_S = 0.2;
|
| 54 |
+
|
| 55 |
+
// ─── Types ──────────────────────────────────────────────────────────────
|
| 56 |
+
|
| 57 |
+
export interface HeadOffsetsDeg {
|
| 58 |
+
roll: number;
|
| 59 |
+
pitch: number;
|
| 60 |
+
yaw: number;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
export interface HeadWobblerOptions {
|
| 64 |
+
track: MediaStreamTrack;
|
| 65 |
+
onOffsets: (offsets: HeadOffsetsDeg) => void;
|
| 66 |
+
/**
|
| 67 |
+
* Master scalar applied on top of the per-axis amplitudes. Lets callers
|
| 68 |
+
* tone the whole effect down without recompiling constants. Defaults to 1.
|
| 69 |
+
*/
|
| 70 |
+
gain?: number;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
// ─── Implementation ─────────────────────────────────────────────────────
|
| 74 |
+
|
| 75 |
+
/**
|
| 76 |
+
* Consumes a `MediaStreamTrack` (assistant voice) and emits head rotation
|
| 77 |
+
* offsets in degrees every hop (~50 ms), smoothly ramping in and out with
|
| 78 |
+
* the speech envelope.
|
| 79 |
+
*
|
| 80 |
+
* Call `start()` to begin, `stop()` to tear down, `reset()` to zero the
|
| 81 |
+
* internal sway state (useful when the user barges in on the assistant).
|
| 82 |
+
*/
|
| 83 |
+
export class HeadWobbler {
|
| 84 |
+
private readonly track: MediaStreamTrack;
|
| 85 |
+
private readonly onOffsets: (offsets: HeadOffsetsDeg) => void;
|
| 86 |
+
private readonly gain: number;
|
| 87 |
+
|
| 88 |
+
private audioCtx: AudioContext | null = null;
|
| 89 |
+
private analyser: AnalyserNode | null = null;
|
| 90 |
+
private sourceNode: MediaStreamAudioSourceNode | null = null;
|
| 91 |
+
private frameBuf: Float32Array<ArrayBuffer> | null = null;
|
| 92 |
+
|
| 93 |
+
private timer: number | null = null;
|
| 94 |
+
|
| 95 |
+
// Sway state
|
| 96 |
+
private t = 0; // seconds since start() for the sine oscillators
|
| 97 |
+
private swayEnv = 0; // 0..1 envelope follower on VAD-on/off
|
| 98 |
+
private vadOn = false;
|
| 99 |
+
private pendingOffsets: HeadOffsetsDeg[] = [];
|
| 100 |
+
|
| 101 |
+
// Per-axis phase offsets so the three sines don't line up on the downbeat.
|
| 102 |
+
private readonly phasePitch = 0.13 * Math.PI;
|
| 103 |
+
private readonly phaseYaw = 0.48 * Math.PI;
|
| 104 |
+
private readonly phaseRoll = 1.21 * Math.PI;
|
| 105 |
+
|
| 106 |
+
constructor(options: HeadWobblerOptions) {
|
| 107 |
+
this.track = options.track;
|
| 108 |
+
this.onOffsets = options.onOffsets;
|
| 109 |
+
this.gain = options.gain ?? 1.0;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
start(): void {
|
| 113 |
+
if (this.audioCtx) return; // already running
|
| 114 |
+
|
| 115 |
+
// A dedicated AudioContext keeps the robot mic path untouched. We don't
|
| 116 |
+
// need the browser to actually play this back (the audio is also piped
|
| 117 |
+
// straight to the robot speaker via replaceTrack), we just want samples.
|
| 118 |
+
const ctx = new AudioContext();
|
| 119 |
+
const sourceStream = new MediaStream([this.track]);
|
| 120 |
+
const source = ctx.createMediaStreamSource(sourceStream);
|
| 121 |
+
const analyser = ctx.createAnalyser();
|
| 122 |
+
analyser.fftSize = 1024;
|
| 123 |
+
analyser.smoothingTimeConstant = 0;
|
| 124 |
+
source.connect(analyser);
|
| 125 |
+
|
| 126 |
+
const frameSamples = Math.max(
|
| 127 |
+
128,
|
| 128 |
+
Math.round((ctx.sampleRate * FRAME_MS) / 1000),
|
| 129 |
+
);
|
| 130 |
+
this.frameBuf = new Float32Array(new ArrayBuffer(frameSamples * 4));
|
| 131 |
+
|
| 132 |
+
this.audioCtx = ctx;
|
| 133 |
+
this.sourceNode = source;
|
| 134 |
+
this.analyser = analyser;
|
| 135 |
+
|
| 136 |
+
// Kick the tick loop on setInterval so the rate stays aligned with
|
| 137 |
+
// hop_ms regardless of requestAnimationFrame throttling when the tab
|
| 138 |
+
// is backgrounded. 20 Hz is low enough not to spam the robot.
|
| 139 |
+
this.timer = window.setInterval(() => this.tick(), HOP_MS);
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
stop(): void {
|
| 143 |
+
if (this.timer !== null) {
|
| 144 |
+
clearInterval(this.timer);
|
| 145 |
+
this.timer = null;
|
| 146 |
+
}
|
| 147 |
+
try {
|
| 148 |
+
this.sourceNode?.disconnect();
|
| 149 |
+
this.analyser?.disconnect();
|
| 150 |
+
this.audioCtx?.close();
|
| 151 |
+
} catch {
|
| 152 |
+
// ignored
|
| 153 |
+
}
|
| 154 |
+
this.sourceNode = null;
|
| 155 |
+
this.analyser = null;
|
| 156 |
+
this.audioCtx = null;
|
| 157 |
+
this.frameBuf = null;
|
| 158 |
+
this.pendingOffsets = [];
|
| 159 |
+
|
| 160 |
+
// Return to neutral head pose on teardown so the robot doesn't freeze
|
| 161 |
+
// mid-motion.
|
| 162 |
+
this.onOffsets({ roll: 0, pitch: 0, yaw: 0 });
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
/**
|
| 166 |
+
* Re-activate the internal AudioContext after a visibility change. Some
|
| 167 |
+
* browsers (Safari, iOS) suspend contexts when the tab is hidden and
|
| 168 |
+
* don't auto-resume; without this call the RMS meter reads zero forever
|
| 169 |
+
* after the first background trip.
|
| 170 |
+
*/
|
| 171 |
+
resumeAudio(): void {
|
| 172 |
+
const ctx = this.audioCtx;
|
| 173 |
+
if (!ctx) return;
|
| 174 |
+
if (ctx.state === "suspended") {
|
| 175 |
+
ctx.resume().catch((err) => {
|
| 176 |
+
console.warn("[head-wobbler] audioCtx resume failed:", err);
|
| 177 |
+
});
|
| 178 |
+
}
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
/**
|
| 182 |
+
* Reset the sway envelope + latency queue. Call this when the user starts
|
| 183 |
+
* speaking while the assistant is still talking: we want motion to fade
|
| 184 |
+
* out smoothly instead of holding the last sine value.
|
| 185 |
+
*/
|
| 186 |
+
reset(): void {
|
| 187 |
+
this.swayEnv = 0;
|
| 188 |
+
this.vadOn = false;
|
| 189 |
+
this.pendingOffsets = [];
|
| 190 |
+
this.onOffsets({ roll: 0, pitch: 0, yaw: 0 });
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
// ─── Internals ───────────────────────────────────────────────────────
|
| 194 |
+
|
| 195 |
+
private tick(): void {
|
| 196 |
+
if (!this.analyser || !this.frameBuf) return;
|
| 197 |
+
|
| 198 |
+
// 1. Measure current loudness in dBFS on the most recent ~20 ms.
|
| 199 |
+
this.analyser.getFloatTimeDomainData(this.frameBuf);
|
| 200 |
+
const db = rmsDbfs(this.frameBuf) + SENS_DB_OFFSET;
|
| 201 |
+
|
| 202 |
+
// 2. Hysteretic VAD on the dB envelope.
|
| 203 |
+
if (this.vadOn) {
|
| 204 |
+
if (db < VAD_DB_OFF) this.vadOn = false;
|
| 205 |
+
} else {
|
| 206 |
+
if (db > VAD_DB_ON) this.vadOn = true;
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
// 3. Smooth 0..1 envelope the sines are modulated by.
|
| 210 |
+
const target = this.vadOn ? 1 : 0;
|
| 211 |
+
this.swayEnv += (target - this.swayEnv) * ENV_FOLLOW_GAIN;
|
| 212 |
+
|
| 213 |
+
// 4. Loudness gain - how much of the max amplitude we reach on this hop.
|
| 214 |
+
const loud = loudnessGain(db);
|
| 215 |
+
|
| 216 |
+
// 5. Time-evolve the oscillators.
|
| 217 |
+
this.t += HOP_MS / 1000;
|
| 218 |
+
const twoPiT = 2 * Math.PI * this.t;
|
| 219 |
+
const mod = loud * this.swayEnv * this.gain * SWAY_MASTER;
|
| 220 |
+
|
| 221 |
+
const pitchDeg =
|
| 222 |
+
SWAY_A_PITCH_DEG * mod * Math.sin(twoPiT * SWAY_F_PITCH + this.phasePitch);
|
| 223 |
+
const yawDeg =
|
| 224 |
+
SWAY_A_YAW_DEG * mod * Math.sin(twoPiT * SWAY_F_YAW + this.phaseYaw);
|
| 225 |
+
const rollDeg =
|
| 226 |
+
SWAY_A_ROLL_DEG * mod * Math.sin(twoPiT * SWAY_F_ROLL + this.phaseRoll);
|
| 227 |
+
|
| 228 |
+
// 6. Enqueue with latency, then emit the oldest ready sample.
|
| 229 |
+
this.pendingOffsets.push({ roll: rollDeg, pitch: pitchDeg, yaw: yawDeg });
|
| 230 |
+
const maxQueue = Math.ceil((MOVEMENT_LATENCY_S * 1000) / HOP_MS);
|
| 231 |
+
while (this.pendingOffsets.length > maxQueue) {
|
| 232 |
+
const next = this.pendingOffsets.shift();
|
| 233 |
+
if (next) this.onOffsets(next);
|
| 234 |
+
}
|
| 235 |
+
}
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
// ─── Pure helpers ───────────────────────────────────────────────────────
|
| 239 |
+
|
| 240 |
+
function rmsDbfs(samples: Float32Array<ArrayBuffer>): number {
|
| 241 |
+
let sum = 0;
|
| 242 |
+
for (let i = 0; i < samples.length; i++) {
|
| 243 |
+
const s = samples[i];
|
| 244 |
+
sum += s * s;
|
| 245 |
+
}
|
| 246 |
+
const rms = Math.sqrt(sum / Math.max(1, samples.length));
|
| 247 |
+
if (rms <= 1e-8) return -120;
|
| 248 |
+
return 20 * Math.log10(rms);
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
function loudnessGain(db: number): number {
|
| 252 |
+
// Linear remap [SWAY_DB_LOW..SWAY_DB_HIGH] -> [0..1] with a gamma.
|
| 253 |
+
const norm = (db - SWAY_DB_LOW) / (SWAY_DB_HIGH - SWAY_DB_LOW);
|
| 254 |
+
const clamped = Math.max(0, Math.min(1, norm));
|
| 255 |
+
return Math.pow(clamped, SWAY_DB_GAMMA);
|
| 256 |
+
}
|
src/move-player.ts
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Move player - streams Reachy Mini recorded choreographies on the WebRTC
|
| 3 |
+
* data channel as `set_full_target` frames.
|
| 4 |
+
*
|
| 5 |
+
* Why not just ask the daemon to play a move by name?
|
| 6 |
+
* The daemon *does* expose `POST /api/move/play/recorded-move-dataset/{ds}/
|
| 7 |
+
* {move}` which plays a RecordedMove internally (see `move.py` on the
|
| 8 |
+
* daemon, and `pollen-robotics/reachy-mini-{dances,emotions}-library` HF
|
| 9 |
+
* datasets it preloads at boot). But that endpoint is HTTP on the robot's
|
| 10 |
+
* LAN, which a browser running on `https://*.hf.space` can't reach (mixed
|
| 11 |
+
* content, no DNS). The WebRTC data channel protocol does not have a
|
| 12 |
+
* `play_dance` command either (`PlayMoveTaskRequest not yet implemented
|
| 13 |
+
* over WS`). So we replicate what the daemon does internally: fetch the
|
| 14 |
+
* move JSON from the public HF CDN, evaluate it client-side at 100 Hz,
|
| 15 |
+
* and stream each frame as `set_full_target`.
|
| 16 |
+
*
|
| 17 |
+
* What the JSON looks like (recorded move, see `RecordedMove` in
|
| 18 |
+
* `reachy_mini/motion/recorded_move.py`):
|
| 19 |
+
* {
|
| 20 |
+
* description: string,
|
| 21 |
+
* time: number[], // seconds, monotonically increasing
|
| 22 |
+
* set_target_data: Array<{
|
| 23 |
+
* head: number[4][4], // 4x4 pose (last row may be [0,0,0,0] in some files)
|
| 24 |
+
* antennas: [right_rad, left_rad],
|
| 25 |
+
* body_yaw: number,
|
| 26 |
+
* check_collision: boolean | null,
|
| 27 |
+
* }>
|
| 28 |
+
* }
|
| 29 |
+
*
|
| 30 |
+
* Two gotchas this module handles:
|
| 31 |
+
* 1. The datasets in the wild have `[0,0,0,0]` as the last row of `head`
|
| 32 |
+
* instead of `[0,0,0,1]`. We clean that up before sending so the wire
|
| 33 |
+
* payload is a proper homogeneous matrix.
|
| 34 |
+
* 2. Rotations must be interpolated on the rotation group (SE(3)), not
|
| 35 |
+
* elementwise - otherwise the intermediate matrices aren't orthogonal
|
| 36 |
+
* and the IK picks up weird shear. We port the daemon's
|
| 37 |
+
* `linear_pose_interpolation` (axis-angle slerp) to TypeScript.
|
| 38 |
+
*/
|
| 39 |
+
|
| 40 |
+
import type { ReachyMiniInstance } from "./globals.d.ts";
|
| 41 |
+
|
| 42 |
+
const HF_DATASET_BASE = "https://huggingface.co/datasets";
|
| 43 |
+
const STREAM_HZ = 100;
|
| 44 |
+
|
| 45 |
+
// ─── Curated catalog ────────────────────────────────────────────────────
|
| 46 |
+
|
| 47 |
+
/**
|
| 48 |
+
* A single entry the LLM can pick from. `id` is the opaque identifier used
|
| 49 |
+
* in tool calls; we map it to a dataset + file stem at playback time.
|
| 50 |
+
*/
|
| 51 |
+
export interface MoveCatalogEntry {
|
| 52 |
+
readonly id: string;
|
| 53 |
+
readonly kind: "dance" | "emotion";
|
| 54 |
+
readonly dataset: string;
|
| 55 |
+
readonly file: string;
|
| 56 |
+
readonly description: string;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
/**
|
| 60 |
+
* Curated selection exposed to the model: a handful of dances for
|
| 61 |
+
* rhythmic/punctuating moments, and a handful of emotions for reactive
|
| 62 |
+
* body language. Ordered roughly by how often we expect them to be useful.
|
| 63 |
+
*/
|
| 64 |
+
export const MOVE_CATALOG: readonly MoveCatalogEntry[] = [
|
| 65 |
+
// Dances - rhythmic, expressive, ~1-2s
|
| 66 |
+
{
|
| 67 |
+
id: "yeah_nod",
|
| 68 |
+
kind: "dance",
|
| 69 |
+
dataset: "pollen-robotics/reachy-mini-dances-library",
|
| 70 |
+
file: "yeah_nod",
|
| 71 |
+
description: "A confident, rhythmic nod - agreement, affirmation.",
|
| 72 |
+
},
|
| 73 |
+
{
|
| 74 |
+
id: "uh_huh_tilt",
|
| 75 |
+
kind: "dance",
|
| 76 |
+
dataset: "pollen-robotics/reachy-mini-dances-library",
|
| 77 |
+
file: "uh_huh_tilt",
|
| 78 |
+
description: "Curious acknowledgement, small sidelong tilt.",
|
| 79 |
+
},
|
| 80 |
+
{
|
| 81 |
+
id: "side_peekaboo",
|
| 82 |
+
kind: "dance",
|
| 83 |
+
dataset: "pollen-robotics/reachy-mini-dances-library",
|
| 84 |
+
file: "side_peekaboo",
|
| 85 |
+
description: "Playful peek from the side - teasing moments.",
|
| 86 |
+
},
|
| 87 |
+
{
|
| 88 |
+
id: "dizzy_spin",
|
| 89 |
+
kind: "dance",
|
| 90 |
+
dataset: "pollen-robotics/reachy-mini-dances-library",
|
| 91 |
+
file: "dizzy_spin",
|
| 92 |
+
description: "Dizzy spin - for lighthearted, silly replies.",
|
| 93 |
+
},
|
| 94 |
+
{
|
| 95 |
+
id: "groovy_sway",
|
| 96 |
+
kind: "dance",
|
| 97 |
+
dataset: "pollen-robotics/reachy-mini-dances-library",
|
| 98 |
+
file: "groovy_sway_and_roll",
|
| 99 |
+
description: "Grooving sway and roll - when the conversation gets musical.",
|
| 100 |
+
},
|
| 101 |
+
{
|
| 102 |
+
id: "chicken_peck",
|
| 103 |
+
kind: "dance",
|
| 104 |
+
dataset: "pollen-robotics/reachy-mini-dances-library",
|
| 105 |
+
file: "chicken_peck",
|
| 106 |
+
description: "Sharp forward pecks - lively and percussive.",
|
| 107 |
+
},
|
| 108 |
+
|
| 109 |
+
// Emotions - reactive, affective, 1-4s
|
| 110 |
+
{
|
| 111 |
+
id: "cheerful",
|
| 112 |
+
kind: "emotion",
|
| 113 |
+
dataset: "pollen-robotics/reachy-mini-emotions-library",
|
| 114 |
+
file: "cheerful1",
|
| 115 |
+
description: "Bright, upbeat body language - praise, small wins.",
|
| 116 |
+
},
|
| 117 |
+
{
|
| 118 |
+
id: "surprised",
|
| 119 |
+
kind: "emotion",
|
| 120 |
+
dataset: "pollen-robotics/reachy-mini-emotions-library",
|
| 121 |
+
file: "surprised1",
|
| 122 |
+
description: "Startled, a tiny recoil - when something unexpected happens.",
|
| 123 |
+
},
|
| 124 |
+
{
|
| 125 |
+
id: "curious",
|
| 126 |
+
kind: "emotion",
|
| 127 |
+
dataset: "pollen-robotics/reachy-mini-emotions-library",
|
| 128 |
+
file: "curious1",
|
| 129 |
+
description: "Inquisitive gaze shift - investigating, asking.",
|
| 130 |
+
},
|
| 131 |
+
{
|
| 132 |
+
id: "amazed",
|
| 133 |
+
kind: "emotion",
|
| 134 |
+
dataset: "pollen-robotics/reachy-mini-emotions-library",
|
| 135 |
+
file: "amazed1",
|
| 136 |
+
description: "In awe, long upward look - genuine wonder.",
|
| 137 |
+
},
|
| 138 |
+
{
|
| 139 |
+
id: "confused",
|
| 140 |
+
kind: "emotion",
|
| 141 |
+
dataset: "pollen-robotics/reachy-mini-emotions-library",
|
| 142 |
+
file: "confused1",
|
| 143 |
+
description: "Hesitant head tilt - when something doesn't compute.",
|
| 144 |
+
},
|
| 145 |
+
{
|
| 146 |
+
id: "proud",
|
| 147 |
+
kind: "emotion",
|
| 148 |
+
dataset: "pollen-robotics/reachy-mini-emotions-library",
|
| 149 |
+
file: "proud1",
|
| 150 |
+
description: "Chin up, chest out - taking credit, feeling good.",
|
| 151 |
+
},
|
| 152 |
+
{
|
| 153 |
+
id: "shy",
|
| 154 |
+
kind: "emotion",
|
| 155 |
+
dataset: "pollen-robotics/reachy-mini-emotions-library",
|
| 156 |
+
file: "shy1",
|
| 157 |
+
description: "Small look-away - embarrassed, modest.",
|
| 158 |
+
},
|
| 159 |
+
{
|
| 160 |
+
id: "sad",
|
| 161 |
+
kind: "emotion",
|
| 162 |
+
dataset: "pollen-robotics/reachy-mini-emotions-library",
|
| 163 |
+
file: "sad1",
|
| 164 |
+
description: "Subtle downward look - sympathy, bad news.",
|
| 165 |
+
},
|
| 166 |
+
];
|
| 167 |
+
|
| 168 |
+
export type MoveId = (typeof MOVE_CATALOG)[number]["id"];
|
| 169 |
+
|
| 170 |
+
export const MOVE_IDS: readonly MoveId[] = MOVE_CATALOG.map((m) => m.id);
|
| 171 |
+
|
| 172 |
+
function findMove(id: string): MoveCatalogEntry | undefined {
|
| 173 |
+
return MOVE_CATALOG.find((m) => m.id === id);
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
// ─── File format & loading ──────────────────────────────────────────────
|
| 177 |
+
|
| 178 |
+
interface RawFrame {
|
| 179 |
+
head: number[][];
|
| 180 |
+
antennas: [number, number];
|
| 181 |
+
body_yaw: number;
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
interface MoveFile {
|
| 185 |
+
description: string;
|
| 186 |
+
time: number[];
|
| 187 |
+
set_target_data: RawFrame[];
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
interface LoadedMove {
|
| 191 |
+
readonly id: MoveId;
|
| 192 |
+
readonly description: string;
|
| 193 |
+
readonly duration: number;
|
| 194 |
+
readonly times: readonly number[];
|
| 195 |
+
readonly frames: readonly RawFrame[];
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
// ─── SE(3) interpolation ────────────────────────────────────────────────
|
| 199 |
+
//
|
| 200 |
+
// Ported from `linear_pose_interpolation` in
|
| 201 |
+
// reachy_mini/utils/interpolation.py. Rotations are slerp'd via quaternions;
|
| 202 |
+
// translations are linearly interpolated.
|
| 203 |
+
|
| 204 |
+
type Quat = readonly [number, number, number, number]; // [x, y, z, w]
|
| 205 |
+
|
| 206 |
+
/** Extract a unit quaternion from the top-left 3x3 of a 4x4 homogeneous matrix. */
|
| 207 |
+
function quatFromMatrix(m: readonly number[][]): Quat {
|
| 208 |
+
const m00 = m[0][0], m01 = m[0][1], m02 = m[0][2];
|
| 209 |
+
const m10 = m[1][0], m11 = m[1][1], m12 = m[1][2];
|
| 210 |
+
const m20 = m[2][0], m21 = m[2][1], m22 = m[2][2];
|
| 211 |
+
const trace = m00 + m11 + m22;
|
| 212 |
+
let x: number, y: number, z: number, w: number;
|
| 213 |
+
if (trace > 0) {
|
| 214 |
+
const s = 0.5 / Math.sqrt(trace + 1.0);
|
| 215 |
+
w = 0.25 / s;
|
| 216 |
+
x = (m21 - m12) * s;
|
| 217 |
+
y = (m02 - m20) * s;
|
| 218 |
+
z = (m10 - m01) * s;
|
| 219 |
+
} else if (m00 > m11 && m00 > m22) {
|
| 220 |
+
const s = 2.0 * Math.sqrt(1.0 + m00 - m11 - m22);
|
| 221 |
+
w = (m21 - m12) / s;
|
| 222 |
+
x = 0.25 * s;
|
| 223 |
+
y = (m01 + m10) / s;
|
| 224 |
+
z = (m02 + m20) / s;
|
| 225 |
+
} else if (m11 > m22) {
|
| 226 |
+
const s = 2.0 * Math.sqrt(1.0 + m11 - m00 - m22);
|
| 227 |
+
w = (m02 - m20) / s;
|
| 228 |
+
x = (m01 + m10) / s;
|
| 229 |
+
y = 0.25 * s;
|
| 230 |
+
z = (m12 + m21) / s;
|
| 231 |
+
} else {
|
| 232 |
+
const s = 2.0 * Math.sqrt(1.0 + m22 - m00 - m11);
|
| 233 |
+
w = (m10 - m01) / s;
|
| 234 |
+
x = (m02 + m20) / s;
|
| 235 |
+
y = (m12 + m21) / s;
|
| 236 |
+
z = 0.25 * s;
|
| 237 |
+
}
|
| 238 |
+
const len = Math.sqrt(x * x + y * y + z * z + w * w) || 1;
|
| 239 |
+
return [x / len, y / len, z / len, w / len];
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
/** Convert a unit quaternion to a 3x3 rotation matrix (flat row-major). */
|
| 243 |
+
function quatToRot3(q: Quat): number[] {
|
| 244 |
+
const [x, y, z, w] = q;
|
| 245 |
+
const xx = x * x, yy = y * y, zz = z * z;
|
| 246 |
+
const xy = x * y, xz = x * z, yz = y * z;
|
| 247 |
+
const wx = w * x, wy = w * y, wz = w * z;
|
| 248 |
+
return [
|
| 249 |
+
1 - 2 * (yy + zz), 2 * (xy - wz), 2 * (xz + wy),
|
| 250 |
+
2 * (xy + wz), 1 - 2 * (xx + zz), 2 * (yz - wx),
|
| 251 |
+
2 * (xz - wy), 2 * (yz + wx), 1 - 2 * (xx + yy),
|
| 252 |
+
];
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
/** Spherical linear interpolation between two unit quaternions. */
|
| 256 |
+
function quatSlerp(a: Quat, b: Quat, t: number): Quat {
|
| 257 |
+
let [ax, ay, az, aw] = a;
|
| 258 |
+
let [bx, by, bz, bw] = b;
|
| 259 |
+
// Pick the shorter arc by flipping b if needed.
|
| 260 |
+
let dot = ax * bx + ay * by + az * bz + aw * bw;
|
| 261 |
+
if (dot < 0) {
|
| 262 |
+
bx = -bx; by = -by; bz = -bz; bw = -bw;
|
| 263 |
+
dot = -dot;
|
| 264 |
+
}
|
| 265 |
+
// Fall back to lerp when quaternions are nearly colinear, to avoid div/0.
|
| 266 |
+
if (dot > 0.9995) {
|
| 267 |
+
const x = ax + (bx - ax) * t;
|
| 268 |
+
const y = ay + (by - ay) * t;
|
| 269 |
+
const z = az + (bz - az) * t;
|
| 270 |
+
const w = aw + (bw - aw) * t;
|
| 271 |
+
const len = Math.sqrt(x * x + y * y + z * z + w * w) || 1;
|
| 272 |
+
return [x / len, y / len, z / len, w / len];
|
| 273 |
+
}
|
| 274 |
+
const theta0 = Math.acos(Math.min(1, Math.max(-1, dot)));
|
| 275 |
+
const sinTheta0 = Math.sin(theta0);
|
| 276 |
+
const theta = theta0 * t;
|
| 277 |
+
const s1 = Math.sin(theta) / sinTheta0;
|
| 278 |
+
const s0 = Math.cos(theta) - dot * s1;
|
| 279 |
+
return [
|
| 280 |
+
ax * s0 + bx * s1,
|
| 281 |
+
ay * s0 + by * s1,
|
| 282 |
+
az * s0 + bz * s1,
|
| 283 |
+
aw * s0 + bw * s1,
|
| 284 |
+
];
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
/**
|
| 288 |
+
* Interpolate a full 4x4 pose at parameter `t` in [0, 1] between two
|
| 289 |
+
* recorded frames. Output is a flat 16-float row-major homogeneous matrix
|
| 290 |
+
* with a well-formed `[0,0,0,1]` last row.
|
| 291 |
+
*/
|
| 292 |
+
function interpolateHeadFlat(
|
| 293 |
+
a: readonly number[][],
|
| 294 |
+
b: readonly number[][],
|
| 295 |
+
t: number,
|
| 296 |
+
): number[] {
|
| 297 |
+
const rot = quatToRot3(quatSlerp(quatFromMatrix(a), quatFromMatrix(b), t));
|
| 298 |
+
// Translation is always 0 in the recorded dances, but we still lerp in
|
| 299 |
+
// case a future dataset uses it.
|
| 300 |
+
const tx = a[0][3] + (b[0][3] - a[0][3]) * t;
|
| 301 |
+
const ty = a[1][3] + (b[1][3] - a[1][3]) * t;
|
| 302 |
+
const tz = a[2][3] + (b[2][3] - a[2][3]) * t;
|
| 303 |
+
return [
|
| 304 |
+
rot[0], rot[1], rot[2], tx,
|
| 305 |
+
rot[3], rot[4], rot[5], ty,
|
| 306 |
+
rot[6], rot[7], rot[8], tz,
|
| 307 |
+
0, 0, 0, 1,
|
| 308 |
+
];
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
/** Same cleanup applied to a frame served as-is (no interpolation). */
|
| 312 |
+
function headFlatFromFrame(m: readonly number[][]): number[] {
|
| 313 |
+
// Re-normalize the rotation block via quat roundtrip - this also scrubs
|
| 314 |
+
// the degenerate `[0,0,0,0]` last row found in the dataset.
|
| 315 |
+
const rot = quatToRot3(quatFromMatrix(m));
|
| 316 |
+
return [
|
| 317 |
+
rot[0], rot[1], rot[2], m[0][3] ?? 0,
|
| 318 |
+
rot[3], rot[4], rot[5], m[1][3] ?? 0,
|
| 319 |
+
rot[6], rot[7], rot[8], m[2][3] ?? 0,
|
| 320 |
+
0, 0, 0, 1,
|
| 321 |
+
];
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
// ─── Player ─────────────────────────────────────────────────────────────
|
| 325 |
+
|
| 326 |
+
/**
|
| 327 |
+
* Streams moves to the robot one at a time. Load-once / play-many: fetched
|
| 328 |
+
* JSON is cached in memory for the session.
|
| 329 |
+
*/
|
| 330 |
+
export class MovePlayer {
|
| 331 |
+
private readonly robot: ReachyMiniInstance;
|
| 332 |
+
private readonly cache = new Map<string, LoadedMove>();
|
| 333 |
+
private timer: number | null = null;
|
| 334 |
+
private current: { move: LoadedMove; t0: number } | null = null;
|
| 335 |
+
private onFinish: (() => void) | null = null;
|
| 336 |
+
|
| 337 |
+
constructor(robot: ReachyMiniInstance) {
|
| 338 |
+
this.robot = robot;
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
/** True while a move is actively streaming. */
|
| 342 |
+
get isPlaying(): boolean {
|
| 343 |
+
return this.current !== null;
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
/**
|
| 347 |
+
* Fetch (or hit cache) the trajectory JSON and return the parsed move.
|
| 348 |
+
* Safe to call ahead of time to prewarm a move that's likely to play.
|
| 349 |
+
*/
|
| 350 |
+
async load(id: MoveId): Promise<LoadedMove> {
|
| 351 |
+
const cached = this.cache.get(id);
|
| 352 |
+
if (cached) return cached;
|
| 353 |
+
|
| 354 |
+
const entry = findMove(id);
|
| 355 |
+
if (!entry) throw new Error(`Unknown move id '${id}'`);
|
| 356 |
+
|
| 357 |
+
const url = `${HF_DATASET_BASE}/${entry.dataset}/resolve/main/${encodeURIComponent(entry.file)}.json`;
|
| 358 |
+
const response = await fetch(url);
|
| 359 |
+
if (!response.ok) {
|
| 360 |
+
throw new Error(
|
| 361 |
+
`Failed to fetch move '${id}' from ${entry.dataset}: ${response.status} ${response.statusText}`,
|
| 362 |
+
);
|
| 363 |
+
}
|
| 364 |
+
const raw = (await response.json()) as MoveFile;
|
| 365 |
+
if (!Array.isArray(raw.time) || !Array.isArray(raw.set_target_data)) {
|
| 366 |
+
throw new Error(`Malformed move file '${id}'`);
|
| 367 |
+
}
|
| 368 |
+
if (raw.time.length !== raw.set_target_data.length) {
|
| 369 |
+
throw new Error(`Move '${id}' has mismatched time/frame lengths`);
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
const move: LoadedMove = {
|
| 373 |
+
id,
|
| 374 |
+
description: raw.description ?? entry.description,
|
| 375 |
+
times: raw.time.slice(),
|
| 376 |
+
frames: raw.set_target_data,
|
| 377 |
+
duration: raw.time[raw.time.length - 1] ?? 0,
|
| 378 |
+
};
|
| 379 |
+
this.cache.set(id, move);
|
| 380 |
+
return move;
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
/**
|
| 384 |
+
* Play a move. If another move is already streaming it is cancelled
|
| 385 |
+
* first. Resolves when the move finishes (or is cancelled via `stop()`).
|
| 386 |
+
*/
|
| 387 |
+
async play(id: MoveId): Promise<void> {
|
| 388 |
+
const move = await this.load(id);
|
| 389 |
+
this.stop();
|
| 390 |
+
|
| 391 |
+
return new Promise<void>((resolve) => {
|
| 392 |
+
this.onFinish = resolve;
|
| 393 |
+
this.current = { move, t0: performance.now() / 1000 };
|
| 394 |
+
this.timer = window.setInterval(() => this.tick(), 1000 / STREAM_HZ);
|
| 395 |
+
});
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
/** Abort playback immediately. Safe to call when idle. */
|
| 399 |
+
stop(): void {
|
| 400 |
+
if (this.timer !== null) {
|
| 401 |
+
clearInterval(this.timer);
|
| 402 |
+
this.timer = null;
|
| 403 |
+
}
|
| 404 |
+
const cb = this.onFinish;
|
| 405 |
+
this.current = null;
|
| 406 |
+
this.onFinish = null;
|
| 407 |
+
if (cb) cb();
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
// ─── internals ──────────────────────────────────────────────────────
|
| 411 |
+
|
| 412 |
+
private tick(): void {
|
| 413 |
+
if (!this.current) return;
|
| 414 |
+
const { move, t0 } = this.current;
|
| 415 |
+
const t = performance.now() / 1000 - t0;
|
| 416 |
+
|
| 417 |
+
if (t >= move.duration) {
|
| 418 |
+
// Snap to the final frame for a clean landing before stopping.
|
| 419 |
+
const last = move.frames[move.frames.length - 1];
|
| 420 |
+
this.sendFrame(headFlatFromFrame(last.head), last.antennas, last.body_yaw);
|
| 421 |
+
this.stop();
|
| 422 |
+
return;
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
const sample = sampleAt(move, t);
|
| 426 |
+
this.sendFrame(sample.head, sample.antennas, sample.body_yaw);
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
private sendFrame(
|
| 430 |
+
headFlat: number[],
|
| 431 |
+
antennas: readonly [number, number],
|
| 432 |
+
bodyYaw: number,
|
| 433 |
+
): void {
|
| 434 |
+
this.robot.sendRaw({
|
| 435 |
+
type: "set_full_target",
|
| 436 |
+
head: headFlat,
|
| 437 |
+
antennas: [antennas[0], antennas[1]],
|
| 438 |
+
body_yaw: bodyYaw,
|
| 439 |
+
});
|
| 440 |
+
}
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
// ─── Sampling ───────────────────────────────────────────────────────────
|
| 444 |
+
|
| 445 |
+
interface SampledFrame {
|
| 446 |
+
head: number[]; // flat row-major 4x4
|
| 447 |
+
antennas: [number, number];
|
| 448 |
+
body_yaw: number;
|
| 449 |
+
}
|
| 450 |
+
|
| 451 |
+
function sampleAt(move: LoadedMove, t: number): SampledFrame {
|
| 452 |
+
const { times, frames } = move;
|
| 453 |
+
const n = times.length;
|
| 454 |
+
if (n === 0) {
|
| 455 |
+
return { head: identity4Flat(), antennas: [0, 0], body_yaw: 0 };
|
| 456 |
+
}
|
| 457 |
+
if (t <= times[0]) {
|
| 458 |
+
return {
|
| 459 |
+
head: headFlatFromFrame(frames[0].head),
|
| 460 |
+
antennas: frames[0].antennas,
|
| 461 |
+
body_yaw: frames[0].body_yaw,
|
| 462 |
+
};
|
| 463 |
+
}
|
| 464 |
+
if (t >= times[n - 1]) {
|
| 465 |
+
const last = frames[n - 1];
|
| 466 |
+
return {
|
| 467 |
+
head: headFlatFromFrame(last.head),
|
| 468 |
+
antennas: last.antennas,
|
| 469 |
+
body_yaw: last.body_yaw,
|
| 470 |
+
};
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
// Binary search for the bracket [i, i+1] with times[i] <= t < times[i+1].
|
| 474 |
+
let lo = 0;
|
| 475 |
+
let hi = n - 1;
|
| 476 |
+
while (lo + 1 < hi) {
|
| 477 |
+
const mid = (lo + hi) >> 1;
|
| 478 |
+
if (times[mid] <= t) lo = mid;
|
| 479 |
+
else hi = mid;
|
| 480 |
+
}
|
| 481 |
+
const ta = times[lo];
|
| 482 |
+
const tb = times[lo + 1];
|
| 483 |
+
const alpha = tb > ta ? (t - ta) / (tb - ta) : 0;
|
| 484 |
+
|
| 485 |
+
const a = frames[lo];
|
| 486 |
+
const b = frames[lo + 1];
|
| 487 |
+
return {
|
| 488 |
+
head: interpolateHeadFlat(a.head, b.head, alpha),
|
| 489 |
+
antennas: [
|
| 490 |
+
a.antennas[0] + (b.antennas[0] - a.antennas[0]) * alpha,
|
| 491 |
+
a.antennas[1] + (b.antennas[1] - a.antennas[1]) * alpha,
|
| 492 |
+
],
|
| 493 |
+
body_yaw: a.body_yaw + (b.body_yaw - a.body_yaw) * alpha,
|
| 494 |
+
};
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
function identity4Flat(): number[] {
|
| 498 |
+
return [
|
| 499 |
+
1, 0, 0, 0,
|
| 500 |
+
0, 1, 0, 0,
|
| 501 |
+
0, 0, 1, 0,
|
| 502 |
+
0, 0, 0, 1,
|
| 503 |
+
];
|
| 504 |
+
}
|
src/openai-realtime.ts
ADDED
|
@@ -0,0 +1,611 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Minimal OpenAI Realtime WebRTC client.
|
| 3 |
+
*
|
| 4 |
+
* We let the caller provide the input audio `MediaStreamTrack` (the robot's
|
| 5 |
+
* microphone, received on a separate RTCPeerConnection) and expose the remote
|
| 6 |
+
* track produced by OpenAI (synthesized voice) via the `outputTrack` event so
|
| 7 |
+
* it can be routed to the robot's speakers with `sender.replaceTrack()`.
|
| 8 |
+
*
|
| 9 |
+
* References:
|
| 10 |
+
* https://platform.openai.com/docs/guides/realtime-webrtc
|
| 11 |
+
* https://platform.openai.com/docs/api-reference/realtime-client-events
|
| 12 |
+
* https://platform.openai.com/docs/api-reference/realtime-server-events
|
| 13 |
+
*/
|
| 14 |
+
|
| 15 |
+
// OpenAI blocks direct browser calls to their Realtime endpoints (no CORS
|
| 16 |
+
// headers for user API keys). We therefore always hit a same-origin path
|
| 17 |
+
// (`/openai/v1/realtime`) which is forwarded to `api.openai.com` by:
|
| 18 |
+
// - Vite dev server (see vite.config.ts `server.proxy`) during `npm run dev`
|
| 19 |
+
// - nginx on HF Spaces (see nginx.conf `location /openai/`) in production.
|
| 20 |
+
// The Bearer token travels through the proxy untouched.
|
| 21 |
+
const REALTIME_BASE_URL = "/openai/v1/realtime";
|
| 22 |
+
|
| 23 |
+
// ─── WiFi robustness knobs ──────────────────────────────────────────────
|
| 24 |
+
// Peak bitrate we allow Opus to use on our uplink. 32 kbps is plenty for
|
| 25 |
+
// clean voice; capping avoids saturating a weak WiFi AP which causes
|
| 26 |
+
// bursty packet loss.
|
| 27 |
+
const MAX_AUDIO_BITRATE_BPS = 32_000;
|
| 28 |
+
// Hint to the browser's jitter buffer: we'd rather play audio ~150 ms
|
| 29 |
+
// late than chop it up on jitter spikes. Voice assistants are forgiving
|
| 30 |
+
// of a bit of latency.
|
| 31 |
+
const PLAYOUT_DELAY_HINT_S = 0.15;
|
| 32 |
+
// Opus fmtp knobs forced via SDP munging. FEC inline gives us ~5-15%
|
| 33 |
+
// loss resilience "for free", DTX off keeps the comfort-noise packets
|
| 34 |
+
// arriving continuously so our own VAD/wobbler never thinks the line
|
| 35 |
+
// went silent.
|
| 36 |
+
const OPUS_FMTP_OVERRIDES: Record<string, string> = {
|
| 37 |
+
minptime: "10",
|
| 38 |
+
useinbandfec: "1",
|
| 39 |
+
usedtx: "0",
|
| 40 |
+
stereo: "0",
|
| 41 |
+
"sprop-stereo": "0",
|
| 42 |
+
maxaveragebitrate: String(MAX_AUDIO_BITRATE_BPS),
|
| 43 |
+
};
|
| 44 |
+
// How long we tolerate an ICE `disconnected` state before giving up.
|
| 45 |
+
// WiFi blips often self-heal within ~2 s; we budget a bit more than that.
|
| 46 |
+
const ICE_DISCONNECT_GRACE_MS = 5_000;
|
| 47 |
+
|
| 48 |
+
export type RealtimeStatus =
|
| 49 |
+
| "idle"
|
| 50 |
+
| "connecting"
|
| 51 |
+
| "connected"
|
| 52 |
+
| "user-speaking"
|
| 53 |
+
| "processing"
|
| 54 |
+
| "ai-speaking"
|
| 55 |
+
| "closed"
|
| 56 |
+
| "error";
|
| 57 |
+
|
| 58 |
+
/**
|
| 59 |
+
* OpenAI Realtime tool definition (a.k.a. function calling). We only expose
|
| 60 |
+
* the subset of fields we actually populate; the API accepts more.
|
| 61 |
+
*/
|
| 62 |
+
export interface RealtimeTool {
|
| 63 |
+
name: string;
|
| 64 |
+
description: string;
|
| 65 |
+
parameters: Record<string, unknown>;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
export interface RealtimeToolCall {
|
| 69 |
+
callId: string;
|
| 70 |
+
name: string;
|
| 71 |
+
arguments: Record<string, unknown>;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
export interface RealtimeOptions {
|
| 75 |
+
apiKey: string;
|
| 76 |
+
model: string;
|
| 77 |
+
voice: string;
|
| 78 |
+
instructions: string;
|
| 79 |
+
inputTrack: MediaStreamTrack;
|
| 80 |
+
/** Optional list of tools exposed to the model. */
|
| 81 |
+
tools?: RealtimeTool[];
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
type EventMap = {
|
| 85 |
+
status: { status: RealtimeStatus };
|
| 86 |
+
outputTrack: { track: MediaStreamTrack };
|
| 87 |
+
transcript: { role: "user" | "assistant"; text: string; partial: boolean };
|
| 88 |
+
toolCall: RealtimeToolCall;
|
| 89 |
+
error: { error: unknown };
|
| 90 |
+
};
|
| 91 |
+
|
| 92 |
+
type Listener<K extends keyof EventMap> = (detail: EventMap[K]) => void;
|
| 93 |
+
|
| 94 |
+
export class OpenaiRealtimeClient {
|
| 95 |
+
private pc: RTCPeerConnection | null = null;
|
| 96 |
+
private dc: RTCDataChannel | null = null;
|
| 97 |
+
private listeners: { [K in keyof EventMap]?: Set<Listener<K>> } = {};
|
| 98 |
+
private status: RealtimeStatus = "idle";
|
| 99 |
+
// Debounces transient ICE disconnects so a brief WiFi blip doesn't
|
| 100 |
+
// bubble up as an error all the way to the UI.
|
| 101 |
+
private iceGraceTimer: number | null = null;
|
| 102 |
+
// One-shot visibilitychange listener used when we disconnect while the
|
| 103 |
+
// tab is hidden: we re-arm the grace timer only once the tab is active
|
| 104 |
+
// so throttled timers can't cause spurious errors.
|
| 105 |
+
private pendingVisibilityHandler: (() => void) | null = null;
|
| 106 |
+
|
| 107 |
+
readonly options: RealtimeOptions;
|
| 108 |
+
|
| 109 |
+
constructor(options: RealtimeOptions) {
|
| 110 |
+
this.options = options;
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
on<K extends keyof EventMap>(event: K, listener: Listener<K>): () => void {
|
| 114 |
+
let set = this.listeners[event] as Set<Listener<K>> | undefined;
|
| 115 |
+
if (!set) {
|
| 116 |
+
set = new Set<Listener<K>>();
|
| 117 |
+
(this.listeners as Record<string, Set<Listener<K>>>)[event] = set;
|
| 118 |
+
}
|
| 119 |
+
set.add(listener);
|
| 120 |
+
return () => set?.delete(listener);
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
private emit<K extends keyof EventMap>(event: K, detail: EventMap[K]): void {
|
| 124 |
+
const set = this.listeners[event] as Set<Listener<K>> | undefined;
|
| 125 |
+
if (!set) return;
|
| 126 |
+
for (const listener of set) {
|
| 127 |
+
try {
|
| 128 |
+
listener(detail);
|
| 129 |
+
} catch (err) {
|
| 130 |
+
console.error("[openai-realtime] listener error:", err);
|
| 131 |
+
}
|
| 132 |
+
}
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
private setStatus(status: RealtimeStatus): void {
|
| 136 |
+
if (this.status === status) return;
|
| 137 |
+
this.status = status;
|
| 138 |
+
this.emit("status", { status });
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
/**
|
| 142 |
+
* Bump the state to "ai-speaking" from any pre-speaking phase. Called
|
| 143 |
+
* from whichever server event lands first to flag that Reachy has
|
| 144 |
+
* started talking. Deliberately permissive (processing → ai-speaking
|
| 145 |
+
* AND connected → ai-speaking) because depending on the transport we
|
| 146 |
+
* might skip the intermediate processing phase entirely.
|
| 147 |
+
*/
|
| 148 |
+
private markAudible(): void {
|
| 149 |
+
if (this.status === "ai-speaking") return;
|
| 150 |
+
if (this.status === "closed" || this.status === "error") return;
|
| 151 |
+
this.setStatus("ai-speaking");
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
/**
|
| 155 |
+
* Establish the WebRTC session with OpenAI. Resolves once the SDP exchange
|
| 156 |
+
* completes; the data channel may still be opening asynchronously.
|
| 157 |
+
*/
|
| 158 |
+
async connect(): Promise<void> {
|
| 159 |
+
if (this.pc) throw new Error("Already connected");
|
| 160 |
+
this.setStatus("connecting");
|
| 161 |
+
|
| 162 |
+
const pc = new RTCPeerConnection({
|
| 163 |
+
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
|
| 164 |
+
});
|
| 165 |
+
this.pc = pc;
|
| 166 |
+
|
| 167 |
+
pc.addTransceiver(this.options.inputTrack, {
|
| 168 |
+
direction: "sendrecv",
|
| 169 |
+
streams: [new MediaStream([this.options.inputTrack])],
|
| 170 |
+
});
|
| 171 |
+
|
| 172 |
+
pc.ontrack = (event) => {
|
| 173 |
+
const [stream] = event.streams;
|
| 174 |
+
const track = event.track.kind === "audio" ? event.track : stream?.getAudioTracks()[0];
|
| 175 |
+
if (track) {
|
| 176 |
+
// Ask the browser to buffer a bit of playout. This costs latency
|
| 177 |
+
// but trades it for resilience on jittery WiFi links.
|
| 178 |
+
try {
|
| 179 |
+
// `playoutDelayHint` is a non-standard but widely-shipped property.
|
| 180 |
+
(event.receiver as RTCRtpReceiver & { playoutDelayHint?: number }).playoutDelayHint =
|
| 181 |
+
PLAYOUT_DELAY_HINT_S;
|
| 182 |
+
} catch {
|
| 183 |
+
// Not fatal - older browsers just ignore the hint.
|
| 184 |
+
}
|
| 185 |
+
this.emit("outputTrack", { track });
|
| 186 |
+
}
|
| 187 |
+
};
|
| 188 |
+
|
| 189 |
+
pc.oniceconnectionstatechange = () => {
|
| 190 |
+
const s = pc.iceConnectionState;
|
| 191 |
+
if (s === "connected" || s === "completed") {
|
| 192 |
+
// We recovered before the grace timer fired - good.
|
| 193 |
+
this.clearIceGrace();
|
| 194 |
+
return;
|
| 195 |
+
}
|
| 196 |
+
if (s === "failed") {
|
| 197 |
+
this.clearIceGrace();
|
| 198 |
+
this.emit("error", { error: new Error("ICE failed") });
|
| 199 |
+
this.setStatus("error");
|
| 200 |
+
return;
|
| 201 |
+
}
|
| 202 |
+
if (s === "disconnected") {
|
| 203 |
+
// Wait a bit - WebRTC flips to `disconnected` on WiFi blips and
|
| 204 |
+
// self-heals once ICE finds packets again. Only escalate if it
|
| 205 |
+
// stays that way past the grace window.
|
| 206 |
+
//
|
| 207 |
+
// Background tabs: browsers throttle JS timers heavily so a 5 s
|
| 208 |
+
// setTimeout can fire minutes late, AND the native WebRTC stack
|
| 209 |
+
// keeps running so the connection often recovers by then. We
|
| 210 |
+
// pause the grace timer while the tab is hidden and wait for
|
| 211 |
+
// visibilitychange before deciding anything.
|
| 212 |
+
if (this.iceGraceTimer !== null) return;
|
| 213 |
+
if (typeof document !== "undefined" && document.hidden) {
|
| 214 |
+
console.warn(
|
| 215 |
+
"[openai-realtime] ICE disconnected while tab hidden — deferring grace timer until visibility returns.",
|
| 216 |
+
);
|
| 217 |
+
this.armIceGraceOnVisibility();
|
| 218 |
+
return;
|
| 219 |
+
}
|
| 220 |
+
this.scheduleIceGrace();
|
| 221 |
+
}
|
| 222 |
+
};
|
| 223 |
+
|
| 224 |
+
const dc = pc.createDataChannel("oai-events");
|
| 225 |
+
this.dc = dc;
|
| 226 |
+
|
| 227 |
+
dc.addEventListener("open", () => {
|
| 228 |
+
this.setStatus("connected");
|
| 229 |
+
const tools = (this.options.tools ?? []).map((t) => ({
|
| 230 |
+
type: "function" as const,
|
| 231 |
+
name: t.name,
|
| 232 |
+
description: t.description,
|
| 233 |
+
parameters: t.parameters,
|
| 234 |
+
}));
|
| 235 |
+
this.sendEvent({
|
| 236 |
+
type: "session.update",
|
| 237 |
+
session: {
|
| 238 |
+
modalities: ["audio", "text"],
|
| 239 |
+
voice: this.options.voice,
|
| 240 |
+
instructions: this.options.instructions,
|
| 241 |
+
input_audio_format: "pcm16",
|
| 242 |
+
output_audio_format: "pcm16",
|
| 243 |
+
input_audio_transcription: { model: "whisper-1" },
|
| 244 |
+
turn_detection: {
|
| 245 |
+
type: "server_vad",
|
| 246 |
+
threshold: 0.5,
|
| 247 |
+
prefix_padding_ms: 300,
|
| 248 |
+
silence_duration_ms: 500,
|
| 249 |
+
create_response: true,
|
| 250 |
+
interrupt_response: true,
|
| 251 |
+
},
|
| 252 |
+
...(tools.length ? { tools, tool_choice: "auto" } : {}),
|
| 253 |
+
},
|
| 254 |
+
});
|
| 255 |
+
});
|
| 256 |
+
|
| 257 |
+
dc.addEventListener("message", (event) => this.handleEvent(event.data));
|
| 258 |
+
dc.addEventListener("error", (event) => {
|
| 259 |
+
console.error("[openai-realtime] data channel error:", event);
|
| 260 |
+
});
|
| 261 |
+
|
| 262 |
+
const rawOffer = await pc.createOffer();
|
| 263 |
+
// Patch the offer SDP to force Opus FEC + a sane maxaveragebitrate.
|
| 264 |
+
// This tells OpenAI how to decode our uplink AND advertises our
|
| 265 |
+
// decoder's capabilities for the answer it sends back.
|
| 266 |
+
const offerSdp = patchOpusFmtp(rawOffer.sdp ?? "");
|
| 267 |
+
await pc.setLocalDescription({ type: "offer", sdp: offerSdp });
|
| 268 |
+
|
| 269 |
+
// Enforce the bitrate cap on our sender too - many impls ignore the
|
| 270 |
+
// SDP `maxaveragebitrate` without this.
|
| 271 |
+
capSenderBitrate(pc, MAX_AUDIO_BITRATE_BPS).catch((err) => {
|
| 272 |
+
console.warn("[openai-realtime] failed to cap sender bitrate:", err);
|
| 273 |
+
});
|
| 274 |
+
|
| 275 |
+
const url = `${REALTIME_BASE_URL}?model=${encodeURIComponent(this.options.model)}`;
|
| 276 |
+
const response = await fetch(url, {
|
| 277 |
+
method: "POST",
|
| 278 |
+
headers: {
|
| 279 |
+
Authorization: `Bearer ${this.options.apiKey}`,
|
| 280 |
+
"Content-Type": "application/sdp",
|
| 281 |
+
},
|
| 282 |
+
body: offerSdp,
|
| 283 |
+
});
|
| 284 |
+
|
| 285 |
+
if (!response.ok) {
|
| 286 |
+
const text = await response.text().catch(() => "");
|
| 287 |
+
throw new Error(`OpenAI Realtime handshake failed (${response.status}): ${text}`);
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
const rawAnswer = await response.text();
|
| 291 |
+
// Patch the answer too so our *decoder* honours the same FEC knobs.
|
| 292 |
+
const answerSdp = patchOpusFmtp(rawAnswer);
|
| 293 |
+
await pc.setRemoteDescription({ type: "answer", sdp: answerSdp });
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
private clearIceGrace(): void {
|
| 297 |
+
if (this.iceGraceTimer !== null) {
|
| 298 |
+
clearTimeout(this.iceGraceTimer);
|
| 299 |
+
this.iceGraceTimer = null;
|
| 300 |
+
}
|
| 301 |
+
if (this.pendingVisibilityHandler) {
|
| 302 |
+
document.removeEventListener("visibilitychange", this.pendingVisibilityHandler);
|
| 303 |
+
this.pendingVisibilityHandler = null;
|
| 304 |
+
}
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
/**
|
| 308 |
+
* Arm the ICE grace timer proper. Safe to call once we know the tab
|
| 309 |
+
* is in the foreground (timers aren't throttled).
|
| 310 |
+
*/
|
| 311 |
+
private scheduleIceGrace(): void {
|
| 312 |
+
if (this.iceGraceTimer !== null) return;
|
| 313 |
+
console.warn(
|
| 314 |
+
`[openai-realtime] ICE disconnected, waiting ${ICE_DISCONNECT_GRACE_MS}ms before giving up`,
|
| 315 |
+
);
|
| 316 |
+
this.iceGraceTimer = window.setTimeout(() => {
|
| 317 |
+
this.iceGraceTimer = null;
|
| 318 |
+
if (!this.pc) return;
|
| 319 |
+
const still = this.pc.iceConnectionState;
|
| 320 |
+
if (still === "disconnected" || still === "failed") {
|
| 321 |
+
this.emit("error", {
|
| 322 |
+
error: new Error(`ICE stuck in '${still}' for > ${ICE_DISCONNECT_GRACE_MS}ms`),
|
| 323 |
+
});
|
| 324 |
+
this.setStatus("error");
|
| 325 |
+
}
|
| 326 |
+
}, ICE_DISCONNECT_GRACE_MS);
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
/**
|
| 330 |
+
* We're disconnected AND the tab is hidden. Wait for visibility to
|
| 331 |
+
* return before deciding: the connection often self-heals while we're
|
| 332 |
+
* in the background, and JS timers there fire unpredictably late.
|
| 333 |
+
*/
|
| 334 |
+
private armIceGraceOnVisibility(): void {
|
| 335 |
+
if (this.pendingVisibilityHandler) return;
|
| 336 |
+
const handler = () => {
|
| 337 |
+
if (document.hidden) return;
|
| 338 |
+
document.removeEventListener("visibilitychange", handler);
|
| 339 |
+
this.pendingVisibilityHandler = null;
|
| 340 |
+
if (!this.pc) return;
|
| 341 |
+
const s = this.pc.iceConnectionState;
|
| 342 |
+
if (s === "connected" || s === "completed") return; // healed itself
|
| 343 |
+
if (s === "failed") {
|
| 344 |
+
this.emit("error", { error: new Error("ICE failed") });
|
| 345 |
+
this.setStatus("error");
|
| 346 |
+
return;
|
| 347 |
+
}
|
| 348 |
+
// Still disconnected when we came back - now give it the normal
|
| 349 |
+
// grace window with a foreground timer that'll actually fire.
|
| 350 |
+
this.scheduleIceGrace();
|
| 351 |
+
};
|
| 352 |
+
document.addEventListener("visibilitychange", handler);
|
| 353 |
+
this.pendingVisibilityHandler = handler;
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
private handleEvent(raw: string): void {
|
| 357 |
+
let event: { type?: string; [key: string]: unknown };
|
| 358 |
+
try {
|
| 359 |
+
event = JSON.parse(raw);
|
| 360 |
+
} catch {
|
| 361 |
+
return;
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
// Lightweight trace of every server event. Makes it trivial to see
|
| 365 |
+
// in DevTools whether the state-triggering events (speech_started,
|
| 366 |
+
// output_audio.delta, audio_transcript.delta, response.done, ...)
|
| 367 |
+
// actually reach the data channel for the current transport.
|
| 368 |
+
if (typeof event.type === "string") {
|
| 369 |
+
console.debug("[openai-realtime] event:", event.type);
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
switch (event.type) {
|
| 373 |
+
case "input_audio_buffer.speech_started":
|
| 374 |
+
this.setStatus("user-speaking");
|
| 375 |
+
break;
|
| 376 |
+
|
| 377 |
+
case "input_audio_buffer.speech_stopped":
|
| 378 |
+
// User stopped talking; the model is computing its reply and will
|
| 379 |
+
// start audio shortly. We surface this as `processing` so the UI
|
| 380 |
+
// can show a "thinking" visual until audio actually arrives.
|
| 381 |
+
if (this.status === "user-speaking") {
|
| 382 |
+
this.setStatus("processing");
|
| 383 |
+
}
|
| 384 |
+
break;
|
| 385 |
+
|
| 386 |
+
// ─── "Assistant is speaking" triggers ─────────────────────────
|
| 387 |
+
//
|
| 388 |
+
// Multiple events can mark the start of an audible reply, and
|
| 389 |
+
// which ones actually fire depends on the transport:
|
| 390 |
+
//
|
| 391 |
+
// - WebSocket: the audio itself streams as `response.output_audio.delta`
|
| 392 |
+
// events on the same channel.
|
| 393 |
+
// - WebRTC: the audio goes through the media track, not the
|
| 394 |
+
// data channel, so `output_audio.delta` frames may be sparse
|
| 395 |
+
// or missing entirely. But `output_audio_transcript.delta`
|
| 396 |
+
// and `content_part.added` with an output_audio part DO
|
| 397 |
+
// fire reliably over the data channel, so we listen to all
|
| 398 |
+
// of them and whichever lands first flips us to ai-speaking.
|
| 399 |
+
//
|
| 400 |
+
// Transcript deltas are handled in their own case below (they
|
| 401 |
+
// also need to emit a `transcript` event), so we duplicate the
|
| 402 |
+
// `markAudible()` call there rather than listing them here.
|
| 403 |
+
case "response.audio.delta":
|
| 404 |
+
case "response.output_audio.delta":
|
| 405 |
+
this.markAudible();
|
| 406 |
+
break;
|
| 407 |
+
|
| 408 |
+
case "response.content_part.added": {
|
| 409 |
+
const part = event.part as { type?: string } | undefined;
|
| 410 |
+
if (part?.type === "audio" || part?.type === "output_audio") {
|
| 411 |
+
this.markAudible();
|
| 412 |
+
}
|
| 413 |
+
break;
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
case "response.created":
|
| 417 |
+
case "response.output_item.added":
|
| 418 |
+
// No audio yet, just metadata; keep us in `processing` until
|
| 419 |
+
// we get a clearer "speaking" signal above.
|
| 420 |
+
if (this.status === "connected" || this.status === "user-speaking") {
|
| 421 |
+
this.setStatus("processing");
|
| 422 |
+
}
|
| 423 |
+
break;
|
| 424 |
+
|
| 425 |
+
case "response.done":
|
| 426 |
+
case "response.cancelled":
|
| 427 |
+
if (this.status === "ai-speaking" || this.status === "processing") {
|
| 428 |
+
this.setStatus("connected");
|
| 429 |
+
}
|
| 430 |
+
break;
|
| 431 |
+
|
| 432 |
+
case "conversation.item.input_audio_transcription.delta": {
|
| 433 |
+
const delta = typeof event.delta === "string" ? event.delta : "";
|
| 434 |
+
if (delta) {
|
| 435 |
+
this.emit("transcript", { role: "user", text: delta, partial: true });
|
| 436 |
+
}
|
| 437 |
+
break;
|
| 438 |
+
}
|
| 439 |
+
|
| 440 |
+
case "conversation.item.input_audio_transcription.completed": {
|
| 441 |
+
const transcript = typeof event.transcript === "string" ? event.transcript : "";
|
| 442 |
+
if (transcript) {
|
| 443 |
+
this.emit("transcript", { role: "user", text: transcript, partial: false });
|
| 444 |
+
}
|
| 445 |
+
break;
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
case "response.audio_transcript.delta":
|
| 449 |
+
case "response.output_audio_transcript.delta": {
|
| 450 |
+
// Transcript deltas fire continuously while the model streams
|
| 451 |
+
// audio. Over WebRTC they're our most reliable "Reachy is
|
| 452 |
+
// actually talking right now" signal because `output_audio.delta`
|
| 453 |
+
// metadata events don't always reach the data channel.
|
| 454 |
+
this.markAudible();
|
| 455 |
+
const delta = typeof event.delta === "string" ? event.delta : "";
|
| 456 |
+
if (delta) {
|
| 457 |
+
this.emit("transcript", { role: "assistant", text: delta, partial: true });
|
| 458 |
+
}
|
| 459 |
+
break;
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
case "response.audio_transcript.done":
|
| 463 |
+
case "response.output_audio_transcript.done": {
|
| 464 |
+
const transcript = typeof event.transcript === "string" ? event.transcript : "";
|
| 465 |
+
if (transcript) {
|
| 466 |
+
this.emit("transcript", { role: "assistant", text: transcript, partial: false });
|
| 467 |
+
}
|
| 468 |
+
break;
|
| 469 |
+
}
|
| 470 |
+
|
| 471 |
+
case "response.function_call_arguments.done": {
|
| 472 |
+
// The model finished streaming tool arguments; execute & reply.
|
| 473 |
+
const callId = typeof event.call_id === "string" ? event.call_id : "";
|
| 474 |
+
const name = typeof event.name === "string" ? event.name : "";
|
| 475 |
+
const argsRaw = typeof event.arguments === "string" ? event.arguments : "{}";
|
| 476 |
+
let args: Record<string, unknown> = {};
|
| 477 |
+
try {
|
| 478 |
+
args = JSON.parse(argsRaw);
|
| 479 |
+
} catch {
|
| 480 |
+
args = {};
|
| 481 |
+
}
|
| 482 |
+
if (callId && name) {
|
| 483 |
+
this.emit("toolCall", { callId, name, arguments: args });
|
| 484 |
+
}
|
| 485 |
+
break;
|
| 486 |
+
}
|
| 487 |
+
|
| 488 |
+
case "error": {
|
| 489 |
+
const err = event.error as { message?: string } | undefined;
|
| 490 |
+
this.emit("error", { error: new Error(err?.message ?? "OpenAI error") });
|
| 491 |
+
break;
|
| 492 |
+
}
|
| 493 |
+
}
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
private sendEvent(event: Record<string, unknown>): void {
|
| 497 |
+
if (!this.dc || this.dc.readyState !== "open") return;
|
| 498 |
+
this.dc.send(JSON.stringify(event));
|
| 499 |
+
}
|
| 500 |
+
|
| 501 |
+
/**
|
| 502 |
+
* Send the result of a previously received `toolCall` back to the model
|
| 503 |
+
* so it can continue the conversation. `output` will be JSON-stringified
|
| 504 |
+
* if it isn't a string already.
|
| 505 |
+
*/
|
| 506 |
+
sendToolResponse(callId: string, output: unknown): void {
|
| 507 |
+
const outputStr = typeof output === "string" ? output : JSON.stringify(output);
|
| 508 |
+
this.sendEvent({
|
| 509 |
+
type: "conversation.item.create",
|
| 510 |
+
item: {
|
| 511 |
+
type: "function_call_output",
|
| 512 |
+
call_id: callId,
|
| 513 |
+
output: outputStr,
|
| 514 |
+
},
|
| 515 |
+
});
|
| 516 |
+
// Ask the model to pick up from here and speak the reply.
|
| 517 |
+
this.sendEvent({ type: "response.create" });
|
| 518 |
+
}
|
| 519 |
+
|
| 520 |
+
async close(): Promise<void> {
|
| 521 |
+
this.clearIceGrace();
|
| 522 |
+
|
| 523 |
+
try {
|
| 524 |
+
this.dc?.close();
|
| 525 |
+
} catch {
|
| 526 |
+
// ignored
|
| 527 |
+
}
|
| 528 |
+
this.dc = null;
|
| 529 |
+
|
| 530 |
+
try {
|
| 531 |
+
this.pc?.close();
|
| 532 |
+
} catch {
|
| 533 |
+
// ignored
|
| 534 |
+
}
|
| 535 |
+
this.pc = null;
|
| 536 |
+
|
| 537 |
+
this.setStatus("closed");
|
| 538 |
+
}
|
| 539 |
+
}
|
| 540 |
+
|
| 541 |
+
// ─── Helpers ────────────────────────────────────────────────────────────
|
| 542 |
+
|
| 543 |
+
/**
|
| 544 |
+
* Rewrite all `a=fmtp:<pt> ...` lines for Opus payload types so that they
|
| 545 |
+
* include our required parameters (FEC on, DTX off, bitrate cap). Missing
|
| 546 |
+
* fmtp lines are synthesized next to the matching `a=rtpmap` line.
|
| 547 |
+
*/
|
| 548 |
+
function patchOpusFmtp(sdp: string): string {
|
| 549 |
+
if (!sdp) return sdp;
|
| 550 |
+
|
| 551 |
+
// Find every Opus payload type via its rtpmap entry.
|
| 552 |
+
const rtpmapRe = /^a=rtpmap:(\d+)\s+opus\/48000/gim;
|
| 553 |
+
const payloadTypes: string[] = [];
|
| 554 |
+
let m: RegExpExecArray | null;
|
| 555 |
+
while ((m = rtpmapRe.exec(sdp)) !== null) {
|
| 556 |
+
payloadTypes.push(m[1]);
|
| 557 |
+
}
|
| 558 |
+
if (payloadTypes.length === 0) return sdp;
|
| 559 |
+
|
| 560 |
+
let patched = sdp;
|
| 561 |
+
for (const pt of payloadTypes) {
|
| 562 |
+
const fmtpLineRe = new RegExp(`^a=fmtp:${pt}\\s+([^\\r\\n]*)`, "m");
|
| 563 |
+
const existing = fmtpLineRe.exec(patched);
|
| 564 |
+
if (existing) {
|
| 565 |
+
const merged = mergeFmtpParams(existing[1], OPUS_FMTP_OVERRIDES);
|
| 566 |
+
patched = patched.replace(fmtpLineRe, `a=fmtp:${pt} ${merged}`);
|
| 567 |
+
} else {
|
| 568 |
+
const merged = mergeFmtpParams("", OPUS_FMTP_OVERRIDES);
|
| 569 |
+
// Insert the new fmtp line right after the matching rtpmap.
|
| 570 |
+
const rtpmapLineRe = new RegExp(`(^a=rtpmap:${pt}\\s+opus/48000[^\\r\\n]*)`, "m");
|
| 571 |
+
patched = patched.replace(rtpmapLineRe, `$1\r\na=fmtp:${pt} ${merged}`);
|
| 572 |
+
}
|
| 573 |
+
}
|
| 574 |
+
return patched;
|
| 575 |
+
}
|
| 576 |
+
|
| 577 |
+
function mergeFmtpParams(existing: string, overrides: Record<string, string>): string {
|
| 578 |
+
const params = new Map<string, string>();
|
| 579 |
+
for (const chunk of existing.split(";")) {
|
| 580 |
+
const kv = chunk.trim();
|
| 581 |
+
if (!kv) continue;
|
| 582 |
+
const eq = kv.indexOf("=");
|
| 583 |
+
if (eq < 0) params.set(kv, "");
|
| 584 |
+
else params.set(kv.slice(0, eq).trim(), kv.slice(eq + 1).trim());
|
| 585 |
+
}
|
| 586 |
+
for (const [k, v] of Object.entries(overrides)) {
|
| 587 |
+
params.set(k, v);
|
| 588 |
+
}
|
| 589 |
+
return Array.from(params.entries())
|
| 590 |
+
.map(([k, v]) => (v ? `${k}=${v}` : k))
|
| 591 |
+
.join(";");
|
| 592 |
+
}
|
| 593 |
+
|
| 594 |
+
/**
|
| 595 |
+
* Cap the outbound audio sender's bitrate. The spec recommends setting
|
| 596 |
+
* `maxBitrate` on the first (and typically only) encoding.
|
| 597 |
+
*/
|
| 598 |
+
async function capSenderBitrate(pc: RTCPeerConnection, maxBitrate: number): Promise<void> {
|
| 599 |
+
const sender = pc
|
| 600 |
+
.getSenders()
|
| 601 |
+
.find((s) => s.track && s.track.kind === "audio");
|
| 602 |
+
if (!sender) return;
|
| 603 |
+
const params = sender.getParameters();
|
| 604 |
+
if (!params.encodings || params.encodings.length === 0) {
|
| 605 |
+
params.encodings = [{}];
|
| 606 |
+
}
|
| 607 |
+
for (const enc of params.encodings) {
|
| 608 |
+
enc.maxBitrate = maxBitrate;
|
| 609 |
+
}
|
| 610 |
+
await sender.setParameters(params);
|
| 611 |
+
}
|
src/robot-tools.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// src/robot-tools.ts — extracted from the reference app's main.ts
|
| 2 |
+
import {
|
| 3 |
+
MovePlayer,
|
| 4 |
+
MOVE_CATALOG,
|
| 5 |
+
MOVE_IDS,
|
| 6 |
+
type MoveId,
|
| 7 |
+
} from "./move-player.js";
|
| 8 |
+
import type { ReachyMiniInstance } from "./globals.d.ts";
|
| 9 |
+
import type { RealtimeTool } from "./openai-realtime.js";
|
| 10 |
+
|
| 11 |
+
const HEAD_POSES = {
|
| 12 |
+
center: { roll: 0, pitch: 0, yaw: 0 },
|
| 13 |
+
up: { roll: 0, pitch: -18, yaw: 0 },
|
| 14 |
+
down: { roll: 0, pitch: 18, yaw: 0 },
|
| 15 |
+
left: { roll: 0, pitch: 0, yaw: 25 },
|
| 16 |
+
right: { roll: 0, pitch: 0, yaw: -25 },
|
| 17 |
+
tilt_left: { roll: -15, pitch: 0, yaw: 0 },
|
| 18 |
+
tilt_right: { roll: 15, pitch: 0, yaw: 0 },
|
| 19 |
+
} as const;
|
| 20 |
+
|
| 21 |
+
export const ROBOT_TOOLS: RealtimeTool[] = [
|
| 22 |
+
{
|
| 23 |
+
name: "move_head",
|
| 24 |
+
description:
|
| 25 |
+
"Point the robot's head in a named direction. Use to accompany speech with a tiny gesture.",
|
| 26 |
+
parameters: {
|
| 27 |
+
type: "object",
|
| 28 |
+
properties: {
|
| 29 |
+
direction: { type: "string", enum: Object.keys(HEAD_POSES),
|
| 30 |
+
description: "Named head pose to assume." },
|
| 31 |
+
},
|
| 32 |
+
required: ["direction"],
|
| 33 |
+
},
|
| 34 |
+
},
|
| 35 |
+
{
|
| 36 |
+
name: "play_move",
|
| 37 |
+
description:
|
| 38 |
+
"Trigger a short pre-recorded body-language move (1-4s) from the Reachy library.\n" +
|
| 39 |
+
MOVE_CATALOG.map((m) => ` - ${m.id} | ${m.kind} | ${m.description}`).join("\n"),
|
| 40 |
+
parameters: {
|
| 41 |
+
type: "object",
|
| 42 |
+
properties: {
|
| 43 |
+
name: { type: "string", enum: [...MOVE_IDS],
|
| 44 |
+
description: "Catalog id." },
|
| 45 |
+
},
|
| 46 |
+
required: ["name"],
|
| 47 |
+
},
|
| 48 |
+
},
|
| 49 |
+
];
|
| 50 |
+
|
| 51 |
+
export class RobotToolDispatcher {
|
| 52 |
+
private movePlayer: MovePlayer;
|
| 53 |
+
constructor(robot: ReachyMiniInstance) {
|
| 54 |
+
this.movePlayer = new MovePlayer(robot);
|
| 55 |
+
}
|
| 56 |
+
async handle(robot: ReachyMiniInstance, name: string, args: Record<string, unknown>): Promise<string> {
|
| 57 |
+
if (name === "move_head") {
|
| 58 |
+
const dir = args.direction as keyof typeof HEAD_POSES;
|
| 59 |
+
const pose = HEAD_POSES[dir];
|
| 60 |
+
if (!pose) return `Unknown direction: ${args.direction}`;
|
| 61 |
+
robot.setHeadPose(pose.roll, pose.pitch, pose.yaw);
|
| 62 |
+
return `Head moved to ${dir}.`;
|
| 63 |
+
}
|
| 64 |
+
if (name === "play_move") {
|
| 65 |
+
const moveId = args.name as MoveId;
|
| 66 |
+
await this.movePlayer.play(moveId);
|
| 67 |
+
return `Played move: ${moveId}.`;
|
| 68 |
+
}
|
| 69 |
+
return `Unknown robot tool: ${name}`;
|
| 70 |
+
}
|
| 71 |
+
}
|
tsconfig.app.json
CHANGED
|
@@ -2,7 +2,8 @@
|
|
| 2 |
"extends": "./tsconfig.base.json",
|
| 3 |
"compilerOptions": {
|
| 4 |
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
| 5 |
-
"types": []
|
|
|
|
| 6 |
},
|
| 7 |
"include": ["src/**/*", "index.html"]
|
| 8 |
}
|
|
|
|
| 2 |
"extends": "./tsconfig.base.json",
|
| 3 |
"compilerOptions": {
|
| 4 |
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
| 5 |
+
"types": [],
|
| 6 |
+
"noUncheckedIndexedAccess": false
|
| 7 |
},
|
| 8 |
"include": ["src/**/*", "index.html"]
|
| 9 |
}
|