talking-head-studio 0.4.5 → 0.4.6
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/dist/TalkingHead.d.ts
CHANGED
|
@@ -61,6 +61,7 @@ export interface TalkingHeadProps {
|
|
|
61
61
|
onReady?: () => void;
|
|
62
62
|
onError?: (message: string) => void;
|
|
63
63
|
onAvatarState?: (state: string) => void;
|
|
64
|
+
onVoiceMood?: (mood: TalkingHeadMood) => void;
|
|
64
65
|
style?: StyleProp<ViewStyle>;
|
|
65
66
|
/** Base URL for vendored assets. When set, replaces all cdn.jsdelivr.net references. */
|
|
66
67
|
vendorBaseUrl?: string | null;
|
package/dist/TalkingHead.js
CHANGED
|
@@ -6,7 +6,7 @@ const react_1 = require("react");
|
|
|
6
6
|
const react_native_1 = require("react-native");
|
|
7
7
|
const react_native_webview_1 = require("react-native-webview");
|
|
8
8
|
const html_1 = require("./html");
|
|
9
|
-
exports.TalkingHead = (0, react_1.forwardRef)(({ avatarUrl, authToken, mood = 'neutral', cameraView = 'upper', cameraDistance = -0.5, hairColor, skinColor, eyeColor, accessories, onLoadingChange, onReady, onError, onAvatarState, style, vendorBaseUrl, }, ref) => {
|
|
9
|
+
exports.TalkingHead = (0, react_1.forwardRef)(({ avatarUrl, authToken, mood = 'neutral', cameraView = 'upper', cameraDistance = -0.5, hairColor, skinColor, eyeColor, accessories, onLoadingChange, onReady, onError, onAvatarState, onVoiceMood, style, vendorBaseUrl, }, ref) => {
|
|
10
10
|
const webViewRef = (0, react_1.useRef)(null);
|
|
11
11
|
const readyRef = (0, react_1.useRef)(false);
|
|
12
12
|
const firstMessageSeenRef = (0, react_1.useRef)(false);
|
|
@@ -25,6 +25,8 @@ exports.TalkingHead = (0, react_1.forwardRef)(({ avatarUrl, authToken, mood = 'n
|
|
|
25
25
|
onErrorRef.current = onError;
|
|
26
26
|
const onAvatarStateRef = (0, react_1.useRef)(onAvatarState);
|
|
27
27
|
onAvatarStateRef.current = onAvatarState;
|
|
28
|
+
const onVoiceMoodRef = (0, react_1.useRef)(onVoiceMood);
|
|
29
|
+
onVoiceMoodRef.current = onVoiceMood;
|
|
28
30
|
// The WebView HTML is built once from stable initial values.
|
|
29
31
|
// avatarUrl + authToken changing causes a controlled key-based remount.
|
|
30
32
|
// All other prop changes (mood, colors, accessories) go via postMessage.
|
|
@@ -226,6 +228,9 @@ exports.TalkingHead = (0, react_1.forwardRef)(({ avatarUrl, authToken, mood = 'n
|
|
|
226
228
|
else if (msg.type === 'avatarState') {
|
|
227
229
|
onAvatarStateRef.current?.(msg.state);
|
|
228
230
|
}
|
|
231
|
+
else if (msg.type === 'voiceMood') {
|
|
232
|
+
onVoiceMoodRef.current?.(msg.mood);
|
|
233
|
+
}
|
|
229
234
|
else if (msg.type === 'log') {
|
|
230
235
|
console.log('[TalkingHead]', msg.message);
|
|
231
236
|
}
|
|
@@ -16,6 +16,7 @@ interface TalkingHeadVisualizationProps {
|
|
|
16
16
|
requestId: string | null;
|
|
17
17
|
appliedAtMs: number;
|
|
18
18
|
}) => void;
|
|
19
|
+
onVoiceMood?: (mood: TalkingHeadMood) => void;
|
|
19
20
|
vendorBaseUrl?: string | null;
|
|
20
21
|
}
|
|
21
22
|
export interface TalkingHeadVisualizationRef {
|
|
@@ -24,6 +25,8 @@ export interface TalkingHeadVisualizationRef {
|
|
|
24
25
|
sendViseme: (viseme: TalkingHeadViseme, weight?: number) => void;
|
|
25
26
|
scheduleVisemes: (schedule: TalkingHeadVisemeSchedule) => void;
|
|
26
27
|
clearVisemes: () => void;
|
|
28
|
+
/** Trigger a named motion on the avatar (e.g. 'celebrate', 'groove', 'wave') */
|
|
29
|
+
playMotion: (name: string) => void;
|
|
27
30
|
}
|
|
28
31
|
/**
|
|
29
32
|
* TalkingHeadVisualization — optimized component for rendering the 3D avatar.
|
|
@@ -44,7 +44,7 @@ function getLoadingLabel(stage) {
|
|
|
44
44
|
* On native: uses WgpuAvatar (direct morph writes, no WebView bridge).
|
|
45
45
|
* On web: uses TalkingHead WebView renderer.
|
|
46
46
|
*/
|
|
47
|
-
exports.TalkingHeadVisualization = (0, react_1.forwardRef)(({ style, avatarUrl, authToken, cameraView = 'head', cameraDistance = 0.2, accessories, mood: initialMood = 'neutral', aspect, focalLength, visemeSchedule, onVisemeScheduleApplied, vendorBaseUrl }, ref) => {
|
|
47
|
+
exports.TalkingHeadVisualization = (0, react_1.forwardRef)(({ style, avatarUrl, authToken, cameraView = 'head', cameraDistance = 0.2, accessories, mood: initialMood = 'neutral', aspect, focalLength, visemeSchedule, onVisemeScheduleApplied, onVoiceMood, vendorBaseUrl }, ref) => {
|
|
48
48
|
const avatarRef = (0, react_1.useRef)(null);
|
|
49
49
|
// On native, WgpuAvatar ref is wired via callback ref — store it here so
|
|
50
50
|
// scheduleVisemes / sendAmplitude can route to it.
|
|
@@ -109,6 +109,7 @@ exports.TalkingHeadVisualization = (0, react_1.forwardRef)(({ style, avatarUrl,
|
|
|
109
109
|
sendAmplitude: (a) => activeAvatar()?.sendAmplitude(a),
|
|
110
110
|
sendViseme: (viseme, weight) => activeAvatar()?.sendViseme(viseme, weight),
|
|
111
111
|
clearVisemes: () => activeAvatar()?.clearVisemes(),
|
|
112
|
+
playMotion: (name) => avatarRef.current?.dispatchMotion(name),
|
|
112
113
|
scheduleVisemes: (schedule) => {
|
|
113
114
|
const scheduleKey = `${schedule.requestId ?? 'anonymous'}:${schedule.startedAtMs ?? 0}`;
|
|
114
115
|
if (lastScheduledVisemeKeyRef.current === scheduleKey)
|
|
@@ -198,7 +199,7 @@ exports.TalkingHeadVisualization = (0, react_1.forwardRef)(({ style, avatarUrl,
|
|
|
198
199
|
if (!effectiveAvatarUrl) {
|
|
199
200
|
return ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: [style, styles.placeholder] }));
|
|
200
201
|
}
|
|
201
|
-
return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [style, styles.container], pointerEvents: "box-none", children: [(0, jsx_runtime_1.jsx)(TalkingHead_1.TalkingHead, { ref: avatarRef, avatarUrl: effectiveAvatarUrl, authToken: authToken, cameraView: cameraView, cameraDistance: cameraDistance, accessories: accessories, mood: mood, onLoadingChange: handleLoadingChange, onReady: handleReady, onError: handleError, style: react_native_1.StyleSheet.absoluteFill, vendorBaseUrl: vendorBaseUrl }), !isAvatarReady && ((0, jsx_runtime_1.jsx)(react_native_1.View, { testID: "talking-head-loading", style: styles.loadingOverlay, pointerEvents: "none", children: (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.loadingCard, children: [(0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.loadingBadge, children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.loadingBadgeText, children: "AVATAR" }) }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.loadingTitle, children: avatarError ? 'Avatar failed to load' : getLoadingLabel(loadingState.stage) }), avatarError ? ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.loadingHint, children: avatarError })) : typeof loadingState.progress === 'number' ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)(react_native_1.Text, { style: styles.loadingPercent, children: [loadingState.progress, "%"] }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.progressTrack, children: (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [
|
|
202
|
+
return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [style, styles.container], pointerEvents: "box-none", children: [(0, jsx_runtime_1.jsx)(TalkingHead_1.TalkingHead, { ref: avatarRef, avatarUrl: effectiveAvatarUrl, authToken: authToken, cameraView: cameraView, cameraDistance: cameraDistance, accessories: accessories, mood: mood, onLoadingChange: handleLoadingChange, onReady: handleReady, onError: handleError, onVoiceMood: onVoiceMood, style: react_native_1.StyleSheet.absoluteFill, vendorBaseUrl: vendorBaseUrl }), !isAvatarReady && ((0, jsx_runtime_1.jsx)(react_native_1.View, { testID: "talking-head-loading", style: styles.loadingOverlay, pointerEvents: "none", children: (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.loadingCard, children: [(0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.loadingBadge, children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.loadingBadgeText, children: "AVATAR" }) }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.loadingTitle, children: avatarError ? 'Avatar failed to load' : getLoadingLabel(loadingState.stage) }), avatarError ? ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.loadingHint, children: avatarError })) : typeof loadingState.progress === 'number' ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)(react_native_1.Text, { style: styles.loadingPercent, children: [loadingState.progress, "%"] }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.progressTrack, children: (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [
|
|
202
203
|
styles.progressFill,
|
|
203
204
|
{ width: `${Math.max(6, loadingState.progress)}%` },
|
|
204
205
|
] }) })] })) : ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.loadingHint, children: "Preparing the avatar scene\u2026" }))] }) }))] }));
|
package/dist/html.js
CHANGED
|
@@ -384,6 +384,33 @@ function startAudioInterception() {
|
|
|
384
384
|
let amplitudeDecay = 0;
|
|
385
385
|
let jawMorphCache = null;
|
|
386
386
|
let visemeMorphCache = null;
|
|
387
|
+
|
|
388
|
+
// Voice-driven mood detection
|
|
389
|
+
let voiceEnergySmoothed = 0;
|
|
390
|
+
let voiceMoodCurrent = 'neutral';
|
|
391
|
+
let voiceMoodFramesAbove = 0;
|
|
392
|
+
let voiceMoodFramesBelow = 0;
|
|
393
|
+
const VOICE_ENERGY_EXCITED_THRESH = 0.45;
|
|
394
|
+
const VOICE_ENERGY_NEUTRAL_THRESH = 0.15;
|
|
395
|
+
const VOICE_MOOD_FRAMES_REQUIRED = 12; // ~0.2s at 60fps
|
|
396
|
+
function updateVoiceMood(rawAmplitude) {
|
|
397
|
+
voiceEnergySmoothed = voiceEnergySmoothed * 0.85 + rawAmplitude * 0.15;
|
|
398
|
+
if (voiceEnergySmoothed > VOICE_ENERGY_EXCITED_THRESH) {
|
|
399
|
+
voiceMoodFramesAbove++;
|
|
400
|
+
voiceMoodFramesBelow = 0;
|
|
401
|
+
if (voiceMoodCurrent !== 'excited' && voiceMoodFramesAbove >= VOICE_MOOD_FRAMES_REQUIRED) {
|
|
402
|
+
voiceMoodCurrent = 'excited';
|
|
403
|
+
window.ReactNativeWebView?.postMessage(JSON.stringify({ type: 'voiceMood', mood: 'excited' }));
|
|
404
|
+
}
|
|
405
|
+
} else if (voiceEnergySmoothed < VOICE_ENERGY_NEUTRAL_THRESH) {
|
|
406
|
+
voiceMoodFramesBelow++;
|
|
407
|
+
voiceMoodFramesAbove = 0;
|
|
408
|
+
if (voiceMoodCurrent !== 'neutral' && voiceMoodFramesBelow >= VOICE_MOOD_FRAMES_REQUIRED * 3) {
|
|
409
|
+
voiceMoodCurrent = 'neutral';
|
|
410
|
+
window.ReactNativeWebView?.postMessage(JSON.stringify({ type: 'voiceMood', mood: 'neutral' }));
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
387
414
|
const visemeState = {};
|
|
388
415
|
|
|
389
416
|
const VISEME_MORPH_ALIASES = {
|
|
@@ -1109,6 +1136,7 @@ function onIncomingMessage(event) {
|
|
|
1109
1136
|
msg.value * RHUBARB_FALLBACK_AMPLITUDE_GAIN,
|
|
1110
1137
|
);
|
|
1111
1138
|
amplitudeDecay = Math.max(amplitudeDecay * 0.7, val);
|
|
1139
|
+
updateVoiceMood(msg.value);
|
|
1112
1140
|
for (let i = 0; i < jawMorphCache.length; i++) jawMorphCache[i].influences[jawMorphCache[i].idx] = amplitudeDecay;
|
|
1113
1141
|
} else if (msg.type === 'viseme') {
|
|
1114
1142
|
applyViseme(msg.viseme, msg.weight !== undefined ? msg.weight : 1.0);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "talking-head-studio",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.6",
|
|
4
4
|
"description": "Cross-platform 3D avatar component for React Native & web — lip-sync, gestures, accessories, and LLM integration. Powered by TalkingHead + Three.js.",
|
|
5
5
|
"main": "dist/index.web.js",
|
|
6
6
|
"browser": "dist/index.web.js",
|