@convai/web-sdk 1.8.0-beta.1 → 1.8.0-beta.3

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.
@@ -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: 24px;
456
+ gap: 32px;
435
457
  `;
436
- // Bars Container
437
- const barsContainer = document.createElement("div");
438
- barsContainer.id = "voice-bars-container";
439
- 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;
440
462
  display: flex;
441
463
  align-items: center;
442
464
  justify-content: center;
443
- gap: 2px;
444
- height: 80px;
445
- max-width: 300px;
465
+ isolation: isolate;
446
466
  `;
447
- // Create 40 bars
448
- for (let i = 0; i < 40; i++) {
449
- const bar = document.createElement("div");
450
- bar.className = "voice-bar";
451
- bar.style.cssText = `
452
- width: 3px;
453
- height: 15px;
454
- background-color: ${aeroTheme.colors.neutral[400]};
455
- border-radius: 1.5px;
456
- transition: height 0.08s ease-out, background-color 0.2s;
457
- transform-origin: center;
458
- `;
459
- barsContainer.appendChild(bar);
460
- }
461
- overlay.appendChild(barsContainer);
462
- // 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 ------------------------------------------------------
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: 14px;
469
- font-weight: 500;
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 Only Mode";
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: 12px;
562
+ font-size: 13px;
479
563
  color: ${aeroTheme.colors.text.secondary};
480
564
  `;
481
- statusSubtitle.textContent = "Press and hold the microphone to talk";
482
- statusContainer.appendChild(statusTitle);
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
- // 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
+ */
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; // User is speaking (from user-started-speaking)
584
- const isAnimating = isListening || isTalking;
585
- // Update colors based on state
586
- bars.forEach((bar) => {
587
- bar.style.backgroundColor = isTalking
588
- ? `var(--convai-accent, ${aeroTheme.colors.convai.light})`
589
- : isListening
590
- ? aeroTheme.colors.text.primary
591
- : aeroTheme.colors.neutral[400];
592
- });
593
- // 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.
594
789
  const title = refs.voiceModeTitle;
595
790
  const subtitle = refs.voiceModeSubtitle;
596
791
  if (title) {
597
792
  title.textContent = isTalking
598
- ? "Character Speaking..."
599
- : isListening
793
+ ? `${characterName} Speaking...`
794
+ : isListening || micOpen
600
795
  ? "Listening..."
601
- : "Voice Only Mode";
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
- // Animation Logic - Matches React version (animate when user or bot is active)
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; // Center around 0
807
+ const normalized = (dataArray[i] - 128) / 128;
618
808
  sum += normalized * normalized;
619
809
  }
620
810
  const rms = Math.sqrt(sum / dataArray.length);
621
- // Apply some scaling and clamping
622
- const volume = Math.min(1, rms * 3); // Boost sensitivity
623
- // Create left-to-right wave effect
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; // 0.7 to 1.0
631
- const level = volume * waveVariation;
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
- // Reset to idle state
676
- bars.forEach((bar) => {
677
- bar.style.height = "15px";
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