@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/dist/index.cjs +5 -5
- package/dist/index.js +1210 -1190
- package/package.json +1 -1
- package/src/components/SimpleTalkingAvatar.jsx +85 -20
- package/src/lib/talkinghead.mjs +19 -0
package/package.json
CHANGED
|
@@ -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
|
|
63
|
-
autoIdleGroup = null, // e.g., "idle" - will
|
|
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
|
|
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
|
|
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
|
-
//
|
|
488
|
-
animationQueueRef.current =
|
|
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
|
|
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
|
|
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,
|
|
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) {
|
package/src/lib/talkinghead.mjs
CHANGED
|
@@ -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
|