Spaces:
Running
Running
| // 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; | |
| } | |
| } | |
| } | |