"use client"; import { AnimatePresence, type MotionValue, motion, useAnimationFrame, useMotionValue, useReducedMotion, useSpring, useTransform, } from "motion/react"; import { type CSSProperties, useEffect, useRef } from "react"; import { EASE_IN_OUT, EASE_OUT, SPRING_GLIDE } from "@/lib/ease"; import { cn } from "@/lib/utils"; export type VoiceOrbState = "idle" | "listening" | "thinking" | "speaking"; export interface VoiceOrbProps { state?: VoiceOrbState; /** Audio amplitude 0..1 (mic input while listening, output while speaking). */ level?: number; /** Synthesize a plausible `level` for the current state (no audio needed). */ simulate?: boolean; /** Diameter in px. Default 160. */ size?: number; /** Accessible name; the current state is appended. Default "Voice assistant". */ label?: string; className?: string; } const STATE_TEXT: Record = { idle: "idle", listening: "listening", thinking: "thinking", speaking: "speaking", }; // Per-state motion character: how fast the blobs orbit, how far they drift // from center and how much the audio level swells the core. const STATE_MOTION: Record = { idle: { speed: 0.25, drift: 0.05, gain: 0 }, listening: { speed: 0.6, drift: 0.08, gain: 0.18 }, thinking: { speed: 1.4, drift: 0.11, gain: 0 }, speaking: { speed: 0.9, drift: 0.1, gain: 0.3 }, }; // Blob layers: token color, orbit phase offset and relative size. const BLOBS = [ { color: "var(--chart-1)", phase: 0, scale: 0.78 }, { color: "var(--chart-2)", phase: 2.1, scale: 0.7 }, { color: "var(--chart-3)", phase: 4.2, scale: 0.66 }, { color: "var(--primary)", phase: 1.05, scale: 0.6 }, ] as const; /** Deterministic pseudo-speech envelope: layered sines read as syllables. */ function syntheticLevel(state: VoiceOrbState, t: number) { if (state === "speaking") { const syllable = Math.abs(Math.sin(t * 7.3) * Math.sin(t * 2.9 + 1)); const phrase = 0.55 + 0.45 * Math.sin(t * 0.8); return Math.min(1, 0.15 + syllable * 0.85 * phrase); } if (state === "listening") { const burst = Math.max(0, Math.sin(t * 1.3)) ** 2; return 0.08 + burst * Math.abs(Math.sin(t * 9.1)) * 0.55; } return 0; } export function VoiceOrb({ state = "idle", level = 0, simulate = false, size = 160, label = "Voice assistant", className, }: VoiceOrbProps) { const reduce = useReducedMotion() ?? false; const stateRef = useRef(state); useEffect(() => { stateRef.current = state; }, [state]); const target = useMotionValue(0); const smooth = useSpring(target, SPRING_GLIDE); // `clock` integrates speed over time so a state change eases the orbit // rate instead of jumping the blobs to a new phase; drift and gain ease too. const clock = useMotionValue(0); const drift = useMotionValue(STATE_MOTION[state].drift); const gain = useMotionValue(STATE_MOTION[state].gain); const speed = useRef(STATE_MOTION[state].speed); useEffect(() => { if (!simulate) target.set(Math.max(0, Math.min(1, level))); }, [level, simulate, target]); // Motion values update per frame without re-rendering React. useAnimationFrame((t, delta) => { if (reduce) return; const goal = STATE_MOTION[stateRef.current]; const k = Math.min(1, delta / 400); speed.current += (goal.speed - speed.current) * k; drift.set(drift.get() + (goal.drift - drift.get()) * k); gain.set(gain.get() + (goal.gain - gain.get()) * k); clock.set(clock.get() + (delta / 1000) * speed.current); if (simulate) target.set(syntheticLevel(stateRef.current, t / 1000)); }); const coreScale = useTransform(() => 0.92 + smooth.get() * gain.get()); const haloScale = useTransform(() => 1 + smooth.get() * gain.get() * 1.4); const haloOpacity = useTransform(smooth, (l) => 0.35 + l * 0.5); return (
{/* Soft halo that breathes with the level. */}
); } function Blob({ index, color, phase, blobScale, clock, level, drift, gain, still, }: { index: number; color: string; phase: number; blobScale: number; clock: MotionValue; level: MotionValue; drift: MotionValue; gain: MotionValue; still: boolean; }) { const dir = index % 2 === 0 ? 1 : -1; // Each blob orbits on its own Lissajous path (percent of the core), and the // level pushes it outward so loud audio visibly churns the colors. const x = useTransform(() => { const t = clock.get() * dir + phase; const r = (drift.get() + level.get() * gain.get() * 0.35) * 100; return `${Math.cos(t) * r}%`; }); const y = useTransform(() => { const t = clock.get() * 1.3 + phase * 1.7; const r = (drift.get() + level.get() * gain.get() * 0.35) * 100; return `${Math.sin(t) * r}%`; }); const scale = useTransform(() => { const t = clock.get() * 0.7 + phase; return blobScale * (1 + 0.08 * Math.sin(t * 2) + level.get() * gain.get() * 0.6); }); const staticOffset = [ ["-18%", "-14%"], ["16%", "-6%"], ["-4%", "18%"], ["10%", "10%"], ][index] ?? ["0%", "0%"]; return ( ); }