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 files

Relax 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 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
  }