// 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 { 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 { 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 { 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 { this.log?.push("stopAll"); this.puppet.cancelActive(); this.stopSound(); } async returnHome(): Promise { 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; } } }