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.
- package/dist/TalkingHead.d.ts +54 -0
- package/dist/TalkingHead.d.ts.map +1 -1
- package/dist/TalkingHead.js +36 -29
- package/dist/TalkingHead.web.d.ts +4 -0
- package/dist/TalkingHead.web.d.ts.map +1 -1
- package/dist/TalkingHead.web.js +2 -0
- package/dist/html.d.ts.map +1 -1
- package/dist/html.js +232 -145
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/TalkingHead.tsx +100 -31
- package/src/TalkingHead.web.tsx +10 -0
- package/src/html.ts +234 -145
- package/src/index.ts +3 -0
package/src/TalkingHead.tsx
CHANGED
|
@@ -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
|
-
//
|
|
126
|
-
|
|
127
|
-
const [
|
|
128
|
-
const [
|
|
129
|
-
const [
|
|
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
|
|
141
|
-
initialSkinColor
|
|
142
|
-
initialEyeColor
|
|
213
|
+
initialHairColor,
|
|
214
|
+
initialSkinColor,
|
|
215
|
+
initialEyeColor,
|
|
143
216
|
}),
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
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')
|
|
168
|
-
|
|
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
|
|
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}
|
package/src/TalkingHead.web.tsx
CHANGED
|
@@ -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 }),
|