Spaces:
Running
Running
Nicolas Rabault
feat(app): copy plumbing (openai-realtime, antennas, head-wobbler, move-player, robot-tools) from reference app
47e59b7 | /** | |
| * Antennas oscillator - mirrors the `BreathingMove` antenna behaviour from | |
| * `reachy_mini_conversation_app/moves.py`: a gentle sinusoid in opposition | |
| * (right β βleft) at ~0.5 Hz with a Β±15Β° amplitude. The two ears "breathe" | |
| * asymmetrically which makes an idle robot feel alive. | |
| * | |
| * When the user starts speaking we freeze the antennas on their current | |
| * position (just like the Python `set_listening(True)` path), and smoothly | |
| * blend back into the oscillation once they stop. | |
| */ | |
| const FREQ_HZ = 0.5; | |
| const AMPLITUDE_DEG = 15; | |
| const BLEND_MS = 400; // time to smoothly ramp back after a freeze | |
| export interface AntennasOscillatorOptions { | |
| onAntennas: (right: number, left: number) => void; | |
| /** Update rate in Hz. 30 is plenty for smooth motion. Defaults to 30. */ | |
| updateHz?: number; | |
| } | |
| export class AntennasOscillator { | |
| private readonly onAntennas: (right: number, left: number) => void; | |
| private readonly periodMs: number; | |
| private timer: number | null = null; | |
| private t = 0; | |
| private frozen = false; | |
| private frozenRight = 0; | |
| private frozenLeft = 0; | |
| // 0..1 crossfader between frozen pose and live oscillation. | |
| private blend = 1; | |
| constructor(options: AntennasOscillatorOptions) { | |
| this.onAntennas = options.onAntennas; | |
| this.periodMs = 1000 / (options.updateHz ?? 30); | |
| } | |
| start(): void { | |
| if (this.timer !== null) return; | |
| this.t = 0; | |
| this.blend = 1; | |
| this.frozen = false; | |
| this.timer = window.setInterval(() => this.tick(), this.periodMs); | |
| } | |
| stop(): void { | |
| if (this.timer !== null) { | |
| clearInterval(this.timer); | |
| this.timer = null; | |
| } | |
| // Return to neutral so the ears don't stay stuck at a random angle. | |
| this.onAntennas(0, 0); | |
| } | |
| /** | |
| * Freeze the antennas on their current position. Used when the user is | |
| * speaking: the robot "listens attentively" instead of keeping its | |
| * idle animation. | |
| */ | |
| freeze(): void { | |
| if (this.frozen) return; | |
| this.frozen = true; | |
| // Snapshot the current oscillation output so the freeze is seamless. | |
| const { right, left } = this.currentOscillation(); | |
| this.frozenRight = right; | |
| this.frozenLeft = left; | |
| this.blend = 0; // fully on the frozen values | |
| } | |
| /** | |
| * Release the freeze; antennas ramp back into the oscillation over | |
| * ~BLEND_MS ms. | |
| */ | |
| resume(): void { | |
| if (!this.frozen) return; | |
| this.frozen = false; | |
| // `blend` will rise back to 1 via tick(). | |
| } | |
| // βββ Internals βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| private tick(): void { | |
| this.t += this.periodMs / 1000; | |
| // Update blend towards its target (0 when frozen, 1 when live). | |
| const target = this.frozen ? 0 : 1; | |
| const step = this.periodMs / BLEND_MS; | |
| if (this.blend < target) this.blend = Math.min(1, this.blend + step); | |
| else if (this.blend > target) this.blend = Math.max(0, this.blend - step); | |
| const { right: liveR, left: liveL } = this.currentOscillation(); | |
| const right = liveR * this.blend + this.frozenRight * (1 - this.blend); | |
| const left = liveL * this.blend + this.frozenLeft * (1 - this.blend); | |
| this.onAntennas(right, left); | |
| } | |
| private currentOscillation(): { right: number; left: number } { | |
| const phase = 2 * Math.PI * FREQ_HZ * this.t; | |
| const right = AMPLITUDE_DEG * Math.sin(phase); | |
| // Opposition: when right antenna goes "up", left goes "down". | |
| const left = -AMPLITUDE_DEG * Math.sin(phase); | |
| return { right, left }; | |
| } | |
| } | |