@voqalize/avatar 0.2.2 → 0.4.0
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.
- package/LICENSE +21 -661
- package/LICENSE-CC-BY-4.0 +396 -0
- package/README.md +191 -662
- package/assets/README.md +48 -0
- package/assets/tanya.glb +0 -0
- package/assets/tara.glb +0 -0
- package/assets/tushar.glb +0 -0
- package/client/Avatar.tsx +57 -0
- package/client/AvatarClient.ts +818 -0
- package/client/arjun.ts +26 -0
- package/client/createAvatar.ts +177 -0
- package/client/createCanvasAvatar.ts +72 -0
- package/client/index.ts +44 -0
- package/client/internal.ts +108 -0
- package/client/interviewer-female.ts +4 -0
- package/client/interviewer-male.ts +4 -0
- package/client/ishita.ts +26 -0
- package/client/kabir.ts +26 -0
- package/client/meera.ts +26 -0
- package/client/naina.ts +26 -0
- package/client/playout.ts +95 -0
- package/client/professional-female-a.ts +4 -0
- package/client/professional-female-b.ts +4 -0
- package/client/professional-male-a.ts +4 -0
- package/client/professional-male-b.ts +4 -0
- package/client/react.ts +13 -0
- package/client/supports.ts +28 -0
- package/client/three/assets.ts +24 -0
- package/client/three/budgets.ts +27 -0
- package/client/three/holds.ts +64 -0
- package/client/three/internal.ts +54 -0
- package/client/three/motion-limits.json +116 -0
- package/client/three/sequences.ts +281 -0
- package/client/three/tanya.ts +69 -0
- package/client/three/tara-rig.ts +1562 -0
- package/client/three/tara.ts +76 -0
- package/client/three/tushar.ts +65 -0
- package/client/types.ts +235 -0
- package/client/useAvatar.ts +74 -0
- package/client/vikram.ts +26 -0
- package/dist/Avatar.d.ts +44 -0
- package/dist/Avatar.d.ts.map +1 -0
- package/{client/dist → dist}/Avatar.js +2 -2
- package/dist/Avatar.js.map +1 -0
- package/dist/AvatarClient.d.ts +274 -0
- package/dist/AvatarClient.d.ts.map +1 -0
- package/dist/AvatarClient.js +712 -0
- package/dist/AvatarClient.js.map +1 -0
- package/dist/arjun.d.ts +7 -0
- package/dist/arjun.d.ts.map +1 -0
- package/dist/arjun.js +20 -0
- package/dist/arjun.js.map +1 -0
- package/dist/createAvatar.d.ts +135 -0
- package/dist/createAvatar.d.ts.map +1 -0
- package/dist/createAvatar.js +75 -0
- package/dist/createAvatar.js.map +1 -0
- package/dist/createCanvasAvatar.d.ts +22 -0
- package/dist/createCanvasAvatar.d.ts.map +1 -0
- package/dist/createCanvasAvatar.js +47 -0
- package/dist/createCanvasAvatar.js.map +1 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +33 -0
- package/dist/index.js.map +1 -0
- package/dist/internal.d.ts +34 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +41 -0
- package/dist/internal.js.map +1 -0
- package/dist/interviewer-female.d.ts +4 -0
- package/dist/interviewer-female.d.ts.map +1 -0
- package/dist/interviewer-female.js +3 -0
- package/dist/interviewer-female.js.map +1 -0
- package/dist/interviewer-male.d.ts +4 -0
- package/dist/interviewer-male.d.ts.map +1 -0
- package/dist/interviewer-male.js +3 -0
- package/dist/interviewer-male.js.map +1 -0
- package/dist/ishita.d.ts +7 -0
- package/dist/ishita.d.ts.map +1 -0
- package/dist/ishita.js +20 -0
- package/dist/ishita.js.map +1 -0
- package/dist/kabir.d.ts +7 -0
- package/dist/kabir.d.ts.map +1 -0
- package/dist/kabir.js +20 -0
- package/dist/kabir.js.map +1 -0
- package/dist/meera.d.ts +7 -0
- package/dist/meera.d.ts.map +1 -0
- package/dist/meera.js +20 -0
- package/dist/meera.js.map +1 -0
- package/dist/naina.d.ts +7 -0
- package/dist/naina.d.ts.map +1 -0
- package/dist/naina.js +20 -0
- package/dist/naina.js.map +1 -0
- package/dist/playout.d.ts +42 -0
- package/dist/playout.d.ts.map +1 -0
- package/dist/playout.js +85 -0
- package/dist/playout.js.map +1 -0
- package/dist/professional-female-a.d.ts +4 -0
- package/dist/professional-female-a.d.ts.map +1 -0
- package/dist/professional-female-a.js +3 -0
- package/dist/professional-female-a.js.map +1 -0
- package/dist/professional-female-b.d.ts +4 -0
- package/dist/professional-female-b.d.ts.map +1 -0
- package/dist/professional-female-b.js +3 -0
- package/dist/professional-female-b.js.map +1 -0
- package/dist/professional-male-a.d.ts +4 -0
- package/dist/professional-male-a.d.ts.map +1 -0
- package/dist/professional-male-a.js +3 -0
- package/dist/professional-male-a.js.map +1 -0
- package/dist/professional-male-b.d.ts +4 -0
- package/dist/professional-male-b.d.ts.map +1 -0
- package/dist/professional-male-b.js +3 -0
- package/dist/professional-male-b.js.map +1 -0
- package/dist/react.d.ts +13 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +13 -0
- package/dist/react.js.map +1 -0
- package/dist/supports.d.ts +20 -0
- package/dist/supports.d.ts.map +1 -0
- package/dist/supports.js +26 -0
- package/dist/supports.js.map +1 -0
- package/dist/three/assets.d.ts +24 -0
- package/dist/three/assets.d.ts.map +1 -0
- package/dist/three/assets.js +22 -0
- package/dist/three/assets.js.map +1 -0
- package/dist/three/budgets.d.ts +17 -0
- package/dist/three/budgets.d.ts.map +1 -0
- package/dist/three/budgets.js +21 -0
- package/dist/three/budgets.js.map +1 -0
- package/dist/three/holds.d.ts +33 -0
- package/dist/three/holds.d.ts.map +1 -0
- package/dist/three/holds.js +56 -0
- package/dist/three/holds.js.map +1 -0
- package/dist/three/internal.d.ts +52 -0
- package/dist/three/internal.d.ts.map +1 -0
- package/dist/three/internal.js +49 -0
- package/dist/three/internal.js.map +1 -0
- package/dist/three/motion-limits.json +116 -0
- package/dist/three/sequences.d.ts +71 -0
- package/dist/three/sequences.d.ts.map +1 -0
- package/dist/three/sequences.js +262 -0
- package/dist/three/sequences.js.map +1 -0
- package/dist/three/tanya.d.ts +35 -0
- package/dist/three/tanya.d.ts.map +1 -0
- package/dist/three/tanya.js +51 -0
- package/dist/three/tanya.js.map +1 -0
- package/dist/three/tara-rig.d.ts +403 -0
- package/dist/three/tara-rig.d.ts.map +1 -0
- package/dist/three/tara-rig.js +1500 -0
- package/dist/three/tara-rig.js.map +1 -0
- package/dist/three/tara.d.ts +35 -0
- package/dist/three/tara.d.ts.map +1 -0
- package/dist/three/tara.js +58 -0
- package/dist/three/tara.js.map +1 -0
- package/dist/three/tushar.d.ts +31 -0
- package/dist/three/tushar.d.ts.map +1 -0
- package/dist/three/tushar.js +47 -0
- package/dist/three/tushar.js.map +1 -0
- package/dist/types.d.ts +149 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +140 -0
- package/dist/types.js.map +1 -0
- package/dist/useAvatar.d.ts +39 -0
- package/dist/useAvatar.d.ts.map +1 -0
- package/dist/useAvatar.js +35 -0
- package/dist/useAvatar.js.map +1 -0
- package/dist/vikram.d.ts +7 -0
- package/dist/vikram.d.ts.map +1 -0
- package/dist/vikram.js +20 -0
- package/dist/vikram.js.map +1 -0
- package/package.json +110 -25
- package/src/avatar.d.ts +218 -120
- package/src/avatar.js +950 -213
- package/src/behavior.d.ts +42 -0
- package/src/behavior.js +114 -0
- package/src/camera.js +29 -0
- package/src/canvas/author/parts/eye.mjs +722 -0
- package/src/canvas/author/parts/hand.mjs +1156 -0
- package/src/canvas/author/parts/mouth.mjs +741 -0
- package/src/canvas/author/parts/nose.mjs +100 -0
- package/src/canvas/author/parts/skin-detail.mjs +67 -0
- package/src/canvas/author/path.mjs +283 -0
- package/src/canvas/author/rig.mjs +405 -0
- package/src/canvas/avatars/round/face.d.mts +3 -0
- package/src/canvas/avatars/round/face.mjs +1307 -0
- package/src/canvas/create-rig.d.ts +15 -0
- package/src/canvas/create-rig.js +100 -0
- package/src/canvas/data/img/professional-female-a-hair-back.webp +0 -0
- package/src/canvas/data/img/professional-female-a-hair-front.webp +0 -0
- package/src/canvas/data/img/professional-female-a-top-body.webp +0 -0
- package/src/canvas/data/img/professional-female-b-hair-back.webp +0 -0
- package/src/canvas/data/img/professional-female-b-hair-front.webp +0 -0
- package/src/canvas/data/img/professional-female-b-top-body.webp +0 -0
- package/src/canvas/data/img/professional-male-a-hair-back.webp +0 -0
- package/src/canvas/data/img/professional-male-a-hair-front.webp +0 -0
- package/src/canvas/data/img/professional-male-a-top-body.webp +0 -0
- package/src/canvas/data/img/professional-male-b-hair-back.webp +0 -0
- package/src/canvas/data/img/professional-male-b-hair-front.webp +0 -0
- package/src/canvas/data/img/professional-male-b-top-body.webp +0 -0
- package/src/canvas/data/img/round-m3-hair-back.webp +0 -0
- package/src/canvas/data/img/round-m3-hair-front.webp +0 -0
- package/src/canvas/data/img/round-m3-top-body.webp +0 -0
- package/src/canvas/data/img/round-w1-hair-back.webp +0 -0
- package/src/canvas/data/img/round-w1-hair-front.webp +0 -0
- package/src/canvas/data/img/round-w1-top-body.webp +0 -0
- package/src/canvas/data/interviewer-female.rig.json +1 -0
- package/src/canvas/data/interviewer-male.rig.json +1 -0
- package/src/canvas/data/professional-female-a.rig.json +1 -0
- package/src/canvas/data/professional-female-b.rig.json +1 -0
- package/src/canvas/data/professional-male-a.rig.json +1 -0
- package/src/canvas/data/professional-male-b.rig.json +1 -0
- package/src/canvas/src/live.js +508 -0
- package/src/canvas/src/render2d.js +218 -0
- package/src/canvas/src/rig.js +297 -0
- package/src/canvas/src/vocab.js +96 -0
- package/src/clips.js +18 -7
- package/src/conformance.js +119 -0
- package/src/emotions.js +8 -5
- package/src/face-core.js +27 -1
- package/src/face-myna.d.ts +7 -0
- package/src/face-myna.js +192 -131
- package/src/face-peep-control-plane.js +167 -0
- package/src/face-peep.d.ts +7 -0
- package/src/face-peep.js +274 -116
- package/src/face-wren.d.ts +7 -0
- package/src/face-wren.js +17 -19
- package/src/faces.d.ts +12 -0
- package/src/faces.js +53 -0
- package/src/gaze.js +434 -58
- package/src/hand.js +120 -91
- package/src/head.js +134 -0
- package/src/idle.js +227 -119
- package/src/interjections.js +200 -31
- package/src/params.js +8 -4
- package/src/perform.js +7 -9
- package/src/prosody.js +647 -0
- package/src/rig.d.ts +24 -0
- package/src/rig.js +32 -0
- package/src/speech-timing.js +23 -0
- package/src/visemes.js +110 -24
- package/client/dist/Avatar.d.ts +0 -27
- package/client/dist/Avatar.d.ts.map +0 -1
- package/client/dist/Avatar.js.map +0 -1
- package/client/dist/AvatarClient.d.ts +0 -139
- package/client/dist/AvatarClient.d.ts.map +0 -1
- package/client/dist/AvatarClient.js +0 -223
- package/client/dist/AvatarClient.js.map +0 -1
- package/client/dist/index.d.ts +0 -22
- package/client/dist/index.d.ts.map +0 -1
- package/client/dist/index.js +0 -22
- package/client/dist/index.js.map +0 -1
- package/client/dist/types.d.ts +0 -86
- package/client/dist/types.d.ts.map +0 -1
- package/client/dist/types.js +0 -31
- package/client/dist/types.js.map +0 -1
- package/client/dist/useAvatar.d.ts +0 -42
- package/client/dist/useAvatar.d.ts.map +0 -1
- package/client/dist/useAvatar.js +0 -51
- package/client/dist/useAvatar.js.map +0 -1
- package/client/src/Avatar.tsx +0 -33
- package/client/src/AvatarClient.ts +0 -270
- package/client/src/index.ts +0 -22
- package/client/src/types.ts +0 -104
- package/client/src/useAvatar.ts +0 -85
- package/docs/contract-avatar.md +0 -371
- package/docs/contract-protocol.md +0 -461
package/client/arjun.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Professional Indian male interviewer, authored to read at call-tile size. */
|
|
2
|
+
|
|
3
|
+
import { createCanvasAvatar } from './createCanvasAvatar.js';
|
|
4
|
+
import type { CanvasAvatarOptions } from './createCanvasAvatar.js';
|
|
5
|
+
import type { AvatarInstance } from './createAvatar.js';
|
|
6
|
+
|
|
7
|
+
const RIG_URL = new URL('../src/canvas/data/interviewer-male.rig.json', import.meta.url);
|
|
8
|
+
const IMAGES = {
|
|
9
|
+
'round-m3-hair-back.webp': new URL('../src/canvas/data/img/round-m3-hair-back.webp', import.meta.url),
|
|
10
|
+
'round-m3-top-body.webp': new URL('../src/canvas/data/img/round-m3-top-body.webp', import.meta.url),
|
|
11
|
+
'round-m3-hair-front.webp': new URL('../src/canvas/data/img/round-m3-hair-front.webp', import.meta.url),
|
|
12
|
+
} as const;
|
|
13
|
+
const FACE = () => import('../src/canvas/avatars/round/face.mjs');
|
|
14
|
+
|
|
15
|
+
export type { CanvasAvatarOptions };
|
|
16
|
+
// Same mixer, same clip library, so the same declaration (`supports.ts`).
|
|
17
|
+
export { supports } from './supports.js';
|
|
18
|
+
|
|
19
|
+
export function createAvatar(options: CanvasAvatarOptions): AvatarInstance {
|
|
20
|
+
return createCanvasAvatar(options, {
|
|
21
|
+
rigUrl: RIG_URL,
|
|
22
|
+
images: IMAGES,
|
|
23
|
+
face: FACE,
|
|
24
|
+
label: 'Arjun — professional male interviewer avatar',
|
|
25
|
+
});
|
|
26
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `createAvatar` — the entire public surface of an avatar.
|
|
3
|
+
*
|
|
4
|
+
* const avatar = createAvatar({ mount: el, client: pipecatClient });
|
|
5
|
+
* // …
|
|
6
|
+
* avatar.destroy();
|
|
7
|
+
*
|
|
8
|
+
* An avatar is an embodiment of a `PipecatClient`. You hand it a mount and a
|
|
9
|
+
* live client; it subscribes and reacts. There is nothing else: no methods to
|
|
10
|
+
* drive it, no callbacks to observe it, no state to read back. Everything it
|
|
11
|
+
* needs to know is already on the wire — Pipecat's factual lifecycle events
|
|
12
|
+
* plus the avatar server-message envelope (docs/contract-wire.md).
|
|
13
|
+
*
|
|
14
|
+
* ## This is also the extension point
|
|
15
|
+
*
|
|
16
|
+
* There is no registry, no loader and no plug-in system. **You add an avatar by
|
|
17
|
+
* publishing a module that exports `createAvatar` with this signature**, and
|
|
18
|
+
* importing yours instead of ours:
|
|
19
|
+
*
|
|
20
|
+
* import { createAvatar } from "@acme/our-mascot";
|
|
21
|
+
*
|
|
22
|
+
* That is the whole mechanism, and it is deliberate. A registry would make us
|
|
23
|
+
* own resolution, versioning and asset paths for code we have never seen; a
|
|
24
|
+
* renderer interface would make us commit to a second public contract before we
|
|
25
|
+
* know what the second renderer actually needs. An ES module export we get for
|
|
26
|
+
* free from the platform.
|
|
27
|
+
*
|
|
28
|
+
* Everything past `mount` and `client` belongs to the implementation. Ours takes
|
|
29
|
+
* `SvgAvatarOptions`; yours takes whatever it likes, and `AvatarFactory<O>` is
|
|
30
|
+
* generic so the caller still gets your options checked.
|
|
31
|
+
*
|
|
32
|
+
* ## What an implementation owes the caller
|
|
33
|
+
*
|
|
34
|
+
* Only that `destroy()` leaves the mount as it found it and unsubscribes from
|
|
35
|
+
* the client. Not returning to rest, not honouring every action, not even
|
|
36
|
+
* having a mouth — a handless, mouthless avatar that only changes colour is a
|
|
37
|
+
* conforming avatar. The obligations that *matter* are perceptual, not typed,
|
|
38
|
+
* and they live in docs/contract-behavior.md.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import type { PipecatClient } from "@pipecat-ai/client-js";
|
|
42
|
+
import { createAvatar as createSvgWidget } from "../src/avatar.js";
|
|
43
|
+
import type { Face, FaceTheme, Gain, HandSide } from "../src/avatar.js";
|
|
44
|
+
import type { BehaviorStateId } from "../src/behavior.js";
|
|
45
|
+
import { peep } from "../src/face-peep.js";
|
|
46
|
+
import { AvatarClient } from "./AvatarClient.js";
|
|
47
|
+
|
|
48
|
+
export type { Face, FaceTheme, Gain, HandSide };
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* What every `createAvatar` takes. Implementations extend it with their own
|
|
52
|
+
* options; the two named here are the only ones the caller can count on.
|
|
53
|
+
*/
|
|
54
|
+
export interface AvatarOptions {
|
|
55
|
+
/** The element to render into. The implementation owns its contents. */
|
|
56
|
+
readonly mount: HTMLElement;
|
|
57
|
+
/**
|
|
58
|
+
* A live `PipecatClient`. Required, and not nullable: an avatar with nothing
|
|
59
|
+
* to embody has no reason to exist yet. React callers get the forgiving
|
|
60
|
+
* version — `<Avatar>` waits for a non-null client before mounting.
|
|
61
|
+
*/
|
|
62
|
+
readonly client: PipecatClient;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** What every `createAvatar` returns. */
|
|
66
|
+
export interface AvatarInstance {
|
|
67
|
+
/** Unsubscribe from the client and remove everything from the mount. */
|
|
68
|
+
destroy(): void;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The shape a third-party avatar module exports, parameterised by its own
|
|
73
|
+
* options so a caller passing them gets them checked. `AvatarFactory` bare is
|
|
74
|
+
* the common denominator — the two options everyone shares.
|
|
75
|
+
*/
|
|
76
|
+
export type AvatarFactory<O extends AvatarOptions = AvatarOptions> =
|
|
77
|
+
(options: O) => AvatarInstance;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* What a mounted avatar answers to — the one *optional* export beside
|
|
81
|
+
* `createAvatar`.
|
|
82
|
+
*
|
|
83
|
+
* A page that drives an avatar has a problem an ordinary consumer does not.
|
|
84
|
+
* The wire's action id is open and an unknown one is ignored in silence
|
|
85
|
+
* (docs/contract-wire.md § Action), which is right for a protocol and useless
|
|
86
|
+
* for a control surface: a button per name cannot tell "this face has no such
|
|
87
|
+
* motion" from "nothing happened". This is the avatar answering that question
|
|
88
|
+
* for a driving UI, and nothing else reads it — `createAvatar` does not take
|
|
89
|
+
* it, the library never consults it, and an avatar that omits it is fully
|
|
90
|
+
* conforming. It is a *declaration*: nobody verifies it, so a list that has
|
|
91
|
+
* drifted from the drawing is a bug in the avatar and not something the
|
|
92
|
+
* library can catch.
|
|
93
|
+
*/
|
|
94
|
+
export interface AvatarSupport {
|
|
95
|
+
/**
|
|
96
|
+
* Every action id this avatar answers to, the two required ones included.
|
|
97
|
+
* An id absent from here is a documented no-op, never an error.
|
|
98
|
+
*/
|
|
99
|
+
readonly actions: readonly string[];
|
|
100
|
+
/**
|
|
101
|
+
* Which of the nine states this avatar draws *distinguishably*.
|
|
102
|
+
*
|
|
103
|
+
* Every avatar must accept all nine; a renderer that draws `CANT_HEAR` as
|
|
104
|
+
* ordinary listening is conforming (docs/contract-behavior.md). So this is
|
|
105
|
+
* the narrower claim — which of them a reviewer can expect to tell apart on
|
|
106
|
+
* the face — and omitting it claims all nine, which is what every avatar in
|
|
107
|
+
* this repo does.
|
|
108
|
+
*/
|
|
109
|
+
readonly states?: readonly BehaviorStateId[];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The bundled avatars' own `supports` value is in `supports.ts`, not here: it
|
|
113
|
+
* is a value, so this module's `peep` import would follow it into the bundle of
|
|
114
|
+
* anything that read it — including the canvas avatars, which draw no SVG face. */
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Options for the bundled SVG avatars — ours alone; nothing outside this
|
|
118
|
+
* package reads them.
|
|
119
|
+
*
|
|
120
|
+
* `face` is a value, not a name: a name needs a table, and a table needs every
|
|
121
|
+
* face imported to answer any lookup. Import the one you want and the other two
|
|
122
|
+
* never enter your bundle.
|
|
123
|
+
*
|
|
124
|
+
* import { createAvatar } from "@voqalize/avatar";
|
|
125
|
+
* import { wren } from "@voqalize/avatar/faces/wren";
|
|
126
|
+
*
|
|
127
|
+
* createAvatar({ mount, client, face: wren });
|
|
128
|
+
*/
|
|
129
|
+
export interface SvgAvatarOptions extends AvatarOptions {
|
|
130
|
+
/** Defaults to `peep`, the face this entry point already carries. */
|
|
131
|
+
readonly face?: Face;
|
|
132
|
+
/** Palette for that face. Its keys are the face's own; see `THEME` in its
|
|
133
|
+
* module, and CLAUDE.md on why `peep` has exactly one. */
|
|
134
|
+
readonly theme?: FaceTheme;
|
|
135
|
+
/** Viseme amplitude, 0..2. `1` is as authored. */
|
|
136
|
+
readonly mouthGain?: Gain;
|
|
137
|
+
/** Gesture-clip amplitude, 0..2. */
|
|
138
|
+
readonly gestureGain?: Gain;
|
|
139
|
+
/** Idle/liveness amplitude, 0..2. Low by design — see CLAUDE.md. */
|
|
140
|
+
readonly motionGain?: Gain;
|
|
141
|
+
/** Render the frame-edge hand at all. Default true. */
|
|
142
|
+
readonly hand?: boolean;
|
|
143
|
+
/** `1` puts it on the viewer's right, `-1` the other side. */
|
|
144
|
+
readonly handSide?: HandSide;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const GAINS = ["mouthGain", "gestureGain", "motionGain"] as const;
|
|
148
|
+
|
|
149
|
+
export function createAvatar(options: SvgAvatarOptions): AvatarInstance {
|
|
150
|
+
const { mount, client, face = peep, ...rest } = options;
|
|
151
|
+
if (!mount) throw new TypeError("createAvatar: `mount` is required");
|
|
152
|
+
if (!client) throw new TypeError("createAvatar: `client` is required");
|
|
153
|
+
// The one range TypeScript cannot state, checked where it is cheapest to fix:
|
|
154
|
+
// at construction, by the caller who typed the number.
|
|
155
|
+
for (const key of GAINS) {
|
|
156
|
+
const g = rest[key];
|
|
157
|
+
if (g === undefined) continue;
|
|
158
|
+
if (!Number.isFinite(g) || g < 0 || g > 2) {
|
|
159
|
+
throw new RangeError(`createAvatar: \`${key}\` must be 0..2, got ${String(g)}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const widget = createSvgWidget({ mount, face, ...rest });
|
|
164
|
+
const driver = new AvatarClient(widget);
|
|
165
|
+
const detach = driver.attach(client);
|
|
166
|
+
|
|
167
|
+
let destroyed = false;
|
|
168
|
+
return {
|
|
169
|
+
destroy() {
|
|
170
|
+
if (destroyed) return;
|
|
171
|
+
destroyed = true;
|
|
172
|
+
detach();
|
|
173
|
+
driver.destroy();
|
|
174
|
+
widget.destroy();
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/** Shared private constructor for the code-authored canvas avatars. */
|
|
2
|
+
|
|
3
|
+
import type { PipecatClient } from '@pipecat-ai/client-js';
|
|
4
|
+
import { createAvatar as createMixer, STATES } from '../src/avatar.js';
|
|
5
|
+
import type { Gain } from '../src/avatar.js';
|
|
6
|
+
import { createCanvasRig } from '../src/canvas/create-rig.js';
|
|
7
|
+
import { AvatarClient } from './AvatarClient.js';
|
|
8
|
+
import type { AvatarInstance, AvatarOptions } from './createAvatar.js';
|
|
9
|
+
|
|
10
|
+
export interface CanvasAvatarOptions extends AvatarOptions {
|
|
11
|
+
/** Viseme amplitude, 0..2. `1` is as authored. */
|
|
12
|
+
readonly mouthGain?: Gain;
|
|
13
|
+
/** Gesture-clip amplitude, 0..2. */
|
|
14
|
+
readonly gestureGain?: Gain;
|
|
15
|
+
/** Idle/liveness amplitude, 0..2. */
|
|
16
|
+
readonly motionGain?: Gain;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** @deprecated Use `CanvasAvatarOptions`; kept for the original entry points. */
|
|
20
|
+
export type InterviewerAvatarOptions = CanvasAvatarOptions;
|
|
21
|
+
|
|
22
|
+
interface CanvasAvatarConfig {
|
|
23
|
+
readonly rigUrl: URL;
|
|
24
|
+
readonly images: Readonly<Record<string, URL>>;
|
|
25
|
+
readonly face: () => Promise<unknown>;
|
|
26
|
+
readonly label: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const GAINS = ['mouthGain', 'gestureGain', 'motionGain'] as const;
|
|
30
|
+
const PRESENCE_FILTERS: Readonly<Record<string, string>> = {
|
|
31
|
+
DEGRADED: String(STATES.DEGRADED.filter ?? ''),
|
|
32
|
+
OFFLINE: String(STATES.OFFLINE.filter ?? ''),
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function createCanvasAvatar(
|
|
36
|
+
options: CanvasAvatarOptions,
|
|
37
|
+
config: CanvasAvatarConfig,
|
|
38
|
+
): AvatarInstance {
|
|
39
|
+
const { mount, client, ...gains } = options;
|
|
40
|
+
if (!mount) throw new TypeError('createAvatar: `mount` is required');
|
|
41
|
+
if (!client) throw new TypeError('createAvatar: `client` is required');
|
|
42
|
+
for (const key of GAINS) {
|
|
43
|
+
const gain = gains[key];
|
|
44
|
+
if (gain === undefined) continue;
|
|
45
|
+
if (!Number.isFinite(gain) || gain < 0 || gain > 2) {
|
|
46
|
+
throw new RangeError(`createAvatar: \`${key}\` must be 0..2, got ${String(gain)}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const canvas = createCanvasRig({
|
|
51
|
+
url: config.rigUrl,
|
|
52
|
+
images: config.images,
|
|
53
|
+
face: config.face,
|
|
54
|
+
label: config.label,
|
|
55
|
+
});
|
|
56
|
+
const widget = createMixer({ mount, rig: canvas.factory, ...gains });
|
|
57
|
+
const driver = new AvatarClient(widget, {
|
|
58
|
+
onPresenceChange: (state) => canvas.setPresenceFilter(PRESENCE_FILTERS[state] ?? ''),
|
|
59
|
+
});
|
|
60
|
+
const detach = driver.attach(client as PipecatClient);
|
|
61
|
+
|
|
62
|
+
let destroyed = false;
|
|
63
|
+
return {
|
|
64
|
+
destroy() {
|
|
65
|
+
if (destroyed) return;
|
|
66
|
+
destroyed = true;
|
|
67
|
+
detach();
|
|
68
|
+
driver.destroy();
|
|
69
|
+
widget.destroy();
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
package/client/index.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@voqalize/avatar` — a talking head that embodies a `PipecatClient`.
|
|
3
|
+
*
|
|
4
|
+
* import { createAvatar } from "@voqalize/avatar";
|
|
5
|
+
*
|
|
6
|
+
* const avatar = createAvatar({ mount: el, client: pipecatClient });
|
|
7
|
+
*
|
|
8
|
+
* That is the whole public surface. Put an `AvatarProcessor` in the pipecat
|
|
9
|
+
* pipeline (`pip install voqalize-avatar`), mount this in the bot's tile, and
|
|
10
|
+
* the face listens, thinks, claims the floor and lipsyncs what the TTS says.
|
|
11
|
+
*
|
|
12
|
+
* Entry points, and the split is the design rather than packaging taste:
|
|
13
|
+
*
|
|
14
|
+
* - `@voqalize/avatar` — this one. Framework-free. `@pipecat-ai/client-js` is
|
|
15
|
+
* a type-only import, so even that peer is genuinely optional at runtime.
|
|
16
|
+
* It carries exactly one drawing: `peep`, the default face.
|
|
17
|
+
* - `@voqalize/avatar/faces/{peep,wren,myna}` — one drawing each. Import the
|
|
18
|
+
* one you want and pass it as `face`; the others never enter your bundle.
|
|
19
|
+
* - `@voqalize/avatar/react` — `<Avatar>`. Pulls React; nothing here does.
|
|
20
|
+
* - `@voqalize/avatar/internal` — the SVG widget, the behavior catalog and the
|
|
21
|
+
* viseme clock. **No semver promise**: these move in any minor. They are
|
|
22
|
+
* exported because an avatar author building on our renderer needs them, not
|
|
23
|
+
* because they are an interface.
|
|
24
|
+
*
|
|
25
|
+
* To ship your own avatar, publish a module exporting `createAvatar` and import
|
|
26
|
+
* that instead — see `createAvatar.ts` and docs/design-avatar-interface.md. A
|
|
27
|
+
* module may also export `supports`, which is how a page that *drives* an
|
|
28
|
+
* avatar learns which action ids this one answers to; every consumer that only
|
|
29
|
+
* mounts a face can ignore it.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
export { createAvatar } from "./createAvatar.js";
|
|
33
|
+
export { supports } from "./supports.js";
|
|
34
|
+
export type {
|
|
35
|
+
AvatarOptions,
|
|
36
|
+
AvatarInstance,
|
|
37
|
+
AvatarFactory,
|
|
38
|
+
AvatarSupport,
|
|
39
|
+
SvgAvatarOptions,
|
|
40
|
+
Face,
|
|
41
|
+
FaceTheme,
|
|
42
|
+
Gain,
|
|
43
|
+
HandSide,
|
|
44
|
+
} from "./createAvatar.js";
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@voqalize/avatar/internal` — the parts of our own implementation that
|
|
3
|
+
* another avatar can build on.
|
|
4
|
+
*
|
|
5
|
+
* **Nothing here is covered by semver.** Names, signatures and semantics move
|
|
6
|
+
* in any minor release. It is exported because an avatar author who wants the
|
|
7
|
+
* SVG mixer, the behavior catalog or — most usefully — the viseme clock should
|
|
8
|
+
* not have to vendor a copy, not because any of it is an interface. The
|
|
9
|
+
* interface is `createAvatar`, and it is one function.
|
|
10
|
+
*
|
|
11
|
+
* The one thing here worth reaching for on purpose is `VisemeTrack`: someone
|
|
12
|
+
* has to turn a cue array plus a clock into "which mouth shape is on screen
|
|
13
|
+
* right now", every renderer needs exactly that, and it is a solved problem.
|
|
14
|
+
*
|
|
15
|
+
* const track = new VisemeTrack();
|
|
16
|
+
* track.start(cues, () => performance.now() - t0);
|
|
17
|
+
* // per frame:
|
|
18
|
+
* const s = track.sample(); // { letter: "D", intensity: 1, phone: "OW" } | null
|
|
19
|
+
*
|
|
20
|
+
* `docs/internal-rig.md` describes the pose-channel model the bundled SVG
|
|
21
|
+
* renderer uses internally. It is *not* the seam to implement — see
|
|
22
|
+
* docs/design-avatar-interface.md.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export {
|
|
26
|
+
// The bundled SVG widget. Not the faces: importing one from here would put
|
|
27
|
+
// all three in every bundle that wanted the viseme clock.
|
|
28
|
+
// `@voqalize/avatar/faces/<name>` is where a face comes from.
|
|
29
|
+
createAvatar as createSvgAvatar,
|
|
30
|
+
// The pose space a custom `rig` is handed on every frame, and the rests it is
|
|
31
|
+
// measured against.
|
|
32
|
+
REST,
|
|
33
|
+
CHANNELS,
|
|
34
|
+
RANGE,
|
|
35
|
+
STATES,
|
|
36
|
+
STATE_NAMES,
|
|
37
|
+
ACTIONS,
|
|
38
|
+
ACTION_IDS,
|
|
39
|
+
GAZE_NAMES,
|
|
40
|
+
GAZE_TARGETS,
|
|
41
|
+
EMOTION_NAMES,
|
|
42
|
+
// The viseme clock and its tables.
|
|
43
|
+
VisemeTrack,
|
|
44
|
+
VISEME_LETTERS,
|
|
45
|
+
VISEME_SHAPES,
|
|
46
|
+
SILENT,
|
|
47
|
+
LEAD_MS,
|
|
48
|
+
// The body's share of a held tilt, for a page driving a rig by hand.
|
|
49
|
+
SHOULDER_TILT,
|
|
50
|
+
shapeFor,
|
|
51
|
+
normalizeCues,
|
|
52
|
+
textToCues,
|
|
53
|
+
ARPABET_TO_VISEME,
|
|
54
|
+
AZURE_VISEME_TO_LETTER,
|
|
55
|
+
} from "../src/avatar.js";
|
|
56
|
+
|
|
57
|
+
export type {
|
|
58
|
+
AvatarApi,
|
|
59
|
+
AvatarStateName,
|
|
60
|
+
AvatarActionId,
|
|
61
|
+
AvatarGazeName,
|
|
62
|
+
AvatarEmotionName,
|
|
63
|
+
AvatarAction,
|
|
64
|
+
AvatarMeta,
|
|
65
|
+
VisemeLetter,
|
|
66
|
+
Cue,
|
|
67
|
+
PoseChannel,
|
|
68
|
+
PoseOverrides,
|
|
69
|
+
CreateAvatarOptions as CreateSvgAvatarOptions,
|
|
70
|
+
} from "../src/avatar.js";
|
|
71
|
+
|
|
72
|
+
// The rig contract, for a renderer that implements `apply(frame)` directly
|
|
73
|
+
// instead of wearing the SVG adapter. `docs/internal-rig.md` — and note that
|
|
74
|
+
// this is the mixer's private seam, not the avatar interface.
|
|
75
|
+
export type {
|
|
76
|
+
AvatarFrame,
|
|
77
|
+
AvatarRig,
|
|
78
|
+
AvatarRigFactory,
|
|
79
|
+
HandFrame,
|
|
80
|
+
RigPose,
|
|
81
|
+
} from "../src/rig.js";
|
|
82
|
+
|
|
83
|
+
export {
|
|
84
|
+
BEHAVIOR_STATE_IDS,
|
|
85
|
+
BEHAVIOR_ACTIONS,
|
|
86
|
+
BEHAVIOR_ACTION_IDS,
|
|
87
|
+
WIRE_ACTION_TO_BEHAVIOR,
|
|
88
|
+
} from "../src/behavior.js";
|
|
89
|
+
|
|
90
|
+
export type { BehaviorStateId, BehaviorActionId } from "../src/behavior.js";
|
|
91
|
+
|
|
92
|
+
export { isAvatarMessage, CORE_ACTION_IDS } from "./types.js";
|
|
93
|
+
// The optional driving-UI declaration, as a type only. The bundled avatar's own
|
|
94
|
+
// `supports` value lives on the public entry point and importing it here would
|
|
95
|
+
// drag `peep`'s drawing in behind it — the same reason the faces are not
|
|
96
|
+
// exported from this module. Compose yours from `CORE_ACTION_IDS` and
|
|
97
|
+
// `ACTION_IDS` above, which cost nothing.
|
|
98
|
+
export type { AvatarSupport } from "./createAvatar.js";
|
|
99
|
+
// A renderer may reuse the one lifecycle/precedence ladder without gaining a
|
|
100
|
+
// second public avatar interface.
|
|
101
|
+
export { AvatarClient } from "./AvatarClient.js";
|
|
102
|
+
export type {
|
|
103
|
+
AvatarCommand,
|
|
104
|
+
AvatarCue,
|
|
105
|
+
AvatarCuesCmd,
|
|
106
|
+
CoreActionId,
|
|
107
|
+
} from "./types.js";
|
|
108
|
+
export type { AvatarClientOptions, AvatarDriver, AvatarPresenceState } from "./AvatarClient.js";
|
package/client/ishita.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Polished professional female avatar, authored to read at call-tile size. */
|
|
2
|
+
|
|
3
|
+
import { createCanvasAvatar } from './createCanvasAvatar.js';
|
|
4
|
+
import type { CanvasAvatarOptions } from './createCanvasAvatar.js';
|
|
5
|
+
import type { AvatarInstance } from './createAvatar.js';
|
|
6
|
+
|
|
7
|
+
const RIG_URL = new URL('../src/canvas/data/professional-female-a.rig.json', import.meta.url);
|
|
8
|
+
const IMAGES = {
|
|
9
|
+
'professional-female-a-top-body.webp': new URL('../src/canvas/data/img/professional-female-a-top-body.webp', import.meta.url),
|
|
10
|
+
'professional-female-a-hair-back.webp': new URL('../src/canvas/data/img/professional-female-a-hair-back.webp', import.meta.url),
|
|
11
|
+
'professional-female-a-hair-front.webp': new URL('../src/canvas/data/img/professional-female-a-hair-front.webp', import.meta.url),
|
|
12
|
+
} as const;
|
|
13
|
+
const FACE = () => import('../src/canvas/avatars/round/face.mjs');
|
|
14
|
+
|
|
15
|
+
export type { CanvasAvatarOptions };
|
|
16
|
+
// Same mixer, same clip library, so the same declaration (`supports.ts`).
|
|
17
|
+
export { supports } from './supports.js';
|
|
18
|
+
|
|
19
|
+
export function createAvatar(options: CanvasAvatarOptions): AvatarInstance {
|
|
20
|
+
return createCanvasAvatar(options, {
|
|
21
|
+
rigUrl: RIG_URL,
|
|
22
|
+
images: IMAGES,
|
|
23
|
+
face: FACE,
|
|
24
|
+
label: 'Ishita — polished professional female avatar',
|
|
25
|
+
});
|
|
26
|
+
}
|
package/client/kabir.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Relaxed professional male avatar, authored to read at call-tile size. */
|
|
2
|
+
|
|
3
|
+
import { createCanvasAvatar } from './createCanvasAvatar.js';
|
|
4
|
+
import type { CanvasAvatarOptions } from './createCanvasAvatar.js';
|
|
5
|
+
import type { AvatarInstance } from './createAvatar.js';
|
|
6
|
+
|
|
7
|
+
const RIG_URL = new URL('../src/canvas/data/professional-male-b.rig.json', import.meta.url);
|
|
8
|
+
const IMAGES = {
|
|
9
|
+
'professional-male-b-top-body.webp': new URL('../src/canvas/data/img/professional-male-b-top-body.webp', import.meta.url),
|
|
10
|
+
'professional-male-b-hair-back.webp': new URL('../src/canvas/data/img/professional-male-b-hair-back.webp', import.meta.url),
|
|
11
|
+
'professional-male-b-hair-front.webp': new URL('../src/canvas/data/img/professional-male-b-hair-front.webp', import.meta.url),
|
|
12
|
+
} as const;
|
|
13
|
+
const FACE = () => import('../src/canvas/avatars/round/face.mjs');
|
|
14
|
+
|
|
15
|
+
export type { CanvasAvatarOptions };
|
|
16
|
+
// Same mixer, same clip library, so the same declaration (`supports.ts`).
|
|
17
|
+
export { supports } from './supports.js';
|
|
18
|
+
|
|
19
|
+
export function createAvatar(options: CanvasAvatarOptions): AvatarInstance {
|
|
20
|
+
return createCanvasAvatar(options, {
|
|
21
|
+
rigUrl: RIG_URL,
|
|
22
|
+
images: IMAGES,
|
|
23
|
+
face: FACE,
|
|
24
|
+
label: 'Kabir — relaxed professional male avatar',
|
|
25
|
+
});
|
|
26
|
+
}
|
package/client/meera.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Professional Indian female interviewer, authored to read at call-tile size. */
|
|
2
|
+
|
|
3
|
+
import { createCanvasAvatar } from './createCanvasAvatar.js';
|
|
4
|
+
import type { CanvasAvatarOptions } from './createCanvasAvatar.js';
|
|
5
|
+
import type { AvatarInstance } from './createAvatar.js';
|
|
6
|
+
|
|
7
|
+
const RIG_URL = new URL('../src/canvas/data/interviewer-female.rig.json', import.meta.url);
|
|
8
|
+
const IMAGES = {
|
|
9
|
+
'round-w1-hair-back.webp': new URL('../src/canvas/data/img/round-w1-hair-back.webp', import.meta.url),
|
|
10
|
+
'round-w1-top-body.webp': new URL('../src/canvas/data/img/round-w1-top-body.webp', import.meta.url),
|
|
11
|
+
'round-w1-hair-front.webp': new URL('../src/canvas/data/img/round-w1-hair-front.webp', import.meta.url),
|
|
12
|
+
} as const;
|
|
13
|
+
const FACE = () => import('../src/canvas/avatars/round/face.mjs');
|
|
14
|
+
|
|
15
|
+
export type { CanvasAvatarOptions };
|
|
16
|
+
// Same mixer, same clip library, so the same declaration (`supports.ts`).
|
|
17
|
+
export { supports } from './supports.js';
|
|
18
|
+
|
|
19
|
+
export function createAvatar(options: CanvasAvatarOptions): AvatarInstance {
|
|
20
|
+
return createCanvasAvatar(options, {
|
|
21
|
+
rigUrl: RIG_URL,
|
|
22
|
+
images: IMAGES,
|
|
23
|
+
face: FACE,
|
|
24
|
+
label: 'Meera — professional female interviewer avatar',
|
|
25
|
+
});
|
|
26
|
+
}
|
package/client/naina.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Relaxed professional female avatar, authored to read at call-tile size. */
|
|
2
|
+
|
|
3
|
+
import { createCanvasAvatar } from './createCanvasAvatar.js';
|
|
4
|
+
import type { CanvasAvatarOptions } from './createCanvasAvatar.js';
|
|
5
|
+
import type { AvatarInstance } from './createAvatar.js';
|
|
6
|
+
|
|
7
|
+
const RIG_URL = new URL('../src/canvas/data/professional-female-b.rig.json', import.meta.url);
|
|
8
|
+
const IMAGES = {
|
|
9
|
+
'professional-female-b-top-body.webp': new URL('../src/canvas/data/img/professional-female-b-top-body.webp', import.meta.url),
|
|
10
|
+
'professional-female-b-hair-back.webp': new URL('../src/canvas/data/img/professional-female-b-hair-back.webp', import.meta.url),
|
|
11
|
+
'professional-female-b-hair-front.webp': new URL('../src/canvas/data/img/professional-female-b-hair-front.webp', import.meta.url),
|
|
12
|
+
} as const;
|
|
13
|
+
const FACE = () => import('../src/canvas/avatars/round/face.mjs');
|
|
14
|
+
|
|
15
|
+
export type { CanvasAvatarOptions };
|
|
16
|
+
// Same mixer, same clip library, so the same declaration (`supports.ts`).
|
|
17
|
+
export { supports } from './supports.js';
|
|
18
|
+
|
|
19
|
+
export function createAvatar(options: CanvasAvatarOptions): AvatarInstance {
|
|
20
|
+
return createCanvasAvatar(options, {
|
|
21
|
+
rigUrl: RIG_URL,
|
|
22
|
+
images: IMAGES,
|
|
23
|
+
face: FACE,
|
|
24
|
+
label: 'Naina — relaxed professional female avatar',
|
|
25
|
+
});
|
|
26
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When the bot's audio actually begins, heard on the track the browser plays.
|
|
3
|
+
*
|
|
4
|
+
* `BotStartedSpeaking` and the audio reach the browser by different roads —
|
|
5
|
+
* the event on the data channel, the sound over RTP and through a jitter
|
|
6
|
+
* buffer — so the event is a statement about *which* audio is starting and
|
|
7
|
+
* not about when anyone hears it. Measured on two stacks, the event arrived
|
|
8
|
+
* 57 ms after the first sample on one and 160 ms before it on the other: the
|
|
9
|
+
* mouth lagged the voice on the first and ran ahead of it on the second, and
|
|
10
|
+
* no constant corrects both. So the turn clock is anchored to the sound.
|
|
11
|
+
*
|
|
12
|
+
* What makes "the sound began" observable is that between turns the decoded
|
|
13
|
+
* track is exact digital zero — Opus decodes silence to silence — so onset is
|
|
14
|
+
* the first sample off zero, not a speech detector's guess at a threshold. That
|
|
15
|
+
* matters: cue `t: 0` is the first sample the TTS produced, lead-in included,
|
|
16
|
+
* and a detector keyed to *speech* would anchor 20-50 ms into the lead-in.
|
|
17
|
+
*
|
|
18
|
+
* What this probe hears is the sound entering the page's audio graph, not
|
|
19
|
+
* leaving the speaker. For a wired speaker the difference is about what the
|
|
20
|
+
* compositor and display add to the picture, and the two cancel. A Bluetooth
|
|
21
|
+
* headset adds 150-250 ms that nothing on the picture side matches, so the
|
|
22
|
+
* probe also reports the device latency the browser admits to, and the
|
|
23
|
+
* client delays the mouth by the excess (`AvatarClient.ts`).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export interface PlayoutProbe {
|
|
27
|
+
/**
|
|
28
|
+
* Where the bot's current sound began, on the caller's `now()` clock.
|
|
29
|
+
* `null`: the track is silent right now. `undefined`: this probe cannot say —
|
|
30
|
+
* the track has been sounding for longer than it can look back, or the
|
|
31
|
+
* audio graph is not running.
|
|
32
|
+
*/
|
|
33
|
+
onset(): number | null | undefined;
|
|
34
|
+
/** The output device's latency as this context reports it, in ms; 0 when it
|
|
35
|
+
* will not say. The probe shares the device the call's audio plays on. */
|
|
36
|
+
outputLatencyMs?(): number;
|
|
37
|
+
/** Ask a suspended graph to run. A browser that needs a gesture for it
|
|
38
|
+
* ignores the request outside one, so this is safe to call at any time. */
|
|
39
|
+
resume?(): void;
|
|
40
|
+
dispose(): void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 8192 samples is 170 ms at 48 kHz: past the 57 ms the sound has been seen to
|
|
44
|
+
* lead the event by, with room for a slower network. */
|
|
45
|
+
const WINDOW = 8192;
|
|
46
|
+
/** -80 dBFS. The gaps between turns are exact zero; this only keeps a
|
|
47
|
+
* denormal from reading as speech. */
|
|
48
|
+
const FLOOR = 1e-4;
|
|
49
|
+
|
|
50
|
+
export function createPlayoutProbe(track: MediaStreamTrack, now: () => number): PlayoutProbe | null {
|
|
51
|
+
const Ctx = globalThis.AudioContext;
|
|
52
|
+
if (!Ctx) return null;
|
|
53
|
+
let ctx: AudioContext;
|
|
54
|
+
let source: MediaStreamAudioSourceNode;
|
|
55
|
+
let analyser: AnalyserNode;
|
|
56
|
+
try {
|
|
57
|
+
ctx = new Ctx();
|
|
58
|
+
source = ctx.createMediaStreamSource(new MediaStream([track]));
|
|
59
|
+
analyser = ctx.createAnalyser();
|
|
60
|
+
analyser.fftSize = WINDOW;
|
|
61
|
+
source.connect(analyser);
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
// A context made after the user's connect click is allowed to run; one that
|
|
66
|
+
// is not simply answers `undefined`, and the event anchors the turn.
|
|
67
|
+
ctx.resume().catch(() => {});
|
|
68
|
+
const buf = new Float32Array(WINDOW);
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
onset() {
|
|
72
|
+
if (ctx.state !== "running") return undefined;
|
|
73
|
+
analyser.getFloatTimeDomainData(buf);
|
|
74
|
+
const t = now();
|
|
75
|
+
let first = -1;
|
|
76
|
+
for (let i = 0; i < buf.length; i++) {
|
|
77
|
+
if (Math.abs(buf[i]!) > FLOOR) { first = i; break; }
|
|
78
|
+
}
|
|
79
|
+
if (first < 0) return null;
|
|
80
|
+
if (first === 0) return undefined;
|
|
81
|
+
return t - ((buf.length - first) / ctx.sampleRate) * 1000;
|
|
82
|
+
},
|
|
83
|
+
outputLatencyMs() {
|
|
84
|
+
const s = ctx.outputLatency;
|
|
85
|
+
return typeof s === "number" && Number.isFinite(s) ? s * 1000 : 0;
|
|
86
|
+
},
|
|
87
|
+
resume() {
|
|
88
|
+
if (ctx.state === "suspended") ctx.resume().catch(() => {});
|
|
89
|
+
},
|
|
90
|
+
dispose() {
|
|
91
|
+
source.disconnect();
|
|
92
|
+
ctx.close().catch(() => {});
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|