{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"voice-orb","type":"registry:component","title":"Voice Orb","description":"Breathing liquid voice visualization with flowing highlights, custom pigments and a soft, voice-reactive silhouette. Accepts a normalized level, MotionValue or caller-owned audio analyser, pauses offscreen and supports reduced motion.","author":"Saurabh <saurabh10102@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// beui.dev/components/agents/voice-orb\n\nimport {\n  isMotionValue,\n  motion,\n  type MotionValue,\n  useMotionValue,\n  useReducedMotion,\n  useSpring,\n  useTransform,\n} from \"motion/react\";\nimport {\n  type ComponentProps,\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { createOrbRenderer } from \"@/components/agents/voice-orb/renderer\";\nimport { SPRING_GLIDE } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type VoiceOrbProps = Omit<\n  ComponentProps<\"div\">,\n  \"children\" | \"onError\"\n> & {\n  /** Normalized activity, from 0 to 1. A MotionValue avoids React render loops. */\n  activity?: number | MotionValue<number>;\n  /** Optional caller-owned audio analyser. The component never requests a microphone. */\n  analyser?: AnalyserNode | null;\n  /** Base pigment, highlight and shadow, as #RGB or #RRGGBB hex colors. */\n  colors?: readonly [string, string, string];\n  /** Pause the material while keeping the current surface visible. */\n  active?: boolean;\n  speed?: number;\n  onError?: (error: Error) => void;\n};\n\nconst DEFAULT_COLORS = [\"#e8754d\", \"#ffe0ac\", \"#346b52\"] as const;\nconst level = (value: number) =>\n  Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 0;\n\n/** A grainy liquid sphere whose surface and silhouette respond to voice activity. */\nexport function VoiceOrb({\n  activity = 0,\n  analyser = null,\n  colors = DEFAULT_COLORS,\n  active = true,\n  speed = 1,\n  onError,\n  className,\n  style,\n  \"aria-label\": label,\n  ...props\n}: VoiceOrbProps) {\n  const reducedMotion = useReducedMotion();\n  const canvas = useRef<HTMLCanvasElement>(null);\n  const renderer = useRef<ReturnType<typeof createOrbRenderer> | null>(null);\n  const [error, setError] = useState<Error | null>(null);\n  const target = useMotionValue(0);\n  const smooth = useSpring(target, SPRING_GLIDE);\n  const transform = useTransform(\n    smooth,\n    (value) =>\n      `translate3d(0, ${-value * 2}px, 0) scale(${1 + value * 0.04}, ${1 - value * 0.018})`,\n  );\n  const latest = useRef({\n    activity,\n    analyser,\n    colors,\n    active,\n    speed,\n    reducedMotion,\n    onError,\n  });\n  useLayoutEffect(() => {\n    latest.current = {\n      activity,\n      analyser,\n      colors,\n      active,\n      speed,\n      reducedMotion,\n      onError,\n    };\n    const value =\n      active && !reducedMotion\n        ? level(isMotionValue(activity) ? activity.get() : activity)\n        : 0;\n    if (reducedMotion) {\n      smooth.jump(0);\n      target.set(0);\n    } else target.set(value);\n    renderer.current?.requestDraw();\n  });\n\n  useEffect(() => {\n    if (!isMotionValue(activity)) return;\n    return activity.on(\"change\", (value) => {\n      if (latest.current.active && !latest.current.reducedMotion)\n        target.set(level(value));\n      renderer.current?.requestDraw();\n    });\n  }, [activity, target]);\n\n  useEffect(\n    () => smooth.on(\"change\", () => renderer.current?.requestDraw()),\n    [smooth],\n  );\n\n  useEffect(() => {\n    const element = canvas.current;\n    if (!element) return;\n    let sample: Uint8Array<ArrayBuffer> | null = null;\n    let audioSource: AnalyserNode | null = null;\n    const failed = (failure: Error) => {\n      setError(failure);\n      latest.current.onError?.(failure);\n    };\n    try {\n      renderer.current = createOrbRenderer(\n        element,\n        () => {\n          const current = latest.current;\n          if (current.analyser && current.active && !current.reducedMotion) {\n            if (\n              audioSource !== current.analyser ||\n              sample?.length !== current.analyser.fftSize\n            ) {\n              audioSource = current.analyser;\n              sample = new Uint8Array(\n                new ArrayBuffer(current.analyser.fftSize),\n              );\n            }\n            if (sample) {\n              current.analyser.getByteTimeDomainData(sample);\n              let sum = 0;\n              for (const byte of sample) sum += ((byte - 128) / 128) ** 2;\n              target.set(level(Math.sqrt(sum / sample.length) * 3));\n            }\n          }\n          return {\n            activity:\n              current.active && !current.reducedMotion\n                ? level(smooth.get())\n                : 0,\n            colors: current.colors,\n            speed: Number.isFinite(current.speed)\n              ? Math.max(0, current.speed)\n              : 1,\n            animated:\n              current.active &&\n              !current.reducedMotion &&\n              (current.speed > 0 || !!current.analyser),\n          };\n        },\n        failed,\n      );\n    } catch (failure) {\n      failed(failure instanceof Error ? failure : new Error(String(failure)));\n    }\n    return () => {\n      renderer.current?.dispose();\n      renderer.current = null;\n    };\n  }, [smooth, target]);\n\n  return (\n    <div\n      {...props}\n      role=\"img\"\n      aria-label={label}\n      aria-hidden={label ? undefined : true}\n      data-slot=\"voice-orb\"\n      data-render-state={error ? \"error\" : \"ready\"}\n      className={cn(\"relative aspect-square w-64 shrink-0\", className)}\n      style={style}\n    >\n      {error ? (\n        <div className=\"flex size-full items-center justify-center rounded-full border border-border bg-card p-8 text-center text-xs text-muted-foreground\">\n          Orb rendering unavailable\n        </div>\n      ) : (\n        <motion.div\n          className=\"size-full\"\n          style={{ transform: reducedMotion ? \"none\" : transform }}\n        >\n          <canvas ref={canvas} className=\"block size-full\" />\n        </motion.div>\n      )}\n    </div>\n  );\n}\n"},{"path":"components/agents/voice-orb/renderer.ts","type":"registry:component","target":"@components/agents/voice-orb/renderer.ts","content":"export type OrbFrame = {\n  activity: number;\n  colors: readonly [string, string, string];\n  speed: number;\n  animated: boolean;\n};\n\nconst VERTEX = `\nattribute vec2 a_position;\nvoid main() { gl_Position = vec4(a_position, 0.0, 1.0); }\n`;\n\n// A breathing silhouette surrounds an advected liquid field. Voice energy\n// opens the folds and ripples the rim; screen-space grain stays stationary.\nconst FRAGMENT = `\nprecision highp float;\nuniform vec2 u_resolution;\nuniform float u_time;\nuniform float u_activity;\nuniform vec3 u_base;\nuniform vec3 u_light;\nuniform vec3 u_dark;\n\nfloat hash(vec3 p) {\n  p = fract(p * 0.1031);\n  p += dot(p, p.yzx + 33.33);\n  return fract((p.x + p.y) * p.z);\n}\nfloat noise(vec3 p) {\n  vec3 i = floor(p);\n  vec3 f = fract(p);\n  f = f * f * (3.0 - 2.0 * f);\n  return mix(mix(mix(hash(i), hash(i + vec3(1,0,0)), f.x),\n                 mix(hash(i + vec3(0,1,0)), hash(i + vec3(1,1,0)), f.x), f.y),\n             mix(mix(hash(i + vec3(0,0,1)), hash(i + vec3(1,0,1)), f.x),\n                 mix(hash(i + vec3(0,1,1)), hash(i + vec3(1,1,1)), f.x), f.y), f.z);\n}\nfloat fbm(vec3 p) {\n  return noise(p) * 0.57 + noise(p * 2.03 + 7.2) * 0.28 + noise(p * 4.07 + 3.1) * 0.15;\n}\nvoid main() {\n  vec2 p = (gl_FragCoord.xy * 2.0 - u_resolution) / min(u_resolution.x, u_resolution.y);\n  float t = u_time * 0.38;\n  float angle = atan(p.y, p.x);\n  float breath = sin(u_time * 1.4) * 0.008;\n  float ripple = sin(angle * 3.0 + t * 2.1) * 0.55\n               + sin(angle * 5.0 - t * 1.7) * 0.30\n               + sin(angle * 2.0 + t) * 0.15;\n  p /= 0.91 + breath + u_activity * 0.025\n       + ripple * (0.004 + u_activity * 0.024);\n  float r2 = dot(p, p);\n  float aa = 3.0 / min(u_resolution.x, u_resolution.y);\n  float alpha = 1.0 - smoothstep(1.0 - aa, 1.0, r2);\n  if (alpha <= 0.0) { gl_FragColor = vec4(0.0); return; }\n  vec3 normal = vec3(p, sqrt(max(0.0, 1.0 - r2)));\n  vec3 flow = vec3(normal.xy * 1.65, normal.z * 1.4);\n  flow.xy += vec2(sin(normal.y * 2.6 + t), cos(normal.x * 2.3 - t * 0.8))\n             * (0.25 + u_activity * 0.22);\n  vec3 warp = vec3(noise(flow + vec3(0.0, t, t * 0.3)),\n                   noise(flow + vec3(4.7, -t * 0.7, t)),\n                   noise(flow + vec3(t * 0.5, 9.2, -t))) - 0.5;\n  float pigment = fbm(flow + warp * 1.4 + vec3(t * 0.3, -t * 0.2, t * 0.4));\n  float fold = normal.x * 0.50 + normal.y * 0.32\n             + sin(normal.y * 2.6 - t) * 0.30 + (pigment - 0.5) * 0.65;\n  float shadow = smoothstep(-0.30, 0.42, fold);\n  vec3 color = mix(u_dark, u_base, shadow);\n  color = mix(color, u_light, smoothstep(0.40, 0.82, pigment) * 0.34);\n  float diffuse = max(0.0, dot(normal, normalize(vec3(-0.4, 0.65, 1.0))));\n  color *= 0.64 + 0.46 * diffuse;\n  float shine = exp(-pow((fold + 0.06) * 8.0, 2.0)) * pow(normal.z, 0.65);\n  float innerLight = exp(-pow((fold - 0.28) * 4.0, 2.0)) * normal.z;\n  color = mix(color, u_light, innerLight * (0.18 + u_activity * 0.16));\n  color = mix(color, mix(u_light, vec3(1.0), 0.55), shine * 0.78);\n  float gleam = pow(max(0.0, dot(normal, normalize(vec3(-0.45, 0.55, 1.0)))), 24.0);\n  color += gleam * 0.16;\n  float rim = pow(1.0 - normal.z, 3.0);\n  color = mix(color, u_light, rim * (0.36 + 0.25 * max(0.0, -normal.x)));\n  float grain = hash(vec3(gl_FragCoord.xy, 1.0)) - 0.5;\n  color += grain * 0.032;\n  gl_FragColor = vec4(clamp(color, 0.0, 1.0), alpha);\n}\n`;\n\nfunction rgb(hex: string) {\n  const short = /^#([\\da-f])([\\da-f])([\\da-f])$/i.exec(hex);\n  const normalized = short\n    ? `#${short[1]}${short[1]}${short[2]}${short[2]}${short[3]}${short[3]}`\n    : hex;\n  if (!/^#[\\da-f]{6}$/i.test(normalized))\n    throw new Error(\"VoiceOrb colors must be #RGB or #RRGGBB hex colors.\");\n  const value = Number.parseInt(normalized.slice(1), 16);\n  return [\n    ((value >> 16) & 255) / 255,\n    ((value >> 8) & 255) / 255,\n    (value & 255) / 255,\n  ] as const;\n}\n\n/** Owns only GPU resources and draw scheduling; it never opens an audio source. */\nexport function createOrbRenderer(\n  canvas: HTMLCanvasElement,\n  read: () => OrbFrame,\n  onError: (error: Error) => void,\n) {\n  const gl = canvas.getContext(\"webgl\", {\n    alpha: true,\n    antialias: false,\n    premultipliedAlpha: false,\n    powerPreference: \"low-power\",\n  });\n  if (!gl) throw new Error(\"WebGL is unavailable in this browser.\");\n  let disposed = false;\n  let frame = 0;\n  let elapsed = 0;\n  let previous = 0;\n  let visible = true;\n  let lost = false;\n  let failed = false;\n  let program: WebGLProgram | null = null;\n  let buffer: WebGLBuffer | null = null;\n  let shaders: WebGLShader[] = [];\n  let uniforms: Record<string, WebGLUniformLocation | null> = {};\n  let palette: OrbFrame[\"colors\"] | null = null;\n  let size = { width: canvas.clientWidth, height: canvas.clientHeight };\n\n  const destroy = () => {\n    if (buffer) gl.deleteBuffer(buffer);\n    if (program) gl.deleteProgram(program);\n    for (const shader of shaders) gl.deleteShader(shader);\n    buffer = null;\n    program = null;\n    shaders = [];\n  };\n  const initialize = () => {\n    const compile = (type: number, source: string) => {\n      const shader = gl.createShader(type);\n      if (!shader) throw new Error(\"Could not allocate an orb shader.\");\n      shaders.push(shader);\n      gl.shaderSource(shader, source);\n      gl.compileShader(shader);\n      if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS))\n        throw new Error(\n          gl.getShaderInfoLog(shader) || \"Could not compile the orb shader.\",\n        );\n      return shader;\n    };\n    const vertex = compile(gl.VERTEX_SHADER, VERTEX);\n    const fragment = compile(gl.FRAGMENT_SHADER, FRAGMENT);\n    program = gl.createProgram();\n    if (!program) throw new Error(\"Could not allocate the orb program.\");\n    gl.attachShader(program, vertex);\n    gl.attachShader(program, fragment);\n    gl.linkProgram(program);\n    if (!gl.getProgramParameter(program, gl.LINK_STATUS))\n      throw new Error(\n        gl.getProgramInfoLog(program) || \"Could not link the orb program.\",\n      );\n    // biome-ignore lint/correctness/useHookAtTopLevel: WebGL's useProgram is a GPU API, not a React hook.\n    gl.useProgram(program);\n    buffer = gl.createBuffer();\n    if (!buffer) throw new Error(\"Could not allocate the orb buffer.\");\n    gl.bindBuffer(gl.ARRAY_BUFFER, buffer);\n    gl.bufferData(\n      gl.ARRAY_BUFFER,\n      new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),\n      gl.STATIC_DRAW,\n    );\n    const attribute = gl.getAttribLocation(program, \"a_position\");\n    gl.enableVertexAttribArray(attribute);\n    gl.vertexAttribPointer(attribute, 2, gl.FLOAT, false, 0, 0);\n    uniforms = Object.fromEntries(\n      [\n        \"u_resolution\",\n        \"u_time\",\n        \"u_activity\",\n        \"u_base\",\n        \"u_light\",\n        \"u_dark\",\n      ].map((name) => [\n        name,\n        gl.getUniformLocation(program as WebGLProgram, name),\n      ]),\n    );\n    palette = null;\n  };\n\n  const pause = () => {\n    cancelAnimationFrame(frame);\n    frame = 0;\n    previous = 0;\n  };\n  const draw = (now: number) => {\n    frame = 0;\n    if (disposed || lost || !visible || document.hidden) {\n      previous = 0;\n      return;\n    }\n    try {\n      const state = read();\n      if (state.animated && previous)\n        elapsed +=\n          Math.min(0.05, (now - previous) / 1000) *\n          state.speed *\n          (1 + state.activity * 0.6);\n      previous = state.animated ? now : 0;\n      const dpr = Math.min(window.devicePixelRatio || 1, 2);\n      const width = Math.max(1, Math.round(size.width * dpr));\n      const height = Math.max(1, Math.round(size.height * dpr));\n      if (canvas.width !== width || canvas.height !== height) {\n        canvas.width = width;\n        canvas.height = height;\n      }\n      gl.viewport(0, 0, width, height);\n      gl.uniform2f(uniforms.u_resolution ?? null, width, height);\n      gl.uniform1f(uniforms.u_time ?? null, elapsed);\n      gl.uniform1f(uniforms.u_activity ?? null, state.activity);\n      if (\n        !palette ||\n        palette.some((color, index) => color !== state.colors[index])\n      ) {\n        const colors = state.colors.map(rgb);\n        gl.uniform3fv(uniforms.u_base ?? null, colors[0]);\n        gl.uniform3fv(uniforms.u_light ?? null, colors[1]);\n        gl.uniform3fv(uniforms.u_dark ?? null, colors[2]);\n        palette = state.colors;\n      }\n      gl.drawArrays(gl.TRIANGLES, 0, 6);\n      if (state.animated) frame = requestAnimationFrame(draw);\n    } catch (error) {\n      failed = true;\n      pause();\n      onError(error instanceof Error ? error : new Error(String(error)));\n    }\n  };\n  const requestDraw = () => {\n    if (!disposed && !lost && !failed && visible && !document.hidden && !frame)\n      frame = requestAnimationFrame(draw);\n  };\n  const visibility = () => {\n    if (document.hidden) pause();\n    else requestDraw();\n  };\n  const contextLost = (event: Event) => {\n    event.preventDefault();\n    lost = true;\n    pause();\n  };\n  const contextRestored = () => {\n    try {\n      destroy();\n      initialize();\n      lost = false;\n      requestDraw();\n    } catch (error) {\n      onError(error instanceof Error ? error : new Error(String(error)));\n    }\n  };\n  try {\n    initialize();\n  } catch (error) {\n    destroy();\n    throw error;\n  }\n  const resize = new ResizeObserver(([entry]) => {\n    if (entry)\n      size = {\n        width: entry.contentRect.width,\n        height: entry.contentRect.height,\n      };\n    requestDraw();\n  });\n  resize.observe(canvas);\n  const intersection = new IntersectionObserver(([entry]) => {\n    visible = entry?.isIntersecting ?? true;\n    if (visible) requestDraw();\n    else pause();\n  });\n  intersection.observe(canvas);\n  canvas.addEventListener(\"webglcontextlost\", contextLost);\n  canvas.addEventListener(\"webglcontextrestored\", contextRestored);\n  document.addEventListener(\"visibilitychange\", visibility);\n  requestDraw();\n  return {\n    requestDraw,\n    dispose: () => {\n      disposed = true;\n      pause();\n      resize.disconnect();\n      intersection.disconnect();\n      canvas.removeEventListener(\"webglcontextlost\", contextLost);\n      canvas.removeEventListener(\"webglcontextrestored\", contextRestored);\n      document.removeEventListener(\"visibilitychange\", visibility);\n      destroy();\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"}]}