{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"voice-orb","type":"registry:component","title":"Voice Orb","description":"Audio-reactive orb for voice mode whose blurred --chart and --primary color blobs orbit inside a glossy sphere and swell with a 0..1 level prop, easing between idle breathing, listening ripples, a circling thinking arc and churning speaking motion, with a simulate mode that synthesizes speech-like levels and a gentle opacity pulse under reduced motion; pairs with VoiceInput for capture.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/agents/voice-orb.tsx","type":"registry:component","target":"@components/agents/voice-orb.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/agents/voice-orb\n\nimport {\n  AnimatePresence,\n  type MotionValue,\n  motion,\n  useAnimationFrame,\n  useMotionValue,\n  useReducedMotion,\n  useSpring,\n  useTransform,\n} from \"motion/react\";\nimport { type CSSProperties, useEffect, useRef } from \"react\";\nimport { EASE_IN_OUT, EASE_OUT, SPRING_GLIDE } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type VoiceOrbState = \"idle\" | \"listening\" | \"thinking\" | \"speaking\";\n\nexport interface VoiceOrbProps {\n  state?: VoiceOrbState;\n  /** Audio amplitude 0..1 (mic input while listening, output while speaking). */\n  level?: number;\n  /** Synthesize a plausible `level` for the current state (no audio needed). */\n  simulate?: boolean;\n  /** Diameter in px. Default 160. */\n  size?: number;\n  /** Accessible name; the current state is appended. Default \"Voice assistant\". */\n  label?: string;\n  className?: string;\n}\n\nconst STATE_TEXT: Record<VoiceOrbState, string> = {\n  idle: \"idle\",\n  listening: \"listening\",\n  thinking: \"thinking\",\n  speaking: \"speaking\",\n};\n\n// Per-state motion character: how fast the blobs orbit, how far they drift\n// from center and how much the audio level swells the core.\nconst STATE_MOTION: Record<VoiceOrbState, { speed: number; drift: number; gain: number }> = {\n  idle: { speed: 0.25, drift: 0.05, gain: 0 },\n  listening: { speed: 0.6, drift: 0.08, gain: 0.18 },\n  thinking: { speed: 1.4, drift: 0.11, gain: 0 },\n  speaking: { speed: 0.9, drift: 0.1, gain: 0.3 },\n};\n\n// Blob layers: token color, orbit phase offset and relative size.\nconst BLOBS = [\n  { color: \"var(--chart-1)\", phase: 0, scale: 0.78 },\n  { color: \"var(--chart-2)\", phase: 2.1, scale: 0.7 },\n  { color: \"var(--chart-3)\", phase: 4.2, scale: 0.66 },\n  { color: \"var(--primary)\", phase: 1.05, scale: 0.6 },\n] as const;\n\n/** Deterministic pseudo-speech envelope: layered sines read as syllables. */\nfunction syntheticLevel(state: VoiceOrbState, t: number) {\n  if (state === \"speaking\") {\n    const syllable = Math.abs(Math.sin(t * 7.3) * Math.sin(t * 2.9 + 1));\n    const phrase = 0.55 + 0.45 * Math.sin(t * 0.8);\n    return Math.min(1, 0.15 + syllable * 0.85 * phrase);\n  }\n  if (state === \"listening\") {\n    const burst = Math.max(0, Math.sin(t * 1.3)) ** 2;\n    return 0.08 + burst * Math.abs(Math.sin(t * 9.1)) * 0.55;\n  }\n  return 0;\n}\n\nexport function VoiceOrb({\n  state = \"idle\",\n  level = 0,\n  simulate = false,\n  size = 160,\n  label = \"Voice assistant\",\n  className,\n}: VoiceOrbProps) {\n  const reduce = useReducedMotion() ?? false;\n  const stateRef = useRef(state);\n  useEffect(() => {\n    stateRef.current = state;\n  }, [state]);\n\n  const target = useMotionValue(0);\n  const smooth = useSpring(target, SPRING_GLIDE);\n  // `clock` integrates speed over time so a state change eases the orbit\n  // rate instead of jumping the blobs to a new phase; drift and gain ease too.\n  const clock = useMotionValue(0);\n  const drift = useMotionValue(STATE_MOTION[state].drift);\n  const gain = useMotionValue(STATE_MOTION[state].gain);\n  const speed = useRef(STATE_MOTION[state].speed);\n\n  useEffect(() => {\n    if (!simulate) target.set(Math.max(0, Math.min(1, level)));\n  }, [level, simulate, target]);\n\n  // Motion values update per frame without re-rendering React.\n  useAnimationFrame((t, delta) => {\n    if (reduce) return;\n    const goal = STATE_MOTION[stateRef.current];\n    const k = Math.min(1, delta / 400);\n    speed.current += (goal.speed - speed.current) * k;\n    drift.set(drift.get() + (goal.drift - drift.get()) * k);\n    gain.set(gain.get() + (goal.gain - gain.get()) * k);\n    clock.set(clock.get() + (delta / 1000) * speed.current);\n    if (simulate) target.set(syntheticLevel(stateRef.current, t / 1000));\n  });\n\n  const coreScale = useTransform(() => 0.92 + smooth.get() * gain.get());\n  const haloScale = useTransform(() => 1 + smooth.get() * gain.get() * 1.4);\n  const haloOpacity = useTransform(smooth, (l) => 0.35 + l * 0.5);\n\n  return (\n    <div\n      role=\"img\"\n      aria-label={`${label}, ${STATE_TEXT[state]}`}\n      data-state={state}\n      className={cn(\"relative isolate inline-flex shrink-0 items-center justify-center\", className)}\n      style={{ width: size, height: size }}\n    >\n      {/* Soft halo that breathes with the level. */}\n      <motion.span\n        aria-hidden=\"true\"\n        className=\"absolute inset-[6%] rounded-full blur-xl\"\n        style={{\n          background:\n            \"radial-gradient(circle, color-mix(in oklab, var(--primary) 45%, transparent), transparent 70%)\",\n          scale: reduce ? 1 : haloScale,\n          opacity: reduce ? 0.5 : haloOpacity,\n        }}\n      />\n\n      {/* Listening: rings ripple outward. */}\n      <AnimatePresence>\n        {state === \"listening\" && !reduce && (\n          <motion.span\n            key=\"ripples\"\n            aria-hidden=\"true\"\n            className=\"absolute inset-0\"\n            initial={{ opacity: 0 }}\n            animate={{ opacity: 1 }}\n            exit={{ opacity: 0, transition: { duration: 0.2 } }}\n          >\n            {[0, 1, 2].map((i) => (\n              <motion.span\n                key={i}\n                className=\"absolute inset-[14%] rounded-full border border-primary/40\"\n                initial={{ scale: 0.9, opacity: 0 }}\n                animate={{ scale: [0.9, 1.35], opacity: [0.7, 0] }}\n                transition={{\n                  duration: 2.1,\n                  ease: EASE_OUT,\n                  repeat: Number.POSITIVE_INFINITY,\n                  delay: i * 0.7,\n                }}\n              />\n            ))}\n          </motion.span>\n        )}\n      </AnimatePresence>\n\n      {/* Thinking: a comet arc circles the orb. */}\n      <AnimatePresence>\n        {state === \"thinking\" && (\n          <motion.span\n            key=\"arc\"\n            aria-hidden=\"true\"\n            className=\"absolute inset-[4%] rounded-full\"\n            style={\n              {\n                background:\n                  \"conic-gradient(from 0deg, transparent 0turn, color-mix(in oklab, var(--chart-2) 80%, transparent) 0.22turn, transparent 0.24turn)\",\n                WebkitMask: \"radial-gradient(closest-side, transparent calc(100% - 3px), black calc(100% - 2px))\",\n                mask: \"radial-gradient(closest-side, transparent calc(100% - 3px), black calc(100% - 2px))\",\n              } as CSSProperties\n            }\n            initial={{ opacity: 0 }}\n            animate={reduce ? { opacity: [0.3, 0.8, 0.3] } : { opacity: 1, rotate: 360 }}\n            exit={{ opacity: 0, transition: { duration: 0.2 } }}\n            transition={\n              reduce\n                ? { duration: 1.8, ease: EASE_IN_OUT, repeat: Number.POSITIVE_INFINITY }\n                : {\n                    rotate: { duration: 1.1, ease: \"linear\", repeat: Number.POSITIVE_INFINITY },\n                    opacity: { duration: 0.25 },\n                  }\n            }\n          />\n        )}\n      </AnimatePresence>\n\n      {/* Core: a clipped sphere holding drifting color blobs. */}\n      <motion.span\n        aria-hidden=\"true\"\n        className=\"relative size-[72%] overflow-hidden rounded-full bg-card shadow-lg ring-1 ring-foreground/10\"\n        style={{ scale: reduce ? 1 : coreScale }}\n        animate={\n          reduce\n            ? { opacity: state === \"idle\" ? [0.7, 0.85, 0.7] : [0.75, 1, 0.75] }\n            : { opacity: 1 }\n        }\n        transition={\n          reduce\n            ? { duration: state === \"speaking\" ? 1.2 : 2.4, ease: EASE_IN_OUT, repeat: Number.POSITIVE_INFINITY }\n            : { duration: 0.3 }\n        }\n      >\n        {BLOBS.map((blob, i) => (\n          <Blob\n            key={blob.color}\n            index={i}\n            color={blob.color}\n            phase={blob.phase}\n            blobScale={blob.scale}\n            clock={clock}\n            level={smooth}\n            drift={drift}\n            gain={gain}\n            still={reduce}\n          />\n        ))}\n        {/* Specular highlight keeps it reading as a sphere. */}\n        <span\n          className=\"absolute inset-0 rounded-full\"\n          style={{\n            background:\n              \"radial-gradient(circle at 32% 26%, color-mix(in oklab, var(--background) 70%, transparent), transparent 42%)\",\n          }}\n        />\n      </motion.span>\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {STATE_TEXT[state]}\n      </span>\n    </div>\n  );\n}\n\nfunction Blob({\n  index,\n  color,\n  phase,\n  blobScale,\n  clock,\n  level,\n  drift,\n  gain,\n  still,\n}: {\n  index: number;\n  color: string;\n  phase: number;\n  blobScale: number;\n  clock: MotionValue<number>;\n  level: MotionValue<number>;\n  drift: MotionValue<number>;\n  gain: MotionValue<number>;\n  still: boolean;\n}) {\n  const dir = index % 2 === 0 ? 1 : -1;\n  // Each blob orbits on its own Lissajous path (percent of the core), and the\n  // level pushes it outward so loud audio visibly churns the colors.\n  const x = useTransform(() => {\n    const t = clock.get() * dir + phase;\n    const r = (drift.get() + level.get() * gain.get() * 0.35) * 100;\n    return `${Math.cos(t) * r}%`;\n  });\n  const y = useTransform(() => {\n    const t = clock.get() * 1.3 + phase * 1.7;\n    const r = (drift.get() + level.get() * gain.get() * 0.35) * 100;\n    return `${Math.sin(t) * r}%`;\n  });\n  const scale = useTransform(() => {\n    const t = clock.get() * 0.7 + phase;\n    return blobScale * (1 + 0.08 * Math.sin(t * 2) + level.get() * gain.get() * 0.6);\n  });\n\n  const staticOffset = [\n    [\"-18%\", \"-14%\"],\n    [\"16%\", \"-6%\"],\n    [\"-4%\", \"18%\"],\n    [\"10%\", \"10%\"],\n  ][index] ?? [\"0%\", \"0%\"];\n\n  return (\n    <motion.span\n      className=\"absolute inset-0 m-auto size-full rounded-full mix-blend-normal blur-md\"\n      style={{\n        background: `radial-gradient(circle, ${color} 0%, color-mix(in oklab, ${color} 50%, transparent) 45%, transparent 70%)`,\n        x: still ? staticOffset[0] : x,\n        y: still ? staticOffset[1] : y,\n        scale: still ? blobScale : scale,\n        opacity: 0.9,\n      }}\n    />\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}