{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "orb",
  "type": "registry:ui",
  "description": "Animated canvas orb with idle/listening/talking states, audio-reactive",
  "files": [
    {
      "path": "components/ui/Orb.tsx",
      "content": "import { useRef, useEffect, useCallback } from \"react\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport type OrbState = \"idle\" | \"listening\" | \"talking\";\n\nexport interface OrbProps {\n  /** Agent state — controls animation speed, deflation, rocking, and pulse. */\n  state?: OrbState;\n  /** Two gradient colors. Default: Deepgram brand palette. */\n  colors?: [string, string];\n  /** Size in px. Default: 200. */\n  size?: number;\n\n  // ── Audio reactivity (automatic mode — getter sampled per frame) ──\n  /** Getter returning current input volume (0–1). Sampled per animation frame. */\n  getInputVolume?: () => number;\n  /** Getter returning current output volume (0–1). Sampled per animation frame. */\n  getOutputVolume?: () => number;\n\n  // ── Audio reactivity (manual mode — direct values) ──\n  /** Direct input volume value (0–1). Use when you control the value yourself. */\n  inputVolume?: number;\n  /** Direct output volume value (0–1). Use when you control the value yourself. */\n  outputVolume?: number;\n\n  className?: string;\n}\n\n// ---------------------------------------------------------------------------\n// Constants (from deepgram/browser-agent hoop.ts)\n// ---------------------------------------------------------------------------\n\nconst PULSE_PERIOD_SECONDS = 3;\nconst PULSE_SIZE_MULTIPLIER = 1.02;\nconst AVERAGE_ROTATION_PERIOD_SECONDS = 6;\nconst ROCKING_PERIOD_SECONDS = 3;\nconst ROCKING_TRANSITION_TIME_MS = 1000;\nconst DEFLATE_PULL = 2;\nconst DEFLATE_TRANSITION_TIME_MS = 1000;\nconst INFLATE_TRANSITION_TIME_MS = 300;\nconst CHATTER_SIZE_MULTIPLIER = 1.15;\nconst CHATTER_WINDOW_SIZE = 10;\nconst CHATTER_FRAME_LAG = 3;\n\nconst pi = (n: number): number => Math.PI * n;\n\n// ---------------------------------------------------------------------------\n// Default color palette\n// ---------------------------------------------------------------------------\n\ninterface Point { x: number; y: number }\ninterface ColorStop { pct: number; color: string }\ninterface LineConfig {\n  segments: ColorStop[];\n  startAngle: number;\n  speedMultiplier: number;\n  centerOffset: Point;\n  radiusOffset: number;\n  width: number;\n}\n\nfunction buildPalette(colors?: [string, string]) {\n  const c1 = colors?.[0] ?? \"#13ef93\";\n  const c2 = colors?.[1] ?? \"#ee028c\";\n  return {\n    primary: c1 + \"cc\",\n    secondary: c2 + \"cc\",\n    lightPurple: \"#ae63f9cc\",\n    lightBlue: \"#14a9fbcc\",\n    green: \"#a1f9d4cc\",\n    transparent: \"transparent\",\n  };\n}\n\nfunction buildLines(colors?: [string, string]): LineConfig[] {\n  const c = buildPalette(colors);\n  return [\n    {\n      segments: [\n        { pct: 0.42, color: c.transparent },\n        { pct: 0.61, color: c.secondary },\n      ],\n      startAngle: 3.52, speedMultiplier: 1.21,\n      centerOffset: { x: 0.01, y: -0.01 }, radiusOffset: 0.02, width: 3.38,\n    },\n    {\n      segments: [\n        { pct: 0.28, color: c.primary },\n        { pct: 0.62, color: c.secondary },\n        { pct: 0.8, color: c.transparent },\n      ],\n      startAngle: 1.59, speedMultiplier: 0.64,\n      centerOffset: { x: -0.03, y: -0.01 }, radiusOffset: 0.05, width: 2.39,\n    },\n    {\n      segments: [\n        { pct: 0.1, color: c.transparent },\n        { pct: 0.31, color: c.green },\n        { pct: 0.45, color: c.lightBlue },\n        { pct: 0.66, color: c.lightPurple },\n      ],\n      startAngle: 2.86, speedMultiplier: 0.94,\n      centerOffset: { x: 0.02, y: 0.02 }, radiusOffset: -0.06, width: 2.64,\n    },\n    {\n      segments: [\n        { pct: 0.1, color: c.lightPurple },\n        { pct: 0.5, color: c.transparent },\n        { pct: 0.9, color: c.green },\n      ],\n      startAngle: 5.67, speedMultiplier: 1.3,\n      centerOffset: { x: -0.01, y: 0.01 }, radiusOffset: 0.04, width: 2.95,\n    },\n  ];\n}\n\nconst LINE_COUNT = 4;\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\nconst coordsFrom = (p: Point, d: number, a: number): Point => ({\n  x: p.x + d * Math.cos(a), y: p.y + d * Math.sin(a),\n});\n\nconst lerp = (a: number, b: number, t: number): number => t * (b - a) + a;\nconst clamp01 = (v: number): number => Math.min(1, Math.max(0, v));\nconst easeInOutQuad = (x: number): number =>\n  x < 0.5 ? 2 * x * x : 1 - (-2 * x + 2) ** 2 / 2;\n\n// ---------------------------------------------------------------------------\n// Drawing\n// ---------------------------------------------------------------------------\n\ninterface Shape {\n  time: number;\n  speed: number;\n  deflation: number;\n  rockingAngle: number;\n  agentNoise: number[];\n  userNoise: number[];\n  targetDeflation: number;\n  targetRocking: number;\n  transitionStart: number;\n  startDeflation: number;\n  startRocking: number;\n}\n\nfunction makeGradient(\n  ctx: CanvasRenderingContext2D, offset: Point, angle: number, parts: ColorStop[],\n): CanvasGradient {\n  const w = ctx.canvas.width / 2;\n  const h = ctx.canvas.height / 2;\n  const g = ctx.createLinearGradient(\n    w * (1 - Math.cos(angle) + offset.x),\n    h * (1 - Math.sin(angle) + offset.y),\n    w * (1 + Math.cos(angle) + offset.x),\n    h * (1 + Math.sin(angle) + offset.y),\n  );\n  parts.forEach(({ pct, color }) => g.addColorStop(pct, color));\n  return g;\n}\n\nfunction drawCrescent(\n  ctx: CanvasRenderingContext2D, offset: Point, radius: number,\n  deflDepth: number, deflAngle: number, gradient: CanvasGradient,\n) {\n  const w = ctx.canvas.width / 2;\n  const h = ctx.canvas.height / 2;\n  const center = { x: w * (1 + offset.x), y: h * (1 + offset.y) };\n  const bezD = radius * (4 / 3) * Math.tan(pi(1 / 8));\n\n  ctx.strokeStyle = gradient;\n  ctx.beginPath();\n\n  const arcStart = deflAngle + pi(1 / 2);\n  const arcEnd = deflAngle + pi(3 / 2);\n  ctx.arc(center.x, center.y, radius, arcStart, arcEnd, false);\n\n  const start = coordsFrom(center, radius, arcEnd);\n  const angleToX = pi(3 / 2) - deflAngle;\n  const distDown = Math.cos(angleToX) * radius;\n  const mid = coordsFrom(\n    coordsFrom(center, radius, deflAngle),\n    distDown * deflDepth * DEFLATE_PULL,\n    pi(1 / 2),\n  );\n  const end = coordsFrom(center, radius, arcStart);\n\n  ctx.bezierCurveTo(\n    ...Object.values(coordsFrom(start, bezD, arcEnd + pi(1 / 2))) as [number, number],\n    ...Object.values(coordsFrom(mid, bezD, deflAngle + pi(3 / 2))) as [number, number],\n    mid.x, mid.y,\n  );\n  ctx.bezierCurveTo(\n    ...Object.values(coordsFrom(mid, bezD, deflAngle + pi(1 / 2))) as [number, number],\n    ...Object.values(coordsFrom(end, bezD, arcStart + pi(3 / 2))) as [number, number],\n    end.x, end.y,\n  );\n  ctx.stroke();\n}\n\nfunction rollingAvg(noise: number[], start: number): number {\n  const win = noise.slice(start, start + CHATTER_WINDOW_SIZE);\n  return win.reduce((a, b) => a + b, 0) / win.length;\n}\n\nfunction drawFrame(ctx: CanvasRenderingContext2D, shape: Shape, dt: number, lineConfigs: LineConfig[]) {\n  shape.time += dt * lerp(1, shape.speed, shape.deflation);\n\n  const elapsed = performance.now() - shape.transitionStart;\n  if (shape.deflation !== shape.targetDeflation) {\n    const dur = shape.targetDeflation > shape.startDeflation\n      ? DEFLATE_TRANSITION_TIME_MS : INFLATE_TRANSITION_TIME_MS;\n    const p = easeInOutQuad(clamp01(elapsed / dur));\n    shape.deflation = p >= 1 ? shape.targetDeflation\n      : lerp(shape.startDeflation, shape.targetDeflation, p);\n  }\n  if (shape.rockingAngle !== shape.targetRocking) {\n    const p = easeInOutQuad(clamp01(elapsed / ROCKING_TRANSITION_TIME_MS));\n    shape.rockingAngle = p >= 1 ? shape.targetRocking\n      : lerp(shape.startRocking, shape.targetRocking, p);\n  }\n\n  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);\n\n  const maxR = Math.min(ctx.canvas.width, ctx.canvas.height) / 2;\n  const pulse = 1 + (PULSE_SIZE_MULTIPLIER - 1)\n    * Math.sin(shape.time * pi(1) / PULSE_PERIOD_SECONDS / 1000)\n    * lerp(1, 0, shape.deflation);\n\n  // Base deflation from state transition\n  const baseDeflation = easeInOutQuad(shape.deflation);\n  // When in talking state (deflation ~0.85), agent volume modulates the mouth:\n  // high volume → less deflation (mouth opens), low volume → more deflation (mouth closes)\n  const isTalking = shape.targetDeflation > 0.3 && shape.targetDeflation < 1;\n\n  lineConfigs.forEach((line, i) => {\n    ctx.lineWidth = line.width;\n    ctx.shadowColor = line.segments[0].color;\n    ctx.shadowBlur = line.width * 1.1;\n\n    let r = maxR * 0.8 * pulse;\n\n    // Listening: subtle radius flutter from mic input volume\n    const isListening = shape.targetDeflation === 0 && shape.deflation < 0.05;\n    if (isListening) {\n      const vol = Math.min(0.7, rollingAvg(shape.userNoise, i * CHATTER_FRAME_LAG));\n      r = Math.min(r * (1 + vol * (CHATTER_SIZE_MULTIPLIER - 1) * 2), maxR * 0.92);\n    }\n\n    const gradient = makeGradient(\n      ctx, line.centerOffset,\n      line.startAngle + (shape.time * pi(1) / 1000 / AVERAGE_ROTATION_PERIOD_SECONDS) * line.speedMultiplier,\n      line.segments,\n    );\n\n    // Mouth movement: volume modulates deflation depth per-line with lag\n    let deflation = baseDeflation;\n    if (isTalking) {\n      const vol = rollingAvg(shape.agentNoise, i * CHATTER_FRAME_LAG);\n      deflation = baseDeflation * (1 - vol * 0.4);\n    }\n\n    drawCrescent(\n      ctx, line.centerOffset,\n      r + line.radiusOffset * r,\n      deflation,\n      pi(3 / 2) + Math.sin(shape.time * pi(2) / ROCKING_PERIOD_SECONDS / 1000) * shape.rockingAngle,\n      gradient,\n    );\n  });\n}\n\n// ---------------------------------------------------------------------------\n// State mapping\n// ---------------------------------------------------------------------------\n\nfunction deflationFor(state: OrbState): number {\n  switch (state) {\n    case \"talking\":   return 0.55; // gentle crescent mouth\n    case \"listening\": return 0;    // full circle\n    case \"idle\": default: return 1; // fully pinched\n  }\n}\n\nfunction rockingFor(state: OrbState): number {\n  switch (state) {\n    case \"talking\":   return 0;        // no rocking — mouth stays oriented\n    case \"listening\": return pi(1 / 15);\n    case \"idle\": default: return pi(1 / 2);\n  }\n}\n\nfunction speedFor(state: OrbState): number {\n  switch (state) {\n    case \"talking\":   return 1;\n    case \"listening\": return 0.7;\n    case \"idle\": default: return 0.2;\n  }\n}\n\n// ---------------------------------------------------------------------------\n// React component\n// ---------------------------------------------------------------------------\n\n/**\n * Deepgram animated orb — the signature hoop visualization.\n *\n * Canvas 2D rendering of 4 crescent arcs with gradient colors that\n * change behavior based on agent state:\n *\n * - `idle` — deflated, slow rocking, minimal animation\n * - `listening` — fully inflated, gentle pulse, awaiting speech\n * - `talking` — fully inflated, fast rotation, active pulse\n *\n * Audio reactivity (optional, two modes):\n * - **Automatic:** pass `getInputVolume` / `getOutputVolume` getter functions\n *   (sampled per animation frame — zero re-renders)\n * - **Manual:** pass `inputVolume` / `outputVolume` number props\n *   (push new values via state/props)\n *\n * Without any volume props the orb animates on state alone.\n *\n * Ported from deepgram/browser-agent `hoop.ts`. No external dependencies.\n *\n * @example State-only:\n * ```tsx\n * <Orb state=\"listening\" />\n * ```\n *\n * @example Automatic volume (getter functions):\n * ```tsx\n * <Orb\n *   state=\"talking\"\n *   getOutputVolume={() => player.getOutputVolume()}\n *   getInputVolume={() => mic.getInputVolume()}\n * />\n * ```\n *\n * @example Manual volume (direct values):\n * ```tsx\n * <Orb state=\"talking\" outputVolume={0.6} inputVolume={0.2} />\n * ```\n *\n * @example Custom colors:\n * ```tsx\n * <Orb state=\"listening\" colors={[\"#6366f1\", \"#ec4899\"]} />\n * ```\n */\nexport function Orb({\n  state = \"idle\",\n  colors,\n  size = 200,\n  getInputVolume,\n  getOutputVolume,\n  inputVolume,\n  outputVolume,\n  className,\n}: OrbProps) {\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const linesRef = useRef(buildLines(colors));\n  const shapeRef = useRef<Shape>({\n    time: 0,\n    speed: speedFor(\"idle\"),\n    deflation: deflationFor(\"idle\"),\n    rockingAngle: rockingFor(\"idle\"),\n    agentNoise: Array(LINE_COUNT * CHATTER_FRAME_LAG + CHATTER_WINDOW_SIZE).fill(0),\n    userNoise: Array(LINE_COUNT * CHATTER_FRAME_LAG + CHATTER_WINDOW_SIZE).fill(0),\n    targetDeflation: deflationFor(\"idle\"),\n    targetRocking: rockingFor(\"idle\"),\n    transitionStart: 0,\n    startDeflation: deflationFor(\"idle\"),\n    startRocking: rockingFor(\"idle\"),\n  });\n\n  // Rebuild line configs when colors change\n  useEffect(() => {\n    linesRef.current = buildLines(colors);\n  }, [colors]);\n\n  // Update state transitions\n  useEffect(() => {\n    const shape = shapeRef.current;\n    shape.speed = speedFor(state);\n    shape.transitionStart = performance.now();\n    shape.startDeflation = shape.deflation;\n    shape.startRocking = shape.rockingAngle;\n    shape.targetDeflation = deflationFor(state);\n    shape.targetRocking = rockingFor(state);\n  }, [state]);\n\n  // Manual volume: push values into noise buffers when props change\n  useEffect(() => {\n    if (outputVolume != null) {\n      const shape = shapeRef.current;\n      shape.agentNoise.shift();\n      shape.agentNoise.push(outputVolume);\n    }\n  }, [outputVolume]);\n\n  useEffect(() => {\n    if (inputVolume != null) {\n      const shape = shapeRef.current;\n      shape.userNoise.shift();\n      shape.userNoise.push(inputVolume);\n    }\n  }, [inputVolume]);\n\n  // Animation loop\n  const animate = useCallback(() => {\n    const canvas = canvasRef.current;\n    if (!canvas) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    let last = performance.now();\n    let raf = 0;\n\n    function loop(now: number) {\n      const dt = now - last;\n      last = now;\n\n      // Automatic mode: sample volume getters per frame\n      const shape = shapeRef.current;\n      if (getOutputVolume) {\n        shape.agentNoise.shift();\n        shape.agentNoise.push(getOutputVolume());\n      }\n      if (getInputVolume) {\n        shape.userNoise.shift();\n        shape.userNoise.push(getInputVolume());\n      }\n\n      drawFrame(ctx!, shape, dt, linesRef.current);\n      raf = requestAnimationFrame(loop);\n    }\n\n    raf = requestAnimationFrame(loop);\n    return () => cancelAnimationFrame(raf);\n  }, [getInputVolume, getOutputVolume]);\n\n  useEffect(() => {\n    const cleanup = animate();\n    return cleanup;\n  }, [animate]);\n\n  const dpr = typeof window !== \"undefined\" ? window.devicePixelRatio || 1 : 1;\n\n  return (\n    <canvas\n      ref={canvasRef}\n      className={className}\n      data-agent-orb\n      data-orb-state={state}\n      width={size * dpr}\n      height={size * dpr}\n      style={{ width: size, height: size, display: \"block\" }}\n      aria-hidden=\"true\"\n    />\n  );\n}\n",
      "type": "registry:ui",
      "target": ""
    }
  ]
}
