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.
- package/README.md +279 -193
- package/dist/TalkingHead.d.ts +28 -3
- package/dist/TalkingHead.js +21 -2
- package/dist/TalkingHead.web.d.ts +31 -4
- package/dist/TalkingHead.web.js +11 -1
- package/dist/TalkingHeadVisualization.d.ts +22 -0
- package/dist/TalkingHeadVisualization.js +30 -10
- package/dist/api/studioApi.d.ts +12 -1
- package/dist/api/studioApi.js +16 -2
- package/dist/contract.d.ts +14 -0
- package/dist/contract.js +30 -0
- package/dist/core/avatar/avatarCapabilities.d.ts +60 -0
- package/dist/core/avatar/avatarCapabilities.js +100 -0
- package/dist/core/avatar/backends/gaussian.js +6 -4
- package/dist/core/avatar/motion.d.ts +1713 -0
- package/dist/core/avatar/motion.js +550 -0
- package/dist/core/avatar/motionRuntime.d.ts +46 -0
- package/dist/core/avatar/motionRuntime.js +84 -0
- package/dist/core/avatar/schema.d.ts +33 -5
- package/dist/core/avatar/visemes.d.ts +16 -1
- package/dist/core/avatar/visemes.js +48 -1
- package/dist/editor/AvatarCanvas.js +92 -1
- package/dist/editor/AvatarEditor.native.js +1 -0
- package/dist/editor/AvatarModel.js +1 -0
- package/dist/editor/FaceSqueezeEditor.d.ts +3 -1
- package/dist/editor/FaceSqueezeEditor.js +176 -112
- package/dist/editor/FaceSqueezeEditor.web.d.ts +3 -1
- package/dist/editor/FaceSqueezeEditor.web.js +30 -28
- package/dist/editor/RigidAccessory.js +17 -2
- package/dist/editor/SkinnedClothing.js +1 -0
- package/dist/editor/boneLockedDrag.d.ts +11 -0
- package/dist/editor/boneLockedDrag.js +68 -0
- package/dist/editor/boneSnap.web.d.ts +27 -0
- package/dist/editor/boneSnap.web.js +99 -0
- package/dist/editor/index.web.d.ts +10 -0
- package/dist/editor/index.web.js +26 -0
- package/dist/editor/sounds/haha.wav +0 -0
- package/dist/editor/sounds/owie.wav +0 -0
- package/dist/editor/sounds/stop.wav +0 -0
- package/dist/editor/studioTheme.d.ts +14 -14
- package/dist/editor/studioTheme.js +17 -14
- package/dist/editor/types.d.ts +1 -0
- package/dist/html/accessories.d.ts +7 -0
- package/dist/html/accessories.js +149 -0
- package/dist/html/motion.d.ts +1 -0
- package/dist/html/motion.js +189 -0
- package/dist/html/visemes.d.ts +7 -0
- package/dist/html/visemes.js +348 -0
- package/dist/html.d.ts +1 -1
- package/dist/html.js +55 -732
- package/dist/index.d.ts +7 -3
- package/dist/index.js +17 -1
- package/dist/index.web.d.ts +18 -1
- package/dist/index.web.js +36 -3
- package/dist/sketchfab/api.js +1 -0
- package/dist/sketchfab/glbInspect.d.ts +22 -0
- package/dist/sketchfab/glbInspect.js +58 -0
- package/dist/sketchfab/index.d.ts +3 -0
- package/dist/sketchfab/index.js +8 -1
- package/dist/sketchfab/inspectRemote.d.ts +13 -0
- package/dist/sketchfab/inspectRemote.js +77 -0
- package/dist/sketchfab/types.d.ts +10 -0
- package/dist/studio/AccessoryBrowserScreen.d.ts +6 -0
- package/dist/studio/AccessoryBrowserScreen.js +626 -0
- package/dist/studio/AccessoryPanel.d.ts +10 -0
- package/dist/studio/AccessoryPanel.js +396 -0
- package/dist/studio/AppearancePanel.d.ts +9 -0
- package/dist/studio/AppearancePanel.js +77 -0
- package/dist/studio/AvatarCreatorScreen.d.ts +5 -0
- package/dist/studio/AvatarCreatorScreen.js +806 -0
- package/dist/studio/AvatarEditorScreen.d.ts +14 -0
- package/dist/studio/AvatarEditorScreen.js +510 -0
- package/dist/studio/AvatarGrid.d.ts +23 -0
- package/dist/studio/AvatarGrid.js +257 -0
- package/dist/studio/ColorSwatch.d.ts +8 -0
- package/dist/studio/ColorSwatch.js +100 -0
- package/dist/studio/CreateVoiceProfileSheet.d.ts +8 -0
- package/dist/studio/CreateVoiceProfileSheet.js +242 -0
- package/dist/studio/DetailsPanel.d.ts +15 -0
- package/dist/studio/DetailsPanel.js +239 -0
- package/dist/studio/FilamentEditor.d.ts +2 -0
- package/dist/studio/FilamentEditor.js +6 -0
- package/dist/studio/PrecisionPanel.d.ts +2 -0
- package/dist/studio/PrecisionPanel.js +7 -0
- package/dist/studio/PublicGalleryScreen.d.ts +5 -0
- package/dist/studio/PublicGalleryScreen.js +358 -0
- package/dist/studio/SketchfabModelCard.d.ts +20 -0
- package/dist/studio/SketchfabModelCard.js +104 -0
- package/dist/studio/StudioBrowseHeader.d.ts +9 -0
- package/dist/studio/StudioBrowseHeader.js +28 -0
- package/dist/studio/StudioEmptyState.d.ts +8 -0
- package/dist/studio/StudioEmptyState.js +29 -0
- package/dist/studio/StudioFloatingAction.d.ts +13 -0
- package/dist/studio/StudioFloatingAction.js +42 -0
- package/dist/studio/StudioSectionHeader.d.ts +7 -0
- package/dist/studio/StudioSectionHeader.js +27 -0
- package/dist/studio/StudioSurfaceCard.d.ts +8 -0
- package/dist/studio/StudioSurfaceCard.js +20 -0
- package/dist/studio/VoicePanel.d.ts +15 -0
- package/dist/studio/VoicePanel.js +305 -0
- package/dist/studio/constants.d.ts +3 -0
- package/dist/studio/constants.js +6 -0
- package/dist/studio/index.d.ts +29 -0
- package/dist/studio/index.js +54 -0
- package/dist/studio/useSketchfabCapabilities.d.ts +31 -0
- package/dist/studio/useSketchfabCapabilities.js +82 -0
- package/dist/tts/useDirectVisemeStream.js +15 -10
- package/dist/utils/avatarUtils.js +92 -5
- package/dist/utils/faceLandmarkerToShapeWeights.js +2 -4
- package/dist/voice/useAudioPlayer.js +17 -4
- package/dist/voice/useVoicePreview.js +4 -2
- package/dist/wardrobe/index.d.ts +1 -0
- package/dist/wardrobe/index.js +6 -1
- package/dist/wardrobe/useAccessoryGestures.d.ts +20 -0
- package/dist/wardrobe/useAccessoryGestures.js +94 -0
- package/dist/wardrobe/useAvatarWardrobeHydration.js +8 -2
- package/dist/wardrobe/useStudioAvatar.js +11 -2
- package/dist/wardrobe/wardrobeStore.d.ts +2 -0
- package/dist/wardrobe/wardrobeStore.js +12 -2
- package/dist/wgpu/R3FWebGpuCanvas.d.ts +15 -0
- package/dist/wgpu/R3FWebGpuCanvas.js +176 -0
- package/dist/wgpu/WgpuAvatar.d.ts +26 -2
- package/dist/wgpu/WgpuAvatar.js +296 -39
- package/dist/wgpu/accessoryDefaults.d.ts +12 -0
- package/dist/wgpu/accessoryDefaults.js +19 -0
- package/dist/wgpu/blobShim.d.ts +2 -0
- package/dist/wgpu/blobShim.js +191 -0
- package/dist/wgpu/index.d.ts +1 -0
- package/dist/wgpu/index.js +4 -1
- package/dist/wgpu/loadGLTFFromUri.d.ts +2 -0
- package/dist/wgpu/loadGLTFFromUri.js +75 -0
- package/dist/wgpu/morphTables.js +21 -10
- package/dist/wgpu/motionState.d.ts +20 -0
- package/dist/wgpu/motionState.js +31 -0
- package/dist/wgpu/patchThreeForRN.d.ts +28 -0
- package/dist/wgpu/patchThreeForRN.js +292 -0
- package/dist/wgpu/scenePlacement.d.ts +5 -0
- package/dist/wgpu/scenePlacement.js +50 -0
- package/dist/wgpu/useAuthedModelUri.js +4 -2
- package/dist/wgpu/useNativeGLTF.d.ts +7 -0
- package/dist/wgpu/useNativeGLTF.js +36 -0
- 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.) */
|