@sage-rsc/talking-head-react 1.0.50 → 1.0.52
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 +2 -2
- package/dist/index.js +801 -799
- package/package.json +1 -1
- package/src/components/CurriculumLearning.jsx +46 -9
- package/src/components/TalkingHeadAvatar.jsx +43 -92
- package/src/lib/talkinghead.mjs +4 -4
package/package.json
CHANGED
|
@@ -299,8 +299,10 @@ const CurriculumLearning = forwardRef(({
|
|
|
299
299
|
});
|
|
300
300
|
}
|
|
301
301
|
|
|
302
|
-
//
|
|
303
|
-
|
|
302
|
+
// Function to speak the first question
|
|
303
|
+
const speakFirstQuestion = () => {
|
|
304
|
+
if (!avatarRef.current || !firstQuestion) return;
|
|
305
|
+
|
|
304
306
|
avatarRef.current.setMood("curious");
|
|
305
307
|
|
|
306
308
|
// Play custom animation if available
|
|
@@ -324,16 +326,30 @@ const CurriculumLearning = forwardRef(({
|
|
|
324
326
|
} else {
|
|
325
327
|
avatarRef.current.speakText(`Now let me ask you some questions. Here's the first one: ${firstQuestion.question}`, { lipsyncLang: config.lipsyncLang });
|
|
326
328
|
}
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
// Try to speak immediately if avatar is ready
|
|
332
|
+
if (avatarRef.current && avatarRef.current.isReady && firstQuestion) {
|
|
333
|
+
speakFirstQuestion();
|
|
327
334
|
} else if (avatarRef.current && avatarRef.current.isReady) {
|
|
335
|
+
// No question but avatar ready - just announce
|
|
328
336
|
const config = defaultAvatarConfigRef.current || { lipsyncLang: 'en' };
|
|
329
337
|
avatarRef.current.speakText("Now let me ask you some questions to test your understanding.", { lipsyncLang: config.lipsyncLang });
|
|
330
338
|
} else {
|
|
331
|
-
// Avatar not ready yet
|
|
332
|
-
|
|
333
|
-
if (
|
|
334
|
-
|
|
339
|
+
// Avatar not ready yet - wait for it to be ready, then speak
|
|
340
|
+
const checkReady = setInterval(() => {
|
|
341
|
+
if (avatarRef.current && avatarRef.current.isReady) {
|
|
342
|
+
clearInterval(checkReady);
|
|
343
|
+
if (firstQuestion) {
|
|
344
|
+
speakFirstQuestion();
|
|
345
|
+
}
|
|
335
346
|
}
|
|
336
347
|
}, 100);
|
|
348
|
+
|
|
349
|
+
// Timeout after 5 seconds
|
|
350
|
+
setTimeout(() => {
|
|
351
|
+
clearInterval(checkReady);
|
|
352
|
+
}, 5000);
|
|
337
353
|
}
|
|
338
354
|
}, [animations.questionStart, getCurrentLesson, getCurrentQuestion]);
|
|
339
355
|
|
|
@@ -357,15 +373,18 @@ const CurriculumLearning = forwardRef(({
|
|
|
357
373
|
});
|
|
358
374
|
}
|
|
359
375
|
|
|
360
|
-
|
|
376
|
+
// Function to speak the next question
|
|
377
|
+
const speakNextQuestion = () => {
|
|
378
|
+
if (!avatarRef.current || !nextQuestionObj) return;
|
|
379
|
+
|
|
361
380
|
avatarRef.current.setMood("happy");
|
|
362
381
|
avatarRef.current.setBodyMovement("idle");
|
|
363
|
-
|
|
382
|
+
|
|
364
383
|
// Play custom animation if available
|
|
365
384
|
if (animations.nextQuestion) {
|
|
366
385
|
try {
|
|
367
386
|
avatarRef.current.playAnimation(animations.nextQuestion, true);
|
|
368
|
-
|
|
387
|
+
} catch (error) {
|
|
369
388
|
console.warn('Failed to play nextQuestion animation:', error);
|
|
370
389
|
}
|
|
371
390
|
}
|
|
@@ -390,6 +409,24 @@ const CurriculumLearning = forwardRef(({
|
|
|
390
409
|
lipsyncLang: config.lipsyncLang
|
|
391
410
|
});
|
|
392
411
|
}
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
// Try to speak immediately if avatar is ready
|
|
415
|
+
if (avatarRef.current && avatarRef.current.isReady && nextQuestionObj) {
|
|
416
|
+
speakNextQuestion();
|
|
417
|
+
} else if (nextQuestionObj) {
|
|
418
|
+
// Avatar not ready yet - wait for it to be ready, then speak
|
|
419
|
+
const checkReady = setInterval(() => {
|
|
420
|
+
if (avatarRef.current && avatarRef.current.isReady) {
|
|
421
|
+
clearInterval(checkReady);
|
|
422
|
+
speakNextQuestion();
|
|
423
|
+
}
|
|
424
|
+
}, 100);
|
|
425
|
+
|
|
426
|
+
// Timeout after 5 seconds
|
|
427
|
+
setTimeout(() => {
|
|
428
|
+
clearInterval(checkReady);
|
|
429
|
+
}, 5000);
|
|
393
430
|
}
|
|
394
431
|
} else {
|
|
395
432
|
// No more questions - notify parent that all questions are done
|
|
@@ -289,120 +289,71 @@ const TalkingHeadAvatar = forwardRef(({
|
|
|
289
289
|
const talkingHead = talkingHeadRef.current;
|
|
290
290
|
|
|
291
291
|
// Set up a polling mechanism to detect when speech finishes
|
|
292
|
-
// Wait for audio to actually start playing before checking if it's finished
|
|
293
292
|
let checkInterval = null;
|
|
294
293
|
let checkCount = 0;
|
|
295
|
-
let audioStarted = false;
|
|
296
294
|
const maxChecks = 1200; // 60 seconds max (50ms intervals)
|
|
297
|
-
|
|
295
|
+
let callbackFired = false;
|
|
298
296
|
|
|
299
|
-
//
|
|
300
|
-
|
|
301
|
-
const waitForAudioStart = setInterval(() => {
|
|
302
|
-
waitForAudioStartCount++;
|
|
303
|
-
|
|
304
|
-
// Check if audio has started playing (audioPlaylist has items OR isAudioPlaying is true)
|
|
305
|
-
// Also check if isSpeaking is true (indicating API call has started processing)
|
|
306
|
-
if (talkingHead && talkingHead.isSpeaking && (
|
|
307
|
-
(talkingHead.audioPlaylist && talkingHead.audioPlaylist.length > 0) ||
|
|
308
|
-
(talkingHead.isAudioPlaying === true)
|
|
309
|
-
)) {
|
|
310
|
-
audioStarted = true;
|
|
311
|
-
clearInterval(waitForAudioStart);
|
|
312
|
-
|
|
313
|
-
// Now start checking if speech has finished
|
|
314
|
-
checkInterval = setInterval(checkSpeechFinished, 50);
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
// Also check if speech queue is empty and not speaking (meaning all sentences processed)
|
|
318
|
-
// This handles the case where text was split into sentences but all are processed
|
|
319
|
-
const speechQueueEmpty = !talkingHead.speechQueue || talkingHead.speechQueue.length === 0;
|
|
320
|
-
|
|
321
|
-
if (talkingHead && !talkingHead.isSpeaking && speechQueueEmpty &&
|
|
322
|
-
(!talkingHead.audioPlaylist || talkingHead.audioPlaylist.length === 0) &&
|
|
323
|
-
(!talkingHead.isAudioPlaying || talkingHead.isAudioPlaying === false)) {
|
|
324
|
-
// All speech has finished (all sentences processed and audio finished)
|
|
325
|
-
clearInterval(waitForAudioStart);
|
|
326
|
-
try {
|
|
327
|
-
options.onSpeechEnd();
|
|
328
|
-
} catch (e) {
|
|
329
|
-
console.error('Error in onSpeechEnd callback:', e);
|
|
330
|
-
}
|
|
331
|
-
return;
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
// Timeout if audio doesn't start within reasonable time
|
|
335
|
-
if (waitForAudioStartCount * 50 > maxWaitForAudioStart) {
|
|
336
|
-
clearInterval(waitForAudioStart);
|
|
337
|
-
// Check if speech has actually started (isSpeaking should be true)
|
|
338
|
-
// If isSpeaking is false, the speech might have failed or completed very quickly
|
|
339
|
-
if (talkingHead && talkingHead.isSpeaking) {
|
|
340
|
-
// Still waiting for API, but assume it will start soon
|
|
341
|
-
audioStarted = true;
|
|
342
|
-
checkInterval = setInterval(checkSpeechFinished, 50);
|
|
343
|
-
} else if (talkingHead && !talkingHead.isSpeaking && speechQueueEmpty &&
|
|
344
|
-
(!talkingHead.audioPlaylist || talkingHead.audioPlaylist.length === 0) &&
|
|
345
|
-
(!talkingHead.isAudioPlaying || talkingHead.isAudioPlaying === false)) {
|
|
346
|
-
// Speech never started or finished immediately, call callback
|
|
347
|
-
try {
|
|
348
|
-
options.onSpeechEnd();
|
|
349
|
-
} catch (e) {
|
|
350
|
-
console.error('Error in onSpeechEnd callback:', e);
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
}, 50);
|
|
355
|
-
|
|
356
|
-
const checkSpeechFinished = () => {
|
|
297
|
+
// Start checking immediately - don't wait for audio to start
|
|
298
|
+
checkInterval = setInterval(() => {
|
|
357
299
|
checkCount++;
|
|
300
|
+
|
|
301
|
+
// Safety timeout - call callback anyway after max time
|
|
358
302
|
if (checkCount > maxChecks) {
|
|
359
|
-
// Timeout - call callback anyway
|
|
360
303
|
if (checkInterval) {
|
|
361
304
|
clearInterval(checkInterval);
|
|
362
305
|
checkInterval = null;
|
|
363
306
|
}
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
307
|
+
if (!callbackFired) {
|
|
308
|
+
callbackFired = true;
|
|
309
|
+
try {
|
|
310
|
+
options.onSpeechEnd();
|
|
311
|
+
} catch (e) {
|
|
312
|
+
console.error('Error in onSpeechEnd callback (timeout):', e);
|
|
313
|
+
}
|
|
368
314
|
}
|
|
369
315
|
return;
|
|
370
316
|
}
|
|
371
317
|
|
|
372
|
-
// Only check if audio has started playing
|
|
373
|
-
if (!audioStarted) {
|
|
374
|
-
return;
|
|
375
|
-
}
|
|
376
|
-
|
|
377
318
|
// Check if speech has finished:
|
|
378
|
-
// 1.
|
|
379
|
-
// 2.
|
|
380
|
-
// 3.
|
|
381
|
-
// 4.
|
|
319
|
+
// 1. isSpeaking is false (not currently processing speech)
|
|
320
|
+
// 2. speechQueue is empty (all sentences processed)
|
|
321
|
+
// 3. audioPlaylist is empty (no more audio to play)
|
|
322
|
+
// 4. isAudioPlaying is false (not currently playing audio)
|
|
382
323
|
const speechQueueEmpty = !talkingHead.speechQueue || talkingHead.speechQueue.length === 0;
|
|
324
|
+
const audioPlaylistEmpty = !talkingHead.audioPlaylist || talkingHead.audioPlaylist.length === 0;
|
|
383
325
|
|
|
384
326
|
const isFinished = talkingHead &&
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
327
|
+
talkingHead.isSpeaking === false &&
|
|
328
|
+
speechQueueEmpty &&
|
|
329
|
+
audioPlaylistEmpty &&
|
|
330
|
+
talkingHead.isAudioPlaying === false;
|
|
389
331
|
|
|
390
|
-
if (isFinished) {
|
|
391
|
-
|
|
392
|
-
clearInterval(checkInterval);
|
|
393
|
-
checkInterval = null;
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
// Small delay to ensure everything is settled
|
|
332
|
+
if (isFinished && !callbackFired) {
|
|
333
|
+
// Double-check after a small delay to ensure it's really finished
|
|
397
334
|
setTimeout(() => {
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
335
|
+
// Re-check one more time to be sure
|
|
336
|
+
const finalCheck = talkingHead &&
|
|
337
|
+
talkingHead.isSpeaking === false &&
|
|
338
|
+
(!talkingHead.speechQueue || talkingHead.speechQueue.length === 0) &&
|
|
339
|
+
(!talkingHead.audioPlaylist || talkingHead.audioPlaylist.length === 0) &&
|
|
340
|
+
talkingHead.isAudioPlaying === false;
|
|
341
|
+
|
|
342
|
+
if (finalCheck && !callbackFired) {
|
|
343
|
+
callbackFired = true;
|
|
344
|
+
if (checkInterval) {
|
|
345
|
+
clearInterval(checkInterval);
|
|
346
|
+
checkInterval = null;
|
|
347
|
+
}
|
|
348
|
+
try {
|
|
349
|
+
options.onSpeechEnd();
|
|
350
|
+
} catch (e) {
|
|
351
|
+
console.error('Error in onSpeechEnd callback:', e);
|
|
352
|
+
}
|
|
402
353
|
}
|
|
403
|
-
},
|
|
354
|
+
}, 100);
|
|
404
355
|
}
|
|
405
|
-
};
|
|
356
|
+
}, 100); // Check every 100ms for better reliability
|
|
406
357
|
}
|
|
407
358
|
|
|
408
359
|
if (talkingHeadRef.current.lipsync && Object.keys(talkingHeadRef.current.lipsync).length > 0) {
|
package/src/lib/talkinghead.mjs
CHANGED
|
@@ -180,9 +180,9 @@ class TalkingHead {
|
|
|
180
180
|
lightSpotDispersion: 1,
|
|
181
181
|
avatarMood: "neutral",
|
|
182
182
|
avatarMute: false,
|
|
183
|
-
avatarIdleEyeContact: 0.
|
|
183
|
+
avatarIdleEyeContact: 0.6,
|
|
184
184
|
avatarIdleHeadMove: 0.5,
|
|
185
|
-
avatarSpeakingEyeContact: 0.
|
|
185
|
+
avatarSpeakingEyeContact: 0.8,
|
|
186
186
|
avatarSpeakingHeadMove: 0.5,
|
|
187
187
|
avatarIgnoreCamera: false,
|
|
188
188
|
listeningSilenceThresholdLevel: 40,
|
|
@@ -426,7 +426,7 @@ class TalkingHead {
|
|
|
426
426
|
|
|
427
427
|
this.animMoods = {
|
|
428
428
|
'neutral' : {
|
|
429
|
-
baseline: { eyesLookDown: 0
|
|
429
|
+
baseline: { eyesLookDown: 0 },
|
|
430
430
|
speech: { deltaRate: 0, deltaPitch: 0, deltaVolume: 0 },
|
|
431
431
|
anims: [
|
|
432
432
|
{ name: 'breathing', delay: 1500, dt: [ 1200,500,1000 ], vs: { chestInhale: [0.5,0.5,0] } },
|
|
@@ -448,7 +448,7 @@ class TalkingHead {
|
|
|
448
448
|
]
|
|
449
449
|
},
|
|
450
450
|
'happy' : {
|
|
451
|
-
baseline: { mouthSmile: 0.2, eyesLookDown: 0
|
|
451
|
+
baseline: { mouthSmile: 0.2, eyesLookDown: 0 },
|
|
452
452
|
speech: { deltaRate: 0, deltaPitch: 0.1, deltaVolume: 0 },
|
|
453
453
|
anims: [
|
|
454
454
|
{ name: 'breathing', delay: 1500, dt: [ 1200,500,1000 ], vs: { chestInhale: [0.5,0.5,0] } },
|