/** * Isomorphic, renderer-agnostic avatar state — part of the optional * `humanoid` package (see docs/HUMANOID.md and the "Module layering" section * of docs/ARCHITECTURE.md for what that means). Pure types and a pure * reducer, no WS/browser dependency, so future animation libraries (eye * look-at, blink, lip-sync, body/gesture) can all key off the same signal * without each re-deriving turn-taking themselves. * * `idle` is split into `idleActive`/`idlePassive` from the start — ported * animation systems (this one's lineage traces to an Unreal Engine avatar * app) commonly key their idle behavior off exactly this distinction: * `idleActive` right after a turn ends (still "engaged"), `idlePassive` * once nothing has happened for a while (settle into bored/ambient idle * animation). This reducer has no timers of its own — pure and isomorphic — * so the active→passive transition rides in on an explicit `idle_timeout` * signal rather than an internal side effect; the client wrapper * (`$lib/client/humanoid/avatar-state.ts`) owns the actual timer, same as * it owns `setListening`. */ export type AvatarState = 'idleActive' | 'idlePassive' | 'listening' | 'thinking' | 'talking'; export type AvatarSignal = | { type: 'listening_started' } | { type: 'listening_stopped' } | { type: 'response_requested' } | { type: 'talking_started' } | { type: 'talking_stopped' } | { type: 'idle_timeout' } | { type: 'reset' }; /** * Pure state transition — no side effects, directly unit-testable. The * browser-facing translation from real `ServerEvent`s (and the imperative * `setListening` a project's mic-capture code calls) into `AvatarSignal`s * lives in `$lib/client/humanoid/avatar-state.ts`, which folds them through * this function; this module doesn't know a `RealtimeClient` exists. */ export function nextAvatarState(current: AvatarState, signal: AvatarSignal): AvatarState { switch (signal.type) { case 'listening_started': return 'listening'; // Ending listening almost always means a turn just completed and a // response is expected next — go straight to 'thinking' rather than // 'idle', so the avatar doesn't visibly relax for the split second // before the model's response starts arriving. case 'listening_stopped': return current === 'listening' ? 'thinking' : current; case 'response_requested': return current === 'talking' ? current : 'thinking'; case 'talking_started': return 'talking'; case 'talking_stopped': return current === 'talking' ? 'idleActive' : current; // Only fires the active->passive transition; a client that emits this // while something else is happening (listening/thinking/talking) is // racing its own timer against real activity, so it's a no-op there. case 'idle_timeout': return current === 'idleActive' ? 'idlePassive' : current; case 'reset': return 'idleActive'; default: return current; } }