@sage-rsc/talking-head-react 1.0.61 → 1.0.63

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.61",
3
+ "version": "1.0.63",
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",
@@ -52,11 +52,18 @@ const TalkingHeadAvatar = forwardRef(({
52
52
  const showFullAvatarRef = useRef(showFullAvatar);
53
53
  const pausedSpeechRef = useRef(null); // Track paused speech for resume
54
54
  const speechEndIntervalRef = useRef(null); // Track onSpeechEnd polling interval
55
+ const isPausedRef = useRef(false); // Track pause state for interval checks
56
+ const speechProgressRef = useRef({ remainingText: null, originalText: null, options: null }); // Track speech progress for resume
55
57
  const [isLoading, setIsLoading] = useState(true);
56
58
  const [error, setError] = useState(null);
57
59
  const [isReady, setIsReady] = useState(false);
58
60
  const [isPaused, setIsPaused] = useState(false);
59
61
 
62
+ // Keep ref in sync with state
63
+ useEffect(() => {
64
+ isPausedRef.current = isPaused;
65
+ }, [isPaused]);
66
+
60
67
  // Update ref when prop changes
61
68
  useEffect(() => {
62
69
  showFullAvatarRef.current = showFullAvatar;
@@ -286,7 +293,10 @@ const TalkingHeadAvatar = forwardRef(({
286
293
 
287
294
  // Store speech for potential pause/resume
288
295
  pausedSpeechRef.current = { text: textToSpeak, options };
296
+ // Reset progress tracking when starting new speech
297
+ speechProgressRef.current = { remainingText: null, originalText: null, options: null };
289
298
  setIsPaused(false);
299
+ isPausedRef.current = false;
290
300
 
291
301
  // Always resume audio context first (required for user interaction)
292
302
  await resumeAudioContext();
@@ -311,8 +321,8 @@ const TalkingHeadAvatar = forwardRef(({
311
321
  checkInterval = setInterval(() => {
312
322
  checkCount++;
313
323
 
314
- // Don't fire callback if paused
315
- if (isPaused) {
324
+ // Don't fire callback if paused (check ref for current value)
325
+ if (isPausedRef.current) {
316
326
  return;
317
327
  }
318
328
 
@@ -323,7 +333,7 @@ const TalkingHeadAvatar = forwardRef(({
323
333
  checkInterval = null;
324
334
  speechEndIntervalRef.current = null;
325
335
  }
326
- if (!callbackFired && !isPaused) {
336
+ if (!callbackFired && !isPausedRef.current) {
327
337
  callbackFired = true;
328
338
  try {
329
339
  options.onSpeechEnd();
@@ -348,18 +358,18 @@ const TalkingHeadAvatar = forwardRef(({
348
358
  audioPlaylistEmpty &&
349
359
  talkingHead.isAudioPlaying === false;
350
360
 
351
- if (isFinished && !callbackFired && !isPaused) {
361
+ if (isFinished && !callbackFired && !isPausedRef.current) {
352
362
  // Double-check after a small delay to ensure it's really finished
353
363
  setTimeout(() => {
354
364
  // Re-check one more time to be sure, and make sure we're still not paused
355
365
  const finalCheck = talkingHead &&
356
- !isPaused &&
366
+ !isPausedRef.current &&
357
367
  talkingHead.isSpeaking === false &&
358
368
  (!talkingHead.speechQueue || talkingHead.speechQueue.length === 0) &&
359
369
  (!talkingHead.audioPlaylist || talkingHead.audioPlaylist.length === 0) &&
360
370
  talkingHead.isAudioPlaying === false;
361
371
 
362
- if (finalCheck && !callbackFired && !isPaused) {
372
+ if (finalCheck && !callbackFired && !isPausedRef.current) {
363
373
  callbackFired = true;
364
374
  if (checkInterval) {
365
375
  clearInterval(checkInterval);
@@ -430,6 +440,29 @@ const TalkingHeadAvatar = forwardRef(({
430
440
  speechEndIntervalRef.current = null;
431
441
  }
432
442
 
443
+ // Save remaining text from speechQueue before clearing it
444
+ let remainingText = '';
445
+ if (talkingHead.speechQueue && talkingHead.speechQueue.length > 0) {
446
+ // Extract text from remaining queue items
447
+ const remainingParts = talkingHead.speechQueue
448
+ .filter(item => item.text) // Only get items with text
449
+ .map(item => item.text)
450
+ .join(' ');
451
+
452
+ if (remainingParts) {
453
+ remainingText = remainingParts.trim();
454
+ }
455
+ }
456
+
457
+ // If we have remaining text, save it for resume
458
+ if (remainingText && pausedSpeechRef.current) {
459
+ speechProgressRef.current = {
460
+ remainingText: remainingText,
461
+ originalText: pausedSpeechRef.current.text,
462
+ options: pausedSpeechRef.current.options
463
+ };
464
+ }
465
+
433
466
  // Clear speech queue to prevent next statements from playing
434
467
  if (talkingHead.speechQueue) {
435
468
  talkingHead.speechQueue.length = 0;
@@ -437,26 +470,44 @@ const TalkingHeadAvatar = forwardRef(({
437
470
 
438
471
  // Pause the speaking
439
472
  talkingHeadRef.current.pauseSpeaking();
473
+ isPausedRef.current = true;
440
474
  setIsPaused(true);
441
475
  }
442
476
  }
443
477
  }, []);
444
478
 
445
479
  const resumeSpeaking = useCallback(async () => {
446
- if (talkingHeadRef.current && isPaused && pausedSpeechRef.current && pausedSpeechRef.current.text) {
447
- const pausedSpeech = pausedSpeechRef.current;
448
- const pausedOptions = pausedSpeech.options || {};
449
-
480
+ if (talkingHeadRef.current && isPaused) {
450
481
  // Clear pause state first
451
482
  setIsPaused(false);
483
+ isPausedRef.current = false;
452
484
 
453
485
  // Resume audio context
454
486
  await resumeAudioContext();
455
487
 
456
- // Re-speak the paused text with original options (including onSpeechEnd callback)
488
+ // Determine what text to speak - use remaining text if available, otherwise full text
489
+ let textToSpeak = '';
490
+ let optionsToUse = {};
491
+
492
+ if (speechProgressRef.current && speechProgressRef.current.remainingText) {
493
+ // Resume from where we paused - speak only the remaining text
494
+ textToSpeak = speechProgressRef.current.remainingText;
495
+ optionsToUse = speechProgressRef.current.options || {};
496
+ // Clear progress after using it
497
+ speechProgressRef.current = { remainingText: null, originalText: null, options: null };
498
+ } else if (pausedSpeechRef.current && pausedSpeechRef.current.text) {
499
+ // Fallback: if no progress tracked, resume from beginning
500
+ textToSpeak = pausedSpeechRef.current.text;
501
+ optionsToUse = pausedSpeechRef.current.options || {};
502
+ } else {
503
+ // Nothing to resume
504
+ return;
505
+ }
506
+
507
+ // Prepare speak options
457
508
  const speakOptions = {
458
- ...pausedOptions,
459
- lipsyncLang: pausedOptions.lipsyncLang || defaultAvatarConfig.lipsyncLang || 'en'
509
+ ...optionsToUse,
510
+ lipsyncLang: optionsToUse.lipsyncLang || defaultAvatarConfig.lipsyncLang || 'en'
460
511
  };
461
512
 
462
513
  // Use speakText method which will set up the onSpeechEnd callback again
@@ -465,10 +516,8 @@ const TalkingHeadAvatar = forwardRef(({
465
516
  talkingHeadRef.current.setSlowdownRate(1.05);
466
517
  }
467
518
  // Call speakText which will handle everything including onSpeechEnd
468
- await speakText(pausedSpeech.text, speakOptions);
519
+ await speakText(textToSpeak, speakOptions);
469
520
  }
470
-
471
- // Don't clear pausedSpeechRef here - speakText will handle it
472
521
  }
473
522
  }, [resumeAudioContext, isPaused, speakText]);
474
523