talking-head-studio 0.2.4 → 0.2.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.
@@ -5,6 +5,7 @@ import React, {
5
5
  useImperativeHandle,
6
6
  useMemo,
7
7
  useRef,
8
+ useState,
8
9
  } from 'react';
9
10
  import { type StyleProp, StyleSheet, View, type ViewStyle } from 'react-native';
10
11
  import { WebView, type WebViewMessageEvent } from 'react-native-webview';
@@ -20,6 +21,38 @@ export type TalkingHeadMood =
20
21
  | 'concerned'
21
22
  | 'surprised';
22
23
 
24
+ /**
25
+ * Standard viseme keys supported by the avatar.
26
+ * Use with sendViseme() from your TTS viseme callbacks.
27
+ */
28
+ export type TalkingHeadViseme =
29
+ | 'sil' | 'PP' | 'FF' | 'TH' | 'DD' | 'kk' | 'CH' | 'SS' | 'nn' | 'RR'
30
+ | 'aa' | 'ee' | 'ih' | 'oh' | 'ou';
31
+
32
+ /** Rhubarb mouth shape cue (Preston Blair set: A-H, X) */
33
+ export interface TalkingHeadVisemeCue {
34
+ startMs: number;
35
+ endMs: number;
36
+ viseme: 'A' | 'B' | 'C' | 'D' | 'E' | 'F' | 'G' | 'H' | 'X';
37
+ }
38
+
39
+ /**
40
+ * A full viseme schedule from the Rhubarb sidecar endpoint.
41
+ * Pass to scheduleVisemes() when agent_visemes arrives on the data channel.
42
+ */
43
+ export interface TalkingHeadVisemeSchedule {
44
+ /** Matches X-TTS-Request-Id / agent_visemes.requestId */
45
+ requestId?: string;
46
+ /**
47
+ * Wall-clock ms at which audio playback started.
48
+ * Anchor this to the moment you observe agent_state: speaking.
49
+ * Used to skip cues that are already in the past on late delivery.
50
+ */
51
+ startedAtMs?: number;
52
+ durationMs?: number;
53
+ cues: TalkingHeadVisemeCue[];
54
+ }
55
+
23
56
  export interface TalkingHeadAccessory {
24
57
  id: string;
25
58
  url: string;
@@ -46,6 +79,33 @@ export interface TalkingHeadProps {
46
79
 
47
80
  export interface TalkingHeadRef {
48
81
  sendAmplitude: (amplitude: number) => void;
82
+ /**
83
+ * Drive a viseme morph target from your TTS pipeline.
84
+ * Works on iOS/Android WebViews where AudioWorklet is unavailable.
85
+ *
86
+ * @example
87
+ * // ElevenLabs websocket:
88
+ * ws.on('viseme', ({ visemeId }) => avatarRef.current?.sendViseme(ELEVENLABS_MAP[visemeId], 1.0));
89
+ * // Azure TTS:
90
+ * synthesizer.visemeReceived = (s, e) => avatarRef.current?.sendViseme(AZURE_MAP[e.visemeId], 1.0);
91
+ */
92
+ sendViseme: (viseme: TalkingHeadViseme, weight?: number) => void;
93
+ /**
94
+ * Schedule a full Rhubarb viseme payload for playback.
95
+ * Call this when agent_visemes arrives on the LiveKit data channel.
96
+ * The scheduler gates amplitude fallback while visemes are active.
97
+ *
98
+ * @example
99
+ * room.on(RoomEvent.DataReceived, (payload) => {
100
+ * const msg = JSON.parse(new TextDecoder().decode(payload));
101
+ * if (msg.type === 'agent_visemes') {
102
+ * avatarRef.current?.scheduleVisemes({ ...msg, startedAtMs: speakingStartedAt });
103
+ * }
104
+ * });
105
+ */
106
+ scheduleVisemes: (schedule: TalkingHeadVisemeSchedule) => void;
107
+ /** Cancel any running viseme schedule and return to amplitude fallback. */
108
+ clearVisemes: () => void;
49
109
  setMood: (mood: TalkingHeadMood) => void;
50
110
  setHairColor: (color: string) => void;
51
111
  setSkinColor: (color: string) => void;
@@ -72,6 +132,23 @@ export const TalkingHead = forwardRef<TalkingHeadRef, TalkingHeadProps>(
72
132
  ref,
73
133
  ) => {
74
134
  const webViewRef = useRef<WebView>(null);
135
+ const readyRef = useRef(false);
136
+ const accessoriesRef = useRef(accessories);
137
+
138
+ // The WebView HTML is built once from stable initial values.
139
+ // avatarUrl + authToken changing causes a controlled key-based remount.
140
+ // All other prop changes (mood, colors, accessories) go via postMessage.
141
+ const [webViewKey, setWebViewKey] = useState(() => `${avatarUrl}__${authToken ?? ''}`);
142
+ const prevAvatarRef = useRef({ avatarUrl, authToken });
143
+
144
+ useEffect(() => {
145
+ const prev = prevAvatarRef.current;
146
+ if (prev.avatarUrl !== avatarUrl || prev.authToken !== authToken) {
147
+ prevAvatarRef.current = { avatarUrl, authToken };
148
+ readyRef.current = false;
149
+ setWebViewKey(`${avatarUrl}__${authToken ?? ''}`);
150
+ }
151
+ }, [avatarUrl, authToken]);
75
152
 
76
153
  const post = useCallback((msg: object) => {
77
154
  webViewRef.current?.postMessage(JSON.stringify(msg));
@@ -81,6 +158,9 @@ export const TalkingHead = forwardRef<TalkingHeadRef, TalkingHeadProps>(
81
158
  ref,
82
159
  () => ({
83
160
  sendAmplitude: (amplitude) => post({ type: 'amplitude', value: amplitude }),
161
+ sendViseme: (viseme, weight = 1.0) => post({ type: 'viseme', viseme, weight }),
162
+ scheduleVisemes: (schedule) => post({ type: 'schedule_visemes', schedule }),
163
+ clearVisemes: () => post({ type: 'clear_visemes' }),
84
164
  setMood: (nextMood) => post({ type: 'mood', value: nextMood }),
85
165
  setHairColor: (color) => post({ type: 'hair_color', value: color }),
86
166
  setSkinColor: (color) => post({ type: 'skin_color', value: color }),
@@ -91,20 +171,13 @@ export const TalkingHead = forwardRef<TalkingHeadRef, TalkingHeadProps>(
91
171
  [post],
92
172
  );
93
173
 
174
+ // Sync mood via postMessage only — never causes a WebView reload
94
175
  useEffect(() => {
95
176
  if (readyRef.current) post({ type: 'mood', value: mood });
96
177
  }, [mood, post]);
97
178
 
98
- // Track whether the WebView JS is ready to receive messages
99
- const readyRef = useRef(false);
100
- // Always hold the latest accessories so the ready handler can send them
101
- const accessoriesRef = useRef(accessories);
102
179
  useEffect(() => {
103
180
  accessoriesRef.current = accessories;
104
- }, [accessories]);
105
-
106
- useEffect(() => {
107
- // Only post if the WebView is already ready; otherwise the ready handler sends them
108
181
  if (accessories && readyRef.current) {
109
182
  post({ type: 'set_accessories', accessories });
110
183
  }
@@ -122,13 +195,13 @@ export const TalkingHead = forwardRef<TalkingHeadRef, TalkingHeadProps>(
122
195
  if (eyeColor && readyRef.current) post({ type: 'eye_color', value: eyeColor });
123
196
  }, [eyeColor, post]);
124
197
 
125
- // Color props are intentionally excluded from deps — live updates go via postMessage.
126
- // Only avatarUrl, authToken, cameraView, cameraDistance cause a full WebView reload.
127
- const [initialMood] = React.useState(mood);
128
- const [initialHairColor] = React.useState(hairColor);
129
- const [initialSkinColor] = React.useState(skinColor);
130
- const [initialEyeColor] = React.useState(eyeColor);
198
+ // Capture stable initial values at first mount only
199
+ const [initialMood] = useState(mood);
200
+ const [initialHairColor] = useState(hairColor);
201
+ const [initialSkinColor] = useState(skinColor);
202
+ const [initialEyeColor] = useState(eyeColor);
131
203
 
204
+ // html is stable — only rebuilds when webViewKey changes (avatarUrl/authToken)
132
205
  const html = useMemo(
133
206
  () =>
134
207
  buildAvatarHtml({
@@ -137,20 +210,12 @@ export const TalkingHead = forwardRef<TalkingHeadRef, TalkingHeadProps>(
137
210
  mood: initialMood,
138
211
  cameraView,
139
212
  cameraDistance,
140
- initialHairColor: initialHairColor,
141
- initialSkinColor: initialSkinColor,
142
- initialEyeColor: initialEyeColor,
213
+ initialHairColor,
214
+ initialSkinColor,
215
+ initialEyeColor,
143
216
  }),
144
- [
145
- avatarUrl,
146
- authToken,
147
- cameraView,
148
- cameraDistance,
149
- initialMood,
150
- initialHairColor,
151
- initialSkinColor,
152
- initialEyeColor,
153
- ],
217
+ // eslint-disable-next-line react-hooks/exhaustive-deps
218
+ [webViewKey], // intentionally keyed only on webViewKey, not every prop
154
219
  );
155
220
 
156
221
  const onMessage = useCallback(
@@ -159,15 +224,18 @@ export const TalkingHead = forwardRef<TalkingHeadRef, TalkingHeadProps>(
159
224
  const msg = JSON.parse(event.nativeEvent.data);
160
225
  if (msg.type === 'ready') {
161
226
  readyRef.current = true;
162
- // Flush any accessories that arrived before the WebView was ready
227
+ // Flush pending props that may have arrived before WebView was ready
163
228
  if (accessoriesRef.current?.length) {
164
229
  post({ type: 'set_accessories', accessories: accessoriesRef.current });
165
230
  }
166
231
  onReady?.();
167
- } else if (msg.type === 'error') onError?.(msg.message);
168
- else if (msg.type === 'log') console.log('[TalkingHead]', msg.message);
232
+ } else if (msg.type === 'error') {
233
+ onError?.(msg.message);
234
+ } else if (msg.type === 'log') {
235
+ console.log('[TalkingHead]', msg.message);
236
+ }
169
237
  } catch (err) {
170
- console.warn('[TalkingHead] Invalid message received from WebView:', err);
238
+ console.warn('[TalkingHead] Invalid message from WebView:', err);
171
239
  }
172
240
  },
173
241
  [onReady, onError, post],
@@ -176,6 +244,7 @@ export const TalkingHead = forwardRef<TalkingHeadRef, TalkingHeadProps>(
176
244
  return (
177
245
  <View style={[styles.container, style]}>
178
246
  <WebView
247
+ key={webViewKey}
179
248
  ref={webViewRef}
180
249
  source={{ html }}
181
250
  style={styles.webview}
@@ -8,6 +8,12 @@ import React, {
8
8
  } from 'react';
9
9
  import { type StyleProp, StyleSheet, View, type ViewStyle } from 'react-native';
10
10
  import { buildAvatarHtml } from './html';
11
+ import type {
12
+ TalkingHeadVisemeCue,
13
+ TalkingHeadVisemeSchedule,
14
+ } from './TalkingHead';
15
+
16
+ export type { TalkingHeadVisemeCue, TalkingHeadVisemeSchedule };
11
17
 
12
18
  export type TalkingHeadMood =
13
19
  | 'neutral'
@@ -45,6 +51,8 @@ export interface TalkingHeadProps {
45
51
 
46
52
  export interface TalkingHeadRef {
47
53
  sendAmplitude: (amplitude: number) => void;
54
+ scheduleVisemes: (schedule: TalkingHeadVisemeSchedule) => void;
55
+ clearVisemes: () => void;
48
56
  setMood: (mood: TalkingHeadMood) => void;
49
57
  setHairColor: (color: string) => void;
50
58
  setSkinColor: (color: string) => void;
@@ -86,6 +94,8 @@ export const TalkingHead = forwardRef<TalkingHeadRef, TalkingHeadProps>(
86
94
  ref,
87
95
  () => ({
88
96
  sendAmplitude: (amplitude) => post({ type: 'amplitude', value: amplitude }),
97
+ scheduleVisemes: (schedule) => post({ type: 'schedule_visemes', schedule }),
98
+ clearVisemes: () => post({ type: 'clear_visemes' }),
89
99
  setMood: (nextMood) => post({ type: 'mood', value: nextMood }),
90
100
  setHairColor: (color) => post({ type: 'hair_color', value: color }),
91
101
  setSkinColor: (color) => post({ type: 'skin_color', value: color }),