{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "mic-selector",
  "title": "Mic Selector",
  "description": "Picks the microphone, then proves the choice with a live level meter.",
  "dependencies": [
    "lucide-react@^1.31.0"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/default/mic-selector/mic-selector.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Mic, Square } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport type MicSelectorStatus =\n  \"unsupported\" | \"idle\" | \"requesting\" | \"testing\" | \"denied\" | \"error\"\n\nexport type MicSelectorProps = Omit<\n  React.HTMLAttributes<HTMLDivElement>,\n  \"onChange\" | \"onError\" | \"children\"\n> & {\n  /** The chosen device, when you hold the value yourself. */\n  value?: string\n  defaultValue?: string\n  onValueChange?: (deviceId: string) => void\n  onStatusChange?: (status: MicSelectorStatus) => void\n  /** Segments in the level meter. */\n  segments?: number\n  label?: string\n  disabled?: boolean\n}\n\nconst SEGMENTS = 12\n\nconst noop = () => () => {}\n\nfunction canEnumerate() {\n  return Boolean(navigator.mediaDevices?.enumerateDevices)\n}\n\n/**\n * Picks which microphone to use, and answers the question that follows it by\n * lighting a meter from the one you chose.\n *\n * Device labels are withheld until a page has been granted the microphone\n * once, so an untested list reads \"Microphone 1, Microphone 2\" and fills in\n * with real names as soon as the test is run.\n */\nexport function MicSelector({\n  value,\n  defaultValue = \"\",\n  onValueChange,\n  onStatusChange,\n  segments = SEGMENTS,\n  label = \"Microphone\",\n  disabled = false,\n  className,\n  ...rootProps\n}: MicSelectorProps) {\n  const [devices, setDevices] = React.useState<MediaDeviceInfo[]>([])\n  const [chosen, setChosen] = React.useState(defaultValue)\n  const [status, setStatus] = React.useState<MicSelectorStatus>(\"idle\")\n  const [level, setLevel] = React.useState(0)\n  const deviceId = React.useId()\n\n  const session = React.useRef<{\n    stream: MediaStream\n    audio: AudioContext\n    frame: number\n  } | null>(null)\n\n  const supported = React.useSyncExternalStore(noop, canEnumerate, () => true)\n  const shown: MicSelectorStatus = supported ? status : \"unsupported\"\n  const selected = value ?? chosen\n  const testing = shown === \"testing\"\n\n  const announce = React.useCallback(\n    (next: MicSelectorStatus) => {\n      setStatus(next)\n      onStatusChange?.(next)\n    },\n    [onStatusChange]\n  )\n\n  const list = React.useCallback(() => {\n    if (!canEnumerate()) return\n\n    navigator.mediaDevices\n      .enumerateDevices()\n      .then((found) =>\n        setDevices(found.filter((device) => device.kind === \"audioinput\"))\n      )\n      .catch(() => {})\n  }, [])\n\n  React.useEffect(() => {\n    list()\n    if (!navigator.mediaDevices?.addEventListener) return\n\n    const sync = () => list()\n    navigator.mediaDevices.addEventListener(\"devicechange\", sync)\n    return () =>\n      navigator.mediaDevices.removeEventListener(\"devicechange\", sync)\n  }, [list])\n\n  const release = React.useCallback(() => {\n    const current = session.current\n    if (!current) return\n\n    session.current = null\n    cancelAnimationFrame(current.frame)\n    for (const track of current.stream.getTracks()) track.stop()\n    void current.audio.close().catch(() => {})\n    setLevel(0)\n  }, [])\n\n  React.useEffect(() => release, [release])\n\n  const test = React.useCallback(async () => {\n    if (session.current) return\n    announce(\"requesting\")\n\n    let stream: MediaStream\n    try {\n      stream = await navigator.mediaDevices.getUserMedia({\n        audio: selected ? { deviceId: { exact: selected } } : true,\n      })\n    } catch (error) {\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 = 512\n      audio.createMediaStreamSource(stream).connect(analyser)\n\n      const data = new Uint8Array(analyser.frequencyBinCount)\n      const read = () => {\n        if (!session.current) return\n\n        analyser.getByteTimeDomainData(data)\n        let sum = 0\n        for (const sample of data) sum += ((sample - 128) / 128) ** 2\n\n        setLevel(Math.min(1, Math.sqrt(sum / data.length) * 2.5))\n        session.current.frame = requestAnimationFrame(read)\n      }\n\n      session.current = { stream, audio, frame: 0 }\n      session.current.frame = requestAnimationFrame(read)\n\n      // Permission is what unlocks the real device names.\n      list()\n      announce(\"testing\")\n    } catch {\n      for (const track of stream.getTracks()) track.stop()\n      announce(\"error\")\n    }\n  }, [announce, list, selected])\n\n  const choose = (deviceId: string) => {\n    release()\n    if (status === \"testing\") announce(\"idle\")\n    if (value === undefined) setChosen(deviceId)\n    onValueChange?.(deviceId)\n  }\n\n  const message: Record<MicSelectorStatus, string> = {\n    unsupported: \"This browser cannot list audio devices.\",\n    idle: \"Test to check the microphone is picking you up.\",\n    requesting: \"Waiting for permission to use the microphone.\",\n    testing: \"Testing. Speak, and the meter should move.\",\n    denied: \"Microphone permission was refused.\",\n    error: \"The microphone could not be started.\",\n  }\n\n  const lit = Math.round(level * segments)\n\n  return (\n    <div\n      data-slot=\"mic-selector\"\n      data-status={shown}\n      className={cn(\n        \"border-border bg-background flex w-full flex-col gap-3 rounded-xl border p-3\",\n        className\n      )}\n      {...rootProps}\n    >\n      <div className=\"flex items-center gap-2\">\n        <label className=\"sr-only\" htmlFor={deviceId}>\n          {label}\n        </label>\n        <select\n          id={deviceId}\n          value={selected}\n          disabled={disabled || !supported || devices.length === 0}\n          onChange={(event) => choose(event.target.value)}\n          className=\"border-border bg-background focus-visible:ring-ring h-10 min-w-0 flex-1 rounded-md border px-2 text-sm focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50\"\n        >\n          {devices.length === 0 ? (\n            <option value=\"\">No microphone found</option>\n          ) : null}\n          {devices.map((device, at) => (\n            <option key={device.deviceId || at} value={device.deviceId}>\n              {device.label || `Microphone ${at + 1}`}\n            </option>\n          ))}\n        </select>\n\n        <button\n          type=\"button\"\n          aria-label={testing ? `Stop testing ${label}` : `Test ${label}`}\n          aria-pressed={testing}\n          disabled={disabled || !supported || shown === \"requesting\"}\n          onClick={() => {\n            if (testing) {\n              release()\n              announce(\"idle\")\n            } else {\n              void test()\n            }\n          }}\n          className={cn(\n            \"focus-visible:ring-ring inline-flex h-10 shrink-0 items-center gap-2 rounded-md px-3 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50 motion-reduce:transition-none\",\n            testing\n              ? \"bg-foreground text-background\"\n              : \"bg-muted text-foreground hover:bg-muted/70\"\n          )}\n        >\n          {testing ? (\n            <Square aria-hidden=\"true\" size={13} />\n          ) : (\n            <Mic aria-hidden=\"true\" size={15} />\n          )}\n          {testing ? \"Stop\" : \"Test\"}\n        </button>\n      </div>\n\n      <div\n        data-slot=\"mic-selector-meter\"\n        aria-hidden=\"true\"\n        className=\"flex h-2 items-stretch gap-1\"\n      >\n        {Array.from({ length: segments }, (_, segment) => (\n          <span\n            key={segment}\n            data-lit={segment < lit ? \"\" : undefined}\n            className=\"bg-muted data-lit:bg-primary min-w-0 flex-1 rounded-full transition-colors duration-100 motion-reduce:transition-none\"\n          />\n        ))}\n      </div>\n\n      {/* The live region below carries the same words, so this is the\n          visible half of one message rather than a second one. */}\n      <p aria-hidden=\"true\" className=\"text-muted-foreground text-xs\">\n        {message[shown]}\n      </p>\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",
    "media",
    "audio",
    "control",
    "form"
  ],
  "type": "registry:ui"
}