{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "voice-input",
  "title": "Voice Input",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A microphone control as a round button or an expanding recorder with a scrolling waveform, timer, discard and send, plus a hook that reads the real microphone level.",
  "dependencies": [
    "motion@^12",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/voice-input.tsx",
      "content": "// Ballmac UI: Voice Input. https://ui.ballmac.com/components/voice-input\n\"use client\"\n\nimport * as React from \"react\"\nimport { Check, Loader2, Mic, Square, X } from \"lucide-react\"\nimport { motion, useReducedMotion } from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useMessages } from \"@/lib/ballmac/i18n\"\n\ntype VoiceState = \"idle\" | \"listening\" | \"processing\"\n\n/**\n * Microphone loudness from 0 to 1 while `active` is true, read from the real input with the Web Audio API.\n * Nothing is requested until `active` turns on. If the user refuses, `error` holds the reason.\n */\nfunction useMicrophoneLevel(active: boolean) {\n  const [level, setLevel] = React.useState(0)\n  const [error, setError] = React.useState<string | null>(null)\n\n  React.useEffect(() => {\n    if (!active) {\n      setLevel(0)\n      return\n    }\n    let cancelled = false\n    let raf = 0\n    let stream: MediaStream | undefined\n    let ctx: AudioContext | undefined\n\n    async function start() {\n      try {\n        stream = await navigator.mediaDevices.getUserMedia({ audio: true })\n        if (cancelled) return stream.getTracks().forEach((t) => t.stop())\n        ctx = new AudioContext()\n        const analyser = ctx.createAnalyser()\n        analyser.fftSize = 256\n        ctx.createMediaStreamSource(stream).connect(analyser)\n        const data = new Uint8Array(analyser.frequencyBinCount)\n        const tick = () => {\n          analyser.getByteTimeDomainData(data)\n          let sum = 0\n          for (const v of data) sum += ((v - 128) / 128) ** 2\n          setLevel(Math.min(1, Math.sqrt(sum / data.length) * 3.2))\n          raf = requestAnimationFrame(tick)\n        }\n        tick()\n      } catch (e) {\n        setError(e instanceof Error ? e.message : \"Microphone unavailable\")\n      }\n    }\n    setError(null)\n    void start()\n\n    return () => {\n      cancelled = true\n      cancelAnimationFrame(raf)\n      stream?.getTracks().forEach((t) => t.stop())\n      void ctx?.close()\n    }\n  }, [active])\n\n  return { level, error }\n}\n\nfunction formatClock(total: number) {\n  const s = Math.max(0, Math.floor(total))\n  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, \"0\")}`\n}\n\ntype VoiceInputProps = Omit<React.ComponentProps<\"div\">, \"children\" | \"onChange\"> & {\n  /** Current state (controlled). Without it the component keeps its own state and moves idle → listening → processing. */\n  state?: VoiceState\n  /** Called when recording should begin. */\n  onStart?: () => void\n  /** Called when the person finishes and wants the audio used. */\n  onStop?: () => void\n  /** Called when the person throws the recording away. */\n  onCancel?: () => void\n  /** Loudness from 0 to 1, for example from `useMicrophoneLevel`. Drives the waveform and the button's glow. */\n  level?: number\n  /** \"button\" is a single round microphone button. \"bar\" expands into a recorder with waveform, timer, cancel and send. */\n  variant?: \"button\" | \"bar\"\n  /** Seconds recorded. When omitted the component times itself. */\n  duration?: number\n  /** Text in the bar while processing. */\n  processingLabel?: string\n  /** Accessible name of the button when idle. */\n  label?: string\n}\n\nconst BAR_COUNT = 32\n\nfunction VoiceInput({\n  state: stateProp,\n  onStart,\n  onStop,\n  onCancel,\n  level = 0,\n  variant = \"button\",\n  duration,\n  processingLabel,\n  label,\n  className,\n  ...props\n}: VoiceInputProps) {\n  const msg = useMessages()\n  processingLabel ??= msg(\"voice-input.processingLabel\", \"Transcribing…\")\n  label ??= msg(\"voice-input.label\", \"Start voice input\")\n  const reduce = useReducedMotion()\n  const [internal, setInternal] = React.useState<VoiceState>(\"idle\")\n  const state = stateProp ?? internal\n  const [seconds, setSeconds] = React.useState(0)\n  const [history, setHistory] = React.useState<number[]>(() => Array<number>(BAR_COUNT).fill(0))\n  const startRef = React.useRef<HTMLButtonElement>(null)\n  const prev = React.useRef<VoiceState>(state)\n\n  const listening = state === \"listening\"\n\n  React.useEffect(() => {\n    if (!listening) {\n      setSeconds(0)\n      setHistory(Array<number>(BAR_COUNT).fill(0))\n      return\n    }\n    const id = setInterval(() => setSeconds((s) => s + 1), 1000)\n    return () => clearInterval(id)\n  }, [listening])\n\n  const levelRef = React.useRef(level)\n  React.useEffect(() => {\n    levelRef.current = level\n  })\n  // Sample the level on a steady beat so the waveform scrolls even when the level holds still.\n  React.useEffect(() => {\n    if (!listening || variant !== \"bar\") return\n    const id = setInterval(() => {\n      setHistory((h) => [...h.slice(1), Math.min(1, Math.max(0, levelRef.current))])\n    }, 70)\n    return () => clearInterval(id)\n  }, [listening, variant])\n\n  // After a recording ends, put focus back where the microphone button is.\n  React.useEffect(() => {\n    if (prev.current !== \"idle\" && state === \"idle\") startRef.current?.focus()\n    prev.current = state\n  }, [state])\n\n  function start() {\n    setInternal(\"listening\")\n    onStart?.()\n  }\n  function stop() {\n    setInternal(\"processing\")\n    onStop?.()\n  }\n  function cancel() {\n    setInternal(\"idle\")\n    onCancel?.()\n  }\n\n  const shownSeconds = duration ?? seconds\n  const announce = listening ? \"Listening\" : state === \"processing\" ? processingLabel : \"\"\n  const escape = (e: React.KeyboardEvent) => {\n    if (e.key === \"Escape\" && listening) {\n      e.stopPropagation()\n      cancel()\n    }\n  }\n\n  if (variant === \"button\") {\n    return (\n      <div\n        data-slot=\"voice-input\"\n        data-state={state}\n        data-variant=\"button\"\n        className={cn(\"relative inline-flex size-10 items-center justify-center\", className)}\n        onKeyDown={escape}\n        {...props}\n      >\n        {listening && !reduce && (\n          <motion.span\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute inset-0 rounded-full bg-foreground/15\"\n            animate={{ scale: 1 + level * 0.7, opacity: 0.4 + level * 0.6 }}\n            transition={{ type: \"spring\", stiffness: 260, damping: 18 }}\n          />\n        )}\n        <button\n          ref={startRef}\n          type=\"button\"\n          disabled={state === \"processing\"}\n          aria-label={listening ? msg(\"voice-input.stopAndUseRecording\", \"Stop and use recording\") : state === \"processing\" ? processingLabel : label}\n          aria-pressed={listening}\n          onClick={listening ? stop : start}\n          className={cn(\n            \"relative inline-flex size-10 items-center justify-center rounded-full border outline-none transition-[background-color,color,border-color,box-shadow] duration-150 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-progress motion-reduce:transition-none\",\n            listening\n              ? \"border-transparent bg-primary text-primary-foreground\"\n              : \"bg-background text-foreground shadow-xs hover:bg-accent\"\n          )}\n        >\n          {state === \"processing\" ? (\n            <Loader2 aria-hidden=\"true\" className=\"size-4 animate-spin motion-reduce:animate-none\" />\n          ) : listening ? (\n            <Square aria-hidden=\"true\" className=\"size-3.5 fill-current\" />\n          ) : (\n            <Mic aria-hidden=\"true\" className=\"size-4\" />\n          )}\n        </button>\n        <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n          {announce}\n        </span>\n      </div>\n    )\n  }\n\n  return (\n    <div\n      data-slot=\"voice-input\"\n      data-state={state}\n      data-variant=\"bar\"\n      className={cn(\"w-full max-w-md\", className)}\n      onKeyDown={escape}\n      {...props}\n    >\n      {state === \"idle\" ? (\n        <button\n          ref={startRef}\n          type=\"button\"\n          onClick={start}\n          className=\"inline-flex h-10 items-center gap-2 rounded-full border bg-background px-4 text-sm font-medium shadow-xs outline-none transition-colors hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50\"\n        >\n          <Mic aria-hidden=\"true\" className=\"size-4\" />\n          {label}\n        </button>\n      ) : (\n        <div className=\"flex h-12 items-center gap-2 rounded-full border bg-card px-1.5 shadow-xs\">\n          <button\n            type=\"button\"\n            onClick={cancel}\n            disabled={state === \"processing\"}\n            aria-label={msg(\"voice-input.discardRecording\", \"Discard recording\")}\n            className=\"inline-flex size-9 shrink-0 items-center justify-center rounded-full text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-50\"\n          >\n            <X aria-hidden=\"true\" className=\"size-4\" />\n          </button>\n          {listening ? (\n            <>\n              <div aria-hidden=\"true\" className=\"flex h-8 min-w-0 flex-1 items-center justify-between gap-[3px] overflow-hidden px-1\">\n                {history.map((v, i) => (\n                  <span\n                    key={i}\n                    className=\"w-[3px] shrink-0 rounded-full bg-foreground\"\n                    style={{\n                      height: `${Math.max(12, Math.round(12 + v * 88))}%`,\n                      opacity: 0.25 + (i / BAR_COUNT) * 0.75,\n                    }}\n                  />\n                ))}\n              </div>\n              <span className=\"w-10 shrink-0 text-end font-mono text-xs text-muted-foreground tabular-nums\">\n                <span className=\"sr-only\">{msg(\"voice-input.recordingTime\", \"Recording time\")} </span>\n                {formatClock(shownSeconds)}\n              </span>\n              <button\n                type=\"button\"\n                onClick={stop}\n                aria-label={msg(\"voice-input.stopAndUseRecording\", \"Stop and use recording\")}\n                className=\"inline-flex size-9 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground outline-none transition-colors hover:bg-primary/90 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n              >\n                <Check aria-hidden=\"true\" className=\"size-4\" />\n              </button>\n            </>\n          ) : (\n            <p className=\"flex min-w-0 flex-1 items-center justify-center gap-2 pe-10 text-sm text-muted-foreground\">\n              <Loader2 aria-hidden=\"true\" className=\"size-4 animate-spin motion-reduce:animate-none\" />\n              {processingLabel}\n            </p>\n          )}\n        </div>\n      )}\n      <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n        {announce}\n      </span>\n    </div>\n  )\n}\n\nexport { VoiceInput, useMicrophoneLevel, formatClock, type VoiceInputProps, type VoiceState }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/voice-input.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "ai",
      "voice",
      "microphone",
      "audio",
      "recorder",
      "input"
    ],
    "version": "1.0.0",
    "updated": "2026-09-30",
    "ai": {
      "summary": "The component draws the control; you record. onStart, onStop, onCancel fire; state (idle | listening | processing) can be controlled. Feed level (0 to 1) from useMicrophoneLevel(active) for a real waveform.",
      "whenToUse": [
        "Dictation in a composer",
        "Voice messages"
      ],
      "whenNotToUse": [
        "Text entry; use prompt-input",
        "Playing audio; use audio-player"
      ],
      "composesWith": [
        "prompt-input",
        "ai-orb",
        "ai-chat"
      ],
      "a11y": [
        {
          "keys": "Enter / Space",
          "action": "Starts, and while listening stops and uses the recording"
        },
        {
          "keys": "Escape",
          "action": "Discards the recording while listening"
        },
        {
          "keys": "Screen readers",
          "action": "Button names change with state; status is announced politely; the waveform is decorative"
        }
      ],
      "customization": [
        "variant: button | bar",
        "state",
        "level",
        "duration",
        "processingLabel",
        "useMicrophoneLevel"
      ]
    },
    "examples": [
      "voice-input-demo",
      "voice-input-button"
    ],
    "url": "https://ui.ballmac.com/components/voice-input"
  },
  "categories": [
    "ai"
  ],
  "type": "registry:ui"
}