You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

64 lines
2.9 KiB

/**
* 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;
}
}