talking-head-studio 0.4.11 → 0.4.12

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 (142) hide show
  1. package/README.md +279 -193
  2. package/dist/TalkingHead.d.ts +28 -3
  3. package/dist/TalkingHead.js +21 -2
  4. package/dist/TalkingHead.web.d.ts +31 -4
  5. package/dist/TalkingHead.web.js +11 -1
  6. package/dist/TalkingHeadVisualization.d.ts +22 -0
  7. package/dist/TalkingHeadVisualization.js +30 -10
  8. package/dist/api/studioApi.d.ts +12 -1
  9. package/dist/api/studioApi.js +16 -2
  10. package/dist/contract.d.ts +14 -0
  11. package/dist/contract.js +30 -0
  12. package/dist/core/avatar/avatarCapabilities.d.ts +60 -0
  13. package/dist/core/avatar/avatarCapabilities.js +100 -0
  14. package/dist/core/avatar/backends/gaussian.js +6 -4
  15. package/dist/core/avatar/motion.d.ts +1713 -0
  16. package/dist/core/avatar/motion.js +550 -0
  17. package/dist/core/avatar/motionRuntime.d.ts +46 -0
  18. package/dist/core/avatar/motionRuntime.js +84 -0
  19. package/dist/core/avatar/schema.d.ts +33 -5
  20. package/dist/core/avatar/visemes.d.ts +16 -1
  21. package/dist/core/avatar/visemes.js +48 -1
  22. package/dist/editor/AvatarCanvas.js +92 -1
  23. package/dist/editor/AvatarEditor.native.js +1 -0
  24. package/dist/editor/AvatarModel.js +1 -0
  25. package/dist/editor/FaceSqueezeEditor.d.ts +3 -1
  26. package/dist/editor/FaceSqueezeEditor.js +176 -112
  27. package/dist/editor/FaceSqueezeEditor.web.d.ts +3 -1
  28. package/dist/editor/FaceSqueezeEditor.web.js +30 -28
  29. package/dist/editor/RigidAccessory.js +17 -2
  30. package/dist/editor/SkinnedClothing.js +1 -0
  31. package/dist/editor/boneLockedDrag.d.ts +11 -0
  32. package/dist/editor/boneLockedDrag.js +68 -0
  33. package/dist/editor/boneSnap.web.d.ts +27 -0
  34. package/dist/editor/boneSnap.web.js +99 -0
  35. package/dist/editor/index.web.d.ts +10 -0
  36. package/dist/editor/index.web.js +26 -0
  37. package/dist/editor/sounds/haha.wav +0 -0
  38. package/dist/editor/sounds/owie.wav +0 -0
  39. package/dist/editor/sounds/stop.wav +0 -0
  40. package/dist/editor/studioTheme.d.ts +14 -14
  41. package/dist/editor/studioTheme.js +17 -14
  42. package/dist/editor/types.d.ts +1 -0
  43. package/dist/html/accessories.d.ts +7 -0
  44. package/dist/html/accessories.js +149 -0
  45. package/dist/html/motion.d.ts +1 -0
  46. package/dist/html/motion.js +189 -0
  47. package/dist/html/visemes.d.ts +7 -0
  48. package/dist/html/visemes.js +348 -0
  49. package/dist/html.d.ts +1 -1
  50. package/dist/html.js +55 -732
  51. package/dist/index.d.ts +7 -3
  52. package/dist/index.js +17 -1
  53. package/dist/index.web.d.ts +18 -1
  54. package/dist/index.web.js +36 -3
  55. package/dist/sketchfab/api.js +1 -0
  56. package/dist/sketchfab/glbInspect.d.ts +22 -0
  57. package/dist/sketchfab/glbInspect.js +58 -0
  58. package/dist/sketchfab/index.d.ts +3 -0
  59. package/dist/sketchfab/index.js +8 -1
  60. package/dist/sketchfab/inspectRemote.d.ts +13 -0
  61. package/dist/sketchfab/inspectRemote.js +77 -0
  62. package/dist/sketchfab/types.d.ts +10 -0
  63. package/dist/studio/AccessoryBrowserScreen.d.ts +6 -0
  64. package/dist/studio/AccessoryBrowserScreen.js +626 -0
  65. package/dist/studio/AccessoryPanel.d.ts +10 -0
  66. package/dist/studio/AccessoryPanel.js +396 -0
  67. package/dist/studio/AppearancePanel.d.ts +9 -0
  68. package/dist/studio/AppearancePanel.js +77 -0
  69. package/dist/studio/AvatarCreatorScreen.d.ts +5 -0
  70. package/dist/studio/AvatarCreatorScreen.js +806 -0
  71. package/dist/studio/AvatarEditorScreen.d.ts +14 -0
  72. package/dist/studio/AvatarEditorScreen.js +510 -0
  73. package/dist/studio/AvatarGrid.d.ts +23 -0
  74. package/dist/studio/AvatarGrid.js +257 -0
  75. package/dist/studio/ColorSwatch.d.ts +8 -0
  76. package/dist/studio/ColorSwatch.js +100 -0
  77. package/dist/studio/CreateVoiceProfileSheet.d.ts +8 -0
  78. package/dist/studio/CreateVoiceProfileSheet.js +242 -0
  79. package/dist/studio/DetailsPanel.d.ts +15 -0
  80. package/dist/studio/DetailsPanel.js +239 -0
  81. package/dist/studio/FilamentEditor.d.ts +2 -0
  82. package/dist/studio/FilamentEditor.js +6 -0
  83. package/dist/studio/PrecisionPanel.d.ts +2 -0
  84. package/dist/studio/PrecisionPanel.js +7 -0
  85. package/dist/studio/PublicGalleryScreen.d.ts +5 -0
  86. package/dist/studio/PublicGalleryScreen.js +358 -0
  87. package/dist/studio/SketchfabModelCard.d.ts +20 -0
  88. package/dist/studio/SketchfabModelCard.js +104 -0
  89. package/dist/studio/StudioBrowseHeader.d.ts +9 -0
  90. package/dist/studio/StudioBrowseHeader.js +28 -0
  91. package/dist/studio/StudioEmptyState.d.ts +8 -0
  92. package/dist/studio/StudioEmptyState.js +29 -0
  93. package/dist/studio/StudioFloatingAction.d.ts +13 -0
  94. package/dist/studio/StudioFloatingAction.js +42 -0
  95. package/dist/studio/StudioSectionHeader.d.ts +7 -0
  96. package/dist/studio/StudioSectionHeader.js +27 -0
  97. package/dist/studio/StudioSurfaceCard.d.ts +8 -0
  98. package/dist/studio/StudioSurfaceCard.js +20 -0
  99. package/dist/studio/VoicePanel.d.ts +15 -0
  100. package/dist/studio/VoicePanel.js +305 -0
  101. package/dist/studio/constants.d.ts +3 -0
  102. package/dist/studio/constants.js +6 -0
  103. package/dist/studio/index.d.ts +29 -0
  104. package/dist/studio/index.js +54 -0
  105. package/dist/studio/useSketchfabCapabilities.d.ts +31 -0
  106. package/dist/studio/useSketchfabCapabilities.js +82 -0
  107. package/dist/tts/useDirectVisemeStream.js +15 -10
  108. package/dist/utils/avatarUtils.js +92 -5
  109. package/dist/utils/faceLandmarkerToShapeWeights.js +2 -4
  110. package/dist/voice/useAudioPlayer.js +17 -4
  111. package/dist/voice/useVoicePreview.js +4 -2
  112. package/dist/wardrobe/index.d.ts +1 -0
  113. package/dist/wardrobe/index.js +6 -1
  114. package/dist/wardrobe/useAccessoryGestures.d.ts +20 -0
  115. package/dist/wardrobe/useAccessoryGestures.js +94 -0
  116. package/dist/wardrobe/useAvatarWardrobeHydration.js +8 -2
  117. package/dist/wardrobe/useStudioAvatar.js +11 -2
  118. package/dist/wardrobe/wardrobeStore.d.ts +2 -0
  119. package/dist/wardrobe/wardrobeStore.js +12 -2
  120. package/dist/wgpu/R3FWebGpuCanvas.d.ts +15 -0
  121. package/dist/wgpu/R3FWebGpuCanvas.js +176 -0
  122. package/dist/wgpu/WgpuAvatar.d.ts +26 -2
  123. package/dist/wgpu/WgpuAvatar.js +296 -39
  124. package/dist/wgpu/accessoryDefaults.d.ts +12 -0
  125. package/dist/wgpu/accessoryDefaults.js +19 -0
  126. package/dist/wgpu/blobShim.d.ts +2 -0
  127. package/dist/wgpu/blobShim.js +191 -0
  128. package/dist/wgpu/index.d.ts +1 -0
  129. package/dist/wgpu/index.js +4 -1
  130. package/dist/wgpu/loadGLTFFromUri.d.ts +2 -0
  131. package/dist/wgpu/loadGLTFFromUri.js +75 -0
  132. package/dist/wgpu/morphTables.js +21 -10
  133. package/dist/wgpu/motionState.d.ts +20 -0
  134. package/dist/wgpu/motionState.js +31 -0
  135. package/dist/wgpu/patchThreeForRN.d.ts +28 -0
  136. package/dist/wgpu/patchThreeForRN.js +292 -0
  137. package/dist/wgpu/scenePlacement.d.ts +5 -0
  138. package/dist/wgpu/scenePlacement.js +50 -0
  139. package/dist/wgpu/useAuthedModelUri.js +4 -2
  140. package/dist/wgpu/useNativeGLTF.d.ts +7 -0
  141. package/dist/wgpu/useNativeGLTF.js +36 -0
  142. package/package.json +97 -31
@@ -0,0 +1,348 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildVisemeScript = buildVisemeScript;
4
+ /**
5
+ * Generated avatar WebView visemes script section.
6
+ *
7
+ * Kept as a string builder so html.ts can compose the final document without
8
+ * owning every browser-side subsystem inline.
9
+ */
10
+ function buildVisemeScript() {
11
+ return `let amplitudeDecay = 0;
12
+ let jawMorphCache = null;
13
+ let visemeMorphCache = null;
14
+
15
+ // Voice-driven mood detection
16
+ let voiceEnergySmoothed = 0;
17
+ let voiceMoodCurrent = 'neutral';
18
+ let voiceMoodFramesAbove = 0;
19
+ let voiceMoodFramesBelow = 0;
20
+ const VOICE_ENERGY_EXCITED_THRESH = 0.45;
21
+ const VOICE_ENERGY_NEUTRAL_THRESH = 0.15;
22
+ const VOICE_MOOD_FRAMES_REQUIRED = 12; // ~0.2s at 60fps
23
+ function updateVoiceMood(rawAmplitude) {
24
+ voiceEnergySmoothed = voiceEnergySmoothed * 0.85 + rawAmplitude * 0.15;
25
+ if (voiceEnergySmoothed > VOICE_ENERGY_EXCITED_THRESH) {
26
+ voiceMoodFramesAbove++;
27
+ voiceMoodFramesBelow = 0;
28
+ if (voiceMoodCurrent !== 'excited' && voiceMoodFramesAbove >= VOICE_MOOD_FRAMES_REQUIRED) {
29
+ voiceMoodCurrent = 'excited';
30
+ rnPost(JSON.stringify({ type: 'voiceMood', mood: 'excited' }));
31
+ }
32
+ } else if (voiceEnergySmoothed < VOICE_ENERGY_NEUTRAL_THRESH) {
33
+ voiceMoodFramesBelow++;
34
+ voiceMoodFramesAbove = 0;
35
+ if (voiceMoodCurrent !== 'neutral' && voiceMoodFramesBelow >= VOICE_MOOD_FRAMES_REQUIRED * 3) {
36
+ voiceMoodCurrent = 'neutral';
37
+ rnPost(JSON.stringify({ type: 'voiceMood', mood: 'neutral' }));
38
+ }
39
+ }
40
+ }
41
+ const visemeState = {};
42
+
43
+ const VISEME_MORPH_ALIASES = {
44
+ sil: ['viseme_sil', 'sil', 'mouthClose', 'mouth_close'],
45
+ PP: ['viseme_PP', 'pp', 'viseme_pp', 'mouthPucker', 'mouth_pucker'],
46
+ FF: ['viseme_FF', 'ff', 'viseme_ff', 'mouthLowerLipIn', 'mouth_lower_lip_in', 'mouthRollLower', 'mouthShrugLower'],
47
+ TH: ['viseme_TH', 'th', 'viseme_th', 'tongueOut', 'tongue_out'],
48
+ DD: ['viseme_DD', 'dd', 'viseme_dd', 'mouthShrugUpper', 'mouth_shrug_upper'],
49
+ kk: ['viseme_kk', 'kk', 'viseme_k', 'mouthStretchLeft', 'mouth_stretch_left'],
50
+ CH: ['viseme_CH', 'ch', 'viseme_ch', 'mouthSmile', 'mouth_smile', 'mouthSmileLeft', 'mouth_smile_left'],
51
+ SS: ['viseme_SS', 'ss', 'viseme_ss', 'mouthStretchRight', 'mouth_stretch_right'],
52
+ nn: ['viseme_nn', 'nn', 'viseme_n', 'mouthDimpleLeft', 'mouth_dimple_left'],
53
+ RR: ['viseme_RR', 'rr', 'viseme_r', 'mouthDimpleRight', 'mouth_dimple_right'],
54
+ aa: ['viseme_aa', 'viseme_AA', 'aa', 'jawOpen', 'jaw_open', 'jawopen', 'mouthOpen', 'mouth_open', 'mouthopen'],
55
+ ee: ['viseme_ee', 'viseme_E', 'ee', 'mouthSmileLeft', 'mouth_smile_left'],
56
+ ih: ['viseme_ih', 'viseme_I', 'ih', 'mouthSmileRight', 'mouth_smile_right'],
57
+ oh: ['viseme_oh', 'viseme_O', 'oh', 'mouthFunnel', 'mouth_funnel'],
58
+ ou: ['viseme_ou', 'viseme_U', 'ou', 'mouthRollLower', 'mouth_roll_lower'],
59
+ };
60
+
61
+ // For ARKit models, each viseme may need multiple blend shapes driven together.
62
+ // Each entry is a list of morph names to combine for that viseme.
63
+ // The first alias list that has ANY match on the model is used.
64
+ const VISEME_COMPOUND_ARKIT = {
65
+ aa: [['jawOpen', 'mouthLowerDownLeft', 'mouthLowerDownRight'], ['jawOpen', 'mouthOpen']],
66
+ oh: [['mouthFunnel', 'jawOpen'], ['mouthFunnel']],
67
+ ou: [['mouthPucker', 'mouthRollLower'], ['mouthPucker']],
68
+ PP: [['mouthPucker', 'mouthClose'], ['mouthPucker']],
69
+ FF: [['mouthRollLower', 'mouthLowerDownLeft', 'mouthLowerDownRight'], ['mouthShrugLower']],
70
+ CH: [['mouthSmileLeft', 'mouthSmileRight', 'mouthStretchLeft', 'mouthStretchRight'], ['mouthSmileLeft', 'mouthSmileRight']],
71
+ ee: [['mouthSmileLeft', 'mouthSmileRight'], ['mouthSmileLeft']],
72
+ ih: [['mouthSmileLeft', 'mouthSmileRight'], ['mouthSmileRight']],
73
+ };
74
+
75
+ function buildVisemeMorphCache() {
76
+ visemeMorphCache = {};
77
+ for (const [visemeKey, aliases] of Object.entries(VISEME_MORPH_ALIASES)) {
78
+ const entries = [];
79
+ // Check if we have a compound ARKit mapping for this viseme
80
+ const compoundOptions = VISEME_COMPOUND_ARKIT[visemeKey];
81
+ if (compoundOptions) {
82
+ // Try each compound option; use the first one where all names exist on at least one mesh
83
+ let usedCompound = false;
84
+ for (const nameList of compoundOptions) {
85
+ // Collect entries for all names across all meshes
86
+ const compoundEntries = [];
87
+ for (const mesh of mouthMeshes) {
88
+ if (!mesh.morphTargetDictionary) continue;
89
+ const dict = mesh.morphTargetDictionary;
90
+ const dictKeysLower = Object.fromEntries(Object.keys(dict).map(k => [k.toLowerCase(), k]));
91
+ for (const name of nameList) {
92
+ const found = dictKeysLower[name.toLowerCase()];
93
+ if (found !== undefined) {
94
+ compoundEntries.push({ influences: mesh.morphTargetInfluences, idx: dict[found], morphName: found });
95
+ }
96
+ }
97
+ }
98
+ if (compoundEntries.length > 0) {
99
+ entries.push(...compoundEntries);
100
+ usedCompound = true;
101
+ break;
102
+ }
103
+ }
104
+ if (usedCompound) {
105
+ visemeMorphCache[visemeKey] = entries;
106
+ continue;
107
+ }
108
+ }
109
+ // Fallback: single-alias lookup
110
+ for (const mesh of mouthMeshes) {
111
+ if (!mesh.morphTargetDictionary) continue;
112
+ const dictKeys = Object.keys(mesh.morphTargetDictionary);
113
+ for (const alias of aliases) {
114
+ const found = dictKeys.find(k => k.toLowerCase() === alias.toLowerCase());
115
+ if (found !== undefined) {
116
+ entries.push({ influences: mesh.morphTargetInfluences, idx: mesh.morphTargetDictionary[found], morphName: found });
117
+ break;
118
+ }
119
+ }
120
+ }
121
+ if (entries.length > 0) visemeMorphCache[visemeKey] = entries;
122
+ }
123
+ const found = Object.keys(visemeMorphCache);
124
+ log('Viseme cache: ' + (found.length > 0 ? found.join(', ') : 'none — check morph target names'));
125
+
126
+ // Always log all available morphs so we can see what the model actually has
127
+ if (mouthMeshes.length > 0) {
128
+ const allMorphs = Object.keys(mouthMeshes[0].morphTargetDictionary || {});
129
+ log('Available morphs: ' + (allMorphs.length > 0 ? allMorphs.join(', ') : 'none'));
130
+ }
131
+ }
132
+
133
+ function applyViseme(visemeKey, weight) {
134
+ if (!visemeMorphCache) buildVisemeMorphCache();
135
+ if (visemeKey === 'sil' || weight <= 0) {
136
+ for (const key of Object.keys(visemeState)) visemeState[key] = 0;
137
+ return;
138
+ }
139
+ visemeState[visemeKey] = Math.min(1, weight);
140
+ visemeStateLastSet.set(visemeKey, Date.now());
141
+ }
142
+
143
+ const RHUBARB_DEFAULT_VISEME_WEIGHT = 0.72;
144
+ const RHUBARB_LABIAL_VISEME_WEIGHT = 0.85;
145
+ const RHUBARB_AA_VISEME_WEIGHT = 0.72;
146
+ const RHUBARB_ROUNDED_VISEME_WEIGHT = 0.62;
147
+ const RHUBARB_FALLBACK_AMPLITUDE_CAP = 0.72;
148
+ const RHUBARB_FALLBACK_AMPLITUDE_GAIN = 0.75;
149
+ const RHUBARB_VISEME_WEIGHTS = {
150
+ PP: RHUBARB_LABIAL_VISEME_WEIGHT,
151
+ FF: 0.78,
152
+ ee: 0.72,
153
+ ih: 0.68,
154
+ oh: RHUBARB_ROUNDED_VISEME_WEIGHT,
155
+ ou: 0.58,
156
+ aa: RHUBARB_AA_VISEME_WEIGHT,
157
+ };
158
+
159
+ const RHUBARB_TO_VISEME = {
160
+ A: 'aa',
161
+ B: 'PP',
162
+ C: 'ih',
163
+ D: 'FF',
164
+ E: 'ee',
165
+ F: 'oh',
166
+ G: 'ou',
167
+ H: 'nn',
168
+ X: 'sil',
169
+ };
170
+
171
+ let rhubarbMorphCache = null;
172
+ let visemeTimers = [];
173
+ let activeVisemeScheduleId = 0;
174
+ let visemeModeUntil = 0;
175
+ const visemeStateLastSet = new Map();
176
+
177
+ function buildRhubarbMorphCache() {
178
+ if (!visemeMorphCache) buildVisemeMorphCache();
179
+ rhubarbMorphCache = {};
180
+ for (const [rhubarbShape, visemeKey] of Object.entries(RHUBARB_TO_VISEME)) {
181
+ if (visemeKey === 'sil') { rhubarbMorphCache[rhubarbShape] = null; continue; }
182
+ rhubarbMorphCache[rhubarbShape] = visemeMorphCache[visemeKey] || null;
183
+ }
184
+ log('Rhubarb morph cache built');
185
+ }
186
+
187
+ function applyRhubarbCue(shape) {
188
+ if (!rhubarbMorphCache) buildRhubarbMorphCache();
189
+ // Zero all active viseme channels first
190
+ for (const key of Object.keys(visemeState)) visemeState[key] = 0;
191
+ if (shape === 'X' || !rhubarbMorphCache[shape]) return;
192
+ const visemeKey = RHUBARB_TO_VISEME[shape];
193
+ if (visemeKey && visemeKey !== 'sil') {
194
+ visemeState[visemeKey] = RHUBARB_VISEME_WEIGHTS[visemeKey] || RHUBARB_DEFAULT_VISEME_WEIGHT;
195
+ visemeStateLastSet.set(visemeKey, Date.now());
196
+ }
197
+ }
198
+
199
+ function clearScheduledVisemes() {
200
+ activeVisemeScheduleId++;
201
+ for (const id of visemeTimers) clearTimeout(id);
202
+ visemeTimers = [];
203
+ visemeModeUntil = 0;
204
+ // Zero all mouth morphs
205
+ for (const key of Object.keys(visemeState)) visemeState[key] = 0;
206
+ }
207
+
208
+ function tickVisemeDecay(deltaSeconds) {
209
+ if (!visemeMorphCache) return;
210
+
211
+ const isScheduled = Date.now() < visemeModeUntil;
212
+ const hasSpecificLipShape =
213
+ visemeState.PP > 0.05 ||
214
+ visemeState.FF > 0.05 ||
215
+ visemeState.kk > 0.05 ||
216
+ visemeState.ee > 0.05 ||
217
+ visemeState.ih > 0.05;
218
+
219
+ for (const [key, weight] of Object.entries(visemeState)) {
220
+ // Only decay if we aren't in the middle of a viseme schedule.
221
+ // Scheduled visemes are cleared manually by timeouts.
222
+ if (!isScheduled) {
223
+ // Time-delta-aware decay: maintain consistent feel regardless of frame rate.
224
+ // Base rate is calibrated for 60 fps (0.82 per frame = ~12 frames to 10%).
225
+ // pow(0.82, delta*60) is frame-rate independent.
226
+ const dt = deltaSeconds ?? (1 / 60);
227
+ const decayFactor = Math.pow(0.82, dt * 60);
228
+ const decayed = weight * decayFactor;
229
+ visemeState[key] = decayed < 0.01 ? 0 : decayed;
230
+ }
231
+
232
+ const entries = visemeMorphCache[key];
233
+ if (!entries) continue;
234
+
235
+ let targetWeight = visemeState[key];
236
+ if (key === 'aa' && hasSpecificLipShape) targetWeight = Math.min(targetWeight, 0.45);
237
+
238
+ for (const e of entries) {
239
+ // When TalkingHead is active, write through its morph API so the internal
240
+ // render loop doesn't overwrite our values every frame.
241
+ // Use realtime (not newvalue) — newvalue is consumed and cleared after
242
+ // a single frame, so scheduled visemes would vanish immediately.
243
+ // realtime persists until explicitly set to null.
244
+ if (head && head.mtAvatar && e.morphName && head.mtAvatar[e.morphName]) {
245
+ const mt = head.mtAvatar[e.morphName];
246
+ mt.realtime = targetWeight > 0 ? targetWeight : null;
247
+ mt.needsUpdate = true;
248
+ } else {
249
+ e.influences[e.idx] = targetWeight;
250
+ }
251
+ }
252
+ }
253
+ }
254
+
255
+ function scheduleVisemes(schedule) {
256
+ clearScheduledVisemes();
257
+
258
+ // Prune visemeState keys that haven't been written in the last 2 seconds to
259
+ // prevent unbounded accumulation across many utterances.
260
+ const staleThreshold = Date.now() - 2000;
261
+ for (const key of Object.keys(visemeState)) {
262
+ if ((visemeStateLastSet.get(key) ?? 0) < staleThreshold) {
263
+ delete visemeState[key];
264
+ visemeStateLastSet.delete(key);
265
+ }
266
+ }
267
+
268
+ if (!schedule || !Array.isArray(schedule.cues) || schedule.cues.length === 0) return;
269
+
270
+ const myScheduleId = activeVisemeScheduleId;
271
+ // Anchor selection priority:
272
+ // 1. audioStartedAtMs — stamped when audio actually begins playing (most accurate)
273
+ // 2. startedAtMs + pipeline delay — stamped at TTS request fire time
274
+ //
275
+ // AUDIO_PIPELINE_DELAY_MS compensates for the gap between "TTS request fired"
276
+ // and "audio audible from speaker". Qwen3-TTS on local/tailnet is ~80–150 ms;
277
+ // LiveKit adds ~50–80 ms of jitter buffer on top. 150 ms is conservative but
278
+ // avoids the mouth running ahead of audio on fast connections.
279
+ const AUDIO_PIPELINE_DELAY_MS = 50;
280
+ let startedAt = schedule.audioStartedAtMs
281
+ ?? ((schedule.startedAtMs || Date.now()) + AUDIO_PIPELINE_DELAY_MS);
282
+ const durationMs = schedule.durationMs || 0;
283
+ const now = Date.now();
284
+ let elapsedMs = Math.max(0, now - startedAt);
285
+
286
+ // If the schedule still arrives late after the pipeline offset, shift further
287
+ if (elapsedMs > 300 && schedule.cues.length > 3) {
288
+ const shift = Math.min(elapsedMs - 50, 500);
289
+ startedAt += shift;
290
+ elapsedMs -= shift;
291
+ log('Viseme schedule arrived late, shifting anchor forward by ' + shift + 'ms');
292
+ }
293
+
294
+ const remainingMs = Math.max(0, durationMs - elapsedMs);
295
+ let scheduledCueCount = 0;
296
+ let skippedCueCount = 0;
297
+
298
+ // Gate amplitude fallback for the locally remaining duration plus a small buffer.
299
+ // If the schedule arrives a bit late, keep amplitude out of the way for the rest
300
+ // of the utterance instead of expiring immediately from the original timestamp.
301
+ visemeModeUntil = now + remainingMs + 200;
302
+
303
+ for (const cue of schedule.cues) {
304
+ const delay = cue.startMs - (Date.now() - startedAt);
305
+ if (delay < -50) {
306
+ skippedCueCount++;
307
+ continue; // already in the past, skip
308
+ }
309
+ scheduledCueCount++;
310
+
311
+ const applyId = setTimeout(() => {
312
+ if (activeVisemeScheduleId !== myScheduleId) return;
313
+ applyRhubarbCue(cue.viseme);
314
+ }, Math.max(0, delay));
315
+
316
+ const clearId = setTimeout(() => {
317
+ if (activeVisemeScheduleId !== myScheduleId) return;
318
+ // Only clear if the next cue hasn't already overwritten state
319
+ visemeState[RHUBARB_TO_VISEME[cue.viseme]] = 0;
320
+ }, Math.max(0, delay + (cue.endMs - cue.startMs)));
321
+
322
+ visemeTimers.push(applyId, clearId);
323
+ }
324
+
325
+ log(
326
+ 'Viseme schedule received: requestId=' +
327
+ (schedule.requestId || 'unknown') +
328
+ ' cues=' + schedule.cues.length +
329
+ ' scheduled=' + scheduledCueCount +
330
+ ' skipped=' + skippedCueCount +
331
+ ' elapsedMs=' + elapsedMs +
332
+ ' remainingMs=' + remainingMs,
333
+ );
334
+
335
+ // Ensure silence at schedule end
336
+ const endDelay = durationMs - (Date.now() - startedAt);
337
+ if (endDelay > 0) {
338
+ visemeTimers.push(setTimeout(() => {
339
+ if (activeVisemeScheduleId !== myScheduleId) return;
340
+ for (const key of Object.keys(visemeState)) visemeState[key] = 0;
341
+ visemeModeUntil = 0;
342
+ }, endDelay + 100));
343
+ }
344
+ }
345
+ // ============ END RHUBARB VISEME SCHEDULER ============
346
+
347
+ `;
348
+ }
package/dist/html.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export type AvatarConfig = {
2
2
  avatarUrl: string;
3
3
  authToken?: string | null;
4
- mood: 'neutral' | 'happy' | 'sad' | 'angry' | 'excited' | 'thinking' | 'concerned' | 'surprised';
4
+ mood: 'neutral' | 'happy' | 'sad' | 'angry' | 'fear' | 'disgust' | 'love' | 'sleep' | 'excited' | 'thinking' | 'concerned' | 'surprised';
5
5
  cameraView: 'head' | 'upper' | 'full';
6
6
  cameraDistance: number;
7
7
  /** Initial colors only — live updates go via postMessage (setHairColor etc.) */