reachy-blocks / src /robot /virtual.ts
Claude
feat: RealRobotAdapter, multiplex fan-out, full embed wiring
aa2dcd1 unverified
Raw
History Blame Contribute Delete
2.97 kB
// VirtualRobotAdapter — drives the on-screen puppet. This is the whole
// robot in practice mode, and the mirror of the real robot in embed mode.
import type { CommandLog } from "../util/log";
import {
AbortError,
type LoadedEmotion,
type LoadedSound,
type PoseTargetDeg,
type RobotAdapter,
sleep,
throwIfAborted,
} from "./adapter";
import { INIT_LOGICAL_POSE, homeDurationS } from "./poses";
import { Puppet } from "../puppet/puppet";
export interface VirtualRobotOptions {
/** Silent mirror mode: skip browser audio (the real robot's speaker
* plays it) while still mirroring all motion. */
muteAudio?: boolean;
}
export class VirtualRobotAdapter implements RobotAdapter {
readonly kind = "virtual" as const;
readonly puppet: Puppet;
private soundEl: HTMLAudioElement | null = null;
constructor(
puppetHost: HTMLElement,
private log?: CommandLog,
private opts: VirtualRobotOptions = {},
) {
this.puppet = new Puppet(puppetHost);
}
async goto(target: PoseTargetDeg, durationSec: number, signal?: AbortSignal): Promise<void> {
throwIfAborted(signal);
this.log?.push("goto", { target, durationSec });
const outcome = await this.puppet.tweenTo(target, durationSec * 1000, signal);
if (outcome === "aborted") throw new AbortError();
}
async playEmotion(emotion: LoadedEmotion, signal?: AbortSignal): Promise<void> {
throwIfAborted(signal);
this.log?.push("playEmotion", { id: emotion.id });
const frames = await emotion.frames();
throwIfAborted(signal);
const audioBlob = this.opts.muteAudio ? null : await emotion.browserAudio();
throwIfAborted(signal);
const url = audioBlob ? URL.createObjectURL(audioBlob) : null;
try {
const outcome = await this.puppet.playFrames(frames, url, signal);
if (outcome === "aborted") throw new AbortError();
} finally {
if (url) URL.revokeObjectURL(url);
}
}
async playSound(sound: LoadedSound, signal?: AbortSignal): Promise<void> {
throwIfAborted(signal);
this.log?.push("playSound", { id: sound.id });
this.stopSound();
if (!this.opts.muteAudio) {
this.soundEl = new Audio(sound.url);
this.soundEl.play().catch(() => {});
}
try {
await sleep(sound.durationMs, signal);
} finally {
this.stopSound();
}
}
antennaTouched(): boolean {
return this.puppet.antennaTouched();
}
async stopAll(): Promise<void> {
this.log?.push("stopAll");
this.puppet.cancelActive();
this.stopSound();
}
async returnHome(): Promise<void> {
this.log?.push("returnHome");
const duration = homeDurationS(this.puppet.pose);
await this.puppet.tweenTo(INIT_LOGICAL_POSE, duration * 1000);
}
dispose(): void {
this.puppet.cancelActive();
this.stopSound();
}
private stopSound(): void {
if (this.soundEl) {
this.soundEl.pause();
this.soundEl.src = "";
this.soundEl = null;
}
}
}