@convai/web-sdk 1.8.0-beta.1 → 1.8.0-beta.2
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/vanilla/ConvaiWidget.d.ts.map +1 -1
- package/dist/vanilla/ConvaiWidget.js +259 -115
- package/dist/vanilla/ConvaiWidget.js.map +1 -1
- package/dist/vanilla/styles.d.ts.map +1 -1
- package/dist/vanilla/styles.js +24 -0
- package/dist/vanilla/styles.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ConvaiWidget.d.ts","sourceRoot":"","sources":["../../src/vanilla/ConvaiWidget.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAWH,OAAO,KAAK,EACV,oBAAoB,EACpB,aAAa,EAEd,MAAM,SAAS,CAAC;AA2HjB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,WAAW,EACtB,OAAO,EAAE,oBAAoB,GAC5B,aAAa,
|
|
1
|
+
{"version":3,"file":"ConvaiWidget.d.ts","sourceRoot":"","sources":["../../src/vanilla/ConvaiWidget.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAWH,OAAO,KAAK,EACV,oBAAoB,EACpB,aAAa,EAEd,MAAM,SAAS,CAAC;AA2HjB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,WAAW,EACtB,OAAO,EAAE,oBAAoB,GAC5B,aAAa,CAgmFf;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,aAAa,GAAG,IAAI,CAE/D"}
|
|
@@ -417,6 +417,28 @@ export function createConvaiWidget(container, options) {
|
|
|
417
417
|
morphingContainer.addEventListener("click", handleLauncherClick);
|
|
418
418
|
};
|
|
419
419
|
// Create Voice Mode Overlay
|
|
420
|
+
/**
|
|
421
|
+
* Voice mode, matched to the React overlay (VoiceModeOverlay.tsx) rather
|
|
422
|
+
* than to what this file used to draw.
|
|
423
|
+
*
|
|
424
|
+
* The two had diverged badly: React centres the character's avatar inside
|
|
425
|
+
* two counter-rotating liquid-gradient layers and paints the user's
|
|
426
|
+
* microphone as three overlapping sine waves on a canvas, while this file
|
|
427
|
+
* drew forty grey vertical bars and no avatar at all. Same product, two
|
|
428
|
+
* unrelated screens.
|
|
429
|
+
*
|
|
430
|
+
* What is reproduced here, value for value: the 120px avatar with its 3px
|
|
431
|
+
* rgba(255,255,255,0.2) ring and letter fallback, the 126px conic and
|
|
432
|
+
* radial gradient layers (20s clockwise / 15s anticlockwise, blurred 8px
|
|
433
|
+
* and 6px), the 250x80 canvas carrying three waves, and the status text at
|
|
434
|
+
* 16px/600 over 13px with React's wording.
|
|
435
|
+
*
|
|
436
|
+
* The one deliberate omission is the `AudioVisualizer` ring that React
|
|
437
|
+
* fades in behind the avatar while the character speaks: it is a 364-line
|
|
438
|
+
* component wired directly to a LiveKit `Room`, and porting it is its own
|
|
439
|
+
* change. The avatar still breathes while the character speaks, so the
|
|
440
|
+
* speaking state reads without it.
|
|
441
|
+
*/
|
|
420
442
|
const createVoiceModeOverlay = () => {
|
|
421
443
|
const overlay = document.createElement("div");
|
|
422
444
|
overlay.style.cssText = `
|
|
@@ -431,59 +453,159 @@ export function createConvaiWidget(container, options) {
|
|
|
431
453
|
display: none;
|
|
432
454
|
flex-direction: column;
|
|
433
455
|
align-items: center;
|
|
434
|
-
gap:
|
|
456
|
+
gap: 32px;
|
|
435
457
|
`;
|
|
436
|
-
//
|
|
437
|
-
const
|
|
438
|
-
|
|
439
|
-
|
|
458
|
+
// --- Avatar, with the two rotating gradient layers behind it ----------
|
|
459
|
+
const avatarWrap = document.createElement("div");
|
|
460
|
+
avatarWrap.style.cssText = `
|
|
461
|
+
position: relative;
|
|
440
462
|
display: flex;
|
|
441
463
|
align-items: center;
|
|
442
464
|
justify-content: center;
|
|
443
|
-
|
|
444
|
-
height: 80px;
|
|
445
|
-
max-width: 300px;
|
|
465
|
+
isolation: isolate;
|
|
446
466
|
`;
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
|
|
467
|
+
const avatarStack = document.createElement("div");
|
|
468
|
+
avatarStack.className = "convai-voice-avatar";
|
|
469
|
+
refs.voiceModeAvatar = avatarStack;
|
|
470
|
+
avatarStack.style.cssText = `
|
|
471
|
+
position: relative;
|
|
472
|
+
z-index: 10;
|
|
473
|
+
border-radius: 50%;
|
|
474
|
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
|
475
|
+
background: white;
|
|
476
|
+
display: flex;
|
|
477
|
+
align-items: center;
|
|
478
|
+
justify-content: center;
|
|
479
|
+
`;
|
|
480
|
+
const accent = `var(--convai-accent, ${aeroTheme.colors.convai.light})`;
|
|
481
|
+
const layer1 = document.createElement("div");
|
|
482
|
+
layer1.className = "convai-voice-gradient";
|
|
483
|
+
layer1.style.cssText = `
|
|
484
|
+
position: absolute;
|
|
485
|
+
width: 126px;
|
|
486
|
+
height: 126px;
|
|
487
|
+
border-radius: 50%;
|
|
488
|
+
background: conic-gradient(
|
|
489
|
+
from 0deg,
|
|
490
|
+
${aeroTheme.colors.convai.light}00 0%,
|
|
491
|
+
${aeroTheme.colors.convai.light}40 25%,
|
|
492
|
+
${aeroTheme.colors.convai.dark}60 50%,
|
|
493
|
+
${aeroTheme.colors.convai.light}40 75%,
|
|
494
|
+
${aeroTheme.colors.convai.light}00 100%
|
|
495
|
+
);
|
|
496
|
+
opacity: 0.8;
|
|
497
|
+
filter: blur(8px);
|
|
498
|
+
z-index: -2;
|
|
499
|
+
animation: convaiVoiceSpin 20s linear infinite;
|
|
500
|
+
`;
|
|
501
|
+
const layer2 = document.createElement("div");
|
|
502
|
+
layer2.className = "convai-voice-gradient";
|
|
503
|
+
layer2.style.cssText = `
|
|
504
|
+
position: absolute;
|
|
505
|
+
width: 126px;
|
|
506
|
+
height: 126px;
|
|
507
|
+
border-radius: 50%;
|
|
508
|
+
background:
|
|
509
|
+
radial-gradient(ellipse 80% 100% at 30% 40%,
|
|
510
|
+
${aeroTheme.colors.convai.light}50,
|
|
511
|
+
${aeroTheme.colors.convai.light}30 40%,
|
|
512
|
+
transparent 70%),
|
|
513
|
+
radial-gradient(ellipse 80% 100% at 70% 60%,
|
|
514
|
+
${aeroTheme.colors.convai.dark}50,
|
|
515
|
+
${aeroTheme.colors.convai.dark}30 40%,
|
|
516
|
+
transparent 70%);
|
|
517
|
+
opacity: 0.9;
|
|
518
|
+
filter: blur(6px);
|
|
519
|
+
z-index: -1;
|
|
520
|
+
animation: convaiVoiceSpinReverse 15s linear infinite;
|
|
521
|
+
`;
|
|
522
|
+
// The image is clipped by its own container so the gradients above stay
|
|
523
|
+
// visible outside it — React nests it the same way for the same reason.
|
|
524
|
+
const avatarClip = document.createElement("div");
|
|
525
|
+
avatarClip.style.cssText = `
|
|
526
|
+
position: relative;
|
|
527
|
+
border-radius: 50%;
|
|
528
|
+
overflow: hidden;
|
|
529
|
+
border: 3px solid rgba(255, 255, 255, 0.2);
|
|
530
|
+
width: 120px;
|
|
531
|
+
height: 120px;
|
|
532
|
+
display: flex;
|
|
533
|
+
align-items: center;
|
|
534
|
+
justify-content: center;
|
|
535
|
+
`;
|
|
536
|
+
refs.voiceModeAvatarClip = avatarClip;
|
|
537
|
+
avatarStack.append(layer1, layer2, avatarClip);
|
|
538
|
+
avatarWrap.appendChild(avatarStack);
|
|
539
|
+
overlay.appendChild(avatarWrap);
|
|
540
|
+
// --- The user's microphone, as three waves on a canvas ----------------
|
|
541
|
+
const canvas = document.createElement("canvas");
|
|
542
|
+
canvas.className = "convai-voice-wave";
|
|
543
|
+
canvas.style.cssText = `width: 250px; height: 80px;`;
|
|
544
|
+
refs.voiceModeCanvas = canvas;
|
|
545
|
+
overlay.appendChild(canvas);
|
|
546
|
+
// --- Status text ------------------------------------------------------
|
|
463
547
|
const statusContainer = document.createElement("div");
|
|
464
548
|
const statusTitle = document.createElement("div");
|
|
465
549
|
statusTitle.id = "voice-mode-title";
|
|
466
550
|
refs.voiceModeTitle = statusTitle;
|
|
467
551
|
statusTitle.style.cssText = `
|
|
468
|
-
font-size:
|
|
469
|
-
font-weight:
|
|
552
|
+
font-size: 16px;
|
|
553
|
+
font-weight: 600;
|
|
470
554
|
color: var(--convai-panel-fg, ${aeroTheme.colors.text.primary});
|
|
471
555
|
margin-bottom: 8px;
|
|
472
556
|
`;
|
|
473
|
-
statusTitle.textContent = "Voice
|
|
557
|
+
statusTitle.textContent = "Voice Mode";
|
|
474
558
|
const statusSubtitle = document.createElement("div");
|
|
475
559
|
statusSubtitle.id = "voice-mode-subtitle";
|
|
476
560
|
refs.voiceModeSubtitle = statusSubtitle;
|
|
477
561
|
statusSubtitle.style.cssText = `
|
|
478
|
-
font-size:
|
|
562
|
+
font-size: 13px;
|
|
479
563
|
color: ${aeroTheme.colors.text.secondary};
|
|
480
564
|
`;
|
|
481
|
-
statusSubtitle.textContent = "
|
|
482
|
-
statusContainer.
|
|
483
|
-
statusContainer.appendChild(statusSubtitle);
|
|
565
|
+
statusSubtitle.textContent = "Tap microphone to talk";
|
|
566
|
+
statusContainer.append(statusTitle, statusSubtitle);
|
|
484
567
|
overlay.appendChild(statusContainer);
|
|
568
|
+
void accent;
|
|
485
569
|
return overlay;
|
|
486
570
|
};
|
|
571
|
+
/**
|
|
572
|
+
* Fills the voice-mode avatar from the character info fetch, or falls back
|
|
573
|
+
* to the character's initial the way React does. Called from
|
|
574
|
+
* updateHeader(), so it tracks a late `fetchCharacterInfo()` rather than
|
|
575
|
+
* being fixed at construction.
|
|
576
|
+
*/
|
|
577
|
+
const updateVoiceModeAvatar = () => {
|
|
578
|
+
const clip = refs.voiceModeAvatarClip;
|
|
579
|
+
if (!clip)
|
|
580
|
+
return;
|
|
581
|
+
const wanted = characterImage || `letter:${characterName}`;
|
|
582
|
+
if (clip.dataset.filledWith === wanted)
|
|
583
|
+
return;
|
|
584
|
+
clip.dataset.filledWith = wanted;
|
|
585
|
+
clip.replaceChildren();
|
|
586
|
+
if (characterImage) {
|
|
587
|
+
const img = document.createElement("img");
|
|
588
|
+
img.src = characterImage;
|
|
589
|
+
img.alt = characterName;
|
|
590
|
+
img.style.cssText = `width: 120px; height: 120px; object-fit: cover; display: block;`;
|
|
591
|
+
clip.appendChild(img);
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
const letter = document.createElement("div");
|
|
595
|
+
letter.style.cssText = `
|
|
596
|
+
width: 120px;
|
|
597
|
+
height: 120px;
|
|
598
|
+
background-color: ${aeroTheme.colors.convai.light}20;
|
|
599
|
+
display: flex;
|
|
600
|
+
align-items: center;
|
|
601
|
+
justify-content: center;
|
|
602
|
+
font-size: 48px;
|
|
603
|
+
font-weight: 600;
|
|
604
|
+
color: var(--convai-accent, ${aeroTheme.colors.convai.light});
|
|
605
|
+
`;
|
|
606
|
+
letter.textContent = characterName[0]?.toUpperCase() || "C";
|
|
607
|
+
clip.appendChild(letter);
|
|
608
|
+
};
|
|
487
609
|
// Create Connecting Overlay -- matches React's inline AnimatePresence
|
|
488
610
|
// block exactly (ConvaiWidget.tsx:681-737): shown whenever
|
|
489
611
|
// `isConnected && !isBotReady`, covering just the content area (not the
|
|
@@ -571,112 +693,137 @@ export function createConvaiWidget(container, options) {
|
|
|
571
693
|
};
|
|
572
694
|
// Audio Analysis State for Voice Mode
|
|
573
695
|
let audioLevels = Array(40).fill(0);
|
|
574
|
-
let targetLevels = Array(40).fill(0.05);
|
|
575
|
-
let currentLevels = Array(40).fill(0.05);
|
|
576
696
|
let startTime = 0;
|
|
577
|
-
|
|
697
|
+
/**
|
|
698
|
+
* Paints the three overlapping sine waves React draws for the user's
|
|
699
|
+
* microphone (VoiceModeOverlay.tsx's `drawWaves`), including its two
|
|
700
|
+
* performance guards, which matter more here than there: this canvas sits
|
|
701
|
+
* over whatever the host page is rendering, and every repaint forces the
|
|
702
|
+
* compositor to re-blend the whole panel. So it runs at 30fps rather than
|
|
703
|
+
* 60, and stops entirely while the input is silent — the static line is
|
|
704
|
+
* already on the canvas at that point.
|
|
705
|
+
*/
|
|
706
|
+
let waveFrameParity = 0;
|
|
707
|
+
let waveWasSilent = false;
|
|
708
|
+
const drawWaves = () => {
|
|
709
|
+
const canvas = refs.voiceModeCanvas;
|
|
710
|
+
if (!canvas)
|
|
711
|
+
return;
|
|
712
|
+
waveFrameParity ^= 1;
|
|
713
|
+
if (waveFrameParity)
|
|
714
|
+
return;
|
|
715
|
+
let sum = 0;
|
|
716
|
+
for (let i = 0; i < audioLevels.length; i++)
|
|
717
|
+
sum += audioLevels[i];
|
|
718
|
+
const silent = sum / audioLevels.length < 0.012;
|
|
719
|
+
if (silent && waveWasSilent)
|
|
720
|
+
return;
|
|
721
|
+
waveWasSilent = silent;
|
|
722
|
+
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
723
|
+
if (canvas.width !== 250 * dpr) {
|
|
724
|
+
canvas.width = 250 * dpr;
|
|
725
|
+
canvas.height = 80 * dpr;
|
|
726
|
+
}
|
|
727
|
+
const ctx = canvas.getContext("2d");
|
|
728
|
+
if (!ctx)
|
|
729
|
+
return;
|
|
730
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
731
|
+
ctx.clearRect(0, 0, 250, 80);
|
|
732
|
+
const centerY = 40;
|
|
733
|
+
const waveWidth = 250;
|
|
734
|
+
const segments = 40;
|
|
735
|
+
const avgLevel = sum / audioLevels.length;
|
|
736
|
+
const waveColors = [
|
|
737
|
+
aeroTheme.colors.convai.light,
|
|
738
|
+
aeroTheme.colors.convai.light,
|
|
739
|
+
"#34d399",
|
|
740
|
+
];
|
|
741
|
+
ctx.lineCap = "round";
|
|
742
|
+
ctx.lineJoin = "round";
|
|
743
|
+
for (let waveIndex = 2; waveIndex >= 0; waveIndex--) {
|
|
744
|
+
ctx.beginPath();
|
|
745
|
+
for (let i = 0; i <= segments; i++) {
|
|
746
|
+
const x = (i / segments) * waveWidth;
|
|
747
|
+
const levelIndex = Math.floor((i / segments) * (audioLevels.length - 1));
|
|
748
|
+
const level = audioLevels[levelIndex] || 0;
|
|
749
|
+
const phase = (Date.now() / 500 + i * 0.2 + waveIndex * 1.5) % (Math.PI * 2);
|
|
750
|
+
const baseAmplitude = avgLevel > 0.05 ? 2 + waveIndex * 1.5 : 0;
|
|
751
|
+
const amplitude = baseAmplitude + level * (25 - baseAmplitude);
|
|
752
|
+
const y = centerY + Math.sin(phase) * amplitude;
|
|
753
|
+
if (i === 0)
|
|
754
|
+
ctx.moveTo(x, y);
|
|
755
|
+
else
|
|
756
|
+
ctx.lineTo(x, y);
|
|
757
|
+
}
|
|
758
|
+
ctx.strokeStyle = waveColors[waveIndex];
|
|
759
|
+
ctx.globalAlpha = 0.18 * (0.3 + avgLevel);
|
|
760
|
+
ctx.lineWidth = 2.5 - waveIndex * 0.4 + 8 * avgLevel + 3;
|
|
761
|
+
ctx.stroke();
|
|
762
|
+
ctx.globalAlpha = 0.95 - waveIndex * 0.15;
|
|
763
|
+
ctx.lineWidth = 2.5 - waveIndex * 0.4;
|
|
764
|
+
ctx.stroke();
|
|
765
|
+
}
|
|
766
|
+
ctx.globalAlpha = 1;
|
|
767
|
+
};
|
|
768
|
+
/**
|
|
769
|
+
* The voice-mode frame loop. Keeps the name `updateAudioBars` because
|
|
770
|
+
* every call site refers to it; what it drives is now the avatar, the
|
|
771
|
+
* canvas and the status text rather than forty bars.
|
|
772
|
+
*/
|
|
578
773
|
const updateAudioBars = () => {
|
|
579
774
|
if (!voiceModeOverlay)
|
|
580
775
|
return;
|
|
581
|
-
const bars = voiceModeOverlay.querySelectorAll(".voice-bar");
|
|
582
776
|
const isTalking = client.state.isSpeaking;
|
|
583
|
-
const isListening = client.state.isListening;
|
|
584
|
-
const
|
|
585
|
-
//
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
}
|
|
593
|
-
//
|
|
777
|
+
const isListening = client.state.isListening;
|
|
778
|
+
const micOpen = !client.audioControls.isAudioMuted;
|
|
779
|
+
// The avatar breathes while the character speaks — React drives the same
|
|
780
|
+
// thing with a framer-motion scale keyframe.
|
|
781
|
+
const avatar = refs.voiceModeAvatar;
|
|
782
|
+
if (avatar) {
|
|
783
|
+
const wanted = isTalking ? "convaiVoiceBreathe 3s ease-in-out infinite" : "";
|
|
784
|
+
if (avatar.style.animation !== wanted)
|
|
785
|
+
avatar.style.animation = wanted;
|
|
786
|
+
}
|
|
787
|
+
// React's exact wording, including "Voice Mode" rather than the
|
|
788
|
+
// "Voice Only Mode" this file used to show.
|
|
594
789
|
const title = refs.voiceModeTitle;
|
|
595
790
|
const subtitle = refs.voiceModeSubtitle;
|
|
596
791
|
if (title) {
|
|
597
792
|
title.textContent = isTalking
|
|
598
|
-
?
|
|
599
|
-
: isListening
|
|
793
|
+
? `${characterName} Speaking...`
|
|
794
|
+
: isListening || micOpen
|
|
600
795
|
? "Listening..."
|
|
601
|
-
: "Voice
|
|
796
|
+
: "Voice Mode";
|
|
602
797
|
}
|
|
603
798
|
if (subtitle) {
|
|
604
799
|
subtitle.textContent =
|
|
605
|
-
isListening || isTalking
|
|
606
|
-
? "Audio active"
|
|
607
|
-
: "Press and hold the microphone to talk";
|
|
800
|
+
isListening || micOpen || isTalking ? "Audio active" : "Tap microphone to talk";
|
|
608
801
|
}
|
|
609
|
-
|
|
610
|
-
if ((isListening || !client.audioControls.isAudioMuted) && analyzer && dataArray) {
|
|
611
|
-
// Use time domain data (waveform) instead of frequency
|
|
802
|
+
if ((isListening || micOpen) && analyzer && dataArray) {
|
|
612
803
|
// @ts-ignore - TypeScript strict mode issue with Uint8Array type
|
|
613
804
|
analyzer.getByteTimeDomainData(dataArray);
|
|
614
|
-
// Calculate RMS (Root Mean Square) for volume
|
|
615
805
|
let sum = 0;
|
|
616
806
|
for (let i = 0; i < dataArray.length; i++) {
|
|
617
|
-
const normalized = (dataArray[i] - 128) / 128;
|
|
807
|
+
const normalized = (dataArray[i] - 128) / 128;
|
|
618
808
|
sum += normalized * normalized;
|
|
619
809
|
}
|
|
620
810
|
const rms = Math.sqrt(sum / dataArray.length);
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
const minHeight = 8;
|
|
625
|
-
const maxHeight = 70;
|
|
626
|
-
bars.forEach((bar, i) => {
|
|
627
|
-
// Progressive wave from left to right
|
|
628
|
-
const position = i / 40; // 0 to 1 from left to right
|
|
811
|
+
const volume = Math.min(1, rms * 3);
|
|
812
|
+
for (let i = 0; i < audioLevels.length; i++) {
|
|
813
|
+
const position = i / audioLevels.length;
|
|
629
814
|
const wavePhase = Date.now() / 300 + position * Math.PI * 2;
|
|
630
|
-
const waveVariation = Math.sin(wavePhase) * 0.15 + 0.85;
|
|
631
|
-
|
|
632
|
-
const height = minHeight + level * (maxHeight - minHeight);
|
|
633
|
-
bar.style.height = `${Math.max(minHeight, height)}px`;
|
|
634
|
-
});
|
|
635
|
-
}
|
|
636
|
-
else if (isTalking) {
|
|
637
|
-
// Simulate speaking bars with natural speech patterns
|
|
638
|
-
const elapsed = (Date.now() - startTime) / 1000; // seconds
|
|
639
|
-
// Generate new random target levels occasionally (simulating syllables/words)
|
|
640
|
-
if (Math.random() < 0.08) {
|
|
641
|
-
// 8% chance per frame = ~5 times per second
|
|
642
|
-
targetLevels = Array(40)
|
|
643
|
-
.fill(0)
|
|
644
|
-
.map((_, i) => {
|
|
645
|
-
// More variation in the middle bars, less on edges for natural spread
|
|
646
|
-
const position = i / 40;
|
|
647
|
-
const centerWeight = 1 - Math.abs(position - 0.5) * 0.5;
|
|
648
|
-
// Random peaks and valleys like speech patterns
|
|
649
|
-
const randomPeak = 0.2 + Math.random() * 0.7; // 0.2 to 0.9
|
|
650
|
-
// Add some neighbor correlation so bars don't jump independently
|
|
651
|
-
const prevTarget = targetLevels[i] || 0.3;
|
|
652
|
-
const correlation = prevTarget * 0.4 + randomPeak * 0.6;
|
|
653
|
-
return correlation * centerWeight;
|
|
654
|
-
});
|
|
815
|
+
const waveVariation = Math.sin(wavePhase) * 0.15 + 0.85;
|
|
816
|
+
audioLevels[i] = volume * waveVariation;
|
|
655
817
|
}
|
|
656
|
-
// Smoothly interpolate current levels toward targets (organic movement)
|
|
657
|
-
currentLevels = currentLevels.map((current, i) => {
|
|
658
|
-
const target = targetLevels[i];
|
|
659
|
-
const speed = 0.2; // Smooth but responsive
|
|
660
|
-
return current + (target - current) * speed;
|
|
661
|
-
});
|
|
662
|
-
// Apply a gentle fade-in for the first 0.3 seconds
|
|
663
|
-
const fadeIn = Math.min(1, elapsed / 0.3);
|
|
664
|
-
const minHeight = 8;
|
|
665
|
-
const maxHeight = 70;
|
|
666
|
-
bars.forEach((bar, i) => {
|
|
667
|
-
// Small random jitter for micro-variation
|
|
668
|
-
const microJitter = 0.95 + Math.random() * 0.1; // 0.95 to 1.05
|
|
669
|
-
const level = Math.max(0.05, Math.min(1, currentLevels[i] * fadeIn * microJitter));
|
|
670
|
-
const height = minHeight + level * (maxHeight - minHeight);
|
|
671
|
-
bar.style.height = `${Math.max(minHeight, height)}px`;
|
|
672
|
-
});
|
|
673
818
|
}
|
|
674
819
|
else {
|
|
675
|
-
//
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
820
|
+
// The waves settle to a flat line rather than animating on the
|
|
821
|
+
// character's behalf: React paints this canvas from the microphone
|
|
822
|
+
// only, and the character's own speech is the avatar's job.
|
|
823
|
+
for (let i = 0; i < audioLevels.length; i++)
|
|
824
|
+
audioLevels[i] = 0;
|
|
679
825
|
}
|
|
826
|
+
drawWaves();
|
|
680
827
|
rafId = requestAnimationFrame(updateAudioBars);
|
|
681
828
|
};
|
|
682
829
|
const startAudioAnalysis = async () => {
|
|
@@ -921,6 +1068,7 @@ export function createConvaiWidget(container, options) {
|
|
|
921
1068
|
// needs the connecting overlay re-evaluated -- it depends on the same
|
|
922
1069
|
// `isConnected`/`isBotReady`/`characterName` inputs.
|
|
923
1070
|
updateConnectingOverlay();
|
|
1071
|
+
updateVoiceModeAvatar();
|
|
924
1072
|
};
|
|
925
1073
|
// Create message list
|
|
926
1074
|
const createMessageList = () => {
|
|
@@ -1770,8 +1918,6 @@ export function createConvaiWidget(container, options) {
|
|
|
1770
1918
|
updateHeader();
|
|
1771
1919
|
// Reset animation state for voice mode
|
|
1772
1920
|
startTime = Date.now();
|
|
1773
|
-
currentLevels = Array(40).fill(0.05);
|
|
1774
|
-
targetLevels = Array(40).fill(0.05);
|
|
1775
1921
|
// Start Audio Analysis
|
|
1776
1922
|
startAudioAnalysis();
|
|
1777
1923
|
}
|
|
@@ -2137,8 +2283,6 @@ export function createConvaiWidget(container, options) {
|
|
|
2137
2283
|
// Reset when not speaking
|
|
2138
2284
|
if (startTime && !client.state.isSpeaking) {
|
|
2139
2285
|
startTime = 0;
|
|
2140
|
-
currentLevels = Array(40).fill(0.05);
|
|
2141
|
-
targetLevels = Array(40).fill(0.05);
|
|
2142
2286
|
}
|
|
2143
2287
|
}
|
|
2144
2288
|
// Auto-collapse when disconnected
|