@sage-rsc/talking-head-react 1.0.68 → 1.0.70

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sage-rsc/talking-head-react",
3
- "version": "1.0.68",
3
+ "version": "1.0.70",
4
4
  "description": "A reusable React component for 3D talking avatars with lip-sync and text-to-speech",
5
5
  "main": "./dist/index.cjs",
6
6
  "module": "./dist/index.js",
@@ -689,6 +689,10 @@ const CurriculumLearning = forwardRef(({
689
689
  const currentLesson = getCurrentLesson();
690
690
  const totalQuestionsInLesson = currentLesson?.questions?.length || 0;
691
691
  const isLastQuestion = stateRef.current.currentQuestionIndex >= totalQuestionsInLesson - 1;
692
+ const hasNextQuestion = stateRef.current.currentQuestionIndex < totalQuestionsInLesson - 1;
693
+
694
+ // Debug logging
695
+ console.log('[CurriculumLearning] Answer feedback - questionIndex:', stateRef.current.currentQuestionIndex, 'totalQuestions:', totalQuestionsInLesson, 'hasNextQuestion:', hasNextQuestion);
692
696
 
693
697
  const successMessage = currentQuestion.type === "code_test"
694
698
  ? `Great job! Your code passed all the tests! ${currentQuestion.explanation || ''}`
@@ -707,7 +711,7 @@ const CurriculumLearning = forwardRef(({
707
711
  lessonIndex: stateRef.current.currentLessonIndex,
708
712
  questionIndex: stateRef.current.currentQuestionIndex,
709
713
  isCorrect: true,
710
- hasNextQuestion: stateRef.current.currentQuestionIndex < totalQuestionsInLesson - 1,
714
+ hasNextQuestion: hasNextQuestion,
711
715
  score: stateRef.current.score,
712
716
  totalQuestions: stateRef.current.totalQuestions
713
717
  });
@@ -728,6 +732,10 @@ const CurriculumLearning = forwardRef(({
728
732
  const currentLesson = getCurrentLesson();
729
733
  const totalQuestionsInLesson = currentLesson?.questions?.length || 0;
730
734
  const isLastQuestion = stateRef.current.currentQuestionIndex >= totalQuestionsInLesson - 1;
735
+ const hasNextQuestion = stateRef.current.currentQuestionIndex < totalQuestionsInLesson - 1;
736
+
737
+ // Debug logging
738
+ console.log('[CurriculumLearning] Answer feedback (incorrect) - questionIndex:', stateRef.current.currentQuestionIndex, 'totalQuestions:', totalQuestionsInLesson, 'hasNextQuestion:', hasNextQuestion);
731
739
 
732
740
  const failureMessage = currentQuestion.type === "code_test"
733
741
  ? `Your code didn't pass all the tests. ${currentQuestion.explanation || 'Try again!'}`
@@ -746,7 +754,7 @@ const CurriculumLearning = forwardRef(({
746
754
  lessonIndex: stateRef.current.currentLessonIndex,
747
755
  questionIndex: stateRef.current.currentQuestionIndex,
748
756
  isCorrect: false,
749
- hasNextQuestion: stateRef.current.currentQuestionIndex < totalQuestionsInLesson - 1,
757
+ hasNextQuestion: hasNextQuestion,
750
758
  score: stateRef.current.score,
751
759
  totalQuestions: stateRef.current.totalQuestions
752
760
  });
@@ -0,0 +1,426 @@
1
+ import React, { useEffect, useRef, useState, useCallback, forwardRef, useImperativeHandle } from 'react';
2
+ import { TalkingHead } from '../lib/talkinghead.mjs';
3
+ import { getActiveTTSConfig, ELEVENLABS_CONFIG, DEEPGRAM_CONFIG } from '../config/ttsConfig';
4
+
5
+ /**
6
+ * SimpleTalkingAvatar - A simple React component for 3D talking avatars
7
+ *
8
+ * This component provides all avatar settings and functionality without curriculum features.
9
+ * You can pass text to speak via props or use the ref methods.
10
+ *
11
+ * @param {Object} props
12
+ * @param {string} props.text - Text to speak (optional, can also use speakText method)
13
+ * @param {string} props.avatarUrl - URL/path to the GLB avatar file
14
+ * @param {string} props.avatarBody - Avatar body type ('M' or 'F')
15
+ * @param {string} props.mood - Initial mood ('happy', 'sad', 'neutral', etc.)
16
+ * @param {string} props.ttsLang - Text-to-speech language code
17
+ * @param {string} props.ttsService - TTS service ('edge', 'elevenlabs', 'deepgram', 'google', 'azure', 'browser')
18
+ * @param {string} props.ttsVoice - TTS voice ID
19
+ * @param {string} props.ttsApiKey - TTS API key (overrides config for ElevenLabs, Google Cloud, Azure)
20
+ * @param {string} props.bodyMovement - Initial body movement type
21
+ * @param {number} props.movementIntensity - Movement intensity (0-1)
22
+ * @param {boolean} props.showFullAvatar - Whether to show full body avatar
23
+ * @param {string} props.cameraView - Camera view ('upper', 'full', etc.)
24
+ * @param {Function} props.onReady - Callback when avatar is ready
25
+ * @param {Function} props.onLoading - Callback for loading progress
26
+ * @param {Function} props.onError - Callback for errors
27
+ * @param {Function} props.onSpeechEnd - Callback when speech ends
28
+ * @param {string} props.className - Additional CSS classes
29
+ * @param {Object} props.style - Additional inline styles
30
+ * @param {Object} props.animations - Object mapping animation names to FBX file paths
31
+ * @param {boolean} props.autoSpeak - Whether to automatically speak the text prop when ready
32
+ * @param {Object} ref - Ref to access component methods
33
+ */
34
+ const SimpleTalkingAvatar = forwardRef(({
35
+ text = null,
36
+ avatarUrl = "/avatars/brunette.glb",
37
+ avatarBody = "F",
38
+ mood = "neutral",
39
+ ttsLang = "en",
40
+ ttsService = null,
41
+ ttsVoice = null,
42
+ ttsApiKey = null,
43
+ bodyMovement = "idle",
44
+ movementIntensity = 0.5,
45
+ showFullAvatar = false,
46
+ cameraView = "upper",
47
+ onReady = () => {},
48
+ onLoading = () => {},
49
+ onError = () => {},
50
+ onSpeechEnd = () => {},
51
+ className = "",
52
+ style = {},
53
+ animations = {},
54
+ autoSpeak = false
55
+ }, ref) => {
56
+ const containerRef = useRef(null);
57
+ const talkingHeadRef = useRef(null);
58
+ const showFullAvatarRef = useRef(showFullAvatar);
59
+ const pausedSpeechRef = useRef(null);
60
+ const speechEndIntervalRef = useRef(null);
61
+ const isPausedRef = useRef(false);
62
+ const speechProgressRef = useRef({ remainingText: null, originalText: null, options: null });
63
+ const originalSentencesRef = useRef([]);
64
+ const [isLoading, setIsLoading] = useState(true);
65
+ const [error, setError] = useState(null);
66
+ const [isReady, setIsReady] = useState(false);
67
+ const [isPaused, setIsPaused] = useState(false);
68
+
69
+ // Keep ref in sync with state
70
+ useEffect(() => {
71
+ isPausedRef.current = isPaused;
72
+ }, [isPaused]);
73
+
74
+ // Update ref when prop changes
75
+ useEffect(() => {
76
+ showFullAvatarRef.current = showFullAvatar;
77
+ }, [showFullAvatar]);
78
+
79
+ // Get TTS configuration
80
+ const ttsConfig = getActiveTTSConfig();
81
+
82
+ // Override TTS service if specified in props
83
+ const effectiveTtsService = ttsService || ttsConfig.service;
84
+
85
+ // Build effective TTS config
86
+ let effectiveTtsConfig;
87
+
88
+ if (effectiveTtsService === 'browser') {
89
+ effectiveTtsConfig = {
90
+ service: 'browser',
91
+ endpoint: '',
92
+ apiKey: null,
93
+ defaultVoice: 'Google US English'
94
+ };
95
+ } else if (effectiveTtsService === 'elevenlabs') {
96
+ const apiKey = ttsApiKey || ttsConfig.apiKey;
97
+ effectiveTtsConfig = {
98
+ service: 'elevenlabs',
99
+ endpoint: 'https://api.elevenlabs.io/v1/text-to-speech',
100
+ apiKey: apiKey,
101
+ defaultVoice: ttsVoice || ttsConfig.defaultVoice || ELEVENLABS_CONFIG.defaultVoice,
102
+ voices: ttsConfig.voices || ELEVENLABS_CONFIG.voices
103
+ };
104
+ } else if (effectiveTtsService === 'deepgram') {
105
+ const apiKey = ttsApiKey || ttsConfig.apiKey;
106
+ effectiveTtsConfig = {
107
+ service: 'deepgram',
108
+ endpoint: 'https://api.deepgram.com/v1/speak',
109
+ apiKey: apiKey,
110
+ defaultVoice: ttsVoice || ttsConfig.defaultVoice || DEEPGRAM_CONFIG.defaultVoice,
111
+ voices: ttsConfig.voices || DEEPGRAM_CONFIG.voices
112
+ };
113
+ } else {
114
+ effectiveTtsConfig = {
115
+ ...ttsConfig,
116
+ apiKey: ttsApiKey !== null ? ttsApiKey : ttsConfig.apiKey
117
+ };
118
+ }
119
+
120
+ const defaultAvatarConfig = {
121
+ url: avatarUrl,
122
+ body: avatarBody,
123
+ avatarMood: mood,
124
+ ttsLang: effectiveTtsService === 'browser' ? "en-US" : ttsLang,
125
+ ttsVoice: ttsVoice || effectiveTtsConfig.defaultVoice,
126
+ lipsyncLang: 'en',
127
+ showFullAvatar: showFullAvatar,
128
+ bodyMovement: bodyMovement,
129
+ movementIntensity: movementIntensity,
130
+ };
131
+
132
+ const defaultOptions = {
133
+ ttsEndpoint: effectiveTtsConfig.endpoint,
134
+ ttsApikey: effectiveTtsConfig.apiKey,
135
+ ttsService: effectiveTtsService,
136
+ lipsyncModules: ["en"],
137
+ cameraView: cameraView
138
+ };
139
+
140
+ const initializeTalkingHead = useCallback(async () => {
141
+ if (!containerRef.current || talkingHeadRef.current) return;
142
+
143
+ try {
144
+ setIsLoading(true);
145
+ setError(null);
146
+
147
+ talkingHeadRef.current = new TalkingHead(containerRef.current, defaultOptions);
148
+
149
+ await talkingHeadRef.current.showAvatar(defaultAvatarConfig, (ev) => {
150
+ if (ev.lengthComputable) {
151
+ const progress = Math.min(100, Math.round(ev.loaded / ev.total * 100));
152
+ onLoading(progress);
153
+ }
154
+ });
155
+
156
+ setIsLoading(false);
157
+ setIsReady(true);
158
+ onReady(talkingHeadRef.current);
159
+
160
+ // Handle visibility change
161
+ const handleVisibilityChange = () => {
162
+ if (document.visibilityState === "visible") {
163
+ talkingHeadRef.current?.start();
164
+ } else {
165
+ talkingHeadRef.current?.stop();
166
+ }
167
+ };
168
+
169
+ document.addEventListener("visibilitychange", handleVisibilityChange);
170
+
171
+ return () => {
172
+ document.removeEventListener("visibilitychange", handleVisibilityChange);
173
+ };
174
+
175
+ } catch (err) {
176
+ console.error('Error initializing TalkingHead:', err);
177
+ setError(err.message || 'Failed to initialize avatar');
178
+ setIsLoading(false);
179
+ onError(err);
180
+ }
181
+ }, [avatarUrl, avatarBody, mood, ttsLang, effectiveTtsService, ttsVoice, ttsApiKey, bodyMovement, movementIntensity, showFullAvatar, cameraView, onLoading, onReady, onError]);
182
+
183
+ useEffect(() => {
184
+ initializeTalkingHead();
185
+
186
+ return () => {
187
+ if (talkingHeadRef.current) {
188
+ talkingHeadRef.current.stop();
189
+ talkingHeadRef.current.dispose();
190
+ talkingHeadRef.current = null;
191
+ }
192
+ };
193
+ }, [initializeTalkingHead]);
194
+
195
+ // Auto-speak text when ready and autoSpeak is true
196
+ useEffect(() => {
197
+ if (isReady && text && autoSpeak && talkingHeadRef.current) {
198
+ speakText(text);
199
+ }
200
+ }, [isReady, text, autoSpeak]);
201
+
202
+ // Speak text with proper callback handling
203
+ const speakText = useCallback((textToSpeak, options = {}) => {
204
+ if (!talkingHeadRef.current || !isReady) {
205
+ console.warn('Avatar not ready for speaking');
206
+ return;
207
+ }
208
+
209
+ if (!textToSpeak || textToSpeak.trim() === '') {
210
+ console.warn('No text provided to speak');
211
+ return;
212
+ }
213
+
214
+ // Reset speech progress tracking
215
+ speechProgressRef.current = { remainingText: null, originalText: null, options: null };
216
+ originalSentencesRef.current = [];
217
+
218
+ // Store for pause/resume
219
+ pausedSpeechRef.current = { text: textToSpeak, options };
220
+
221
+ // Clear any existing speech end interval
222
+ if (speechEndIntervalRef.current) {
223
+ clearInterval(speechEndIntervalRef.current);
224
+ speechEndIntervalRef.current = null;
225
+ }
226
+
227
+ // Reset pause state
228
+ setIsPaused(false);
229
+ isPausedRef.current = false;
230
+
231
+ // Split text into sentences for tracking
232
+ const sentences = textToSpeak.split(/[.!?]+/).filter(s => s.trim().length > 0);
233
+ originalSentencesRef.current = sentences;
234
+
235
+ const speakOptions = {
236
+ lipsyncLang: options.lipsyncLang || 'en',
237
+ onSpeechEnd: () => {
238
+ // Clear interval
239
+ if (speechEndIntervalRef.current) {
240
+ clearInterval(speechEndIntervalRef.current);
241
+ speechEndIntervalRef.current = null;
242
+ }
243
+
244
+ // Call user's onSpeechEnd callback
245
+ if (options.onSpeechEnd) {
246
+ options.onSpeechEnd();
247
+ }
248
+ onSpeechEnd();
249
+ }
250
+ };
251
+
252
+ try {
253
+ talkingHeadRef.current.speakText(textToSpeak, speakOptions);
254
+ } catch (err) {
255
+ console.error('Error speaking text:', err);
256
+ setError(err.message || 'Failed to speak text');
257
+ }
258
+ }, [isReady, onSpeechEnd]);
259
+
260
+ // Pause speaking
261
+ const pauseSpeaking = useCallback(() => {
262
+ if (!talkingHeadRef.current) return;
263
+
264
+ try {
265
+ // Check if currently speaking
266
+ const isSpeaking = talkingHeadRef.current.isSpeaking || false;
267
+ const audioPlaylist = talkingHeadRef.current.audioPlaylist || [];
268
+ const speechQueue = talkingHeadRef.current.speechQueue || [];
269
+
270
+ if (isSpeaking || audioPlaylist.length > 0 || speechQueue.length > 0) {
271
+ // Clear speech end interval
272
+ if (speechEndIntervalRef.current) {
273
+ clearInterval(speechEndIntervalRef.current);
274
+ speechEndIntervalRef.current = null;
275
+ }
276
+
277
+ // Extract remaining text from speech queue
278
+ let remainingText = '';
279
+ if (speechQueue.length > 0) {
280
+ remainingText = speechQueue.map(item => {
281
+ if (item.text && Array.isArray(item.text)) {
282
+ return item.text.map(wordObj => wordObj.word).join(' ');
283
+ }
284
+ return item.text || '';
285
+ }).join(' ');
286
+ }
287
+
288
+ // Store progress for resume
289
+ speechProgressRef.current = {
290
+ remainingText: remainingText || null,
291
+ originalText: pausedSpeechRef.current?.text || null,
292
+ options: pausedSpeechRef.current?.options || null
293
+ };
294
+
295
+ // Clear speech queue and pause
296
+ talkingHeadRef.current.speechQueue.length = 0;
297
+ talkingHeadRef.current.pauseSpeaking();
298
+ setIsPaused(true);
299
+ isPausedRef.current = true;
300
+ }
301
+ } catch (err) {
302
+ console.warn('Error pausing speech:', err);
303
+ }
304
+ }, []);
305
+
306
+ // Resume speaking
307
+ const resumeSpeaking = useCallback(async () => {
308
+ if (!talkingHeadRef.current || !isPaused) return;
309
+
310
+ try {
311
+ // Resume audio context if needed
312
+ if (talkingHeadRef.current.audioContext && talkingHeadRef.current.audioContext.state === 'suspended') {
313
+ await talkingHeadRef.current.audioContext.resume();
314
+ }
315
+
316
+ setIsPaused(false);
317
+ isPausedRef.current = false;
318
+
319
+ // Determine what text to speak
320
+ const remainingText = speechProgressRef.current?.remainingText;
321
+ const originalText = speechProgressRef.current?.originalText || pausedSpeechRef.current?.text;
322
+ const originalOptions = speechProgressRef.current?.options || pausedSpeechRef.current?.options || {};
323
+
324
+ const textToSpeak = remainingText || originalText;
325
+
326
+ if (textToSpeak) {
327
+ speakText(textToSpeak, originalOptions);
328
+ }
329
+ } catch (err) {
330
+ console.warn('Error resuming speech:', err);
331
+ setIsPaused(false);
332
+ isPausedRef.current = false;
333
+ }
334
+ }, [isPaused, speakText]);
335
+
336
+ // Stop speaking
337
+ const stopSpeaking = useCallback(() => {
338
+ if (talkingHeadRef.current) {
339
+ talkingHeadRef.current.stopSpeaking();
340
+ if (speechEndIntervalRef.current) {
341
+ clearInterval(speechEndIntervalRef.current);
342
+ speechEndIntervalRef.current = null;
343
+ }
344
+ setIsPaused(false);
345
+ isPausedRef.current = false;
346
+ }
347
+ }, []);
348
+
349
+ // Expose methods via ref
350
+ useImperativeHandle(ref, () => ({
351
+ speakText,
352
+ pauseSpeaking,
353
+ resumeSpeaking,
354
+ stopSpeaking,
355
+ isPaused: () => isPaused,
356
+ setMood: (mood) => talkingHeadRef.current?.setMood(mood),
357
+ setBodyMovement: (movement) => {
358
+ if (talkingHeadRef.current) {
359
+ talkingHeadRef.current.setBodyMovement(movement);
360
+ }
361
+ },
362
+ playAnimation: (animationName, disablePositionLock = false) => {
363
+ if (talkingHeadRef.current && talkingHeadRef.current.playAnimation) {
364
+ talkingHeadRef.current.playAnimation(animationName, null, 10, 0, 0.01, disablePositionLock);
365
+ }
366
+ },
367
+ playReaction: (reactionType) => talkingHeadRef.current?.playReaction(reactionType),
368
+ playCelebration: () => talkingHeadRef.current?.playCelebration(),
369
+ setShowFullAvatar: (show) => {
370
+ if (talkingHeadRef.current) {
371
+ showFullAvatarRef.current = show;
372
+ talkingHeadRef.current.setShowFullAvatar(show);
373
+ }
374
+ },
375
+ isReady,
376
+ talkingHead: talkingHeadRef.current
377
+ }));
378
+
379
+ return (
380
+ <div className={`simple-talking-avatar-container ${className}`} style={style}>
381
+ <div
382
+ ref={containerRef}
383
+ className="talking-head-viewer"
384
+ style={{
385
+ width: '100%',
386
+ height: '100%',
387
+ minHeight: '400px',
388
+ }}
389
+ />
390
+ {isLoading && (
391
+ <div className="loading-overlay" style={{
392
+ position: 'absolute',
393
+ top: '50%',
394
+ left: '50%',
395
+ transform: 'translate(-50%, -50%)',
396
+ color: 'white',
397
+ fontSize: '18px',
398
+ zIndex: 10
399
+ }}>
400
+ Loading avatar...
401
+ </div>
402
+ )}
403
+ {error && (
404
+ <div className="error-overlay" style={{
405
+ position: 'absolute',
406
+ top: '50%',
407
+ left: '50%',
408
+ transform: 'translate(-50%, -50%)',
409
+ color: '#ff6b6b',
410
+ fontSize: '16px',
411
+ textAlign: 'center',
412
+ zIndex: 10,
413
+ padding: '20px',
414
+ borderRadius: '8px'
415
+ }}>
416
+ {error}
417
+ </div>
418
+ )}
419
+ </div>
420
+ );
421
+ });
422
+
423
+ SimpleTalkingAvatar.displayName = 'SimpleTalkingAvatar';
424
+
425
+ export default SimpleTalkingAvatar;
426
+
package/src/index.js CHANGED
@@ -6,6 +6,7 @@
6
6
 
7
7
  export { default as TalkingHeadAvatar } from './components/TalkingHeadAvatar';
8
8
  export { default as TalkingHeadComponent } from './components/TalkingHeadComponent';
9
+ export { default as SimpleTalkingAvatar } from './components/SimpleTalkingAvatar';
9
10
  export { default as CurriculumLearning } from './components/CurriculumLearning';
10
11
  export { getActiveTTSConfig, getVoiceOptions } from './config/ttsConfig';
11
12
  export { animations, getAnimation, hasAnimation } from './config/animations';