@usamanawaz/react-native-ai-avatar 0.0.0-stage → 1.0.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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +86 -2
  3. package/assets/models/male-avatar-clothed.glb +0 -0
  4. package/assets/models/male-avatar.glb +0 -0
  5. package/dist/AIAvatar.d.ts +20 -0
  6. package/dist/AvatarView.d.ts +3 -0
  7. package/dist/animation/animator.d.ts +44 -0
  8. package/dist/animation/channels.d.ts +32 -0
  9. package/dist/animation/expressions.d.ts +4 -0
  10. package/dist/index.d.ts +13 -0
  11. package/dist/model/bakeSkinning.d.ts +8 -0
  12. package/dist/model/blendshapes.d.ts +12 -0
  13. package/dist/model/boneMap.d.ts +9 -0
  14. package/dist/model/catalog.d.ts +2 -0
  15. package/dist/model/cpuMorphs.d.ts +24 -0
  16. package/dist/model/cpuSkin.d.ts +19 -0
  17. package/dist/model/loadAvatar.d.ts +21 -0
  18. package/dist/model/skeleton.d.ts +17 -0
  19. package/dist/model/visemes.d.ts +19 -0
  20. package/dist/rendering/AvatarStage.d.ts +7 -0
  21. package/dist/rendering/applyPose.d.ts +3 -0
  22. package/dist/speech/ExpoSpeechProvider.d.ts +13 -0
  23. package/dist/speech/approximateMouth.d.ts +15 -0
  24. package/dist/types.d.ts +35 -0
  25. package/dist/voice/types.d.ts +18 -0
  26. package/package.json +72 -4
  27. package/src/avatar/AIAvatar.tsx +137 -0
  28. package/src/avatar/AvatarView.tsx +11 -0
  29. package/src/avatar/animation/animator.ts +344 -0
  30. package/src/avatar/animation/channels.ts +59 -0
  31. package/src/avatar/animation/expressions.ts +51 -0
  32. package/src/avatar/index.ts +23 -0
  33. package/src/avatar/model/bakeSkinning.ts +46 -0
  34. package/src/avatar/model/blendshapes.ts +70 -0
  35. package/src/avatar/model/boneMap.ts +17 -0
  36. package/src/avatar/model/catalog.ts +15 -0
  37. package/src/avatar/model/cpuMorphs.ts +124 -0
  38. package/src/avatar/model/cpuSkin.ts +105 -0
  39. package/src/avatar/model/jpeg-js.d.ts +6 -0
  40. package/src/avatar/model/loadAvatar.ts +497 -0
  41. package/src/avatar/model/skeleton.ts +21 -0
  42. package/src/avatar/model/visemes.ts +55 -0
  43. package/src/avatar/rendering/AvatarStage.tsx +119 -0
  44. package/src/avatar/rendering/applyPose.ts +59 -0
  45. package/src/avatar/speech/ExpoSpeechProvider.ts +42 -0
  46. package/src/avatar/speech/approximateMouth.ts +244 -0
  47. package/src/avatar/types.ts +45 -0
  48. package/src/avatar/voice/types.ts +20 -0
@@ -0,0 +1,59 @@
1
+ import * as THREE from 'three';
2
+
3
+ import type { AvatarPose } from '../animation/animator';
4
+ import { NECK_BONE_SHARE } from '../model/boneMap';
5
+ import { applyCpuMorphs } from '../model/cpuMorphs';
6
+ import { updateCpuSkins } from '../model/cpuSkin';
7
+ import type { LoadedAvatar } from '../model/loadAvatar';
8
+ import type { BoneName, BoneRotation } from '../model/skeleton';
9
+
10
+ const deltaEuler = new THREE.Euler(0, 0, 0, 'XYZ');
11
+ const deltaQuaternion = new THREE.Quaternion();
12
+ const lookEuler = new THREE.Euler(0, 0, 0, 'YXZ');
13
+
14
+ export function applyPose(avatar: LoadedAvatar, pose: AvatarPose) {
15
+ applyCpuMorphs(avatar.cpuMorphs, pose.weights);
16
+
17
+ for (const [name, bones] of Object.entries(avatar.bones) as [BoneName, NonNullable<LoadedAvatar['bones'][BoneName]>][]) {
18
+ if (!bones) continue;
19
+ const rotation = pose.bones[name];
20
+ const share = name === 'Neck' && bones.length > 1 ? NECK_BONE_SHARE : 1;
21
+ for (const bound of bones) {
22
+ if (!rotation) {
23
+ bound.bone.quaternion.copy(bound.rest);
24
+ continue;
25
+ }
26
+ setDelta(rotation, share);
27
+ bound.bone.quaternion.copy(bound.rest).multiply(deltaQuaternion);
28
+ }
29
+ }
30
+
31
+ if (avatar.cpuSkins.length > 0) {
32
+ avatar.lookGroup.quaternion.identity();
33
+ avatar.scene.updateMatrixWorld(true);
34
+ updateCpuSkins(avatar.cpuSkins);
35
+ return;
36
+ }
37
+
38
+ // Static meshes have no skeleton to bend. Turn the whole figure instead.
39
+ const head = pose.bones.Head;
40
+ const neck = pose.bones.Neck;
41
+ const pitch = ((head?.x ?? 0) + (neck?.x ?? 0)) * 0.55;
42
+ const yaw = ((head?.y ?? 0) + (neck?.y ?? 0)) * 0.55;
43
+ const roll = ((head?.z ?? 0) + (neck?.z ?? 0)) * 0.4;
44
+ lookEuler.set(
45
+ THREE.MathUtils.degToRad(pitch),
46
+ THREE.MathUtils.degToRad(yaw),
47
+ THREE.MathUtils.degToRad(roll),
48
+ );
49
+ avatar.lookGroup.quaternion.setFromEuler(lookEuler);
50
+ }
51
+
52
+ function setDelta(rotation: BoneRotation, share: number) {
53
+ deltaEuler.set(
54
+ THREE.MathUtils.degToRad(rotation.x * share),
55
+ THREE.MathUtils.degToRad(rotation.y * share),
56
+ THREE.MathUtils.degToRad(rotation.z * share),
57
+ );
58
+ deltaQuaternion.setFromEuler(deltaEuler);
59
+ }
@@ -0,0 +1,42 @@
1
+ import * as Speech from 'expo-speech';
2
+
3
+ import type { SpeakRequest } from '../types';
4
+ import type { VoiceProvider } from '../voice/types';
5
+
6
+ export type ExpoSpeakRequest = SpeakRequest & {
7
+ onBoundary?: (event: { charIndex: number; charLength: number }) => void;
8
+ };
9
+
10
+ /**
11
+ * On-device system voice. Word boundaries keep mouth shapes aligned with
12
+ * speech; phoneme-accurate tracks still come from the animator.
13
+ */
14
+ export function createExpoSpeechProvider(): VoiceProvider {
15
+ return {
16
+ id: 'expo-speech',
17
+ speak(request) {
18
+ const spoken = request as ExpoSpeakRequest;
19
+ return new Promise((resolve, reject) => {
20
+ Speech.speak(spoken.text, {
21
+ voice: spoken.voiceId,
22
+ rate: 0.92,
23
+ onBoundary: (event: { charIndex?: number; charLength?: number } | unknown) => {
24
+ const native = event as { charIndex?: number; charLength?: number };
25
+ if (typeof native.charIndex === 'number') {
26
+ spoken.onBoundary?.({
27
+ charIndex: native.charIndex,
28
+ charLength: native.charLength ?? 1,
29
+ });
30
+ }
31
+ },
32
+ onDone: () => resolve({}),
33
+ onStopped: () => resolve({}),
34
+ onError: (error) => reject(error),
35
+ });
36
+ });
37
+ },
38
+ async stop() {
39
+ await Speech.stop();
40
+ },
41
+ };
42
+ }
@@ -0,0 +1,244 @@
1
+ import type { BlendshapeWeights } from '../model/blendshapes';
2
+ import { VISEME_BLENDSHAPES, type VisemeCue, type VisemeId } from '../model/visemes';
3
+
4
+ /**
5
+ * Phone voices do not report phoneme times. We build a phoneme schedule from
6
+ * spelling, then retarget it to real speech using word-boundary events from
7
+ * expo-speech so the mouth stays locked to the spoken words.
8
+ */
9
+ const VOWEL_SEC = 0.12;
10
+ const CLOSURE_SEC = 0.06;
11
+ const HOLD_SEC = 0.05;
12
+ const WORD_GAP_SEC = 0.04;
13
+ const PAUSE_SEC = 0.12;
14
+
15
+ /** Whole words whose spelling does not match the mouth shape. */
16
+ const WORDS: Record<string, ReadonlyArray<readonly [VisemeId, number]>> = {
17
+ a: [['aa', 0.35]],
18
+ i: [['aa', 1], ['ih', 0.65]],
19
+ you: [['ou', 1]],
20
+ your: [['ou', 0.9], ['RR', 0.45]],
21
+ the: [['TH', 0.9], ['aa', 0.28]],
22
+ to: [['DD', 0.65], ['ou', 0.75]],
23
+ too: [['DD', 0.6], ['ou', 1]],
24
+ hello: [['E', 0.9], ['DD', 0.55], ['oh', 1]],
25
+ hi: [['aa', 0.9], ['ih', 0.7]],
26
+ how: [['aa', 1], ['ou', 0.55]],
27
+ can: [['kk', 0.75], ['aa', 0.8], ['nn', 0.75]],
28
+ help: [['E', 1], ['DD', 0.5], ['PP', 1]],
29
+ today: [['DD', 0.7], ['aa', 0.3], ['DD', 0.65], ['E', 0.95]],
30
+ what: [['RR', 0.7], ['aa', 0.55], ['DD', 0.6]],
31
+ is: [['ih', 0.7], ['SS', 0.85]],
32
+ this: [['TH', 0.85], ['ih', 0.55], ['SS', 0.8]],
33
+ please: [['PP', 1], ['DD', 0.45], ['E', 0.9], ['SS', 0.7]],
34
+ thanks: [['TH', 0.85], ['aa', 0.7], ['nn', 0.5], ['kk', 0.6], ['SS', 0.75]],
35
+ thank: [['TH', 0.85], ['aa', 0.7], ['nn', 0.5], ['kk', 0.7]],
36
+ yes: [['ih', 0.55], ['E', 0.85], ['SS', 0.8]],
37
+ no: [['nn', 0.7], ['oh', 1]],
38
+ ok: [['oh', 0.9], ['kk', 0.7], ['E', 0.75]],
39
+ okay: [['oh', 0.9], ['kk', 0.7], ['E', 0.85]],
40
+ };
41
+
42
+ type Token = { viseme: VisemeId; duration: number; weight: number };
43
+
44
+ export type MouthWord = {
45
+ /** Inclusive start character index in the original spoken string. */
46
+ start: number;
47
+ /** Exclusive end character index. */
48
+ end: number;
49
+ cues: VisemeCue[];
50
+ duration: number;
51
+ };
52
+
53
+ export function approximateMouthCues(text: string): VisemeCue[] {
54
+ return flattenWords(buildMouthWords(text));
55
+ }
56
+
57
+ export function buildMouthWords(text: string): MouthWord[] {
58
+ const words: MouthWord[] = [];
59
+ const pattern = /[A-Za-z']+|[^.!?\s]+|[.!?]|\s+/g;
60
+ let match: RegExpExecArray | null;
61
+ while ((match = pattern.exec(text)) !== null) {
62
+ const raw = match[0];
63
+ const start = match.index;
64
+ const end = start + raw.length;
65
+ if (/^\s+$/.test(raw)) continue;
66
+ if (/^[.!?]+$/.test(raw)) {
67
+ words.push({
68
+ start,
69
+ end,
70
+ cues: [{ time: 0, viseme: 'sil', weight: 1 }],
71
+ duration: PAUSE_SEC,
72
+ });
73
+ continue;
74
+ }
75
+ if (/^[^A-Za-z']+$/.test(raw)) {
76
+ words.push({
77
+ start,
78
+ end,
79
+ cues: [{ time: 0, viseme: 'sil', weight: 1 }],
80
+ duration: WORD_GAP_SEC,
81
+ });
82
+ continue;
83
+ }
84
+ const spoken = raw.toLowerCase().replace(/'/g, '');
85
+ const tokens = WORDS[spoken] ? tokensFrom(WORDS[spoken]) : spellWord(spoken);
86
+ const cues: VisemeCue[] = [];
87
+ let time = 0;
88
+ for (const token of tokens) {
89
+ cues.push({ time, viseme: token.viseme, weight: token.weight });
90
+ time += token.duration;
91
+ }
92
+ if (cues.length === 0) cues.push({ time: 0, viseme: 'sil', weight: 1 });
93
+ words.push({ start, end, cues, duration: Math.max(time, HOLD_SEC) });
94
+ }
95
+ return words;
96
+ }
97
+
98
+ export function mouthWeightsAt(cues: VisemeCue[], elapsedSec: number): BlendshapeWeights {
99
+ if (cues.length === 0 || elapsedSec < 0) return {};
100
+ const last = cues[cues.length - 1];
101
+ if (elapsedSec >= last.time) return scale(VISEME_BLENDSHAPES[last.viseme], last.weight);
102
+
103
+ let index = 0;
104
+ while (index < cues.length - 1 && cues[index + 1].time <= elapsedSec) index += 1;
105
+
106
+ const current = cues[index];
107
+ const next = cues[index + 1];
108
+ const currentWeights = scale(VISEME_BLENDSHAPES[current.viseme], current.weight);
109
+ if (!next) return currentWeights;
110
+
111
+ const span = Math.max(0.001, next.time - current.time);
112
+ const raw = Math.min(1, Math.max(0, (elapsedSec - current.time) / span));
113
+ // Neighboring shapes overlap, the way the jaw carries one sound into the next.
114
+ return mix(currentWeights, scale(VISEME_BLENDSHAPES[next.viseme], next.weight), smoothstep(raw));
115
+ }
116
+
117
+ /** Stretch a word's cues so they fill `durationSec`, then rest the mouth. */
118
+ export function scaledWordCues(word: MouthWord, durationSec: number): VisemeCue[] {
119
+ const span = Math.max(0.16, durationSec);
120
+ const speak = span * 0.9;
121
+ const scaleBy = word.duration > 0.001 ? speak / word.duration : 1;
122
+ const cues = word.cues.map((cue) => ({ ...cue, time: cue.time * scaleBy }));
123
+ cues.push({ time: span, viseme: 'sil', weight: 0.7 });
124
+ return cues;
125
+ }
126
+
127
+ function flattenWords(words: MouthWord[]): VisemeCue[] {
128
+ const cues: VisemeCue[] = [];
129
+ let time = 0;
130
+ for (const word of words) {
131
+ for (const cue of word.cues) {
132
+ cues.push({ time: time + cue.time, viseme: cue.viseme, weight: cue.weight });
133
+ }
134
+ time += word.duration + WORD_GAP_SEC;
135
+ }
136
+ if (cues.length === 0) cues.push({ time: 0, viseme: 'sil', weight: 1 });
137
+ return cues;
138
+ }
139
+
140
+ function spellWord(word: string): Token[] {
141
+ const tokens: Token[] = [];
142
+ const letters = word.split('');
143
+ for (let i = 0; i < letters.length; i += 1) {
144
+ if (letters[i] === 'e' && i === letters.length - 1 && i > 1 && !isVowel(letters[i - 1])) continue;
145
+
146
+ const pair = letters[i] + (letters[i + 1] ?? '');
147
+ const triple = pair + (letters[i + 2] ?? '');
148
+ const matched = matchCluster(triple, pair, letters[i], letters[i + 1]);
149
+ tokens.push(matched.token);
150
+ i += matched.consumed - 1;
151
+ }
152
+ return tokens;
153
+ }
154
+
155
+ function matchCluster(
156
+ triple: string,
157
+ pair: string,
158
+ char: string,
159
+ next: string | undefined,
160
+ ): { token: Token; consumed: number } {
161
+ if (triple === 'igh') return { token: vowel('ih'), consumed: 3 };
162
+ if (pair === 'th') return { token: shape('TH', HOLD_SEC, 0.95), consumed: 2 };
163
+ if (pair === 'sh' || pair === 'ch') return { token: shape('CH', HOLD_SEC, 1), consumed: 2 };
164
+ if (pair === 'ph') return { token: shape('FF', HOLD_SEC, 0.95), consumed: 2 };
165
+ if (pair === 'wh') return { token: shape('RR', HOLD_SEC, 0.8), consumed: 2 };
166
+ if (pair === 'qu') return { token: shape('kk', CLOSURE_SEC, 0.9), consumed: 2 };
167
+ if (pair === 'oo' || pair === 'ou' || pair === 'ew') return { token: vowel('ou'), consumed: 2 };
168
+ if (pair === 'ee' || pair === 'ea' || pair === 'ie') return { token: vowel('E'), consumed: 2 };
169
+ if (pair === 'ai' || pair === 'ay' || pair === 'ei') return { token: vowel('E'), consumed: 2 };
170
+ if (pair === 'oa' || pair === 'ow') return { token: vowel('oh'), consumed: 2 };
171
+ if (pair === 'oi' || pair === 'oy') return { token: vowel('oh'), consumed: 2 };
172
+ if (pair === 'au' || pair === 'aw') return { token: vowel('oh'), consumed: 2 };
173
+ if (char === next && !isVowel(char)) return { token: consonant(char), consumed: 2 };
174
+ if (char === 'c' && next !== undefined && 'eiy'.includes(next)) {
175
+ return { token: shape('SS', HOLD_SEC, 0.9), consumed: 1 };
176
+ }
177
+ if (char === 'c') return { token: shape('kk', CLOSURE_SEC, 0.85), consumed: 1 };
178
+ return { token: isVowel(char) ? vowel(vowelFor(char)) : consonant(char), consumed: 1 };
179
+ }
180
+
181
+ function tokensFrom(parts: ReadonlyArray<readonly [VisemeId, number]>): Token[] {
182
+ const open = new Set<VisemeId>(['aa', 'E', 'ih', 'oh', 'ou']);
183
+ return parts.map(([viseme, weight]) => ({
184
+ viseme,
185
+ weight,
186
+ duration: viseme === 'sil' ? 0.04 : open.has(viseme) ? VOWEL_SEC : CLOSURE_SEC,
187
+ }));
188
+ }
189
+
190
+ function vowel(viseme: VisemeId): Token {
191
+ return { viseme, duration: VOWEL_SEC, weight: 0.8 };
192
+ }
193
+
194
+ function shape(viseme: VisemeId, duration: number, weight: number): Token {
195
+ return { viseme, duration, weight };
196
+ }
197
+
198
+ function vowelFor(char: string): VisemeId {
199
+ if (char === 'a') return 'aa';
200
+ if (char === 'e') return 'E';
201
+ if (char === 'i') return 'ih';
202
+ if (char === 'o') return 'oh';
203
+ return 'ou';
204
+ }
205
+
206
+ function consonant(char: string): Token {
207
+ if ('mbp'.includes(char)) return shape('PP', CLOSURE_SEC, 1);
208
+ if ('fv'.includes(char)) return shape('FF', HOLD_SEC, 0.95);
209
+ if (char === 's' || char === 'z' || char === 'x') return shape('SS', HOLD_SEC, 0.9);
210
+ if ('kgq'.includes(char)) return shape('kk', CLOSURE_SEC, 0.85);
211
+ if (char === 'n') return shape('nn', HOLD_SEC, 0.8);
212
+ if (char === 'r' || char === 'w' || char === 'y') return shape('RR', HOLD_SEC, 0.85);
213
+ if (char === 'h') return shape('sil', 0.03, 1);
214
+ if (char === 'l' || char === 't' || char === 'd') return shape('DD', CLOSURE_SEC, 0.9);
215
+ return shape('DD', CLOSURE_SEC, 0.7);
216
+ }
217
+
218
+ function isVowel(char: string | undefined): boolean {
219
+ return char !== undefined && 'aeiou'.includes(char);
220
+ }
221
+
222
+ function scale(weights: BlendshapeWeights, amount: number): BlendshapeWeights {
223
+ const scaled: BlendshapeWeights = {};
224
+ for (const [name, weight] of Object.entries(weights)) {
225
+ scaled[name as keyof BlendshapeWeights] = (weight ?? 0) * amount;
226
+ }
227
+ return scaled;
228
+ }
229
+
230
+ function mix(a: BlendshapeWeights, b: BlendshapeWeights, t: number): BlendshapeWeights {
231
+ const names = new Set([...Object.keys(a), ...Object.keys(b)]);
232
+ const mixed: BlendshapeWeights = {};
233
+ for (const name of names) {
234
+ const key = name as keyof BlendshapeWeights;
235
+ const from = a[key] ?? 0;
236
+ const to = b[key] ?? 0;
237
+ mixed[key] = from + (to - from) * t;
238
+ }
239
+ return mixed;
240
+ }
241
+
242
+ function smoothstep(t: number): number {
243
+ return t * t * (3 - 2 * t);
244
+ }
@@ -0,0 +1,45 @@
1
+ export type AvatarSex = 'female' | 'male';
2
+
3
+ /** Which bundled GLB to show. Female is reserved until that model is exported. */
4
+ export type AvatarModelId = AvatarSex;
5
+
6
+ /** Clothes bundled with the male human. `casual` is the black crew-neck. */
7
+ export type AvatarOutfit = 'casual';
8
+
9
+ export type ExpressionName =
10
+ | 'neutral'
11
+ | 'happy'
12
+ | 'sad'
13
+ | 'angry'
14
+ | 'surprised'
15
+ | 'thinking';
16
+
17
+ /** Gaze direction in view space. 0 is toward the camera. Each axis is -1 to 1. */
18
+ export type GazeTarget = {
19
+ x: number;
20
+ y: number;
21
+ };
22
+
23
+ /**
24
+ * A GLB/GLTF avatar. `source` is a Metro asset module from `require()`
25
+ * or a `file://` URI already on device.
26
+ */
27
+ export type AvatarModel = {
28
+ sex: AvatarSex;
29
+ source: number | string;
30
+ };
31
+
32
+ export type SpeakRequest = {
33
+ text: string;
34
+ voiceId?: string;
35
+ /** Fired when TTS reaches a word (iOS/Android). Used to lock lip-sync. */
36
+ onBoundary?: (event: { charIndex: number; charLength: number }) => void;
37
+ };
38
+
39
+ export type AvatarViewProps = {
40
+ model?: AvatarModel;
41
+ sex?: AvatarSex;
42
+ expression?: ExpressionName;
43
+ gaze?: GazeTarget;
44
+ speak?: SpeakRequest | null;
45
+ };
@@ -0,0 +1,20 @@
1
+ import type { VisemeCue } from '../model/visemes';
2
+ import type { SpeakRequest } from '../types';
3
+
4
+ export type SpeechResult = {
5
+ /** Playable file when the provider synthesizes audio. System TTS may omit this. */
6
+ audioUri?: string;
7
+ durationMs?: number;
8
+ visemes?: VisemeCue[];
9
+ };
10
+
11
+ /**
12
+ * One implementation per engine. The avatar never calls a TTS SDK directly.
13
+ * First planned adapter is on-device system speech. A later adapter can be
14
+ * an offline neural voice that also returns a viseme track.
15
+ */
16
+ export type VoiceProvider = {
17
+ readonly id: string;
18
+ speak(request: SpeakRequest): Promise<SpeechResult>;
19
+ stop(): Promise<void>;
20
+ };