@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.
@@ -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,CA87Ef;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,aAAa,GAAG,IAAI,CAE/D"}
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: 24px;
456
+ gap: 32px;
423
457
  `;
424
- // Bars Container
425
- const barsContainer = document.createElement("div");
426
- barsContainer.id = "voice-bars-container";
427
- barsContainer.style.cssText = `
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
- gap: 2px;
432
- height: 80px;
433
- max-width: 300px;
465
+ isolation: isolate;
434
466
  `;
435
- // Create 40 bars
436
- for (let i = 0; i < 40; i++) {
437
- const bar = document.createElement("div");
438
- bar.className = "voice-bar";
439
- bar.style.cssText = `
440
- width: 3px;
441
- height: 15px;
442
- background-color: ${aeroTheme.colors.neutral[400]};
443
- border-radius: 1.5px;
444
- transition: height 0.08s ease-out, background-color 0.2s;
445
- transform-origin: center;
446
- `;
447
- barsContainer.appendChild(bar);
448
- }
449
- overlay.appendChild(barsContainer);
450
- // Status Text
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: 14px;
457
- font-weight: 500;
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 Only Mode";
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: 12px;
562
+ font-size: 13px;
467
563
  color: ${aeroTheme.colors.text.secondary};
468
564
  `;
469
- statusSubtitle.textContent = "Press and hold the microphone to talk";
470
- statusContainer.appendChild(statusTitle);
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
- const shouldShow = client.state.isConnected && !client.isBotReady;
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
- // Audio Analysis Logic
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; // User is speaking (from user-started-speaking)
560
- const isAnimating = isListening || isTalking;
561
- // Update colors based on state
562
- bars.forEach((bar) => {
563
- bar.style.backgroundColor = isTalking
564
- ? `var(--convai-accent, ${aeroTheme.colors.convai.light})`
565
- : isListening
566
- ? aeroTheme.colors.text.primary
567
- : aeroTheme.colors.neutral[400];
568
- });
569
- // Update Text
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
- ? "Character Speaking..."
575
- : isListening
793
+ ? `${characterName} Speaking...`
794
+ : isListening || micOpen
576
795
  ? "Listening..."
577
- : "Voice Only Mode";
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
- // Animation Logic - Matches React version (animate when user or bot is active)
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; // Center around 0
807
+ const normalized = (dataArray[i] - 128) / 128;
594
808
  sum += normalized * normalized;
595
809
  }
596
810
  const rms = Math.sqrt(sum / dataArray.length);
597
- // Apply some scaling and clamping
598
- const volume = Math.min(1, rms * 3); // Boost sensitivity
599
- // Create left-to-right wave effect
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; // 0.7 to 1.0
607
- const level = volume * waveVariation;
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
- // Reset to idle state
652
- bars.forEach((bar) => {
653
- bar.style.height = "15px";
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