{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "voice-input",
  "title": "Voice Input",
  "description": "A microphone that draws what it is hearing, so a live one is told apart from a dead one at a glance.",
  "dependencies": [
    "lucide-react@^1.31.0"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.tinkererslabs.com/r/render-surface.json"
  ],
  "files": [
    {
      "path": "registry/default/voice-input/voice-input.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Mic, MicOff, Square } from \"lucide-react\"\n\nimport {\n  RenderSurface,\n  useThemeColors,\n  type SurfaceColor,\n} from \"@/registry/default/render-surface/render-surface\"\nimport { cn } from \"@/lib/utils\"\n\nexport type VoiceInputStatus =\n  \"unsupported\" | \"idle\" | \"requesting\" | \"listening\" | \"denied\" | \"error\"\n\nexport type VoiceInputProps = Omit<\n  React.HTMLAttributes<HTMLDivElement>,\n  \"onError\"\n> & {\n  /** The recording, once it has stopped. Send it wherever it gets read. */\n  onResult?: (recording: Blob) => void\n  onStart?: () => void\n  onStop?: () => void\n  onStatusChange?: (status: VoiceInputStatus) => void\n  /** Seconds after which it stops on its own. Off by default. */\n  maxDuration?: number\n  /** A theme token for the trace. */\n  color?: string\n  /** Preferred container. The browser decides if it cannot honour this. */\n  mimeType?: string\n  label?: string\n  disabled?: boolean\n}\n\nconst noop = () => () => {}\n\nfunction canRecord() {\n  return (\n    Boolean(navigator.mediaDevices?.getUserMedia) &&\n    typeof MediaRecorder !== \"undefined\"\n  )\n}\n\nfunction formatElapsed(seconds: number) {\n  const whole = Math.floor(seconds)\n  return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, \"0\")}`\n}\n\n/**\n * A microphone with something to show for itself: it draws what it is hearing\n * while it hears it, so the difference between a live microphone and a dead\n * one is visible rather than a matter of trust.\n *\n * It captures and hands back the recording. It does not transcribe, because\n * that is a service, not a component, and pretending otherwise would put a\n * vendor inside something you have to copy into your own project.\n */\nexport function VoiceInput({\n  onResult,\n  onStart,\n  onStop,\n  onStatusChange,\n  maxDuration,\n  color = \"--primary\",\n  mimeType,\n  label = \"Record a message\",\n  disabled = false,\n  className,\n  children,\n  ...rootProps\n}: VoiceInputProps) {\n  const rootRef = React.useRef<HTMLDivElement>(null)\n  const [status, setStatus] = React.useState<VoiceInputStatus>(\"idle\")\n  const [elapsed, setElapsed] = React.useState(0)\n\n  // The live sample the canvas reads. A ref, because a waveform arriving at\n  // the frame rate is not something to re-render the tree for.\n  const samples = React.useRef<Uint8Array<ArrayBuffer> | null>(null)\n  const session = React.useRef<{\n    stream: MediaStream\n    audio: AudioContext\n    analyser: AnalyserNode\n    recorder: MediaRecorder\n    chunks: Blob[]\n  } | null>(null)\n\n  const tokens = React.useMemo(\n    () => (color.startsWith(\"--\") ? [color, \"--muted-foreground\"] : []),\n    [color]\n  )\n  const resolved = useThemeColors(rootRef, tokens)\n  const ink = color.startsWith(\"--\") ? resolved[color] : undefined\n\n  // The server cannot know whether this browser can record, and most can, so\n  // it renders as though it can and the client corrects that if it cannot.\n  // Nothing to record with is a state to show, not a button that does nothing.\n  const supported = React.useSyncExternalStore(noop, canRecord, () => true)\n  const shown: VoiceInputStatus = supported ? status : \"unsupported\"\n\n  const listening = shown === \"listening\"\n\n  // A single painted frame is the right answer for decoration and the wrong\n  // one for a live meter: it would sit frozen while the microphone was open.\n  // Where motion is unwelcome the trace gives way to the words.\n  const [reduced, setReduced] = React.useState(false)\n\n  React.useEffect(() => {\n    const query = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n    const sync = () => setReduced(query.matches)\n\n    sync()\n    query.addEventListener(\"change\", sync)\n    return () => query.removeEventListener(\"change\", sync)\n  }, [])\n\n  const announce = React.useCallback(\n    (next: VoiceInputStatus) => {\n      setStatus(next)\n      onStatusChange?.(next)\n    },\n    [onStatusChange]\n  )\n\n  const release = React.useCallback(() => {\n    const current = session.current\n    if (!current) return\n\n    session.current = null\n    samples.current = null\n\n    if (current.recorder.state !== \"inactive\") current.recorder.stop()\n    for (const track of current.stream.getTracks()) track.stop()\n    void current.audio.close().catch(() => {})\n  }, [])\n\n  React.useEffect(() => release, [release])\n\n  React.useEffect(() => {\n    if (!listening) return\n\n    const started = Date.now()\n    const timer = setInterval(\n      () => setElapsed((Date.now() - started) / 1000),\n      200\n    )\n\n    return () => clearInterval(timer)\n  }, [listening])\n\n  const stop = React.useCallback(() => {\n    const current = session.current\n    if (!current) return\n\n    // The recorder hands over the audio on stop, so the blob is assembled in\n    // its own handler rather than here.\n    if (current.recorder.state !== \"inactive\") current.recorder.stop()\n    announce(\"idle\")\n    onStop?.()\n  }, [announce, onStop])\n\n  React.useEffect(() => {\n    if (!listening || maxDuration === undefined) return\n    if (elapsed < maxDuration) return\n\n    stop()\n  }, [elapsed, listening, maxDuration, stop])\n\n  const start = React.useCallback(async () => {\n    if (session.current) return\n    announce(\"requesting\")\n\n    let stream: MediaStream\n    try {\n      stream = await navigator.mediaDevices.getUserMedia({ audio: true })\n    } catch (error) {\n      // A refusal and a missing device are different things to say.\n      announce(\n        (error as DOMException)?.name === \"NotAllowedError\" ? \"denied\" : \"error\"\n      )\n      return\n    }\n\n    try {\n      const audio = new AudioContext()\n      const analyser = audio.createAnalyser()\n      analyser.fftSize = 1024\n      audio.createMediaStreamSource(stream).connect(analyser)\n\n      const recorder = new MediaRecorder(\n        stream,\n        mimeType && MediaRecorder.isTypeSupported(mimeType)\n          ? { mimeType }\n          : undefined\n      )\n      const chunks: Blob[] = []\n\n      recorder.addEventListener(\"dataavailable\", (event) => {\n        if (event.data.size > 0) chunks.push(event.data)\n      })\n      recorder.addEventListener(\"stop\", () => {\n        onResult?.(new Blob(chunks, { type: recorder.mimeType }))\n        release()\n      })\n\n      session.current = { stream, audio, analyser, recorder, chunks }\n      samples.current = new Uint8Array(analyser.frequencyBinCount)\n\n      recorder.start()\n      setElapsed(0)\n      announce(\"listening\")\n      onStart?.()\n    } catch {\n      for (const track of stream.getTracks()) track.stop()\n      announce(\"error\")\n    }\n  }, [announce, mimeType, onResult, onStart, release])\n\n  const setup = React.useCallback(() => null, [])\n\n  const draw = React.useCallback(\n    ({\n      context,\n      size: box,\n    }: {\n      context: CanvasRenderingContext2D\n      size: { width: number; height: number; dpr: number }\n    }) => {\n      if (!ink) return\n\n      context.setTransform(box.dpr, 0, 0, box.dpr, 0, 0)\n      context.clearRect(0, 0, box.width, box.height)\n\n      const current = session.current\n      const data = samples.current\n\n      // The surface already runs a frame loop, so the sample is taken here\n      // rather than in a second one of our own.\n      if (current && data) current.analyser.getByteTimeDomainData(data)\n      const [r, g, b] = ink as SurfaceColor\n      const middle = box.height / 2\n      const bars = Math.max(8, Math.floor(box.width / 5))\n      const width = box.width / bars\n\n      context.fillStyle = `rgb(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)})`\n\n      for (let bar = 0; bar < bars; bar += 1) {\n        // A flat line when there is nothing to hear, rather than an empty box.\n        let peak = 0.02\n\n        if (data && data.length > 0) {\n          const from = Math.floor((bar / bars) * data.length)\n          const to = Math.floor(((bar + 1) / bars) * data.length)\n\n          for (let at = from; at < to; at += 1) {\n            peak = Math.max(peak, Math.abs((data[at]! - 128) / 128))\n          }\n        }\n\n        const height = Math.max(2, peak * box.height * 0.9)\n        const x = bar * width\n\n        context.beginPath()\n        context.roundRect(\n          x + width * 0.2,\n          middle - height / 2,\n          Math.max(width * 0.6, 1),\n          height,\n          width * 0.3\n        )\n        context.fill()\n      }\n    },\n    [ink]\n  )\n\n  const message: Record<VoiceInputStatus, string> = {\n    unsupported: \"Recording is not available in this browser.\",\n    idle: \"Ready to record.\",\n    requesting: \"Waiting for permission to use the microphone.\",\n    listening: `Recording, ${formatElapsed(elapsed)}.`,\n    denied: \"Microphone permission was refused.\",\n    error: \"The microphone could not be started.\",\n  }\n\n  const blocked = disabled || !supported || shown === \"requesting\"\n\n  return (\n    <div\n      ref={rootRef}\n      data-slot=\"voice-input\"\n      data-status={shown}\n      className={cn(\n        \"border-border bg-background flex min-h-14 items-center gap-3 rounded-full border px-2 ps-3\",\n        className\n      )}\n      {...rootProps}\n    >\n      <button\n        type=\"button\"\n        aria-label={listening ? \"Stop recording\" : label}\n        aria-pressed={listening}\n        disabled={blocked}\n        onClick={() => (listening ? stop() : void start())}\n        className={cn(\n          \"focus-visible:ring-ring inline-flex size-10 shrink-0 items-center justify-center rounded-full transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50\",\n          listening\n            ? \"bg-foreground text-background\"\n            : \"bg-muted text-foreground hover:bg-muted/70\"\n        )}\n      >\n        {shown === \"unsupported\" || shown === \"denied\" ? (\n          <MicOff aria-hidden=\"true\" size={17} />\n        ) : listening ? (\n          <Square aria-hidden=\"true\" size={15} />\n        ) : (\n          <Mic aria-hidden=\"true\" size={17} />\n        )}\n      </button>\n\n      <div className=\"relative min-w-0 flex-1\">\n        {ink !== undefined && listening && !reduced ? (\n          <RenderSurface<null, \"2d\">\n            setup={setup}\n            draw={draw}\n            className=\"h-9 w-full\"\n            canvasClassName=\"h-full w-full\"\n          />\n        ) : (\n          // The same words are announced through the live region below, so\n          // this is the visual half of one message rather than a second one.\n          <p\n            aria-hidden=\"true\"\n            className=\"text-muted-foreground truncate text-xs\"\n          >\n            {children ?? message[shown]}\n          </p>\n        )}\n      </div>\n\n      {listening ? (\n        <span\n          data-slot=\"voice-input-elapsed\"\n          className=\"text-muted-foreground shrink-0 pe-2 font-mono text-xs tabular-nums\"\n        >\n          {formatElapsed(elapsed)}\n        </span>\n      ) : null}\n\n      <span aria-live=\"polite\" className=\"sr-only\">\n        {message[shown]}\n      </span>\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "ai",
    "agent",
    "chat",
    "input",
    "canvas",
    "media"
  ],
  "type": "registry:ui"
}