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

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.59",
3
+ "version": "1.0.61",
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",
@@ -185,11 +185,9 @@ const CurriculumLearning = forwardRef(({
185
185
  : 100;
186
186
  let feedbackMessage = `Congratulations! You've completed this lesson`;
187
187
  if (stateRef.current.totalQuestions > 0) {
188
- // Rephrase for clearer TTS: "You got X correct out of Y questions, which is Z percent"
189
- const scoreText = stateRef.current.score === 1 ? 'one' : stateRef.current.score;
190
- const totalText = stateRef.current.totalQuestions === 1 ? 'one' : stateRef.current.totalQuestions;
191
- const percentText = percentage === 50 ? 'fifty' : `${percentage}`;
192
- feedbackMessage += ` You got ${scoreText} correct out of ${totalText} question${stateRef.current.totalQuestions === 1 ? '' : 's'}, which is ${percentText} percent. `;
188
+ // Rephrase for clearer TTS: "You got X correct out of Y questions, achieving Z percent"
189
+ // Use numbers as-is for TTS to handle naturally
190
+ feedbackMessage += ` You got ${stateRef.current.score} correct out of ${stateRef.current.totalQuestions} question${stateRef.current.totalQuestions === 1 ? '' : 's'}, achieving a score of ${percentage} percent. `;
193
191
  } else {
194
192
  feedbackMessage += `! `;
195
193
  }
@@ -51,6 +51,7 @@ 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
54
55
  const [isLoading, setIsLoading] = useState(true);
55
56
  const [error, setError] = useState(null);
56
57
  const [isReady, setIsReady] = useState(false);
@@ -277,7 +278,13 @@ const TalkingHeadAvatar = forwardRef(({
277
278
  const speakText = useCallback(async (textToSpeak, options = {}) => {
278
279
  if (talkingHeadRef.current && isReady) {
279
280
  try {
280
- // Clear paused speech when starting new speech
281
+ // Stop any existing speech end polling
282
+ if (speechEndIntervalRef.current) {
283
+ clearInterval(speechEndIntervalRef.current);
284
+ speechEndIntervalRef.current = null;
285
+ }
286
+
287
+ // Store speech for potential pause/resume
281
288
  pausedSpeechRef.current = { text: textToSpeak, options };
282
289
  setIsPaused(false);
283
290
 
@@ -304,13 +311,19 @@ const TalkingHeadAvatar = forwardRef(({
304
311
  checkInterval = setInterval(() => {
305
312
  checkCount++;
306
313
 
314
+ // Don't fire callback if paused
315
+ if (isPaused) {
316
+ return;
317
+ }
318
+
307
319
  // Safety timeout - call callback anyway after max time
308
320
  if (checkCount > maxChecks) {
309
321
  if (checkInterval) {
310
322
  clearInterval(checkInterval);
311
323
  checkInterval = null;
324
+ speechEndIntervalRef.current = null;
312
325
  }
313
- if (!callbackFired) {
326
+ if (!callbackFired && !isPaused) {
314
327
  callbackFired = true;
315
328
  try {
316
329
  options.onSpeechEnd();
@@ -335,21 +348,23 @@ const TalkingHeadAvatar = forwardRef(({
335
348
  audioPlaylistEmpty &&
336
349
  talkingHead.isAudioPlaying === false;
337
350
 
338
- if (isFinished && !callbackFired) {
351
+ if (isFinished && !callbackFired && !isPaused) {
339
352
  // Double-check after a small delay to ensure it's really finished
340
353
  setTimeout(() => {
341
- // Re-check one more time to be sure
354
+ // Re-check one more time to be sure, and make sure we're still not paused
342
355
  const finalCheck = talkingHead &&
356
+ !isPaused &&
343
357
  talkingHead.isSpeaking === false &&
344
358
  (!talkingHead.speechQueue || talkingHead.speechQueue.length === 0) &&
345
359
  (!talkingHead.audioPlaylist || talkingHead.audioPlaylist.length === 0) &&
346
360
  talkingHead.isAudioPlaying === false;
347
361
 
348
- if (finalCheck && !callbackFired) {
362
+ if (finalCheck && !callbackFired && !isPaused) {
349
363
  callbackFired = true;
350
364
  if (checkInterval) {
351
365
  clearInterval(checkInterval);
352
366
  checkInterval = null;
367
+ speechEndIntervalRef.current = null;
353
368
  }
354
369
  try {
355
370
  options.onSpeechEnd();
@@ -360,6 +375,9 @@ const TalkingHeadAvatar = forwardRef(({
360
375
  }, 100);
361
376
  }
362
377
  }, 100); // Check every 100ms for better reliability
378
+
379
+ // Store interval ref so we can clear it on pause
380
+ speechEndIntervalRef.current = checkInterval;
363
381
  }
364
382
 
365
383
  if (talkingHeadRef.current.lipsync && Object.keys(talkingHeadRef.current.lipsync).length > 0) {
@@ -405,13 +423,19 @@ const TalkingHeadAvatar = forwardRef(({
405
423
  (talkingHead.audioPlaylist && talkingHead.audioPlaylist.length > 0) ||
406
424
  (talkingHead.speechQueue && talkingHead.speechQueue.length > 0);
407
425
 
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
426
+ if (isCurrentlySpeaking) {
427
+ // Stop any onSpeechEnd polling to prevent callbacks from firing
428
+ if (speechEndIntervalRef.current) {
429
+ clearInterval(speechEndIntervalRef.current);
430
+ speechEndIntervalRef.current = null;
431
+ }
432
+
433
+ // Clear speech queue to prevent next statements from playing
434
+ if (talkingHead.speechQueue) {
435
+ talkingHead.speechQueue.length = 0;
436
+ }
437
+
438
+ // Pause the speaking
415
439
  talkingHeadRef.current.pauseSpeaking();
416
440
  setIsPaused(true);
417
441
  }
@@ -419,34 +443,34 @@ const TalkingHeadAvatar = forwardRef(({
419
443
  }, []);
420
444
 
421
445
  const resumeSpeaking = useCallback(async () => {
422
- if (talkingHeadRef.current && isPaused) {
446
+ if (talkingHeadRef.current && isPaused && pausedSpeechRef.current && pausedSpeechRef.current.text) {
447
+ const pausedSpeech = pausedSpeechRef.current;
448
+ const pausedOptions = pausedSpeech.options || {};
449
+
450
+ // Clear pause state first
423
451
  setIsPaused(false);
424
452
 
425
453
  // Resume audio context
426
454
  await resumeAudioContext();
427
455
 
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);
456
+ // Re-speak the paused text with original options (including onSpeechEnd callback)
457
+ const speakOptions = {
458
+ ...pausedOptions,
459
+ lipsyncLang: pausedOptions.lipsyncLang || defaultAvatarConfig.lipsyncLang || 'en'
460
+ };
461
+
462
+ // Use speakText method which will set up the onSpeechEnd callback again
463
+ if (talkingHeadRef.current.lipsync && Object.keys(talkingHeadRef.current.lipsync).length > 0) {
464
+ if (talkingHeadRef.current.setSlowdownRate) {
465
+ talkingHeadRef.current.setSlowdownRate(1.05);
443
466
  }
444
- } else {
445
- // If no paused speech stored, just clear the pause state
446
- pausedSpeechRef.current = null;
467
+ // Call speakText which will handle everything including onSpeechEnd
468
+ await speakText(pausedSpeech.text, speakOptions);
447
469
  }
470
+
471
+ // Don't clear pausedSpeechRef here - speakText will handle it
448
472
  }
449
- }, [resumeAudioContext, isPaused]);
473
+ }, [resumeAudioContext, isPaused, speakText]);
450
474
 
451
475
  const setMood = useCallback((mood) => {
452
476
  if (talkingHeadRef.current) {