@sage-rsc/talking-head-react 1.0.45 → 1.0.47

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.45",
3
+ "version": "1.0.47",
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",
@@ -4,8 +4,14 @@ import TalkingHeadAvatar from './TalkingHeadAvatar';
4
4
  /**
5
5
  * CurriculumLearning - A controller component for curriculum-based learning
6
6
  *
7
- * This component manages curriculum flow programmatically. It has no UI elements.
8
- * All interactions are controlled via props and callbacks.
7
+ * This component manages curriculum flow interactively. It has no UI elements.
8
+ * All progression is controlled by the parent app via ref methods (e.g., startTeaching, nextQuestion, nextLesson).
9
+ * The component exposes methods that can be called from buttons or other UI controls in the consuming app.
10
+ *
11
+ * INTERACTIVE MODE: The component does NOT automatically progress. Instead, it:
12
+ * - Reads content when methods are called (startTeaching, startQuestions, nextQuestion, etc.)
13
+ * - Triggers events via onCustomAction when actions complete (teachingComplete, answerFeedbackComplete, etc.)
14
+ * - Waits for parent app to call the next method via ref
9
15
  *
10
16
  * @param {Object} props
11
17
  * @param {Object} props.curriculumData - Curriculum data object
@@ -16,7 +22,28 @@ import TalkingHeadAvatar from './TalkingHeadAvatar';
16
22
  * @param {Function} props.onQuestionAnswer - Callback when question is answered
17
23
  * @param {Function} props.onCurriculumComplete - Callback when curriculum completes
18
24
  * @param {Function} props.onCustomAction - Callback for custom actions (receives action type and data)
19
- * @param {boolean} props.autoStart - Whether to auto-start the curriculum
25
+ * - 'teachingComplete': Fired when teaching finishes. Check data.hasQuestions to know if questions are available.
26
+ * - 'answerFeedbackComplete': Fired when answer feedback finishes. Check data.hasNextQuestion to know if more questions exist.
27
+ * - 'lessonCompleteFeedbackDone': Fired when lesson completion feedback finishes. Check data.hasNextLesson to know if more lessons exist.
28
+ * - 'allQuestionsComplete': Fired when all questions in a lesson are done. Parent should call completeLesson() when ready.
29
+ * @param {boolean} props.autoStart - Whether to auto-start teaching when avatar is ready (only affects initial start, not progression)
30
+ *
31
+ * @example
32
+ * // In your app component:
33
+ * const curriculumRef = useRef(null);
34
+ *
35
+ * // Call methods via ref to control progression:
36
+ * <button onClick={() => curriculumRef.current?.startTeaching()}>Start Lesson</button>
37
+ * <button onClick={() => curriculumRef.current?.startQuestions()}>Start Questions</button>
38
+ * <button onClick={() => curriculumRef.current?.nextQuestion()}>Next Question</button>
39
+ * <button onClick={() => curriculumRef.current?.nextLesson()}>Next Lesson</button>
40
+ *
41
+ * // Listen to events to enable/disable buttons:
42
+ * onCustomAction={(action) => {
43
+ * if (action.type === 'teachingComplete') {
44
+ * setCanStartQuestions(action.hasQuestions);
45
+ * }
46
+ * }}
20
47
  */
21
48
  const CurriculumLearning = forwardRef(({
22
49
  curriculumData = null,
@@ -208,29 +235,22 @@ const CurriculumLearning = forwardRef(({
208
235
 
209
236
  const config = defaultAvatarConfigRef.current || { lipsyncLang: 'en' };
210
237
 
211
- if (hasNextLesson) {
212
- // Wait for speech to finish, then automatically move to next lesson
213
- avatarRef.current.speakText(feedbackMessage, {
214
- lipsyncLang: config.lipsyncLang,
215
- onSpeechEnd: () => {
216
- // Immediately transition to next lesson with no delay
217
- if (nextLessonRef.current) {
218
- nextLessonRef.current();
219
- }
220
- }
221
- });
222
- } else {
223
- // This is the last lesson in the last module, complete curriculum instead
224
- avatarRef.current.speakText(feedbackMessage, {
225
- lipsyncLang: config.lipsyncLang,
226
- onSpeechEnd: () => {
227
- // Immediately complete curriculum with no delay
228
- if (completeCurriculumRef.current) {
229
- completeCurriculumRef.current();
230
- }
231
- }
232
- });
233
- }
238
+ // Read completion feedback - no automatic progression, user controls via buttons
239
+ avatarRef.current.speakText(feedbackMessage, {
240
+ lipsyncLang: config.lipsyncLang,
241
+ onSpeechEnd: () => {
242
+ // Notify parent that lesson completion feedback is done - parent decides next action
243
+ callbacksRef.current.onCustomAction({
244
+ type: 'lessonCompleteFeedbackDone',
245
+ moduleIndex: stateRef.current.currentModuleIndex,
246
+ lessonIndex: stateRef.current.currentLessonIndex,
247
+ score: stateRef.current.score,
248
+ totalQuestions: stateRef.current.totalQuestions,
249
+ percentage: percentage,
250
+ hasNextLesson: hasNextLesson
251
+ });
252
+ }
253
+ });
234
254
  }
235
255
  }, [animations.lessonComplete]);
236
256
 
@@ -372,10 +392,15 @@ const CurriculumLearning = forwardRef(({
372
392
  }
373
393
  }
374
394
  } else {
375
- // Use ref to avoid circular dependency
376
- if (completeLessonRef.current) {
377
- completeLessonRef.current();
378
- }
395
+ // No more questions - notify parent that all questions are done
396
+ // Parent can call completeLesson() when ready
397
+ callbacksRef.current.onCustomAction({
398
+ type: 'allQuestionsComplete',
399
+ moduleIndex: stateRef.current.currentModuleIndex,
400
+ lessonIndex: stateRef.current.currentLessonIndex,
401
+ totalQuestions: stateRef.current.totalQuestions,
402
+ score: stateRef.current.score
403
+ });
379
404
  }
380
405
  }, [animations.nextQuestion, getCurrentLesson, getCurrentQuestion]);
381
406
 
@@ -397,21 +422,23 @@ const CurriculumLearning = forwardRef(({
397
422
  stateRef.current.score = 0;
398
423
  stateRef.current.totalQuestions = 0;
399
424
 
400
- // Clear current question in UI
425
+ // Clear current question in UI and notify parent
401
426
  callbacksRef.current.onCustomAction({
402
427
  type: 'lessonStart',
403
428
  moduleIndex: stateRef.current.currentModuleIndex,
404
429
  lessonIndex: stateRef.current.currentLessonIndex
405
430
  });
406
431
 
432
+ // Notify parent that lesson has changed - parent decides when to start teaching
433
+ callbacksRef.current.onLessonStart({
434
+ moduleIndex: stateRef.current.currentModuleIndex,
435
+ lessonIndex: stateRef.current.currentLessonIndex,
436
+ lesson: getCurrentLesson()
437
+ });
438
+
407
439
  if (avatarRef.current) {
408
- avatarRef.current.setMood("happy");
440
+ avatarRef.current.setMood("happy");
409
441
  avatarRef.current.setBodyMovement("idle");
410
-
411
- // Immediately start teaching the next lesson
412
- if (startTeachingRef.current) {
413
- startTeachingRef.current();
414
- }
415
442
  }
416
443
  } else {
417
444
  // No more lessons in current module - check if there's a next module
@@ -428,21 +455,23 @@ const CurriculumLearning = forwardRef(({
428
455
  stateRef.current.score = 0;
429
456
  stateRef.current.totalQuestions = 0;
430
457
 
431
- // Clear current question in UI
458
+ // Clear current question in UI and notify parent
432
459
  callbacksRef.current.onCustomAction({
433
460
  type: 'lessonStart',
434
461
  moduleIndex: stateRef.current.currentModuleIndex,
435
462
  lessonIndex: stateRef.current.currentLessonIndex
436
463
  });
437
464
 
438
- if (avatarRef.current) {
465
+ // Notify parent that lesson has changed - parent decides when to start teaching
466
+ callbacksRef.current.onLessonStart({
467
+ moduleIndex: stateRef.current.currentModuleIndex,
468
+ lessonIndex: stateRef.current.currentLessonIndex,
469
+ lesson: getCurrentLesson()
470
+ });
471
+
472
+ if (avatarRef.current) {
439
473
  avatarRef.current.setMood("happy");
440
- avatarRef.current.setBodyMovement("idle");
441
-
442
- // Immediately start teaching the next lesson
443
- if (startTeachingRef.current) {
444
- startTeachingRef.current();
445
- }
474
+ avatarRef.current.setBodyMovement("idle");
446
475
  }
447
476
  } else {
448
477
  // No more modules or lessons - complete curriculum
@@ -508,23 +537,19 @@ const CurriculumLearning = forwardRef(({
508
537
  lesson: currentLesson
509
538
  });
510
539
 
511
- // Wait for avatar to finish speaking before moving to questions
540
+ // Read teaching content - no automatic progression, user controls via buttons
512
541
  avatarRef.current.speakText(teachingText, {
513
542
  lipsyncLang: config.lipsyncLang,
514
- onSpeechEnd: () => {
543
+ onSpeechEnd: () => {
515
544
  stateRef.current.isTeaching = false;
516
- // Immediately transition to next step with no delay
517
- if (currentLesson.questions && currentLesson.questions.length > 0) {
518
- // Use ref to avoid circular dependency
519
- if (startQuestionsRef.current) {
520
- startQuestionsRef.current();
521
- }
522
- } else {
523
- // No questions, complete the lesson using ref to avoid circular dependency
524
- if (completeLessonRef.current) {
525
- completeLessonRef.current();
526
- }
527
- }
545
+ // Notify parent that teaching is complete - parent decides next action
546
+ callbacksRef.current.onCustomAction({
547
+ type: 'teachingComplete',
548
+ moduleIndex: stateRef.current.currentModuleIndex,
549
+ lessonIndex: stateRef.current.currentLessonIndex,
550
+ lesson: currentLesson,
551
+ hasQuestions: currentLesson.questions && currentLesson.questions.length > 0
552
+ });
528
553
  }
529
554
  });
530
555
  }
@@ -565,14 +590,19 @@ const CurriculumLearning = forwardRef(({
565
590
 
566
591
  const config = defaultAvatarConfigRef.current || { lipsyncLang: 'en' };
567
592
 
568
- // Wait for speech to finish before moving to next question
593
+ // Read feedback - no automatic progression, user controls via buttons
569
594
  avatarRef.current.speakText(successMessage, {
570
595
  lipsyncLang: config.lipsyncLang,
571
596
  onSpeechEnd: () => {
572
- // Immediately move to next question with no delay
573
- if (nextQuestionRef.current) {
574
- nextQuestionRef.current();
575
- }
597
+ // Notify parent that feedback is complete - parent decides next action
598
+ callbacksRef.current.onCustomAction({
599
+ type: 'answerFeedbackComplete',
600
+ moduleIndex: stateRef.current.currentModuleIndex,
601
+ lessonIndex: stateRef.current.currentLessonIndex,
602
+ questionIndex: stateRef.current.currentQuestionIndex,
603
+ isCorrect: true,
604
+ hasNextQuestion: stateRef.current.currentQuestionIndex < (getCurrentLesson()?.questions?.length || 0) - 1
605
+ });
576
606
  }
577
607
  });
578
608
  } else {
@@ -591,24 +621,35 @@ const CurriculumLearning = forwardRef(({
591
621
 
592
622
  const config = defaultAvatarConfigRef.current || { lipsyncLang: 'en' };
593
623
 
594
- // Wait for speech to finish before moving to next question
624
+ // Read feedback - no automatic progression, user controls via buttons
595
625
  avatarRef.current.speakText(failureMessage, {
596
626
  lipsyncLang: config.lipsyncLang,
597
627
  onSpeechEnd: () => {
598
- // Immediately move to next question with no delay
599
- if (nextQuestionRef.current) {
600
- nextQuestionRef.current();
601
- }
628
+ // Notify parent that feedback is complete - parent decides next action
629
+ callbacksRef.current.onCustomAction({
630
+ type: 'answerFeedbackComplete',
631
+ moduleIndex: stateRef.current.currentModuleIndex,
632
+ lessonIndex: stateRef.current.currentLessonIndex,
633
+ questionIndex: stateRef.current.currentQuestionIndex,
634
+ isCorrect: false,
635
+ hasNextQuestion: stateRef.current.currentQuestionIndex < (getCurrentLesson()?.questions?.length || 0) - 1
636
+ });
602
637
  }
603
638
  });
604
639
  }
605
640
  } else {
606
- // If avatar not ready, move to next question immediately
607
- if (nextQuestionRef.current) {
608
- nextQuestionRef.current();
609
- }
641
+ // Avatar not ready - notify parent
642
+ callbacksRef.current.onCustomAction({
643
+ type: 'answerFeedbackComplete',
644
+ moduleIndex: stateRef.current.currentModuleIndex,
645
+ lessonIndex: stateRef.current.currentLessonIndex,
646
+ questionIndex: stateRef.current.currentQuestionIndex,
647
+ isCorrect: isCorrect,
648
+ hasNextQuestion: stateRef.current.currentQuestionIndex < (getCurrentLesson()?.questions?.length || 0) - 1,
649
+ avatarNotReady: true
650
+ });
610
651
  }
611
- }, [animations.correct, animations.incorrect, getCurrentQuestion, checkAnswer]);
652
+ }, [animations.correct, animations.incorrect, getCurrentQuestion, getCurrentLesson, checkAnswer]);
612
653
 
613
654
  // Handle code test result submission
614
655
  const handleCodeTestResult = useCallback((testResult) => {
@@ -308,6 +308,23 @@ const TalkingHeadAvatar = forwardRef(({
308
308
  checkInterval = setInterval(checkSpeechFinished, 50);
309
309
  }
310
310
 
311
+ // Also check if speech queue is empty and not speaking (meaning all sentences processed)
312
+ // This handles the case where text was split into sentences but all are processed
313
+ const speechQueueEmpty = !talkingHead.speechQueue || talkingHead.speechQueue.length === 0;
314
+
315
+ if (talkingHead && !talkingHead.isSpeaking && speechQueueEmpty &&
316
+ (!talkingHead.audioPlaylist || talkingHead.audioPlaylist.length === 0) &&
317
+ (!talkingHead.isAudioPlaying || talkingHead.isAudioPlaying === false)) {
318
+ // All speech has finished (all sentences processed and audio finished)
319
+ clearInterval(waitForAudioStart);
320
+ try {
321
+ options.onSpeechEnd();
322
+ } catch (e) {
323
+ console.error('Error in onSpeechEnd callback:', e);
324
+ }
325
+ return;
326
+ }
327
+
311
328
  // Timeout if audio doesn't start within reasonable time
312
329
  if (waitForAudioStartCount * 50 > maxWaitForAudioStart) {
313
330
  clearInterval(waitForAudioStart);
@@ -317,7 +334,9 @@ const TalkingHeadAvatar = forwardRef(({
317
334
  // Still waiting for API, but assume it will start soon
318
335
  audioStarted = true;
319
336
  checkInterval = setInterval(checkSpeechFinished, 50);
320
- } else {
337
+ } else if (talkingHead && !talkingHead.isSpeaking && speechQueueEmpty &&
338
+ (!talkingHead.audioPlaylist || talkingHead.audioPlaylist.length === 0) &&
339
+ (!talkingHead.isAudioPlaying || talkingHead.isAudioPlaying === false)) {
321
340
  // Speech never started or finished immediately, call callback
322
341
  try {
323
342
  options.onSpeechEnd();
@@ -353,10 +372,14 @@ const TalkingHeadAvatar = forwardRef(({
353
372
  // 1. Not speaking OR speech queue is empty
354
373
  // 2. Audio playlist is empty (no more audio to play)
355
374
  // 3. Not currently playing audio
375
+ // 4. Speech queue is empty (all sentences have been processed)
376
+ const speechQueueEmpty = !talkingHead.speechQueue || talkingHead.speechQueue.length === 0;
377
+
356
378
  const isFinished = talkingHead &&
357
379
  (!talkingHead.isSpeaking || talkingHead.isSpeaking === false) &&
358
380
  (!talkingHead.audioPlaylist || talkingHead.audioPlaylist.length === 0) &&
359
- (!talkingHead.isAudioPlaying || talkingHead.isAudioPlaying === false);
381
+ (!talkingHead.isAudioPlaying || talkingHead.isAudioPlaying === false) &&
382
+ speechQueueEmpty;
360
383
 
361
384
  if (isFinished) {
362
385
  if (checkInterval) {