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