Orb

This component creates an audio-reactive orb, configurable with ThreeJS. Click the Orb to start conversation with Vapi agent and as the audio streams, it will morph the ball. Click again to end the call and Vapi connection.

Preview

Code

Copy the following code to your component file for example orb.tsx. Ensure you have Vapi hook setup.

"use client";
import React, { useEffect, useRef } from 'react';
import * as THREE from 'three';
import { createNoise3D } from 'simplex-noise';
import useVapi from '@/hooks/use-vapi';
 
const Orb: React.FC = () => {
  const { volumeLevel, isSessionActive, toggleCall } = useVapi();
  const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
  const sceneRef = useRef<THREE.Scene | null>(null);
  const groupRef = useRef<THREE.Group | null>(null);
  const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
  const ballRef = useRef<THREE.Mesh | null>(null);
  const originalPositionsRef = useRef<any | null>(null);
  const noise = createNoise3D();
 
  useEffect(() => {
    console.log("Initializing visualization...");
    initViz();
    window.addEventListener('resize', onWindowResize);
    return () => {
      window.removeEventListener('resize', onWindowResize);
    };
  }, []);
 
  useEffect(() => {
    if (isSessionActive && ballRef.current) {
      console.log("Session is active, morphing the ball");
      updateBallMorph(ballRef.current, volumeLevel);
    } else if (!isSessionActive && ballRef.current && originalPositionsRef.current) {
      console.log("Session ended, resetting the ball");
      resetBallMorph(ballRef.current, originalPositionsRef.current);
    }
  }, [volumeLevel, isSessionActive]);
 
  const initViz = () => {
    console.log("Initializing Three.js visualization...");
    const scene = new THREE.Scene();
    const group = new THREE.Group();
    const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.5, 100);
    camera.position.set(0, 0, 100);
    camera.lookAt(scene.position);
 
    scene.add(camera);
    sceneRef.current = scene;
    groupRef.current = group;
    cameraRef.current = camera;
 
    const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true });
    renderer.setSize(window.innerWidth, window.innerHeight);
    rendererRef.current = renderer;
 
    const icosahedronGeometry = new THREE.IcosahedronGeometry(10, 8);
    const lambertMaterial = new THREE.MeshLambertMaterial({
      color: 0xFFFFFF,
      wireframe: true,
    });
 
    const ball = new THREE.Mesh(icosahedronGeometry, lambertMaterial);
    ball.position.set(0, 0, 0);
    ballRef.current = ball;
 
    // Store the original positions of the vertices
    originalPositionsRef.current = ball.geometry.attributes.position.array.slice();
 
    group.add(ball);
 
    const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
    scene.add(ambientLight);
 
    const spotLight = new THREE.SpotLight(0xffffff);
    spotLight.intensity = 0.9;
    spotLight.position.set(-10, 40, 20);
    spotLight.lookAt(ball.position);
    spotLight.castShadow = true;
    scene.add(spotLight);
 
    scene.add(group);
 
    const outElement = document.getElementById('out');
    if (outElement) {
      outElement.innerHTML = ''; // Clear any existing renderer
      outElement.appendChild(renderer.domElement);
      renderer.setSize(outElement.clientWidth, outElement.clientHeight);
    }
 
    render();
  };
 
  const render = () => {
    if (!groupRef.current || !ballRef.current || !cameraRef.current || !rendererRef.current || !sceneRef.current) {
      return;
    }
 
    groupRef.current.rotation.y += 0.005;
    rendererRef.current.render(sceneRef.current, cameraRef.current);
    requestAnimationFrame(render);
  };
 
  const onWindowResize = () => {
    if (!cameraRef.current || !rendererRef.current) return;
 
    const outElement = document.getElementById('out');
    if (outElement) {
      cameraRef.current.aspect = outElement.clientWidth / outElement.clientHeight;
      cameraRef.current.updateProjectionMatrix();
      rendererRef.current.setSize(outElement.clientWidth, outElement.clientHeight);
    }
  };
 
  const updateBallMorph = (mesh: THREE.Mesh, volume: number) => {
    console.log("Morphing the ball with volume:", volume);
    const geometry = mesh.geometry as THREE.BufferGeometry;
    const positionAttribute = geometry.getAttribute('position');
 
    for (let i = 0; i < positionAttribute.count; i++) {
      const vertex = new THREE.Vector3(
        positionAttribute.getX(i),
        positionAttribute.getY(i),
        positionAttribute.getZ(i)
      );
 
      const offset = 10; // Radius of the icosahedron
      const amp = 2.5; // Dramatic effect
      const time = window.performance.now();
      vertex.normalize();
      const rf = 0.00001;
      const distance =
        offset +
        volume * 4 + // Amplify volume effect
        noise(vertex.x + time * rf * 7, vertex.y + time * rf * 8, vertex.z + time * rf * 9) * amp * volume;
      vertex.multiplyScalar(distance);
 
      positionAttribute.setXYZ(i, vertex.x, vertex.y, vertex.z);
    }
 
    positionAttribute.needsUpdate = true;
    geometry.computeVertexNormals();
  };
 
  const resetBallMorph = (mesh: THREE.Mesh, originalPositions: Float32Array) => {
    console.log("Resetting the ball to its original shape");
    const geometry = mesh.geometry as THREE.BufferGeometry;
    const positionAttribute = geometry.getAttribute('position');
 
    for (let i = 0; i < positionAttribute.count; i++) {
      positionAttribute.setXYZ(
        i,
        originalPositions[i * 3],
        originalPositions[i * 3 + 1],
        originalPositions[i * 3 + 2]
      );
    }
 
    positionAttribute.needsUpdate = true;
    geometry.computeVertexNormals();
  };
 
  return (
    <div style={{ height: '100%' }}>
      <div id="out" className="hover:cursor-pointer" onClick={toggleCall} style={{ height: '100%', width: '100%' }}></div>
    </div>
  );
};
 
export default Orb;
 

Usage

Import the component in your file and then use it in your page.

Note: This component uses Tailwind CSS, make sure to have it installed in your project.

import Orb from "@/components/vapi/orb";
 
export default function Home() {
  return (
    <main className="flex items-center justify-center h-screen">
      <Orb />
    </main>
  );
}