@convai/web-sdk 1.8.0-beta.0 → 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 +288 -120
- 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"}
|
|
@@ -239,16 +239,28 @@ export function createConvaiWidget(container, options) {
|
|
|
239
239
|
// Prefer authToken over apiKey, matching the precedence ConvaiClient
|
|
240
240
|
// uses when building /connect headers -- a client carrying both must
|
|
241
241
|
// behave identically in both places.
|
|
242
|
+
//
|
|
243
|
+
// The two credentials travel in *different* headers: api.convai.com reads
|
|
244
|
+
// an auth token from API-AUTH-TOKEN and an API key from X-API-Key, and
|
|
245
|
+
// answers an API key sent as API-AUTH-TOKEN with `400 Your session has
|
|
246
|
+
// expired.` Because the failure is swallowed below, sending the wrong one
|
|
247
|
+
// showed up only as a header stuck on the fallback name.
|
|
242
248
|
const credential = client.authToken ?? client.apiKey;
|
|
243
249
|
if (!credential || !client.characterId)
|
|
244
250
|
return;
|
|
251
|
+
const headers = {
|
|
252
|
+
"Content-Type": "application/json",
|
|
253
|
+
};
|
|
254
|
+
if (client.authToken) {
|
|
255
|
+
headers["API-AUTH-TOKEN"] = client.authToken;
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
headers["X-API-Key"] = credential;
|
|
259
|
+
}
|
|
245
260
|
try {
|
|
246
261
|
const response = await fetch("https://api.convai.com/character/get", {
|
|
247
262
|
method: "POST",
|
|
248
|
-
headers
|
|
249
|
-
"Content-Type": "application/json",
|
|
250
|
-
"API-AUTH-TOKEN": credential,
|
|
251
|
-
},
|
|
263
|
+
headers,
|
|
252
264
|
body: JSON.stringify({ charID: client.characterId }),
|
|
253
265
|
});
|
|
254
266
|
if (response.ok) {
|
|
@@ -405,6 +417,28 @@ export function createConvaiWidget(container, options) {
|
|
|
405
417
|
morphingContainer.addEventListener("click", handleLauncherClick);
|
|
406
418
|
};
|
|
407
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
|
+
*/
|
|
408
442
|
const createVoiceModeOverlay = () => {
|
|
409
443
|
const overlay = document.createElement("div");
|
|
410
444
|
overlay.style.cssText = `
|
|
@@ -419,59 +453,159 @@ export function createConvaiWidget(container, options) {
|
|
|
419
453
|
display: none;
|
|
420
454
|
flex-direction: column;
|
|
421
455
|
align-items: center;
|
|
422
|
-
gap:
|
|
456
|
+
gap: 32px;
|
|
423
457
|
`;
|
|
424
|
-
//
|
|
425
|
-
const
|
|
426
|
-
|
|
427
|
-
|
|
458
|
+
// --- Avatar, with the two rotating gradient layers behind it ----------
|
|
459
|
+
const avatarWrap = document.createElement("div");
|
|
460
|
+
avatarWrap.style.cssText = `
|
|
461
|
+
position: relative;
|
|
428
462
|
display: flex;
|
|
429
463
|
align-items: center;
|
|
430
464
|
justify-content: center;
|
|
431
|
-
|
|
432
|
-
height: 80px;
|
|
433
|
-
max-width: 300px;
|
|
465
|
+
isolation: isolate;
|
|
434
466
|
`;
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
|
|
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 ------------------------------------------------------
|
|
451
547
|
const statusContainer = document.createElement("div");
|
|
452
548
|
const statusTitle = document.createElement("div");
|
|
453
549
|
statusTitle.id = "voice-mode-title";
|
|
454
550
|
refs.voiceModeTitle = statusTitle;
|
|
455
551
|
statusTitle.style.cssText = `
|
|
456
|
-
font-size:
|
|
457
|
-
font-weight:
|
|
552
|
+
font-size: 16px;
|
|
553
|
+
font-weight: 600;
|
|
458
554
|
color: var(--convai-panel-fg, ${aeroTheme.colors.text.primary});
|
|
459
555
|
margin-bottom: 8px;
|
|
460
556
|
`;
|
|
461
|
-
statusTitle.textContent = "Voice
|
|
557
|
+
statusTitle.textContent = "Voice Mode";
|
|
462
558
|
const statusSubtitle = document.createElement("div");
|
|
463
559
|
statusSubtitle.id = "voice-mode-subtitle";
|
|
464
560
|
refs.voiceModeSubtitle = statusSubtitle;
|
|
465
561
|
statusSubtitle.style.cssText = `
|
|
466
|
-
font-size:
|
|
562
|
+
font-size: 13px;
|
|
467
563
|
color: ${aeroTheme.colors.text.secondary};
|
|
468
564
|
`;
|
|
469
|
-
statusSubtitle.textContent = "
|
|
470
|
-
statusContainer.
|
|
471
|
-
statusContainer.appendChild(statusSubtitle);
|
|
565
|
+
statusSubtitle.textContent = "Tap microphone to talk";
|
|
566
|
+
statusContainer.append(statusTitle, statusSubtitle);
|
|
472
567
|
overlay.appendChild(statusContainer);
|
|
568
|
+
void accent;
|
|
473
569
|
return overlay;
|
|
474
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
|
+
};
|
|
475
609
|
// Create Connecting Overlay -- matches React's inline AnimatePresence
|
|
476
610
|
// block exactly (ConvaiWidget.tsx:681-737): shown whenever
|
|
477
611
|
// `isConnected && !isBotReady`, covering just the content area (not the
|
|
@@ -541,118 +675,155 @@ export function createConvaiWidget(container, options) {
|
|
|
541
675
|
if (label) {
|
|
542
676
|
label.textContent = `Connecting to ${characterName}...`;
|
|
543
677
|
}
|
|
544
|
-
|
|
678
|
+
// `isConnecting` matters as much as `isConnected` here. The panel opens
|
|
679
|
+
// on the click that *starts* the connect, so between that click and the
|
|
680
|
+
// transport coming up (a real network round trip — ~3s against the live
|
|
681
|
+
// service) `isConnected` is still false. Gating on it alone left the
|
|
682
|
+
// whole of that window with no overlay at all: an empty message area and
|
|
683
|
+
// a nameless header, i.e. the widget looked broken for exactly as long as
|
|
684
|
+
// it was doing the one thing the user asked for. Then the label appeared
|
|
685
|
+
// *after* the transport was already up, which reads backwards.
|
|
686
|
+
//
|
|
687
|
+
// The embed makes this the normal path rather than an edge case: its
|
|
688
|
+
// launcher click opens the panel immediately by design, so the pre-connect
|
|
689
|
+
// window is always visible to the user.
|
|
690
|
+
const shouldShow = (client.state.isConnected || client.state.isConnecting) && !client.isBotReady;
|
|
545
691
|
connectingOverlay.style.opacity = shouldShow ? "1" : "0";
|
|
546
692
|
connectingOverlay.style.pointerEvents = shouldShow ? "auto" : "none";
|
|
547
693
|
};
|
|
548
694
|
// Audio Analysis State for Voice Mode
|
|
549
695
|
let audioLevels = Array(40).fill(0);
|
|
550
|
-
let targetLevels = Array(40).fill(0.05);
|
|
551
|
-
let currentLevels = Array(40).fill(0.05);
|
|
552
696
|
let startTime = 0;
|
|
553
|
-
|
|
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
|
+
*/
|
|
554
773
|
const updateAudioBars = () => {
|
|
555
774
|
if (!voiceModeOverlay)
|
|
556
775
|
return;
|
|
557
|
-
const bars = voiceModeOverlay.querySelectorAll(".voice-bar");
|
|
558
776
|
const isTalking = client.state.isSpeaking;
|
|
559
|
-
const isListening = client.state.isListening;
|
|
560
|
-
const
|
|
561
|
-
//
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
}
|
|
569
|
-
//
|
|
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.
|
|
570
789
|
const title = refs.voiceModeTitle;
|
|
571
790
|
const subtitle = refs.voiceModeSubtitle;
|
|
572
791
|
if (title) {
|
|
573
792
|
title.textContent = isTalking
|
|
574
|
-
?
|
|
575
|
-
: isListening
|
|
793
|
+
? `${characterName} Speaking...`
|
|
794
|
+
: isListening || micOpen
|
|
576
795
|
? "Listening..."
|
|
577
|
-
: "Voice
|
|
796
|
+
: "Voice Mode";
|
|
578
797
|
}
|
|
579
798
|
if (subtitle) {
|
|
580
799
|
subtitle.textContent =
|
|
581
|
-
isListening || isTalking
|
|
582
|
-
? "Audio active"
|
|
583
|
-
: "Press and hold the microphone to talk";
|
|
800
|
+
isListening || micOpen || isTalking ? "Audio active" : "Tap microphone to talk";
|
|
584
801
|
}
|
|
585
|
-
|
|
586
|
-
if ((isListening || !client.audioControls.isAudioMuted) && analyzer && dataArray) {
|
|
587
|
-
// Use time domain data (waveform) instead of frequency
|
|
802
|
+
if ((isListening || micOpen) && analyzer && dataArray) {
|
|
588
803
|
// @ts-ignore - TypeScript strict mode issue with Uint8Array type
|
|
589
804
|
analyzer.getByteTimeDomainData(dataArray);
|
|
590
|
-
// Calculate RMS (Root Mean Square) for volume
|
|
591
805
|
let sum = 0;
|
|
592
806
|
for (let i = 0; i < dataArray.length; i++) {
|
|
593
|
-
const normalized = (dataArray[i] - 128) / 128;
|
|
807
|
+
const normalized = (dataArray[i] - 128) / 128;
|
|
594
808
|
sum += normalized * normalized;
|
|
595
809
|
}
|
|
596
810
|
const rms = Math.sqrt(sum / dataArray.length);
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
const minHeight = 8;
|
|
601
|
-
const maxHeight = 70;
|
|
602
|
-
bars.forEach((bar, i) => {
|
|
603
|
-
// Progressive wave from left to right
|
|
604
|
-
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;
|
|
605
814
|
const wavePhase = Date.now() / 300 + position * Math.PI * 2;
|
|
606
|
-
const waveVariation = Math.sin(wavePhase) * 0.15 + 0.85;
|
|
607
|
-
|
|
608
|
-
const height = minHeight + level * (maxHeight - minHeight);
|
|
609
|
-
bar.style.height = `${Math.max(minHeight, height)}px`;
|
|
610
|
-
});
|
|
611
|
-
}
|
|
612
|
-
else if (isTalking) {
|
|
613
|
-
// Simulate speaking bars with natural speech patterns
|
|
614
|
-
const elapsed = (Date.now() - startTime) / 1000; // seconds
|
|
615
|
-
// Generate new random target levels occasionally (simulating syllables/words)
|
|
616
|
-
if (Math.random() < 0.08) {
|
|
617
|
-
// 8% chance per frame = ~5 times per second
|
|
618
|
-
targetLevels = Array(40)
|
|
619
|
-
.fill(0)
|
|
620
|
-
.map((_, i) => {
|
|
621
|
-
// More variation in the middle bars, less on edges for natural spread
|
|
622
|
-
const position = i / 40;
|
|
623
|
-
const centerWeight = 1 - Math.abs(position - 0.5) * 0.5;
|
|
624
|
-
// Random peaks and valleys like speech patterns
|
|
625
|
-
const randomPeak = 0.2 + Math.random() * 0.7; // 0.2 to 0.9
|
|
626
|
-
// Add some neighbor correlation so bars don't jump independently
|
|
627
|
-
const prevTarget = targetLevels[i] || 0.3;
|
|
628
|
-
const correlation = prevTarget * 0.4 + randomPeak * 0.6;
|
|
629
|
-
return correlation * centerWeight;
|
|
630
|
-
});
|
|
815
|
+
const waveVariation = Math.sin(wavePhase) * 0.15 + 0.85;
|
|
816
|
+
audioLevels[i] = volume * waveVariation;
|
|
631
817
|
}
|
|
632
|
-
// Smoothly interpolate current levels toward targets (organic movement)
|
|
633
|
-
currentLevels = currentLevels.map((current, i) => {
|
|
634
|
-
const target = targetLevels[i];
|
|
635
|
-
const speed = 0.2; // Smooth but responsive
|
|
636
|
-
return current + (target - current) * speed;
|
|
637
|
-
});
|
|
638
|
-
// Apply a gentle fade-in for the first 0.3 seconds
|
|
639
|
-
const fadeIn = Math.min(1, elapsed / 0.3);
|
|
640
|
-
const minHeight = 8;
|
|
641
|
-
const maxHeight = 70;
|
|
642
|
-
bars.forEach((bar, i) => {
|
|
643
|
-
// Small random jitter for micro-variation
|
|
644
|
-
const microJitter = 0.95 + Math.random() * 0.1; // 0.95 to 1.05
|
|
645
|
-
const level = Math.max(0.05, Math.min(1, currentLevels[i] * fadeIn * microJitter));
|
|
646
|
-
const height = minHeight + level * (maxHeight - minHeight);
|
|
647
|
-
bar.style.height = `${Math.max(minHeight, height)}px`;
|
|
648
|
-
});
|
|
649
818
|
}
|
|
650
819
|
else {
|
|
651
|
-
//
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
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;
|
|
655
825
|
}
|
|
826
|
+
drawWaves();
|
|
656
827
|
rafId = requestAnimationFrame(updateAudioBars);
|
|
657
828
|
};
|
|
658
829
|
const startAudioAnalysis = async () => {
|
|
@@ -897,6 +1068,7 @@ export function createConvaiWidget(container, options) {
|
|
|
897
1068
|
// needs the connecting overlay re-evaluated -- it depends on the same
|
|
898
1069
|
// `isConnected`/`isBotReady`/`characterName` inputs.
|
|
899
1070
|
updateConnectingOverlay();
|
|
1071
|
+
updateVoiceModeAvatar();
|
|
900
1072
|
};
|
|
901
1073
|
// Create message list
|
|
902
1074
|
const createMessageList = () => {
|
|
@@ -1746,8 +1918,6 @@ export function createConvaiWidget(container, options) {
|
|
|
1746
1918
|
updateHeader();
|
|
1747
1919
|
// Reset animation state for voice mode
|
|
1748
1920
|
startTime = Date.now();
|
|
1749
|
-
currentLevels = Array(40).fill(0.05);
|
|
1750
|
-
targetLevels = Array(40).fill(0.05);
|
|
1751
1921
|
// Start Audio Analysis
|
|
1752
1922
|
startAudioAnalysis();
|
|
1753
1923
|
}
|
|
@@ -2113,8 +2283,6 @@ export function createConvaiWidget(container, options) {
|
|
|
2113
2283
|
// Reset when not speaking
|
|
2114
2284
|
if (startTime && !client.state.isSpeaking) {
|
|
2115
2285
|
startTime = 0;
|
|
2116
|
-
currentLevels = Array(40).fill(0.05);
|
|
2117
|
-
targetLevels = Array(40).fill(0.05);
|
|
2118
2286
|
}
|
|
2119
2287
|
}
|
|
2120
2288
|
// Auto-collapse when disconnected
|