@sage-rsc/talking-head-react 1.0.60 → 1.0.62

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.60",
3
+ "version": "1.0.62",
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",
@@ -51,11 +51,18 @@ const TalkingHeadAvatar = forwardRef(({
51
51
  const talkingHeadRef = useRef(null);
52
52
  const showFullAvatarRef = useRef(showFullAvatar);
53
53
  const pausedSpeechRef = useRef(null); // Track paused speech for resume
54
+ const speechEndIntervalRef = useRef(null); // Track onSpeechEnd polling interval
55
+ const isPausedRef = useRef(false); // Track pause state for interval checks
54
56
  const [isLoading, setIsLoading] = useState(true);
55
57
  const [error, setError] = useState(null);
56
58
  const [isReady, setIsReady] = useState(false);
57
59
  const [isPaused, setIsPaused] = useState(false);
58
60
 
61
+ // Keep ref in sync with state
62
+ useEffect(() => {
63
+ isPausedRef.current = isPaused;
64
+ }, [isPaused]);
65
+
59
66
  // Update ref when prop changes
60
67
  useEffect(() => {
61
68
  showFullAvatarRef.current = showFullAvatar;
@@ -277,7 +284,13 @@ const TalkingHeadAvatar = forwardRef(({
277
284
  const speakText = useCallback(async (textToSpeak, options = {}) => {
278
285
  if (talkingHeadRef.current && isReady) {
279
286
  try {
280
- // Clear paused speech when starting new speech
287
+ // Stop any existing speech end polling
288
+ if (speechEndIntervalRef.current) {
289
+ clearInterval(speechEndIntervalRef.current);
290
+ speechEndIntervalRef.current = null;
291
+ }
292
+
293
+ // Store speech for potential pause/resume
281
294
  pausedSpeechRef.current = { text: textToSpeak, options };
282
295
  setIsPaused(false);
283
296
 
@@ -304,13 +317,19 @@ const TalkingHeadAvatar = forwardRef(({
304
317
  checkInterval = setInterval(() => {
305
318
  checkCount++;
306
319
 
320
+ // Don't fire callback if paused (check ref for current value)
321
+ if (isPausedRef.current) {
322
+ return;
323
+ }
324
+
307
325
  // Safety timeout - call callback anyway after max time
308
326
  if (checkCount > maxChecks) {
309
327
  if (checkInterval) {
310
328
  clearInterval(checkInterval);
311
329
  checkInterval = null;
330
+ speechEndIntervalRef.current = null;
312
331
  }
313
- if (!callbackFired) {
332
+ if (!callbackFired && !isPausedRef.current) {
314
333
  callbackFired = true;
315
334
  try {
316
335
  options.onSpeechEnd();
@@ -335,21 +354,23 @@ const TalkingHeadAvatar = forwardRef(({
335
354
  audioPlaylistEmpty &&
336
355
  talkingHead.isAudioPlaying === false;
337
356
 
338
- if (isFinished && !callbackFired) {
357
+ if (isFinished && !callbackFired && !isPausedRef.current) {
339
358
  // Double-check after a small delay to ensure it's really finished
340
359
  setTimeout(() => {
341
- // Re-check one more time to be sure
360
+ // Re-check one more time to be sure, and make sure we're still not paused
342
361
  const finalCheck = talkingHead &&
362
+ !isPausedRef.current &&
343
363
  talkingHead.isSpeaking === false &&
344
364
  (!talkingHead.speechQueue || talkingHead.speechQueue.length === 0) &&
345
365
  (!talkingHead.audioPlaylist || talkingHead.audioPlaylist.length === 0) &&
346
366
  talkingHead.isAudioPlaying === false;
347
367
 
348
- if (finalCheck && !callbackFired) {
368
+ if (finalCheck && !callbackFired && !isPausedRef.current) {
349
369
  callbackFired = true;
350
370
  if (checkInterval) {
351
371
  clearInterval(checkInterval);
352
372
  checkInterval = null;
373
+ speechEndIntervalRef.current = null;
353
374
  }
354
375
  try {
355
376
  options.onSpeechEnd();
@@ -360,6 +381,9 @@ const TalkingHeadAvatar = forwardRef(({
360
381
  }, 100);
361
382
  }
362
383
  }, 100); // Check every 100ms for better reliability
384
+
385
+ // Store interval ref so we can clear it on pause
386
+ speechEndIntervalRef.current = checkInterval;
363
387
  }
364
388
 
365
389
  if (talkingHeadRef.current.lipsync && Object.keys(talkingHeadRef.current.lipsync).length > 0) {
@@ -405,48 +429,55 @@ const TalkingHeadAvatar = forwardRef(({
405
429
  (talkingHead.audioPlaylist && talkingHead.audioPlaylist.length > 0) ||
406
430
  (talkingHead.speechQueue && talkingHead.speechQueue.length > 0);
407
431
 
408
- // Only pause if avatar is actually speaking
409
- if (isCurrentlySpeaking && pausedSpeechRef.current && pausedSpeechRef.current.text) {
410
- talkingHeadRef.current.pauseSpeaking();
411
- setIsPaused(true);
412
- } else if (isCurrentlySpeaking) {
413
- // If speaking but no paused speech stored, try to pause anyway
414
- // This handles cases where speech started before we could track it
432
+ if (isCurrentlySpeaking) {
433
+ // Stop any onSpeechEnd polling to prevent callbacks from firing
434
+ if (speechEndIntervalRef.current) {
435
+ clearInterval(speechEndIntervalRef.current);
436
+ speechEndIntervalRef.current = null;
437
+ }
438
+
439
+ // Clear speech queue to prevent next statements from playing
440
+ if (talkingHead.speechQueue) {
441
+ talkingHead.speechQueue.length = 0;
442
+ }
443
+
444
+ // Pause the speaking
415
445
  talkingHeadRef.current.pauseSpeaking();
446
+ isPausedRef.current = true;
416
447
  setIsPaused(true);
417
448
  }
418
449
  }
419
450
  }, []);
420
451
 
421
452
  const resumeSpeaking = useCallback(async () => {
422
- if (talkingHeadRef.current && isPaused) {
453
+ if (talkingHeadRef.current && isPaused && pausedSpeechRef.current && pausedSpeechRef.current.text) {
454
+ const pausedSpeech = pausedSpeechRef.current;
455
+ const pausedOptions = pausedSpeech.options || {};
456
+
457
+ // Clear pause state first
423
458
  setIsPaused(false);
424
459
 
425
460
  // Resume audio context
426
461
  await resumeAudioContext();
427
462
 
428
- // Re-speak the paused text if we have it stored
429
- if (pausedSpeechRef.current && pausedSpeechRef.current.text) {
430
- const pausedSpeech = pausedSpeechRef.current;
431
- pausedSpeechRef.current = null; // Clear after getting the text
432
-
433
- const speakOptions = {
434
- ...pausedSpeech.options,
435
- lipsyncLang: pausedSpeech.options.lipsyncLang || defaultAvatarConfig.lipsyncLang || 'en'
436
- };
437
-
438
- if (talkingHeadRef.current.lipsync && Object.keys(talkingHeadRef.current.lipsync).length > 0) {
439
- if (talkingHeadRef.current.setSlowdownRate) {
440
- talkingHeadRef.current.setSlowdownRate(1.05);
441
- }
442
- talkingHeadRef.current.speakText(pausedSpeech.text, speakOptions);
463
+ // Re-speak the paused text with original options (including onSpeechEnd callback)
464
+ const speakOptions = {
465
+ ...pausedOptions,
466
+ lipsyncLang: pausedOptions.lipsyncLang || defaultAvatarConfig.lipsyncLang || 'en'
467
+ };
468
+
469
+ // Use speakText method which will set up the onSpeechEnd callback again
470
+ if (talkingHeadRef.current.lipsync && Object.keys(talkingHeadRef.current.lipsync).length > 0) {
471
+ if (talkingHeadRef.current.setSlowdownRate) {
472
+ talkingHeadRef.current.setSlowdownRate(1.05);
443
473
  }
444
- } else {
445
- // If no paused speech stored, just clear the pause state
446
- pausedSpeechRef.current = null;
474
+ // Call speakText which will handle everything including onSpeechEnd
475
+ await speakText(pausedSpeech.text, speakOptions);
447
476
  }
477
+
478
+ // Don't clear pausedSpeechRef here - speakText will handle it
448
479
  }
449
- }, [resumeAudioContext, isPaused]);
480
+ }, [resumeAudioContext, isPaused, speakText]);
450
481
 
451
482
  const setMood = useCallback((mood) => {
452
483
  if (talkingHeadRef.current) {