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.
@@ -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;
@@ -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.5",
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",