{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ai-voice",
  "title": "AI Voice",
  "description": "Voice activity visualizer with orb, bars, wave and dots variants, driven by the microphone, simulated speech or a controlled level.",
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://trytweenly.vercel.app/r/use-reduced-motion.json"
  ],
  "files": [
    {
      "path": "registry/new-york/ai-voice/ai-voice.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\nimport { AnimatePresence, motion } from \"motion/react\"\nimport { Mic, MicOff } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\nimport { useReducedMotion } from \"@/registry/new-york/hooks/use-reduced-motion\"\n\nexport type AiVoiceVariant = \"orb\" | \"bars\" | \"wave\" | \"dots\"\nexport type AiVoiceSource = \"mic\" | \"simulated\" | \"level\"\nexport type AiVoiceState = \"idle\" | \"listening\" | \"thinking\" | \"speaking\"\n\nexport interface AiVoiceProps {\n  /** Visual style: breathing orb, equalizer bars, flowing line or bouncing dots. Default: \"orb\" */\n  variant?: AiVoiceVariant\n  /** Where the audio level comes from: the microphone, smooth seeded noise, or the `level` prop. Default: \"simulated\" */\n  source?: AiVoiceSource\n  /** Conversation state. Idle breathes, thinking shimmers, listening and speaking react to audio. Default: \"speaking\" */\n  state?: AiVoiceState\n  /** Controlled audio level from 0 to 1, used when `source` is \"level\". Default: 0 */\n  level?: number\n  /** Controls the microphone when `source` is \"mic\". Leave undefined to use the built-in toggle. Default: undefined */\n  active?: boolean\n  /** Base color for idle and listening (any CSS color). Default: \"currentColor\" */\n  color?: string\n  /** Color for speaking and the thinking shimmer (any CSS color). Default: \"#ff4d12\" */\n  accent?: string\n  /** Size in px. The orb is square; other variants are twice as wide as tall. Default: 120 */\n  size?: number\n  /** Number of bars (bars variant) or dots (dots variant). Default: 24 for bars, 5 for dots */\n  bars?: number\n  /** Multiplier applied to the incoming level. Default: 1 */\n  sensitivity?: number\n  /** Frame-to-frame smoothing from 0 (raw) to 0.98 (very smooth). Default: 0.75 */\n  smoothing?: number\n  /** Show the built-in microphone toggle when `source` is \"mic\" and `active` is undefined. Default: true */\n  showToggle?: boolean\n  /** Called every frame the smoothed level changes, with a value from 0 to 1. */\n  onLevel?: (level: number) => void\n  /** Additional classes for the root element. Default: undefined */\n  className?: string\n}\n\ntype MicStatus = \"off\" | \"requesting\" | \"on\" | \"denied\" | \"unavailable\"\ntype RGB = [number, number, number]\n\nconst ease = [0.22, 1, 0.36, 1] as const\n\nconst clamp = (v: number, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, v))\nconst hash = (n: number, seed: number) => {\n  const x = Math.sin(n * 127.1 + seed * 311.7) * 43758.5453\n  return x - Math.floor(x)\n}\n/** Smooth seeded value noise in [0, 1]. */\nconst noise = (x: number, seed = 0) => {\n  const i = Math.floor(x)\n  const f = x - i\n  const u = f * f * (3 - 2 * f)\n  return hash(i, seed) * (1 - u) + hash(i + 1, seed) * u\n}\n/** Speech-like envelope: syllable bursts riding on slower phrases. */\nconst speech = (t: number) => {\n  const phrase = noise(t * 0.45, 3)\n  const syllable = noise(t * 5.2, 11)\n  const gate = clamp((phrase - 0.28) * 2.4)\n  return clamp(0.08 + gate * (0.25 + syllable * 0.85))\n}\nconst mix = (a: RGB, b: RGB, m: number): RGB => [a[0] + (b[0] - a[0]) * m, a[1] + (b[1] - a[1]) * m, a[2] + (b[2] - a[2]) * m]\nconst rgba = (c: RGB, a: number) => `rgba(${c[0] | 0},${c[1] | 0},${c[2] | 0},${clamp(a)})`\n\nexport function AiVoice({\n  variant = \"orb\",\n  source = \"simulated\",\n  state = \"speaking\",\n  level = 0,\n  active,\n  color = \"currentColor\",\n  accent = \"#ff4d12\",\n  size = 120,\n  bars,\n  sensitivity = 1,\n  smoothing = 0.75,\n  showToggle = true,\n  onLevel,\n  className,\n}: AiVoiceProps) {\n  const reduced = useReducedMotion()\n  const canvasRef = useRef<HTMLCanvasElement>(null)\n  const analyserRef = useRef<AnalyserNode | null>(null)\n\n  const [innerOn, setInnerOn] = useState(false)\n  const [attempt, setAttempt] = useState(0)\n  const micWanted = source === \"mic\" && (active ?? innerOn)\n  const [mic, setMic] = useState<MicStatus>(micWanted ? \"requesting\" : \"off\")\n  const [prevWanted, setPrevWanted] = useState(micWanted)\n  if (prevWanted !== micWanted) {\n    setPrevWanted(micWanted)\n    setMic(micWanted ? \"requesting\" : \"off\")\n  }\n\n  const live = useRef({ variant, source, state, level, color, accent, sensitivity, smoothing, bars, reduced, onLevel })\n  useEffect(() => {\n    live.current = { variant, source, state, level, color, accent, sensitivity, smoothing, bars, reduced, onLevel }\n  })\n\n  // Microphone: getUserMedia + AnalyserNode while wanted\n  useEffect(() => {\n    if (!micWanted) return\n    let cancelled = false\n    let stream: MediaStream | null = null\n    let ctx: AudioContext | null = null\n    const md = typeof navigator !== \"undefined\" ? navigator.mediaDevices : undefined\n    const req = md?.getUserMedia\n      ? md.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true } })\n      : Promise.reject(new DOMException(\"No media devices\", \"NotFoundError\"))\n    req\n      .then((s) => {\n        if (cancelled) {\n          s.getTracks().forEach((t) => t.stop())\n          return\n        }\n        stream = s\n        ctx = new AudioContext()\n        const analyser = ctx.createAnalyser()\n        analyser.fftSize = 1024\n        analyser.smoothingTimeConstant = 0.55\n        ctx.createMediaStreamSource(s).connect(analyser)\n        analyserRef.current = analyser\n        setMic(\"on\")\n      })\n      .catch((err: unknown) => {\n        if (cancelled) return\n        const name = err instanceof DOMException ? err.name : \"\"\n        setMic(name === \"NotAllowedError\" || name === \"SecurityError\" ? \"denied\" : \"unavailable\")\n      })\n    return () => {\n      cancelled = true\n      analyserRef.current = null\n      stream?.getTracks().forEach((t) => t.stop())\n      void ctx?.close()\n    }\n  }, [micWanted, attempt])\n\n  // Single rAF render loop\n  useEffect(() => {\n    const canvas = canvasRef.current\n    const ctx = canvas?.getContext(\"2d\")\n    if (!canvas || !ctx) return\n\n    let raf = 0\n    let last = 0\n    let t = 0\n    let W = 0\n    let H = 0\n    let onScreen = true\n    let lvl = 0\n    let reportedLvl = -1\n    let react = 0\n    let think = 0\n    let speak = 0\n    let idle = 1\n    let bands = new Float32Array(0)\n    let timeBuf = new Float32Array(0)\n    let freqBuf = new Uint8Array(0)\n    let base: RGB = [237, 237, 237]\n    let acc: RGB = [255, 77, 18]\n    let resolvedFor = \"\"\n    let dirty = true\n\n    const probe = document.createElement(\"canvas\").getContext(\"2d\", { willReadFrequently: true })\n    const resolve = (c: string, fallback: RGB): RGB => {\n      if (!probe) return fallback\n      const css = c === \"currentColor\" ? getComputedStyle(canvas).color : c\n      probe.clearRect(0, 0, 1, 1)\n      probe.fillStyle = \"#000\"\n      probe.fillStyle = css\n      probe.fillRect(0, 0, 1, 1)\n      const d = probe.getImageData(0, 0, 1, 1).data\n      return d[3] === 0 ? fallback : [d[0], d[1], d[2]]\n    }\n\n    const resize = () => {\n      const r = canvas.getBoundingClientRect()\n      W = r.width\n      H = r.height\n      const dpr = Math.min(window.devicePixelRatio || 1, 2)\n      canvas.width = Math.max(1, Math.round(W * dpr))\n      canvas.height = Math.max(1, Math.round(H * dpr))\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n    }\n\n    const blob = (cx: number, cy: number, r: number, amp: number, phase: number) => {\n      const N = 72\n      const pts: [number, number][] = []\n      for (let k = 0; k < N; k++) {\n        const a = (k / N) * Math.PI * 2\n        const w2 = noise(t * 0.7 + 10, phase)\n        const w3 = noise(t * 0.9 + 20, phase + 1)\n        const w5 = noise(t * 1.3 + 30, phase + 2)\n        const d =\n          Math.sin(2 * a + t * 1.1 + phase) * w2 * 0.5 +\n          Math.sin(3 * a - t * 1.6 + phase * 2) * w3 * 0.35 +\n          Math.sin(5 * a + t * 2.3 + phase * 3) * w5 * 0.25\n        const rr = r * (1 + amp * d)\n        pts.push([cx + Math.cos(a) * rr, cy + Math.sin(a) * rr])\n      }\n      ctx.beginPath()\n      const m0x = (pts[N - 1][0] + pts[0][0]) / 2\n      const m0y = (pts[N - 1][1] + pts[0][1]) / 2\n      ctx.moveTo(m0x, m0y)\n      for (let k = 0; k < N; k++) {\n        const p = pts[k]\n        const q = pts[(k + 1) % N]\n        ctx.quadraticCurveTo(p[0], p[1], (p[0] + q[0]) / 2, (p[1] + q[1]) / 2)\n      }\n      ctx.closePath()\n    }\n\n    const pill = (x: number, y: number, w: number, h: number) => {\n      ctx.beginPath()\n      if (ctx.roundRect) ctx.roundRect(x, y, w, h, Math.min(w, h) / 2)\n      else ctx.rect(x, y, w, h)\n      ctx.fill()\n    }\n\n    const draw = (dt: number) => {\n      const p = live.current\n      if (!p.reduced) t += dt\n      if (dirty || resolvedFor !== p.color + \"|\" + p.accent) {\n        base = resolve(p.color, base)\n        acc = resolve(p.accent, acc)\n        resolvedFor = p.color + \"|\" + p.accent\n        dirty = false\n      }\n\n      const n = p.variant === \"bars\" ? clamp(Math.round(p.bars ?? 24), 3, 96) : p.variant === \"dots\" ? clamp(Math.round(p.bars ?? 5), 2, 16) : 1\n      if (bands.length !== n) bands = new Float32Array(n)\n\n      const reactive = p.state === \"listening\" || p.state === \"speaking\"\n      const sens = Math.max(0, p.sensitivity)\n      const k = 1 - Math.pow(clamp(p.smoothing, 0, 0.98), dt * 60)\n      const km = 1 - Math.pow(0.88, dt * 60)\n      react += ((reactive ? 1 : 0) - react) * km\n      think += ((p.state === \"thinking\" ? 1 : 0) - think) * km\n      speak += ((p.state === \"speaking\" ? 1 : 0) - speak) * km\n      idle += ((p.state === \"idle\" ? 1 : 0) - idle) * km\n\n      // Raw level from the selected source\n      const an = p.source === \"mic\" ? analyserRef.current : null\n      let raw = 0\n      if (an) {\n        if (timeBuf.length !== an.fftSize) timeBuf = new Float32Array(an.fftSize)\n        if (freqBuf.length !== an.frequencyBinCount) freqBuf = new Uint8Array(an.frequencyBinCount)\n        an.getFloatTimeDomainData(timeBuf)\n        an.getByteFrequencyData(freqBuf)\n        let sum = 0\n        for (let i = 0; i < timeBuf.length; i++) sum += timeBuf[i] * timeBuf[i]\n        raw = clamp(Math.sqrt(sum / timeBuf.length) * 5 * sens)\n      } else if (p.source === \"level\") raw = clamp(p.level * sens)\n      else if (p.source === \"simulated\") raw = clamp(speech(t) * sens)\n\n      lvl += ((reactive ? raw : 0) - lvl) * k\n      if (Math.abs(lvl - reportedLvl) > 0.002) {\n        reportedLvl = lvl\n        p.onLevel?.(lvl)\n      }\n\n      // Per-band values, center weighted\n      const c = (n - 1) / 2\n      for (let i = 0; i < n; i++) {\n        const dist = c === 0 ? 0 : Math.abs(i - c) / c\n        const weight = 0.3 + 0.7 * Math.pow(Math.cos((dist * Math.PI) / 2), 0.9)\n        let v: number\n        if (an && freqBuf.length) {\n          const bin = Math.floor(2 + dist * freqBuf.length * 0.35)\n          v = clamp((freqBuf[bin] / 255) * 1.15 * sens) * (0.55 + 0.45 * weight)\n        } else {\n          v = lvl * (0.35 + 0.75 * noise(i * 0.61 + t * 3.4, 7)) * weight\n        }\n        bands[i] += ((reactive ? clamp(v) : 0) - bands[i]) * k\n      }\n\n      const breathe = 0.5 + 0.5 * Math.sin(t * 1.4)\n      const col = mix(base, acc, speak)\n      ctx.clearRect(0, 0, W, H)\n\n      if (p.variant === \"orb\") {\n        const cx = W / 2\n        const cy = H / 2\n        const R0 = Math.min(W, H) * 0.27\n        const R = R0 * (1 + idle * 0.05 * breathe + think * 0.03 * Math.sin(t * 3) + lvl * 0.24)\n        const amp = 0.012 + idle * 0.01 + react * (0.03 + lvl * 0.2) + think * 0.02\n\n        // Flat halo disc behind the blobs\n        ctx.fillStyle = rgba(col, 0.07 + lvl * 0.12 + think * 0.04)\n        ctx.beginPath()\n        ctx.arc(cx, cy, R * 1.4, 0, Math.PI * 2)\n        ctx.fill()\n\n        blob(cx, cy, R * 1.17, amp * 1.4, 4.1)\n        ctx.fillStyle = rgba(col, 0.14)\n        ctx.fill()\n        blob(cx, cy, R * 1.08, amp * 1.2, 2.3)\n        ctx.fillStyle = rgba(col, 0.26)\n        ctx.fill()\n        blob(cx, cy, R, amp, 0.7)\n        ctx.fillStyle = rgba(col, 1)\n        ctx.fill()\n\n        if (think > 0.01) {\n          // Rotating solid arc\n          const rr = R * 1.32\n          const start = t * 2.6\n          const span = Math.PI * 0.7\n          ctx.lineCap = \"round\"\n          ctx.lineWidth = Math.max(1.5, R0 * 0.06)\n          ctx.strokeStyle = rgba(acc, think)\n          ctx.beginPath()\n          ctx.arc(cx, cy, rr, start, start + span)\n          ctx.stroke()\n          // Flat sheen disc orbiting inside the core\n          ctx.save()\n          blob(cx, cy, R, amp, 0.7)\n          ctx.clip()\n          const sx = cx + Math.cos(t * 2.6) * R * 0.6\n          const sy = cy + Math.sin(t * 2.6) * R * 0.6\n          ctx.fillStyle = rgba(acc, 0.35 * think)\n          ctx.beginPath()\n          ctx.arc(sx, sy, R * 0.45, 0, Math.PI * 2)\n          ctx.fill()\n          ctx.restore()\n        }\n      } else if (p.variant === \"bars\") {\n        const unit = W / (n * 1.6 - 0.6)\n        const bw = Math.min(unit, 14)\n        const gap = n > 1 ? (W - bw * n) / (n - 1) : 0\n        const cy = H / 2\n        for (let i = 0; i < n; i++) {\n          const dist = c === 0 ? 0 : Math.abs(i - c) / c\n          const weight = 0.3 + 0.7 * Math.cos((dist * Math.PI) / 2)\n          const wave = Math.pow(Math.max(0, Math.sin(i * 0.45 - t * 5)), 3)\n          const restH = H * (0.07 + idle * 0.07 * weight * (0.6 + 0.4 * Math.sin(t * 1.4 - dist * 1.6)))\n          const h = Math.max(bw, restH + bands[i] * H * 0.9 + think * wave * weight * H * 0.32)\n          const hh = Math.min(H, h)\n          ctx.fillStyle = rgba(think > 0.01 ? mix(col, acc, wave * think) : col, 1 - think * 0.45 * (1 - wave))\n          pill(i * (bw + gap), cy - hh / 2, bw, hh)\n        }\n      } else if (p.variant === \"wave\") {\n        const cy = H / 2\n        const amp = H * 0.42 * lvl + idle * H * 0.035 * (0.6 + 0.4 * breathe) + think * H * 0.09 + H * 0.01\n        const lines = [\n          { a: 1, w: 2.2, o: 0 },\n          { a: 0.35, w: 1.5, o: 1.7 },\n          { a: 0.18, w: 1.2, o: 3.9 },\n        ]\n        const path = (o: number, scale: number) => {\n          ctx.beginPath()\n          for (let x = 0; x <= W; x += 2) {\n            const u = x / W\n            const env = Math.pow(Math.sin(Math.PI * u), 1.6)\n            const y =\n              cy +\n              amp * scale * env *\n                (Math.sin(u * 9 + t * 3.2 + o) * 0.6 + Math.sin(u * 17 - t * 4.4 + o * 2) * 0.3 + Math.sin(u * 4 + t * 1.3 + o) * 0.25)\n            if (x === 0) ctx.moveTo(x, y)\n            else ctx.lineTo(x, y)\n          }\n        }\n        ctx.lineCap = \"round\"\n        ctx.lineJoin = \"round\"\n        for (let l = lines.length - 1; l >= 0; l--) {\n          const L = lines[l]\n          path(L.o, l === 0 ? 1 : 0.75 - l * 0.12)\n          ctx.lineWidth = L.w\n          ctx.strokeStyle = rgba(col, L.a)\n          ctx.stroke()\n        }\n        if (think > 0.01) {\n          // Solid highlight segment travelling along the main line\n          const pos = ((t * 0.55) % 1.6) - 0.3\n          ctx.save()\n          ctx.beginPath()\n          ctx.rect((pos - 0.1) * W, 0, W * 0.2, H)\n          ctx.clip()\n          path(0, 1)\n          ctx.lineWidth = 2.6\n          ctx.strokeStyle = rgba(acc, think)\n          ctx.stroke()\n          ctx.restore()\n        }\n      } else {\n        const r = Math.min(H * 0.11, W / (n * 3.2))\n        const spacing = r * 3\n        const x0 = W / 2 - ((n - 1) * spacing) / 2\n        const cy = H / 2 + H * 0.12\n        for (let i = 0; i < n; i++) {\n          const hop = Math.pow(Math.max(0, Math.sin(t * 6 - i * 0.75)), 2)\n          const rest = idle * Math.sin(t * 1.4 - i * 0.6) * r * 0.35\n          const y = cy - bands[i] * H * 0.42 - think * hop * H * 0.2 + rest\n          const s = 1 + bands[i] * 0.35\n          ctx.fillStyle = rgba(think > 0.01 ? mix(col, acc, hop * think) : col, 1 - think * 0.4 * (1 - hop))\n          ctx.beginPath()\n          ctx.arc(x0 + i * spacing, y, r * s, 0, Math.PI * 2)\n          ctx.fill()\n        }\n      }\n    }\n\n    const frame = (now: number) => {\n      raf = 0\n      const dt = last ? Math.min(Math.max((now - last) / 1000, 0.001), 0.05) : 1 / 60\n      last = now\n      draw(dt)\n      if (onScreen && !document.hidden) raf = requestAnimationFrame(frame)\n    }\n    const start = () => {\n      if (raf || !onScreen || document.hidden) return\n      last = 0\n      raf = requestAnimationFrame(frame)\n    }\n    const stop = () => {\n      cancelAnimationFrame(raf)\n      raf = 0\n    }\n\n    resize()\n    start()\n    const ro = new ResizeObserver(() => {\n      resize()\n      if (!raf) draw(0)\n    })\n    ro.observe(canvas)\n    const io = new IntersectionObserver((entries) => {\n      onScreen = entries[entries.length - 1]?.isIntersecting ?? true\n      if (onScreen) start()\n      else stop()\n    })\n    io.observe(canvas)\n    const onVisibility = () => (document.hidden ? stop() : start())\n    document.addEventListener(\"visibilitychange\", onVisibility)\n    const mo = new MutationObserver(() => {\n      dirty = true\n    })\n    mo.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\", \"style\", \"data-theme\"] })\n\n    return () => {\n      stop()\n      ro.disconnect()\n      io.disconnect()\n      mo.disconnect()\n      document.removeEventListener(\"visibilitychange\", onVisibility)\n    }\n  }, [])\n\n  const square = variant === \"orb\"\n  const toggleVisible = source === \"mic\" && showToggle && active === undefined\n  const listening = mic === \"on\" || mic === \"requesting\"\n  const message =\n    source !== \"mic\"\n      ? null\n      : mic === \"requesting\"\n        ? \"Waiting for microphone…\"\n        : mic === \"denied\"\n          ? \"Microphone access was denied. Allow it in your browser settings.\"\n          : mic === \"unavailable\"\n            ? \"No microphone available.\"\n            : null\n\n  const toggle = () => {\n    if (innerOn && (mic === \"denied\" || mic === \"unavailable\")) {\n      setMic(\"requesting\")\n      setAttempt((a) => a + 1)\n      return\n    }\n    setInnerOn((v) => !v)\n  }\n\n  return (\n    <div className={cn(\"inline-flex flex-col items-center gap-3\", className)}>\n      <canvas\n        ref={canvasRef}\n        role=\"img\"\n        aria-label={`Voice activity: ${state}`}\n        className=\"block\"\n        style={{ width: square ? size : size * 2, height: square ? size : size * 0.6 }}\n      />\n      {toggleVisible && (\n        <motion.button\n          type=\"button\"\n          onClick={toggle}\n          aria-pressed={listening}\n          aria-label={listening ? \"Turn microphone off\" : \"Turn microphone on\"}\n          whileTap={reduced ? undefined : { scale: 0.94 }}\n          transition={{ type: \"spring\", stiffness: 450, damping: 32 }}\n          className={cn(\n            \"inline-flex h-9 items-center gap-2 rounded-full border px-3.5 text-xs font-medium transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n            listening ? \"border-transparent text-white\" : \"bg-card text-foreground hover:bg-foreground/[0.05]\"\n          )}\n          style={listening ? { backgroundColor: accent } : undefined}\n        >\n          {listening ? <Mic className=\"size-3.5\" aria-hidden /> : <MicOff className=\"size-3.5\" aria-hidden />}\n          {listening ? \"Listening\" : \"Use microphone\"}\n        </motion.button>\n      )}\n      <div role=\"status\" aria-live=\"polite\" className=\"min-h-0 text-center\">\n        <AnimatePresence initial={false}>\n          {message && (\n            <motion.p\n              key={message}\n              initial={{ opacity: 0, y: 4 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={{ opacity: 0, y: -4 }}\n              transition={{ duration: reduced ? 0 : 0.3, ease }}\n              className=\"max-w-[16rem] text-xs text-muted-foreground\"\n            >\n              {message}\n            </motion.p>\n          )}\n        </AnimatePresence>\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "docs": "Docs, live playground and every prop: https://trytweenly.vercel.app/docs/components/ai-voice",
  "type": "registry:component"
}