@sage-rsc/talking-head-react 1.8.8 → 1.9.1

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.8.8",
3
+ "version": "1.9.1",
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",
@@ -27,6 +27,7 @@ import { loadAnimationsFromManifest, autoLoadAnimationsFromFolders } from '../ut
27
27
  * @param {Function} props.onError - Callback for errors
28
28
  * @param {Function} props.onSpeechStart - Callback when avatar starts speaking
29
29
  * @param {Function} props.onSpeechEnd - Callback when avatar finishes speaking
30
+ * @param {Function} props.onSubtitle - Callback for closed captions/subtitles (receives current text being spoken)
30
31
  * @param {string} props.className - Additional CSS classes
31
32
  * @param {Object} props.style - Additional inline styles
32
33
  * @param {Object} props.animations - Object mapping animation names to FBX file paths, or animation groups
@@ -56,11 +57,12 @@ const SimpleTalkingAvatar = forwardRef(({
56
57
  onError = () => {},
57
58
  onSpeechStart = () => {},
58
59
  onSpeechEnd = () => {},
60
+ onSubtitle = null,
59
61
  className = "",
60
62
  style = {},
61
63
  animations = {},
62
- autoAnimationGroup = null, // e.g., "talking" - will randomly select from this group when speaking
63
- autoIdleGroup = null, // e.g., "idle" - will randomly select from this group when idle
64
+ autoAnimationGroup = null, // e.g., "talking" - will select from this group in order when speaking
65
+ autoIdleGroup = null, // e.g., "idle" - will select from this group in order when idle
64
66
  autoSpeak = false
65
67
  }, ref) => {
66
68
  const containerRef = useRef(null);
@@ -85,12 +87,19 @@ const SimpleTalkingAvatar = forwardRef(({
85
87
  const currentSentenceIndexRef = useRef(0); // Track which sentence is currently playing
86
88
  const pausedAudioDataRef = useRef(null); // Store trimmed audio buffer when paused
87
89
  const restartIdleAnimationsRef = useRef(null); // Ref to restartIdleAnimations function to avoid circular dependency
90
+ const onSubtitleRef = useRef(onSubtitle); // Store onSubtitle in ref to avoid stale closures
91
+ const currentSubtitleTextRef = useRef(''); // Track current text being spoken for closed captions
88
92
 
89
93
  // Keep ref in sync with state
90
94
  useEffect(() => {
91
95
  isPausedRef.current = isPaused;
92
96
  }, [isPaused]);
93
97
 
98
+ // Keep onSubtitle ref in sync with prop
99
+ useEffect(() => {
100
+ onSubtitleRef.current = onSubtitle;
101
+ }, [onSubtitle]);
102
+
94
103
  // Helper function to normalize avatar body to gender key
95
104
  const getGenderKey = useCallback((body) => {
96
105
  // Normalize avatarBody - handle 'M'/'F', 'male'/'female', or undefined
@@ -466,26 +475,16 @@ const SimpleTalkingAvatar = forwardRef(({
466
475
  return [];
467
476
  }, [loadedAnimations, avatarBody, getGenderKey]);
468
477
 
469
- // Helper function to shuffle array (Fisher-Yates algorithm)
470
- const shuffleArray = useCallback((array) => {
471
- const shuffled = [...array];
472
- for (let i = shuffled.length - 1; i > 0; i--) {
473
- const j = Math.floor(Math.random() * (i + 1));
474
- [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
475
- }
476
- return shuffled;
477
- }, []);
478
-
479
- // Helper function to get next animation from queue (or create new shuffled queue)
478
+ // Helper function to get next animation from queue (or create new queue in order)
480
479
  const getNextAnimation = useCallback((groupName) => {
481
- // If queue is empty, create a new shuffled queue
480
+ // If queue is empty, create a new queue in order
482
481
  if (animationQueueRef.current.length === 0) {
483
482
  const allAnimations = getAllAnimationsFromGroup(groupName);
484
483
  if (allAnimations.length === 0) {
485
484
  return null;
486
485
  }
487
- // Shuffle and create new queue
488
- animationQueueRef.current = shuffleArray(allAnimations);
486
+ // Create queue in order (no shuffling)
487
+ animationQueueRef.current = [...allAnimations];
489
488
  playedAnimationsRef.current = []; // Reset played list
490
489
  }
491
490
 
@@ -496,7 +495,7 @@ const SimpleTalkingAvatar = forwardRef(({
496
495
  }
497
496
 
498
497
  return nextAnimation;
499
- }, [getAllAnimationsFromGroup, shuffleArray]);
498
+ }, [getAllAnimationsFromGroup]);
500
499
 
501
500
  // Helper function to extract animation name from path
502
501
  const getAnimationName = useCallback((animationPath) => {
@@ -629,7 +628,7 @@ const SimpleTalkingAvatar = forwardRef(({
629
628
  return null;
630
629
  }
631
630
  } else {
632
- // No more animations in queue, but check if we should reshuffle and continue
631
+ // No more animations in queue, but check if we should reset queue and continue
633
632
  const checkStillSpeaking = () => {
634
633
  if (!talkingHeadRef.current) return false;
635
634
 
@@ -655,7 +654,7 @@ const SimpleTalkingAvatar = forwardRef(({
655
654
  const isStillSpeaking = checkStillSpeaking();
656
655
 
657
656
  if (isStillSpeaking) {
658
- // Queue is empty but still speaking, reshuffle and continue playing animations IMMEDIATELY
657
+ // Queue is empty but still speaking, reset queue and continue playing animations IMMEDIATELY
659
658
  animationQueueRef.current = [];
660
659
  playedAnimationsRef.current = [];
661
660
  // Continue immediately - no delay for continuous playback
@@ -779,9 +778,65 @@ const SimpleTalkingAvatar = forwardRef(({
779
778
  const sentences = textToSpeak.split(/[.!?]+/).filter(s => s.trim().length > 0);
780
779
  originalSentencesRef.current = sentences;
781
780
  currentSentenceIndexRef.current = 0; // Reset sentence tracking
781
+
782
+ // Reset subtitle text
783
+ currentSubtitleTextRef.current = '';
784
+
785
+ // Create subtitle callback that extracts text from sentences/words as they're spoken
786
+ // Use a ref to store the latest onSubtitle callback to avoid stale closures
787
+ const subtitleCallback = (subtitleData) => {
788
+ // Extract text from subtitle data
789
+ // Can be called with:
790
+ // 1. Sentence-level: { text: "sentence", subtitles: ["sentence"] }
791
+ // 2. Word-level: { vs: { subtitles: [' word'] } }
792
+ let subtitleText = '';
793
+
794
+ if (subtitleData) {
795
+ // Check for text property first (sentence-level, from startSpeaking)
796
+ if (subtitleData.text) {
797
+ subtitleText = subtitleData.text;
798
+ }
799
+ // Check for vs.subtitles (word-level, from animation queue)
800
+ else if (subtitleData.vs && subtitleData.vs.subtitles) {
801
+ if (Array.isArray(subtitleData.vs.subtitles)) {
802
+ subtitleText = subtitleData.vs.subtitles.join('');
803
+ } else if (typeof subtitleData.vs.subtitles === 'string') {
804
+ subtitleText = subtitleData.vs.subtitles;
805
+ }
806
+ }
807
+ // Check for direct subtitles array (fallback)
808
+ else if (subtitleData.subtitles) {
809
+ if (Array.isArray(subtitleData.subtitles)) {
810
+ subtitleText = subtitleData.subtitles.join('');
811
+ } else if (typeof subtitleData.subtitles === 'string') {
812
+ subtitleText = subtitleData.subtitles;
813
+ }
814
+ }
815
+ }
816
+
817
+ // Update current subtitle text (accumulate words/sentences)
818
+ if (subtitleText.trim()) {
819
+ // Append the new text (words already include spaces)
820
+ currentSubtitleTextRef.current += subtitleText;
821
+
822
+ // Call onSubtitle callback if provided (use ref to avoid stale closures)
823
+ const currentCallback = onSubtitleRef.current;
824
+ if (currentCallback && typeof currentCallback === 'function') {
825
+ try {
826
+ // Use setTimeout to defer the callback and avoid calling setState during render
827
+ setTimeout(() => {
828
+ currentCallback(currentSubtitleTextRef.current.trim());
829
+ }, 0);
830
+ } catch (err) {
831
+ console.warn('Error in onSubtitle callback:', err);
832
+ }
833
+ }
834
+ }
835
+ };
782
836
 
783
837
  const speakOptions = {
784
- lipsyncLang: options.lipsyncLang || 'en'
838
+ lipsyncLang: options.lipsyncLang || 'en',
839
+ onSubtitles: onSubtitle ? subtitleCallback : null
785
840
  };
786
841
 
787
842
  // Set up polling mechanism to detect when speech ends
@@ -888,6 +943,9 @@ const SimpleTalkingAvatar = forwardRef(({
888
943
  animationQueueRef.current = [];
889
944
  playedAnimationsRef.current = [];
890
945
 
946
+ // Clear subtitle text when speech ends
947
+ currentSubtitleTextRef.current = '';
948
+
891
949
  // CRITICAL: Stop current animation to return to idle pose
892
950
  if (currentTalkingHead) {
893
951
  currentTalkingHead.stopAnimation();
@@ -1238,6 +1296,9 @@ const SimpleTalkingAvatar = forwardRef(({
1238
1296
  animationQueueRef.current = [];
1239
1297
  playedAnimationsRef.current = [];
1240
1298
 
1299
+ // Clear subtitle text when speech ends
1300
+ currentSubtitleTextRef.current = '';
1301
+
1241
1302
  // CRITICAL: Stop current animation to return to idle pose
1242
1303
  if (currentTalkingHead) {
1243
1304
  currentTalkingHead.stopAnimation();
@@ -1336,6 +1397,9 @@ const SimpleTalkingAvatar = forwardRef(({
1336
1397
  animationQueueRef.current = [];
1337
1398
  playedAnimationsRef.current = [];
1338
1399
 
1400
+ // Clear subtitle text when speech is stopped
1401
+ currentSubtitleTextRef.current = '';
1402
+
1339
1403
  setIsPaused(false);
1340
1404
  isPausedRef.current = false;
1341
1405
  pausedAudioDataRef.current = null; // Clear any paused audio data
@@ -1367,6 +1431,7 @@ const SimpleTalkingAvatar = forwardRef(({
1367
1431
  stopSpeaking,
1368
1432
  resumeAudioContext,
1369
1433
  isPaused: () => isPaused,
1434
+ getCurrentSubtitle: () => currentSubtitleTextRef.current, // Get current subtitle text
1370
1435
  setMood: (mood) => talkingHeadRef.current?.setMood(mood),
1371
1436
  setBodyMovement: (movement) => {
1372
1437
  if (talkingHeadRef.current) {
@@ -4538,6 +4538,25 @@ class TalkingHead {
4538
4538
  // Look at the camera
4539
4539
  this.lookAtCamera(500);
4540
4540
 
4541
+ // Extract text for subtitle callback
4542
+ let sentenceText = '';
4543
+ if (Array.isArray(line.text)) {
4544
+ // Array format: [{word: "..."}, {word: "..."}]
4545
+ sentenceText = line.text.map(x => x.word).join(' ');
4546
+ } else if (typeof line.text === 'string') {
4547
+ sentenceText = line.text;
4548
+ }
4549
+
4550
+ // Call subtitle callback with sentence text if available
4551
+ if (sentenceText && this.onSubtitles && typeof this.onSubtitles === 'function') {
4552
+ try {
4553
+ // Call with the sentence text
4554
+ this.onSubtitles({ text: sentenceText, subtitles: [sentenceText] });
4555
+ } catch (err) {
4556
+ console.warn('Error in subtitle callback:', err);
4557
+ }
4558
+ }
4559
+
4541
4560
  // Spoken text
4542
4561
  try {
4543
4562
  // Check which TTS service to use