reachy-tales / src /antennas.ts
Nicolas Rabault
feat(app): copy plumbing (openai-realtime, antennas, head-wobbler, move-player, robot-tools) from reference app
47e59b7
Raw
History Blame Contribute Delete
3.59 kB
/**
* 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 };
}
}