@voqalize/avatar 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +692 -0
  3. package/client/dist/Avatar.d.ts +24 -0
  4. package/client/dist/Avatar.d.ts.map +1 -0
  5. package/client/dist/Avatar.js +7 -0
  6. package/client/dist/Avatar.js.map +1 -0
  7. package/client/dist/AvatarClient.d.ts +173 -0
  8. package/client/dist/AvatarClient.d.ts.map +1 -0
  9. package/client/dist/AvatarClient.js +274 -0
  10. package/client/dist/AvatarClient.js.map +1 -0
  11. package/client/dist/pipecat.d.ts +21 -0
  12. package/client/dist/pipecat.d.ts.map +1 -0
  13. package/client/dist/pipecat.js +21 -0
  14. package/client/dist/pipecat.js.map +1 -0
  15. package/client/dist/react.d.ts +16 -0
  16. package/client/dist/react.d.ts.map +1 -0
  17. package/client/dist/react.js +17 -0
  18. package/client/dist/react.js.map +1 -0
  19. package/client/dist/types.d.ts +101 -0
  20. package/client/dist/types.d.ts.map +1 -0
  21. package/client/dist/types.js +31 -0
  22. package/client/dist/types.js.map +1 -0
  23. package/client/dist/useAvatar.d.ts +53 -0
  24. package/client/dist/useAvatar.d.ts.map +1 -0
  25. package/client/dist/useAvatar.js +68 -0
  26. package/client/dist/useAvatar.js.map +1 -0
  27. package/client/src/Avatar.tsx +38 -0
  28. package/client/src/AvatarClient.ts +343 -0
  29. package/client/src/pipecat.ts +38 -0
  30. package/client/src/react.ts +34 -0
  31. package/client/src/types.ts +127 -0
  32. package/client/src/useAvatar.ts +113 -0
  33. package/docs/contract-avatar.md +337 -0
  34. package/docs/contract-protocol.md +401 -0
  35. package/package.json +89 -0
  36. package/src/audio-fallback.js +100 -0
  37. package/src/avatar.d.ts +241 -0
  38. package/src/avatar.js +722 -0
  39. package/src/clips.js +144 -0
  40. package/src/emotions.js +55 -0
  41. package/src/face-core.js +154 -0
  42. package/src/face-myna.js +725 -0
  43. package/src/face-peep.js +767 -0
  44. package/src/face-wren.js +470 -0
  45. package/src/gaze.js +155 -0
  46. package/src/idle.js +535 -0
  47. package/src/interjections.js +578 -0
  48. package/src/line-art.js +111 -0
  49. package/src/params.js +176 -0
  50. package/src/perform.js +105 -0
  51. package/src/visemes.js +230 -0
@@ -0,0 +1,101 @@
1
+ /**
2
+ * types.ts — the avatar wire vocabulary, client side.
3
+ *
4
+ * The binding definition is `docs/contract-protocol.md`; this file is its
5
+ * TypeScript restatement and must not drift from it. The Python half of the
6
+ * same vocabulary is `py/src/voqalize_avatar/messages.py` — the three are
7
+ * maintained together, and a command added to one without the others is
8
+ * incomplete.
9
+ *
10
+ * A server pushes these as RTVI `server-message`s under the envelope
11
+ * `{ type: "avatar", v: 1, ...cmd-specific fields }`. {@link AvatarCommand}
12
+ * describes the *payload*, not the envelope, because the payload is what
13
+ * arrives however the host chose to carry it — `AvatarClient.dispatch()`
14
+ * accepts anything with a string `cmd`, so an application that tunnels these
15
+ * through its own message type can hand them straight over.
16
+ */
17
+ /** A viseme cue: `t` is a ms offset into the utterance's clock, `v` is a Rhubarb A–H (or X) letter. */
18
+ export interface AvatarCue {
19
+ t: number;
20
+ v: string;
21
+ i?: number;
22
+ }
23
+ /** `perform()` timeline action — see docs/contract-protocol.md § Composing behavior. */
24
+ export interface AvatarPerformAction {
25
+ t: number;
26
+ do: "state" | "emotion" | "gaze" | "interject";
27
+ name?: string;
28
+ id?: string;
29
+ i?: number;
30
+ keepGaze?: boolean;
31
+ }
32
+ export interface AvatarStateCmd {
33
+ cmd: "state";
34
+ name: string;
35
+ emotion?: string;
36
+ gaze?: string;
37
+ }
38
+ export interface AvatarInterjectCmd {
39
+ cmd: "interject";
40
+ id: string;
41
+ }
42
+ export interface AvatarPerformCmd {
43
+ cmd: "perform";
44
+ actions: AvatarPerformAction[];
45
+ ctx?: string;
46
+ }
47
+ export interface AvatarCuesCmd {
48
+ cmd: "cues";
49
+ ctx: string;
50
+ /** Discard queued cues at or after this offset (ms), then append `cues`. */
51
+ from_ms: number;
52
+ cues: AvatarCue[];
53
+ /**
54
+ * True on the one chunk that completes this turn's track: the TTS context is
55
+ * closed, so no further chunk will splice into `ctx`. What a client may
56
+ * assume, exactly — nothing about playout. The audio it describes is still
57
+ * ahead, and `speech stop` remains the end of the turn. It is safe to release
58
+ * per-turn cue state (the splice buffer for `ctx`) once the last cue has
59
+ * played, and safe to stop expecting more.
60
+ *
61
+ * Absent on an interrupted turn, deliberately: a turn that was cut never
62
+ * claims to have completed. Absent chunks are the normal case — the widget's
63
+ * own track already completes on the trailing `X`, so ignoring `final`
64
+ * entirely is a correct implementation.
65
+ */
66
+ final?: boolean;
67
+ }
68
+ export interface AvatarSpeechCmd {
69
+ cmd: "speech";
70
+ event: "start" | "stop";
71
+ ctx: string;
72
+ }
73
+ export interface AvatarUserCmd {
74
+ cmd: "user";
75
+ speaking: boolean;
76
+ }
77
+ export interface AvatarHintCmd {
78
+ cmd: "hint";
79
+ kind: "eager_eot" | (string & {});
80
+ }
81
+ /** A cmd this build doesn't recognize — dispatched to nothing, ignored for forward compat. */
82
+ export interface AvatarUnknownCmd {
83
+ cmd: string;
84
+ [key: string]: unknown;
85
+ }
86
+ export type AvatarCommand = AvatarStateCmd | AvatarInterjectCmd | AvatarPerformCmd | AvatarCuesCmd | AvatarSpeechCmd | AvatarUserCmd | AvatarHintCmd | AvatarUnknownCmd;
87
+ /** The full server-message payload: the avatar envelope plus its `cmd`. */
88
+ export type AvatarServerMessage = AvatarCommand & {
89
+ type?: "avatar";
90
+ v?: number;
91
+ };
92
+ /** Narrows an unknown server-message payload to an avatar command. */
93
+ export declare function isAvatarMessage(msg: unknown): msg is AvatarServerMessage;
94
+ /** The envelope `type` the protocol reserves for avatar traffic. */
95
+ export declare const AVATAR_MESSAGE_TYPE = "avatar";
96
+ /** The protocol version this client speaks — matches `AVATAR_PROTOCOL_VERSION`
97
+ * in the Python package. Sent as `v` and, today, never checked: an unknown
98
+ * `cmd` is ignored rather than version-gated, which is the forward-compat rule
99
+ * the contract states. */
100
+ export declare const AVATAR_PROTOCOL_VERSION = 1;
101
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,uGAAuG;AACvG,MAAM,WAAW,SAAS;IACxB,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,CAAC,EAAE,MAAM,CAAC;CACZ;AAED,wFAAwF;AACxF,MAAM,WAAW,mBAAmB;IAClC,CAAC,EAAE,MAAM,CAAC;IACV,EAAE,EAAE,OAAO,GAAG,SAAS,GAAG,MAAM,GAAG,WAAW,CAAC;IAC/C,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,CAAC,CAAC,EAAE,MAAM,CAAC;IACX,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,OAAO,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,kBAAkB;IACjC,GAAG,EAAE,WAAW,CAAC;IACjB,EAAE,EAAE,MAAM,CAAC;CACZ;AAED,MAAM,WAAW,gBAAgB;IAC/B,GAAG,EAAE,SAAS,CAAC;IACf,OAAO,EAAE,mBAAmB,EAAE,CAAC;IAC/B,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,aAAa;IAC5B,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,4EAA4E;IAC5E,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,SAAS,EAAE,CAAC;IAClB;;;;;;;;;;;;OAYG;IACH,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,WAAW,eAAe;IAC9B,GAAG,EAAE,QAAQ,CAAC;IACd,KAAK,EAAE,OAAO,GAAG,MAAM,CAAC;IACxB,GAAG,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,aAAa;IAC5B,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,WAAW,aAAa;IAC5B,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,WAAW,GAAG,CAAC,MAAM,GAAG,EAAE,CAAC,CAAC;CACnC;AAED,8FAA8F;AAC9F,MAAM,WAAW,gBAAgB;IAC/B,GAAG,EAAE,MAAM,CAAC;IACZ,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,MAAM,MAAM,aAAa,GACrB,cAAc,GACd,kBAAkB,GAClB,gBAAgB,GAChB,aAAa,GACb,eAAe,GACf,aAAa,GACb,aAAa,GACb,gBAAgB,CAAC;AAErB,2EAA2E;AAC3E,MAAM,MAAM,mBAAmB,GAAG,aAAa,GAAG;IAChD,IAAI,CAAC,EAAE,QAAQ,CAAC;IAChB,CAAC,CAAC,EAAE,MAAM,CAAC;CACZ,CAAC;AAEF,sEAAsE;AACtE,wBAAgB,eAAe,CAAC,GAAG,EAAE,OAAO,GAAG,GAAG,IAAI,mBAAmB,CAIxE;AAED,oEAAoE;AACpE,eAAO,MAAM,mBAAmB,WAAW,CAAC;AAE5C;;;0BAG0B;AAC1B,eAAO,MAAM,uBAAuB,IAAI,CAAC"}
@@ -0,0 +1,31 @@
1
+ /**
2
+ * types.ts — the avatar wire vocabulary, client side.
3
+ *
4
+ * The binding definition is `docs/contract-protocol.md`; this file is its
5
+ * TypeScript restatement and must not drift from it. The Python half of the
6
+ * same vocabulary is `py/src/voqalize_avatar/messages.py` — the three are
7
+ * maintained together, and a command added to one without the others is
8
+ * incomplete.
9
+ *
10
+ * A server pushes these as RTVI `server-message`s under the envelope
11
+ * `{ type: "avatar", v: 1, ...cmd-specific fields }`. {@link AvatarCommand}
12
+ * describes the *payload*, not the envelope, because the payload is what
13
+ * arrives however the host chose to carry it — `AvatarClient.dispatch()`
14
+ * accepts anything with a string `cmd`, so an application that tunnels these
15
+ * through its own message type can hand them straight over.
16
+ */
17
+ /** Narrows an unknown server-message payload to an avatar command. */
18
+ export function isAvatarMessage(msg) {
19
+ if (typeof msg !== "object" || msg === null)
20
+ return false;
21
+ const m = msg;
22
+ return typeof m.cmd === "string";
23
+ }
24
+ /** The envelope `type` the protocol reserves for avatar traffic. */
25
+ export const AVATAR_MESSAGE_TYPE = "avatar";
26
+ /** The protocol version this client speaks — matches `AVATAR_PROTOCOL_VERSION`
27
+ * in the Python package. Sent as `v` and, today, never checked: an unknown
28
+ * `cmd` is ignored rather than version-gated, which is the forward-compat rule
29
+ * the contract states. */
30
+ export const AVATAR_PROTOCOL_VERSION = 1;
31
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAiGH,sEAAsE;AACtE,MAAM,UAAU,eAAe,CAAC,GAAY;IAC1C,IAAI,OAAO,GAAG,KAAK,QAAQ,IAAI,GAAG,KAAK,IAAI;QAAE,OAAO,KAAK,CAAC;IAC1D,MAAM,CAAC,GAAG,GAA8B,CAAC;IACzC,OAAO,OAAO,CAAC,CAAC,GAAG,KAAK,QAAQ,CAAC;AACnC,CAAC;AAED,oEAAoE;AACpE,MAAM,CAAC,MAAM,mBAAmB,GAAG,QAAQ,CAAC;AAE5C;;;0BAG0B;AAC1B,MAAM,CAAC,MAAM,uBAAuB,GAAG,CAAC,CAAC"}
@@ -0,0 +1,53 @@
1
+ /**
2
+ * useAvatar — mount the widget, wire it to a live session, dispatch its
3
+ * server-messages, and clean up.
4
+ *
5
+ * Options are read through a ref so the effect doesn't re-subscribe on every
6
+ * render. Split in two effects: mounting the widget happens once (an avatar or
7
+ * theme swap remounts by design — see the note in the effect); attaching to
8
+ * the pipecat client re-runs whenever the client identity changes (a session
9
+ * reconnect mints a new one) or once the widget instance becomes available.
10
+ */
11
+ import type { PipecatClient } from "@pipecat-ai/client-js";
12
+ import { type AvatarApi, type CreateAvatarOptions } from "../../src/avatar.js";
13
+ import { AvatarClient, type AvatarClientOptions } from "./AvatarClient.js";
14
+ export interface UseAvatarOptions extends AvatarClientOptions {
15
+ /** Name from `AVATAR_NAMES`. Omit for the widget's own `DEFAULT_AVATAR`. */
16
+ avatar?: string;
17
+ theme?: CreateAvatarOptions["theme"];
18
+ /** Articulation gains — see docs/contract-protocol.md § Events, gains, introspection. */
19
+ mouthGain?: number;
20
+ gestureGain?: number;
21
+ /** The live `PipecatClient` to dispatch server-messages from, or `null`
22
+ * before connect. `useAvatar` (dis)connects the subscription as this
23
+ * changes; it does not create or own the client. */
24
+ client?: PipecatClient | null;
25
+ }
26
+ /**
27
+ * The mount ref's type, written out rather than named as React's `RefObject`.
28
+ *
29
+ * React 18 and 19 declare that alias with different type arguments — 18's
30
+ * `useRef<T>(null)` yields `RefObject<T>`, 19's yields `RefObject<T | null>` —
31
+ * and because both are the *same alias*, TypeScript compares them by variance
32
+ * and rejects whichever one we didn't pick. An anonymous shape forces a
33
+ * structural comparison instead, which both versions satisfy, and which the
34
+ * `ref` prop accepts on both. This is the only place the 18-vs-19 split shows
35
+ * up in the binding; keep it that way.
36
+ */
37
+ export type AvatarMountRef = {
38
+ current: HTMLDivElement | null;
39
+ };
40
+ export interface UseAvatarHandle {
41
+ /** Attach to the mount element: `<div ref={containerRef} />`. */
42
+ containerRef: AvatarMountRef;
43
+ /** The live widget instance once mounted, else `null`. */
44
+ avatar: AvatarApi | null;
45
+ /** The dispatcher wrapping `avatar` — `null` until mounted. Exposed for
46
+ * tests and telemetry (`turnCtx`, `turnCues`) and for manual dispatch. */
47
+ client: AvatarClient | null;
48
+ /** Dispatch one avatar command by hand — e.g. from a dev-tools console, or
49
+ * from a transport that isn't a `PipecatClient`. No-ops before mount. */
50
+ dispatch: (msg: unknown) => void;
51
+ }
52
+ export declare function useAvatar(options?: UseAvatarOptions): UseAvatarHandle;
53
+ //# sourceMappingURL=useAvatar.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useAvatar.d.ts","sourceRoot":"","sources":["../src/useAvatar.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAGH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAC3D,OAAO,EAAgB,KAAK,SAAS,EAAE,KAAK,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAC7F,OAAO,EAAE,YAAY,EAAE,KAAK,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AAE3E,MAAM,WAAW,gBAAiB,SAAQ,mBAAmB;IAC3D,4EAA4E;IAC5E,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,mBAAmB,CAAC,OAAO,CAAC,CAAC;IACrC,yFAAyF;IACzF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;wDAEoD;IACpD,MAAM,CAAC,EAAE,aAAa,GAAG,IAAI,CAAC;CAC/B;AAED;;;;;;;;;;GAUG;AACH,MAAM,MAAM,cAAc,GAAG;IAAE,OAAO,EAAE,cAAc,GAAG,IAAI,CAAA;CAAE,CAAC;AAEhE,MAAM,WAAW,eAAe;IAC9B,iEAAiE;IACjE,YAAY,EAAE,cAAc,CAAC;IAC7B,0DAA0D;IAC1D,MAAM,EAAE,SAAS,GAAG,IAAI,CAAC;IACzB;8EAC0E;IAC1E,MAAM,EAAE,YAAY,GAAG,IAAI,CAAC;IAC5B;6EACyE;IACzE,QAAQ,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,IAAI,CAAC;CAClC;AAED,wBAAgB,SAAS,CAAC,OAAO,GAAE,gBAAqB,GAAG,eAAe,CAyDzE"}
@@ -0,0 +1,68 @@
1
+ /**
2
+ * useAvatar — mount the widget, wire it to a live session, dispatch its
3
+ * server-messages, and clean up.
4
+ *
5
+ * Options are read through a ref so the effect doesn't re-subscribe on every
6
+ * render. Split in two effects: mounting the widget happens once (an avatar or
7
+ * theme swap remounts by design — see the note in the effect); attaching to
8
+ * the pipecat client re-runs whenever the client identity changes (a session
9
+ * reconnect mints a new one) or once the widget instance becomes available.
10
+ */
11
+ import { useCallback, useEffect, useRef, useState } from "react";
12
+ import { createAvatar } from "../../src/avatar.js";
13
+ import { AvatarClient } from "./AvatarClient.js";
14
+ export function useAvatar(options = {}) {
15
+ const containerRef = useRef(null);
16
+ const [avatar, setAvatar] = useState(null);
17
+ const avatarClientRef = useRef(null);
18
+ // Latest-options ref, so the mount effect (which runs once) still reads live
19
+ // callback props without re-subscribing.
20
+ const optionsRef = useRef(options);
21
+ optionsRef.current = options;
22
+ useEffect(() => {
23
+ const mount = containerRef.current;
24
+ if (!mount)
25
+ return;
26
+ const instance = createAvatar({
27
+ mount,
28
+ avatar: optionsRef.current.avatar,
29
+ theme: optionsRef.current.theme,
30
+ mouthGain: optionsRef.current.mouthGain,
31
+ gestureGain: optionsRef.current.gestureGain,
32
+ });
33
+ const wrapper = new AvatarClient(instance, {
34
+ onHint: (kind, msg) => optionsRef.current.onHint?.(kind, msg),
35
+ onUnknownCmd: (msg) => optionsRef.current.onUnknownCmd?.(msg),
36
+ onError: (err, msg) => optionsRef.current.onError?.(err, msg),
37
+ onSpeakingDrift: (info) => optionsRef.current.onSpeakingDrift?.(info),
38
+ accept: optionsRef.current.accept,
39
+ now: optionsRef.current.now,
40
+ });
41
+ avatarClientRef.current = wrapper;
42
+ setAvatar(instance);
43
+ return () => {
44
+ instance.destroy();
45
+ avatarClientRef.current = null;
46
+ setAvatar(null);
47
+ };
48
+ // Mount once. `avatar`/`theme`/the gains are read at mount time only — the
49
+ // widget has no hot-swap-avatar API (`createFace` runs once per mount), so
50
+ // changing them re-renders nothing here by design; a caller that needs a
51
+ // different avatar remounts with a `key` prop (see the component's doc).
52
+ // eslint-disable-next-line react-hooks/exhaustive-deps
53
+ }, []);
54
+ useEffect(() => {
55
+ const wrapper = avatarClientRef.current;
56
+ const pipecatClient = options.client;
57
+ if (!wrapper || !pipecatClient)
58
+ return;
59
+ return wrapper.attach(pipecatClient);
60
+ // Re-subscribe when the widget mounts or the session's client changes.
61
+ // eslint-disable-next-line react-hooks/exhaustive-deps
62
+ }, [avatar, options.client]);
63
+ const dispatch = useCallback((msg) => {
64
+ avatarClientRef.current?.dispatch(msg);
65
+ }, []);
66
+ return { containerRef, avatar, client: avatarClientRef.current, dispatch };
67
+ }
68
+ //# sourceMappingURL=useAvatar.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useAvatar.js","sourceRoot":"","sources":["../src/useAvatar.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAEjE,OAAO,EAAE,YAAY,EAA4C,MAAM,qBAAqB,CAAC;AAC7F,OAAO,EAAE,YAAY,EAA4B,MAAM,mBAAmB,CAAC;AAyC3E,MAAM,UAAU,SAAS,CAAC,UAA4B,EAAE;IACtD,MAAM,YAAY,GAAG,MAAM,CAAiB,IAAI,CAAC,CAAC;IAClD,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,GAAG,QAAQ,CAAmB,IAAI,CAAC,CAAC;IAC7D,MAAM,eAAe,GAAG,MAAM,CAAsB,IAAI,CAAC,CAAC;IAE1D,6EAA6E;IAC7E,yCAAyC;IACzC,MAAM,UAAU,GAAG,MAAM,CAAC,OAAO,CAAC,CAAC;IACnC,UAAU,CAAC,OAAO,GAAG,OAAO,CAAC;IAE7B,SAAS,CAAC,GAAG,EAAE;QACb,MAAM,KAAK,GAAG,YAAY,CAAC,OAAO,CAAC;QACnC,IAAI,CAAC,KAAK;YAAE,OAAO;QACnB,MAAM,QAAQ,GAAG,YAAY,CAAC;YAC5B,KAAK;YACL,MAAM,EAAE,UAAU,CAAC,OAAO,CAAC,MAAM;YACjC,KAAK,EAAE,UAAU,CAAC,OAAO,CAAC,KAAK;YAC/B,SAAS,EAAE,UAAU,CAAC,OAAO,CAAC,SAAS;YACvC,WAAW,EAAE,UAAU,CAAC,OAAO,CAAC,WAAW;SAC5C,CAAC,CAAC;QACH,MAAM,OAAO,GAAG,IAAI,YAAY,CAAC,QAAQ,EAAE;YACzC,MAAM,EAAE,CAAC,IAAI,EAAE,GAAG,EAAE,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,GAAG,CAAC;YAC7D,YAAY,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,YAAY,EAAE,CAAC,GAAG,CAAC;YAC7D,OAAO,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,GAAG,CAAC;YAC7D,eAAe,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,eAAe,EAAE,CAAC,IAAI,CAAC;YACrE,MAAM,EAAE,UAAU,CAAC,OAAO,CAAC,MAAM;YACjC,GAAG,EAAE,UAAU,CAAC,OAAO,CAAC,GAAG;SAC5B,CAAC,CAAC;QACH,eAAe,CAAC,OAAO,GAAG,OAAO,CAAC;QAClC,SAAS,CAAC,QAAQ,CAAC,CAAC;QAEpB,OAAO,GAAG,EAAE;YACV,QAAQ,CAAC,OAAO,EAAE,CAAC;YACnB,eAAe,CAAC,OAAO,GAAG,IAAI,CAAC;YAC/B,SAAS,CAAC,IAAI,CAAC,CAAC;QAClB,CAAC,CAAC;QACF,2EAA2E;QAC3E,2EAA2E;QAC3E,yEAAyE;QACzE,yEAAyE;QACzE,uDAAuD;IACzD,CAAC,EAAE,EAAE,CAAC,CAAC;IAEP,SAAS,CAAC,GAAG,EAAE;QACb,MAAM,OAAO,GAAG,eAAe,CAAC,OAAO,CAAC;QACxC,MAAM,aAAa,GAAG,OAAO,CAAC,MAAM,CAAC;QACrC,IAAI,CAAC,OAAO,IAAI,CAAC,aAAa;YAAE,OAAO;QACvC,OAAO,OAAO,CAAC,MAAM,CAAC,aAAa,CAAC,CAAC;QACrC,uEAAuE;QACvE,uDAAuD;IACzD,CAAC,EAAE,CAAC,MAAM,EAAE,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC;IAE7B,MAAM,QAAQ,GAAG,WAAW,CAAC,CAAC,GAAY,EAAE,EAAE;QAC5C,eAAe,CAAC,OAAO,EAAE,QAAQ,CAAC,GAAG,CAAC,CAAC;IACzC,CAAC,EAAE,EAAE,CAAC,CAAC;IAEP,OAAO,EAAE,YAAY,EAAE,MAAM,EAAE,MAAM,EAAE,eAAe,CAAC,OAAO,EAAE,QAAQ,EAAE,CAAC;AAC7E,CAAC"}
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Avatar — a call-tile-ready wrapper around the widget.
3
+ *
4
+ * Thin by design: `useAvatar` does the work; this is the div it mounts into
5
+ * plus prop plumbing.
6
+ *
7
+ * <Avatar client={session.client} className="avatar-tile" />
8
+ *
9
+ * The widget has no hot-swap-avatar API — `createFace` runs once per mount —
10
+ * so `avatar`/`theme`/`mouthGain`/`gestureGain` are read once, at mount. To
11
+ * switch avatars at runtime, remount with a `key` prop:
12
+ *
13
+ * <Avatar key={name} avatar={name} client={session.client} />
14
+ */
15
+
16
+ import type { CSSProperties } from "react";
17
+ import type { UseAvatarOptions } from "./useAvatar.js";
18
+ import { useAvatar } from "./useAvatar.js";
19
+
20
+ export interface AvatarProps extends UseAvatarOptions {
21
+ className?: string;
22
+ style?: CSSProperties;
23
+ /** Forwarded to the mount `<div>`. */
24
+ "aria-label"?: string;
25
+ }
26
+
27
+ export function Avatar({ className, style, "aria-label": ariaLabel, ...options }: AvatarProps) {
28
+ const { containerRef } = useAvatar(options);
29
+ return (
30
+ <div
31
+ ref={containerRef}
32
+ className={className}
33
+ style={style}
34
+ aria-label={ariaLabel ?? "avatar"}
35
+ role="img"
36
+ />
37
+ );
38
+ }
@@ -0,0 +1,343 @@
1
+ /**
2
+ * AvatarClient — the avatar's server-message dispatcher, turn clock, and cue
3
+ * splice, framework-free (no React; the hook and component wrap this).
4
+ *
5
+ * ## Turn clock anchoring
6
+ *
7
+ * A turn's `t0` is anchored to `performance.now()` **at the moment this client
8
+ * receives the `{cmd:"speech", event:"start"}` message** — cues are
9
+ * client-anchored, not per-cue server-released. That message rides the RTVI
10
+ * data channel, ahead of the jitter-buffered audio path, so the residual error
11
+ * lands on the video-leads side — the side `docs/contract-protocol.md` says
12
+ * perceptual tolerance favours (+125 ms vs -45 ms).
13
+ *
14
+ * We investigated anchoring on pipecat client-js's own `RTVIEvent
15
+ * .BotStartedSpeaking`/`BotStoppedSpeaking` instead (or as a refinement) and
16
+ * chose not to, for two reasons:
17
+ *
18
+ * 1. **No turn correlation.** Those events carry no payload — no `ctx` — so
19
+ * there is no way to tell which turn a firing belongs to. Our own
20
+ * `speech` command carries `ctx`, which the splice logic below needs
21
+ * regardless, so anchoring off it costs nothing extra.
22
+ * 2. **Same source, same path, no accuracy gain.** The `AvatarProcessor`
23
+ * sits between the TTS service and the output transport and observes the
24
+ * transport's own `BotStarted/StoppedSpeakingFrame` broadcasts — the exact
25
+ * frame pipecat's built-in speaking detection is *also* driven from. Both
26
+ * notifications travel the same data-channel path to the browser. There is
27
+ * no local "truly audible now" signal cheaply available: the audio arrives
28
+ * on a `MediaStreamTrack` whose only lifecycle events (`unmute`/`mute`)
29
+ * fire once per call, not per utterance. Tapping the decoded remote audio
30
+ * with a WebAudio `AnalyserNode` RMS gate *would* give one, but it adds
31
+ * its own onset latency and a real audio pipeline to build and tune, and
32
+ * it would eat into the intentional video-first safety margin rather than
33
+ * improve it. Left as a documented option, not built.
34
+ *
35
+ * `attach()` still subscribes to both pipecat events, but only to report a
36
+ * **diagnostic** drift (`onSpeakingDrift`) between our anchor and pipecat's —
37
+ * useful for noticing in logs if the two ever separate by more than jitter,
38
+ * never used to move `t0` itself.
39
+ *
40
+ * ## Cue splice
41
+ *
42
+ * The widget has two cue-track primitives: `speak({cues, clock})` (a full
43
+ * replace) and `pushCues(cues)` (a pure union that can only grow the track,
44
+ * never shrink it). Neither is "discard queued cues at or after `from_ms`,
45
+ * then append" on its own — `pushCues` has no way to drop a stale tail. So
46
+ * this client keeps the turn's canonical cue array itself (kept portion +
47
+ * every appended chunk, spliced on each `cues` message) and picks the cheapest
48
+ * widget call that stays correct:
49
+ *
50
+ * - if the splice's `from_ms` doesn't reach back into anything already
51
+ * queued — the common case past a turn's first sentence, since only the
52
+ * first sentence genuinely plays fast-leg cues — nothing needs discarding:
53
+ * `pushCues(newCues)` is the cheap, correct append.
54
+ * - if it does reach back (a real fast→accurate splice), `pushCues` cannot
55
+ * express the discard; we call `speak()` again with the full spliced
56
+ * canonical array on the turn's original clock. `speak()` is otherwise
57
+ * documented as also killing an in-flight spoken interjection and
58
+ * re-entering `SPEAKING` — both harmless mid-splice (an interjection
59
+ * should not be running while a server track owns the mouth; re-entering
60
+ * an unchanged state is a no-op past the profile/gaze reset the widget
61
+ * already does for a same-name `setState`).
62
+ *
63
+ * Cues commonly arrive **before** `speech start` — the fast leg starts the
64
+ * moment a sentence is handed to TTS, well before `BotStartedSpeakingFrame`.
65
+ * Chunks that arrive before the clock is anchored are spliced into the
66
+ * canonical array but not yet handed to the widget; `speech start` hands over
67
+ * whatever has accumulated as the turn's first `speak()` call. So "the first
68
+ * chunk of a turn starts speak()" means the first *widget* call, not
69
+ * necessarily the first *message*.
70
+ */
71
+
72
+ import type { PipecatClient, RTVIEvent } from "@pipecat-ai/client-js";
73
+ import type { AvatarApi } from "../../src/avatar.js";
74
+ import {
75
+ AVATAR_MESSAGE_TYPE,
76
+ isAvatarMessage,
77
+ type AvatarCommand,
78
+ type AvatarCue,
79
+ type AvatarCuesCmd,
80
+ type AvatarHintCmd,
81
+ type AvatarPerformCmd,
82
+ type AvatarSpeechCmd,
83
+ type AvatarStateCmd,
84
+ type AvatarUnknownCmd,
85
+ } from "./types.js";
86
+
87
+ interface Turn {
88
+ ctx: string;
89
+ /** The canonical, already-spliced cue track for this turn. */
90
+ cues: AvatarCue[];
91
+ /** Whether `speech start` has anchored a clock and issued the first `speak()`. */
92
+ started: boolean;
93
+ clock: (() => number) | null;
94
+ t0: number | null;
95
+ }
96
+
97
+ export interface AvatarClientOptions {
98
+ /** `{cmd:"hint"}` is a no-op hook today — the widget's listening engine
99
+ * already handles acks; a host may still want to know a hint arrived. */
100
+ onHint?: (kind: string, msg: AvatarHintCmd) => void;
101
+ /** An unrecognized `cmd` (forward compat) — the protocol says ignore
102
+ * silently, so this is purely an observability hook, not required. */
103
+ onUnknownCmd?: (msg: AvatarUnknownCmd) => void;
104
+ /** A dispatch threw (e.g. an unknown state or interjection id, which the
105
+ * widget throws on). Defaults to `console.warn`. */
106
+ onError?: (err: unknown, msg: AvatarCommand) => void;
107
+ /** Diagnostic only (see the class doc's "Turn clock anchoring" section) —
108
+ * never moves the anchor, just reports how far pipecat's own
109
+ * botStartedSpeaking/botStoppedSpeaking landed from it. */
110
+ onSpeakingDrift?: (info: { event: "start" | "stop"; ctx: string | null; driftMs: number }) => void;
111
+ /**
112
+ * Which server-messages `attach()` should look inside. Defaults to the
113
+ * protocol's own envelope, `type === "avatar"`.
114
+ *
115
+ * The escape hatch exists because an application may tunnel avatar commands
116
+ * inside a message type of its own — one deployment routes them through a
117
+ * generic `ui_command` envelope so an LLM tool call can drive the face — and
118
+ * teaching this library that envelope would be teaching it one consumer's
119
+ * private vocabulary. Widen it here instead:
120
+ *
121
+ * accept: (m) => m.type === "avatar" ||
122
+ * (m.type === "ui_command" && m.action === "avatar")
123
+ *
124
+ * The predicate only decides *whether to look*; the payload still has to
125
+ * carry a string `cmd` to dispatch at all.
126
+ */
127
+ accept?: (message: Record<string, unknown>) => boolean;
128
+ /** Override for tests. Defaults to `performance.now`. */
129
+ now?: () => number;
130
+ }
131
+
132
+ /**
133
+ * The three `RTVIEvent` members `attach()` subscribes to, spelled as their
134
+ * values.
135
+ *
136
+ * Written out rather than imported because that enum was this module's *only*
137
+ * runtime reference to `@pipecat-ai/client-js`, and one runtime reference makes
138
+ * the whole `/pipecat` subpath fail to load without the peer installed — even
139
+ * for a host that drives `dispatch()` from its own transport and never calls
140
+ * `attach()`. The peer is declared optional; this is what makes that true
141
+ * rather than aspirational.
142
+ *
143
+ * String enums are nominal in TypeScript, so the compiler cannot check these
144
+ * against the real ones from a type-only import. `client/test/AvatarClient.test.ts`
145
+ * does it instead, against the actual enum — the devDependency is present
146
+ * exactly where the check belongs and absent from what we ship.
147
+ */
148
+ export const RTVI_EVENTS = {
149
+ serverMessage: "serverMessage",
150
+ botStartedSpeaking: "botStartedSpeaking",
151
+ botStoppedSpeaking: "botStoppedSpeaking",
152
+ } as const satisfies Record<string, string>;
153
+
154
+ /** Defensive unwrap for the `RTVIEvent.ServerMessage` `{ data }` quirk: some
155
+ * transports deliver the payload directly and some wrap it once more. */
156
+ function unwrapServerMessage(raw: unknown): Record<string, unknown> {
157
+ const obj = (raw ?? {}) as Record<string, unknown>;
158
+ const inner = obj["data"] as Record<string, unknown> | undefined;
159
+ return inner && "type" in inner ? inner : obj;
160
+ }
161
+
162
+ export class AvatarClient {
163
+ private readonly avatar: AvatarApi;
164
+ private readonly opts: AvatarClientOptions;
165
+ private readonly now: () => number;
166
+ private readonly accept: (message: Record<string, unknown>) => boolean;
167
+ private turn: Turn | null = null;
168
+
169
+ constructor(avatar: AvatarApi, opts: AvatarClientOptions = {}) {
170
+ this.avatar = avatar;
171
+ this.opts = opts;
172
+ this.now = opts.now ?? (() => performance.now());
173
+ this.accept = opts.accept ?? ((m) => m.type === AVATAR_MESSAGE_TYPE);
174
+ }
175
+
176
+ /** The active turn's ctx, or `null` between turns. For tests and telemetry. */
177
+ get turnCtx(): string | null {
178
+ return this.turn?.ctx ?? null;
179
+ }
180
+
181
+ /** The active turn's canonical (already-spliced) cue track. For tests and telemetry. */
182
+ get turnCues(): AvatarCue[] {
183
+ return this.turn ? [...this.turn.cues] : [];
184
+ }
185
+
186
+ /** Dispatch one avatar command. Accepts anything with a string `cmd` — an
187
+ * already-unwrapped `{type:"avatar", cmd, ...}` server message, or a bare
188
+ * `{cmd, ...}` payload from whatever else the host is carrying them in.
189
+ * Unknown `cmd`s are ignored, per the wire protocol's forward-compat rule. */
190
+ dispatch(raw: unknown): void {
191
+ if (!isAvatarMessage(raw)) return;
192
+ const msg = raw;
193
+ try {
194
+ switch (msg.cmd) {
195
+ case "state":
196
+ this.handleState(msg as AvatarStateCmd);
197
+ break;
198
+ case "interject":
199
+ this.avatar.interject((msg as { id: string }).id);
200
+ break;
201
+ case "perform":
202
+ this.handlePerform(msg as AvatarPerformCmd);
203
+ break;
204
+ case "cues":
205
+ this.handleCues(msg as AvatarCuesCmd);
206
+ break;
207
+ case "speech":
208
+ this.handleSpeech(msg as AvatarSpeechCmd);
209
+ break;
210
+ case "user":
211
+ this.avatar.setUserSpeaking((msg as { speaking: boolean }).speaking);
212
+ break;
213
+ case "hint": {
214
+ const hint = msg as AvatarHintCmd;
215
+ this.opts.onHint?.(hint.kind, hint);
216
+ break;
217
+ }
218
+ default:
219
+ this.opts.onUnknownCmd?.(msg as AvatarUnknownCmd);
220
+ break;
221
+ }
222
+ } catch (err) {
223
+ if (this.opts.onError) this.opts.onError(err, msg);
224
+ else console.warn("[avatar] dispatch failed", msg, err);
225
+ }
226
+ }
227
+
228
+ private handleState(msg: AvatarStateCmd) {
229
+ // Deliberately no client-side dedup: pass every `state` command straight
230
+ // through. The widget's own setState already no-ops the parts that matter
231
+ // for an unchanged name (`changed` gates the blink and the 'state' event in
232
+ // avatar.js), and a server resending the same state name as a
233
+ // keepalive/resync must still land so an `emotion`/`gaze` override on this
234
+ // particular message takes effect.
235
+ this.avatar.setState(msg.name, { emotion: msg.emotion, gaze: msg.gaze });
236
+ }
237
+
238
+ private handlePerform(msg: AvatarPerformCmd) {
239
+ this.avatar.perform(msg.actions, { clock: this.resolveClock(msg.ctx) });
240
+ }
241
+
242
+ /** Ride the named turn's clock if it's the one we're currently anchored to;
243
+ * otherwise (no active turn, or `perform` names a ctx we never saw a
244
+ * `speech start` for) fall back to a fresh clock anchored at this call — the
245
+ * same "elapsed ms since this call" default `avatar.perform()` itself uses
246
+ * when given no clock and no audio. */
247
+ private resolveClock(ctx: string | undefined): () => number {
248
+ if (ctx && this.turn && this.turn.ctx === ctx && this.turn.clock) {
249
+ return this.turn.clock;
250
+ }
251
+ const start = this.now();
252
+ return () => this.now() - start;
253
+ }
254
+
255
+ private ensureTurn(ctx: string): Turn {
256
+ if (!this.turn || this.turn.ctx !== ctx) {
257
+ // A different ctx supersedes whatever turn we had — a stale trailing
258
+ // message for the old ctx will find `this.turn.ctx !== ctx` in
259
+ // handleSpeech's stop-guard and be ignored, rather than cutting off the
260
+ // new turn.
261
+ this.turn = { ctx, cues: [], started: false, clock: null, t0: null };
262
+ }
263
+ return this.turn;
264
+ }
265
+
266
+ private handleCues(msg: AvatarCuesCmd) {
267
+ const turn = this.ensureTurn(msg.ctx);
268
+ const kept = turn.cues.filter((c) => c.t < msg.from_ms);
269
+ const discarded = turn.cues.length - kept.length;
270
+ turn.cues = [...kept, ...msg.cues].sort((a, b) => a.t - b.t);
271
+
272
+ if (!turn.started) {
273
+ // No clock yet — buffer. `speech start` will hand this over as the turn's
274
+ // first speak() call.
275
+ return;
276
+ }
277
+ if (discarded === 0) {
278
+ this.avatar.pushCues(msg.cues);
279
+ } else {
280
+ this.avatar.speak({ cues: turn.cues, clock: turn.clock! });
281
+ }
282
+ }
283
+
284
+ private handleSpeech(msg: AvatarSpeechCmd) {
285
+ if (msg.event === "start") {
286
+ const turn = this.ensureTurn(msg.ctx);
287
+ const t0 = this.now();
288
+ const clock = () => this.now() - t0;
289
+ turn.t0 = t0;
290
+ turn.clock = clock;
291
+ turn.started = true;
292
+ this.avatar.speak({ cues: turn.cues, clock });
293
+ return;
294
+ }
295
+ // "stop": only act if it names the turn we're actually riding. A stale stop
296
+ // for an already-superseded ctx must not cut off a newer turn.
297
+ if (this.turn && this.turn.ctx === msg.ctx) {
298
+ this.avatar.stopSpeaking();
299
+ this.turn = null;
300
+ }
301
+ }
302
+
303
+ private reportDrift(event: "start" | "stop") {
304
+ if (!this.opts.onSpeakingDrift) return;
305
+ const t0 = this.turn?.t0;
306
+ if (t0 == null) return;
307
+ this.opts.onSpeakingDrift({ event, ctx: this.turn?.ctx ?? null, driftMs: this.now() - t0 });
308
+ }
309
+
310
+ /**
311
+ * Subscribe to a live `PipecatClient`'s server messages and dispatch the
312
+ * avatar commands among them. Which messages count is the `accept` option;
313
+ * by default, the protocol's own `{type:"avatar"}` envelope.
314
+ *
315
+ * Also wires the diagnostic drift cross-check described in the class doc.
316
+ * Never throws on a malformed or irrelevant message.
317
+ *
318
+ * @returns an unsubscribe function; call it on unmount or disconnect.
319
+ */
320
+ attach(client: PipecatClient): () => void {
321
+ const onServerMessage = (raw: unknown) => {
322
+ const message = unwrapServerMessage(raw);
323
+ if (!this.accept(message)) return;
324
+ this.dispatch(message);
325
+ };
326
+ const onBotStartedSpeaking = () => this.reportDrift("start");
327
+ const onBotStoppedSpeaking = () => this.reportDrift("stop");
328
+
329
+ const serverMessage = RTVI_EVENTS.serverMessage as RTVIEvent;
330
+ const started = RTVI_EVENTS.botStartedSpeaking as RTVIEvent;
331
+ const stopped = RTVI_EVENTS.botStoppedSpeaking as RTVIEvent;
332
+
333
+ client.on(serverMessage, onServerMessage);
334
+ client.on(started, onBotStartedSpeaking);
335
+ client.on(stopped, onBotStoppedSpeaking);
336
+
337
+ return () => {
338
+ client.off(serverMessage, onServerMessage);
339
+ client.off(started, onBotStartedSpeaking);
340
+ client.off(stopped, onBotStoppedSpeaking);
341
+ };
342
+ }
343
+ }