talking-head-studio 0.4.10 → 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 +299 -337
- package/dist/TalkingHead.d.ts +44 -28
- package/dist/TalkingHead.js +21 -2
- package/dist/TalkingHead.web.d.ts +37 -4
- package/dist/TalkingHead.web.js +28 -8
- 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 +41 -28
- package/dist/appearance/apply.js +2 -3
- package/dist/appearance/matchers.js +1 -2
- package/dist/appearance/schema.js +1 -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/backend.d.ts +130 -0
- package/dist/core/avatar/backend.js +4 -0
- package/dist/core/avatar/backends/gaussian.d.ts +49 -0
- package/dist/core/avatar/backends/gaussian.js +293 -0
- package/dist/core/avatar/backends/index.d.ts +3 -0
- package/dist/core/avatar/backends/index.js +7 -0
- package/dist/core/avatar/backends/morphTarget.d.ts +39 -0
- package/dist/core/avatar/backends/morphTarget.js +179 -0
- package/dist/core/avatar/faceControls.d.ts +40 -0
- package/dist/core/avatar/faceControls.js +138 -0
- 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 +78 -0
- package/dist/core/avatar/schema.js +134 -0
- package/dist/core/avatar/visemes.d.ts +47 -1
- package/dist/core/avatar/visemes.js +114 -1
- package/dist/editor/AvatarCanvas.js +93 -3
- package/dist/editor/AvatarEditor.native.js +19 -9
- package/dist/editor/AvatarModel.js +2 -2
- package/dist/editor/FaceSqueezeEditor.d.ts +3 -1
- package/dist/editor/FaceSqueezeEditor.js +195 -121
- package/dist/editor/FaceSqueezeEditor.web.d.ts +3 -1
- package/dist/editor/FaceSqueezeEditor.web.js +32 -30
- package/dist/editor/RigidAccessory.js +18 -4
- package/dist/editor/SkinnedClothing.js +19 -9
- package/dist/editor/boneLockedDrag.d.ts +11 -0
- package/dist/editor/boneLockedDrag.js +68 -0
- package/dist/editor/boneSnap.js +22 -12
- 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 +19 -16
- 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 +56 -734
- package/dist/index.d.ts +19 -1
- package/dist/index.js +44 -5
- package/dist/index.web.d.ts +18 -1
- package/dist/index.web.js +36 -3
- package/dist/platform/api/types.d.ts +10 -0
- package/dist/platform/api/types.js +2 -0
- package/dist/platform/marketplace/types.d.ts +32 -0
- package/dist/platform/marketplace/types.js +2 -0
- package/dist/platform/sdk/unity.d.ts +27 -0
- package/dist/platform/sdk/unity.js +2 -0
- package/dist/platform/sdk/unreal.d.ts +23 -0
- package/dist/platform/sdk/unreal.js +2 -0
- package/dist/platform/sdk/web.d.ts +16 -0
- package/dist/platform/sdk/web.js +2 -0
- package/dist/sketchfab/api.js +5 -5
- 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/sketchfab/useSketchfabSearch.js +1 -2
- 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.d.ts +2 -6
- package/dist/tts/useDirectVisemeStream.js +16 -12
- package/dist/tts/useMotionMarkers.d.ts +0 -1
- package/dist/tts/useMotionMarkers.js +1 -2
- package/dist/utils/avatarUtils.js +94 -8
- package/dist/utils/faceLandmarkerToShapeWeights.js +21 -14
- package/dist/voice/convertToWav.js +1 -2
- package/dist/voice/index.d.ts +3 -0
- package/dist/voice/index.js +6 -1
- package/dist/voice/useAudioPlayer.js +18 -6
- package/dist/voice/useAudioRecording.js +1 -2
- package/dist/voice/useFaceControls.d.ts +14 -0
- package/dist/voice/useFaceControls.js +81 -0
- package/dist/voice/useVoicePreview.d.ts +7 -0
- package/dist/voice/useVoicePreview.js +83 -0
- package/dist/wardrobe/index.d.ts +3 -0
- package/dist/wardrobe/index.js +8 -1
- package/dist/wardrobe/useAccessoryGestures.d.ts +20 -0
- package/dist/wardrobe/useAccessoryGestures.js +94 -0
- package/dist/wardrobe/useAvatarWardrobeHydration.js +9 -4
- package/dist/wardrobe/useStudioAvatar.d.ts +29 -0
- package/dist/wardrobe/useStudioAvatar.js +186 -0
- 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 +313 -46
- 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 +22 -11
- package/dist/wgpu/useNativeGLTF.d.ts +7 -0
- package/dist/wgpu/useNativeGLTF.js +36 -0
- package/package.json +102 -32
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.buildMotionScript = buildMotionScript;
|
|
4
|
+
/**
|
|
5
|
+
* Generated avatar WebView motion 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. The motion definitions and bone
|
|
9
|
+
* search map are serialized from the shared, framework-free source of truth in
|
|
10
|
+
* src/core/avatar/motion.ts so the WebView and the native WGPU path can never
|
|
11
|
+
* drift apart. The math below mirrors src/core/avatar/motionRuntime.ts.
|
|
12
|
+
*/
|
|
13
|
+
const motion_1 = require("../core/avatar/motion");
|
|
14
|
+
function buildMotionScript() {
|
|
15
|
+
return `/* ── MotionEngine ────────────────────────────────────────────────────────── */
|
|
16
|
+
/* Quaternion math helpers (no Three.js dep required) */
|
|
17
|
+
const QM = {
|
|
18
|
+
fromAxisAngle(ax, ay, az, angle) {
|
|
19
|
+
const s = Math.sin(angle / 2);
|
|
20
|
+
return { x: ax*s, y: ay*s, z: az*s, w: Math.cos(angle/2) };
|
|
21
|
+
},
|
|
22
|
+
multiply(a, b) {
|
|
23
|
+
return {
|
|
24
|
+
x: a.w*b.x + a.x*b.w + a.y*b.z - a.z*b.y,
|
|
25
|
+
y: a.w*b.y - a.x*b.z + a.y*b.w + a.z*b.x,
|
|
26
|
+
z: a.w*b.z + a.x*b.y - a.y*b.x + a.z*b.w,
|
|
27
|
+
w: a.w*b.w - a.x*b.x - a.y*b.y - a.z*b.z,
|
|
28
|
+
};
|
|
29
|
+
},
|
|
30
|
+
applyTo(bone, q) {
|
|
31
|
+
bone.quaternion.set(q.x, q.y, q.z, q.w);
|
|
32
|
+
},
|
|
33
|
+
copyFrom(bone) {
|
|
34
|
+
const q = bone.quaternion;
|
|
35
|
+
return { x: q.x, y: q.y, z: q.z, w: q.w };
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/* Motion definitions + bone search map — serialized from the shared TS source
|
|
40
|
+
of truth (src/core/avatar/motion.ts) at build time. */
|
|
41
|
+
const MOTION_DEFS = ${JSON.stringify(motion_1.MOTION_DEFS)};
|
|
42
|
+
const BONE_SEARCH = ${JSON.stringify(motion_1.MOTION_BONE_SEARCH)};
|
|
43
|
+
|
|
44
|
+
/* Runtime state */
|
|
45
|
+
let motionActive = false;
|
|
46
|
+
let motionKey = null;
|
|
47
|
+
let motionStartTime = null;
|
|
48
|
+
let motionBones = {};
|
|
49
|
+
let motionRestQuats = {};
|
|
50
|
+
let motionSmileTargets = [];
|
|
51
|
+
let motionBeatTimer = null;
|
|
52
|
+
let motionBeatIndex = 0;
|
|
53
|
+
let motionAutoReturnTimer = null;
|
|
54
|
+
|
|
55
|
+
/* Bone scan: norm() strips punctuation and lowercases, so RPM names like
|
|
56
|
+
LeftShoulder, RightArm, LeftForeArm etc. all match correctly. */
|
|
57
|
+
function motionScanBones(root) {
|
|
58
|
+
motionBones = {}; motionRestQuats = {};
|
|
59
|
+
if (!root) return;
|
|
60
|
+
const norm = s => s.toLowerCase().replace(/[_\\s\\-\\.]/g, '');
|
|
61
|
+
root.traverse(child => {
|
|
62
|
+
if (!child.isBone) return;
|
|
63
|
+
const lk = norm(child.name);
|
|
64
|
+
for (const [key, kws] of Object.entries(BONE_SEARCH)) {
|
|
65
|
+
if (motionBones[key]) continue;
|
|
66
|
+
if (kws.some(kw => lk.includes(norm(kw)))) {
|
|
67
|
+
motionBones[key] = child;
|
|
68
|
+
motionRestQuats[key] = QM.copyFrom(child);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
const found = Object.keys(motionBones);
|
|
73
|
+
log('MotionEngine: ' + (found.length === 0
|
|
74
|
+
? 'No rigged bones detected — motion engine needs a humanoid skeleton.'
|
|
75
|
+
: found.length + ' bones mapped: ' + found.join(', ') + '.'));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function motionScanSmile(root) {
|
|
79
|
+
motionSmileTargets = [];
|
|
80
|
+
if (!root) return;
|
|
81
|
+
root.traverse(child => {
|
|
82
|
+
if (!child.isMesh || !child.morphTargetDictionary) return;
|
|
83
|
+
Object.keys(child.morphTargetDictionary).forEach(name => {
|
|
84
|
+
const lk = name.toLowerCase();
|
|
85
|
+
if (lk.includes('smile') || lk.includes('happy') || lk.includes('joy') || lk.includes('mouthsmile')) {
|
|
86
|
+
motionSmileTargets.push({ mesh: child, index: child.morphTargetDictionary[name] });
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function motionInitFromRoot(root) {
|
|
93
|
+
motionScanBones(root);
|
|
94
|
+
motionScanSmile(root);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function motionSetSmile(v) {
|
|
98
|
+
motionSmileTargets.forEach(t => { t.mesh.morphTargetInfluences[t.index] = v; });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/* BPM beat indicator — no-ops in WebView (no beat UI) */
|
|
102
|
+
function motionStartBeat(bpm) {
|
|
103
|
+
clearInterval(motionBeatTimer);
|
|
104
|
+
motionBeatIndex = 0;
|
|
105
|
+
/* beat UI elements not present in WebView — intentional no-op */
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function motionStopBeat() {
|
|
109
|
+
clearInterval(motionBeatTimer);
|
|
110
|
+
motionBeatTimer = null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/* ── applyMotionBones ────────────────────────────────────────
|
|
114
|
+
Called every frame from inside the render loop
|
|
115
|
+
(fallback: before renderer.render; TH: inside head.opt.update).
|
|
116
|
+
Runs AFTER TH has written its own bones so our values are
|
|
117
|
+
the last thing written before the draw call.
|
|
118
|
+
─────────────────────────────────────────────────────────── */
|
|
119
|
+
function applyMotionBones() {
|
|
120
|
+
if (!motionActive) return;
|
|
121
|
+
const def = MOTION_DEFS[motionKey];
|
|
122
|
+
if (!def) return;
|
|
123
|
+
const tSec = (performance.now() - motionStartTime) / 1000;
|
|
124
|
+
|
|
125
|
+
for (const [boneName, oscillators] of Object.entries(def.bones)) {
|
|
126
|
+
const bone = motionBones[boneName];
|
|
127
|
+
if (!bone) continue;
|
|
128
|
+
const rest = motionRestQuats[boneName];
|
|
129
|
+
let q = { ...rest };
|
|
130
|
+
|
|
131
|
+
for (const osc of oscillators) {
|
|
132
|
+
const angle = osc.freq === 0
|
|
133
|
+
? osc.amp
|
|
134
|
+
: osc.amp * Math.sin(2 * Math.PI * osc.freq * tSec + osc.phase);
|
|
135
|
+
q = QM.multiply(q, QM.fromAxisAngle(osc.ax, osc.ay, osc.az, angle));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
bone.quaternion.set(q.x, q.y, q.z, q.w);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
window.playMotion = function(key) {
|
|
143
|
+
const def = MOTION_DEFS[key];
|
|
144
|
+
if (!def) { log('MotionEngine: unknown motion key: ' + key); return false; }
|
|
145
|
+
const root = (head && head.armature) ? head.armature : staticModel;
|
|
146
|
+
if (!root) { log('MotionEngine: no model loaded'); return false; }
|
|
147
|
+
|
|
148
|
+
if (Object.keys(motionBones).length === 0) motionInitFromRoot(root);
|
|
149
|
+
|
|
150
|
+
window.stopMotion(false);
|
|
151
|
+
|
|
152
|
+
motionKey = key;
|
|
153
|
+
motionActive = true;
|
|
154
|
+
motionStartTime = performance.now();
|
|
155
|
+
|
|
156
|
+
motionStartBeat(def.bpm);
|
|
157
|
+
motionSetSmile(def.smile);
|
|
158
|
+
log('MotionEngine: playing ' + def.label + ' @ ' + def.bpm + ' BPM');
|
|
159
|
+
|
|
160
|
+
/* Pose-like motions (attack/defend) auto-revert to rest so combat loops
|
|
161
|
+
return to guard without an explicit stop message. */
|
|
162
|
+
if (def.autoReturnMs > 0) {
|
|
163
|
+
motionAutoReturnTimer = setTimeout(function() {
|
|
164
|
+
if (motionKey === key) window.stopMotion(true);
|
|
165
|
+
}, def.autoReturnMs);
|
|
166
|
+
}
|
|
167
|
+
return true;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
window.stopMotion = function(restore) {
|
|
171
|
+
if (restore === undefined) restore = true;
|
|
172
|
+
if (motionAutoReturnTimer) { clearTimeout(motionAutoReturnTimer); motionAutoReturnTimer = null; }
|
|
173
|
+
motionActive = false;
|
|
174
|
+
motionStopBeat();
|
|
175
|
+
|
|
176
|
+
if (restore) {
|
|
177
|
+
for (const [key, bone] of Object.entries(motionBones)) {
|
|
178
|
+
const rest = motionRestQuats[key];
|
|
179
|
+
if (rest) bone.quaternion.set(rest.x, rest.y, rest.z, rest.w);
|
|
180
|
+
}
|
|
181
|
+
motionSetSmile(0);
|
|
182
|
+
motionKey = null;
|
|
183
|
+
log('MotionEngine: stopped');
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
/* ── End MotionEngine ─────────────────────────────────────── */
|
|
187
|
+
|
|
188
|
+
`;
|
|
189
|
+
}
|
|
@@ -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.) */
|