{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "voice-input",
  "type": "registry:ui",
  "title": "VoiceInput",
  "description": "Microphone input with cancellation, actionable errors, live audio levels, and an optional local voice recorder.",
  "dependencies": [
    "motion@^12.43.0"
  ],
  "devDependencies": [],
  "registryDependencies": [
    "https://components.dethink.co.uk/r/dethink-base.json",
    "https://components.dethink.co.uk/r/button.json"
  ],
  "files": [
    {
      "path": "packages/components/src/components/voice-input/voice-input.tsx",
      "type": "registry:file",
      "target": "~/components/dethink/components/voice-input/voice-input.tsx",
      "content": "import {\n  forwardRef,\n  useCallback,\n  useEffect,\n  useImperativeHandle,\n  useMemo,\n  useRef,\n  useState,\n  useSyncExternalStore,\n  type ButtonHTMLAttributes,\n  type MouseEventHandler,\n  type Ref,\n} from \"react\";\nimport {\n  motion as motionElement,\n  useMotionValue,\n  useSpring,\n  type HTMLMotionProps,\n  type MotionValue,\n  type Transition,\n} from \"motion/react\";\nimport type { ButtonVariant } from \"../button\";\nimport { cn } from \"../../utils/cn\";\n\nexport type VoiceInputState =\n  | \"idle\"\n  | \"permission-request\"\n  | \"permission-denied\"\n  | \"device-not-found\"\n  | \"device-busy\"\n  | \"constraints-error\"\n  | \"error\"\n  | \"recording\"\n  | \"muted\"\n  | \"unsupported\";\n\nexport type VoiceInputVariant = Exclude<ButtonVariant, \"link\">;\nexport type VoiceInputSize = \"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\";\nexport type VoiceInputMotion = \"none\" | \"subtle\" | \"standard\";\nexport type VoiceInputStopReason =\n  \"user\" | \"unmount\" | \"track-ended\" | \"cancel\" | \"disabled\" | \"error\";\n\nexport interface VoiceInputController {\n  stop: () => void;\n  cancel: () => void;\n}\n\nexport type VoiceInputMediaRequest = (\n  constraints: MediaStreamConstraints,\n) => Promise<MediaStream>;\n\nexport interface VoiceInputProps extends Omit<\n  ButtonHTMLAttributes<HTMLButtonElement>,\n  \"children\" | \"aria-label\" | \"aria-labelledby\" | \"onError\"\n> {\n  variant?: VoiceInputVariant;\n  size?: VoiceInputSize;\n  motion?: VoiceInputMotion;\n  muted?: boolean;\n  audio?: boolean | MediaTrackConstraints;\n  controllerRef?: Ref<VoiceInputController>;\n  getUserMedia?: VoiceInputMediaRequest;\n  labels?: Partial<Record<VoiceInputState, string>>;\n  onStream?: (stream: MediaStream) => void;\n  onStop?: (stream: MediaStream, reason: VoiceInputStopReason) => void;\n  onError?: (error: unknown, state: VoiceInputState) => void;\n  onStateChange?: (state: VoiceInputState) => void;\n}\n\nconst defaultVoiceInputLabels: Record<VoiceInputState, string> = {\n  idle: \"Start voice input\",\n  \"permission-request\": \"Cancel microphone request\",\n  \"permission-denied\": \"Microphone permission denied\",\n  \"device-not-found\": \"No microphone found. Connect a microphone and retry\",\n  \"device-busy\": \"Microphone busy. Close other apps and retry\",\n  \"constraints-error\":\n    \"Microphone settings unavailable. Change settings and retry\",\n  error: \"Voice input failed. Try again\",\n  recording: \"Stop voice input\",\n  muted: \"Voice input muted\",\n  unsupported: \"Microphone unavailable\",\n};\n\nconst voiceInputBaseClasses =\n  \"relative inline-flex shrink-0 transform-gpu select-none items-center justify-start overflow-hidden border border-transparent font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-[requesting=true]:cursor-wait data-[reduced-motion=true]:will-change-auto\";\n\nconst voiceInputVariantClasses: Record<VoiceInputVariant, string> = {\n  solid:\n    \"bg-primary text-primary-foreground hover:bg-primary/90 active:bg-primary/80\",\n  soft: \"bg-primary/10 text-primary hover:bg-primary/15 active:bg-primary/20\",\n  outline:\n    \"border-border bg-background text-foreground hover:bg-muted active:bg-muted/80\",\n  ghost: \"bg-transparent text-foreground hover:bg-muted active:bg-muted/80\",\n  destructive:\n    \"bg-destructive text-destructive-foreground hover:bg-destructive/90 active:bg-destructive/80\",\n};\n\nconst voiceInputStateClasses =\n  \"data-[state=permission-denied]:border-destructive/50 data-[state=permission-denied]:text-destructive data-[state=unsupported]:border-border data-[state=unsupported]:text-muted-foreground data-[state=muted]:opacity-80\";\n\nconst voiceInputSizeClasses: Record<VoiceInputSize, string> = {\n  xs: \"[--voice-input-icon-size:0.875rem] [--voice-input-size:1.75rem] text-xs\",\n  sm: \"[--voice-input-icon-size:1rem] [--voice-input-size:2rem] text-sm\",\n  md: \"[--voice-input-icon-size:1rem] [--voice-input-size:var(--dt-density-control)] text-sm\",\n  lg: \"[--voice-input-icon-size:1.125rem] [--voice-input-size:2.75rem] text-base\",\n  xl: \"[--voice-input-icon-size:1.25rem] [--voice-input-size:3rem] text-base\",\n};\n\nconst voiceInputRootSizeClasses =\n  \"size-[var(--voice-input-size)] min-w-[var(--voice-input-size)] rounded-full\";\n\nconst voiceInputIconWrapClasses =\n  \"pointer-events-none absolute inset-0 inline-flex items-center justify-center\";\n\nconst voiceInputIconClasses =\n  \"inline-flex size-[var(--voice-input-icon-size)] items-center justify-center [&>svg]:size-full\";\n\nconst voiceInputWaveformClasses = \"pointer-events-none absolute inset-0\";\n\nconst voiceInputWaveBarClasses =\n  \"absolute left-1/2 top-[9%] h-[16%] w-[max(1px,calc(var(--voice-input-size)*0.035))] -translate-x-1/2 origin-bottom rounded-full bg-current opacity-90\";\n\nconst voiceInputTransitions: Record<VoiceInputMotion, Transition> = {\n  none: { duration: 0 },\n  subtle: { type: \"spring\", stiffness: 420, damping: 38, mass: 0.85 },\n  standard: { type: \"spring\", stiffness: 520, damping: 34, mass: 0.75 },\n};\n\nconst voiceInputWaveLevels = [0.3, 0.3, 0.3, 0.3, 0.3, 0.3];\n\n// Resting ray length held when the microphone hears no sound.\nconst voiceInputWaveIdleLevel = 0.3;\n// Length held for each ray while muted (stream open, tracks disabled).\nconst voiceInputWaveMutedLevel = 0.3;\n// Smoothed loudness below this is treated as silence, so rays stay idle.\nconst voiceInputSilenceThreshold = 0.06;\n// Lowest ray length while sound is present, so active rays never fully collapse.\nconst voiceInputWaveMinActive = 0.3;\n\nfunction isActiveState(state: VoiceInputState) {\n  return state === \"recording\" || state === \"muted\";\n}\n\nexport function getVoiceInputMotionState(state: VoiceInputState) {\n  return isActiveState(state) ? \"active\" : \"idle\";\n}\n\nexport function voiceInputClassNames({\n  className,\n  size = \"md\",\n  variant = \"soft\",\n}: Pick<VoiceInputProps, \"className\" | \"size\" | \"variant\"> = {}) {\n  return cn(\n    voiceInputBaseClasses,\n    voiceInputRootSizeClasses,\n    voiceInputSizeClasses[size],\n    voiceInputVariantClasses[variant],\n    voiceInputStateClasses,\n    className,\n  );\n}\n\nfunction DefaultMicrophoneIcon({\n  muted = false,\n  reducedMotion = false,\n  transition,\n}: {\n  muted?: boolean;\n  reducedMotion?: boolean;\n  transition?: Transition;\n}) {\n  const slashTransition = reducedMotion ? { duration: 0 } : transition;\n\n  return (\n    <svg aria-hidden=\"true\" fill=\"none\" focusable=\"false\" viewBox=\"0 0 24 24\">\n      <path\n        d=\"M12 14.5a3.5 3.5 0 0 0 3.5-3.5V6a3.5 3.5 0 0 0-7 0v5a3.5 3.5 0 0 0 3.5 3.5Z\"\n        stroke=\"currentColor\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        strokeWidth=\"1.8\"\n      />\n      <path\n        d=\"M5 11a7 7 0 0 0 14 0M12 18v3M9 21h6\"\n        stroke=\"currentColor\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        strokeWidth=\"1.8\"\n      />\n      {/* Diagonal \"mic off\" slash, drawn in when muted. */}\n      <motionElement.line\n        x1=\"4\"\n        y1=\"4\"\n        x2=\"20\"\n        y2=\"20\"\n        stroke=\"currentColor\"\n        strokeLinecap=\"round\"\n        strokeWidth=\"1.8\"\n        initial={false}\n        animate={{ pathLength: muted ? 1 : 0, opacity: muted ? 1 : 0 }}\n        transition={slashTransition}\n      />\n    </svg>\n  );\n}\n\nfunction setTrackMuted(stream: MediaStream, muted: boolean) {\n  for (const track of stream.getAudioTracks()) {\n    track.enabled = !muted;\n  }\n}\n\nfunction stopMediaStream(stream: MediaStream) {\n  for (const track of stream.getTracks()) {\n    track.stop();\n  }\n}\n\nfunction getMediaDevices() {\n  if (\n    typeof navigator === \"undefined\" ||\n    !navigator.mediaDevices ||\n    typeof navigator.mediaDevices.getUserMedia !== \"function\"\n  ) {\n    return undefined;\n  }\n\n  return navigator.mediaDevices;\n}\n\nfunction getAudioContextConstructor() {\n  if (typeof window === \"undefined\") {\n    return undefined;\n  }\n\n  return window.AudioContext;\n}\n\nfunction subscribeReducedMotion(notify: () => void) {\n  const media = window.matchMedia?.(\"(prefers-reduced-motion: reduce)\");\n  media?.addEventListener(\"change\", notify);\n  return () => media?.removeEventListener(\"change\", notify);\n}\n\nfunction useReducedMotionPreference() {\n  // A server snapshot keeps hydration stable and prevents motion before the\n  // browser preference is known. Subscribe so OS changes apply to live streams.\n  return useSyncExternalStore(\n    subscribeReducedMotion,\n    () =>\n      window.matchMedia?.(\"(prefers-reduced-motion: reduce)\").matches ?? false,\n    () => true,\n  );\n}\n\nfunction useWaveformMotionValues({\n  active,\n  muted,\n  reducedMotion,\n  stream,\n}: {\n  active: boolean;\n  muted: boolean;\n  reducedMotion: boolean;\n  stream: MediaStream | null;\n}) {\n  const level0 = useMotionValue(voiceInputWaveLevels[0]);\n  const level1 = useMotionValue(voiceInputWaveLevels[1]);\n  const level2 = useMotionValue(voiceInputWaveLevels[2]);\n  const level3 = useMotionValue(voiceInputWaveLevels[3]);\n  const level4 = useMotionValue(voiceInputWaveLevels[4]);\n  const level5 = useMotionValue(voiceInputWaveLevels[5]);\n  const levels = useMemo(\n    () => [level0, level1, level2, level3, level4, level5],\n    [level0, level1, level2, level3, level4, level5],\n  );\n  const spring0 = useSpring(level0, { stiffness: 520, damping: 42, mass: 0.6 });\n  const spring1 = useSpring(level1, { stiffness: 520, damping: 42, mass: 0.6 });\n  const spring2 = useSpring(level2, { stiffness: 520, damping: 42, mass: 0.6 });\n  const spring3 = useSpring(level3, { stiffness: 520, damping: 42, mass: 0.6 });\n  const spring4 = useSpring(level4, { stiffness: 520, damping: 42, mass: 0.6 });\n  const spring5 = useSpring(level5, { stiffness: 520, damping: 42, mass: 0.6 });\n  const springs = useMemo(\n    () => [spring0, spring1, spring2, spring3, spring4, spring5],\n    [spring0, spring1, spring2, spring3, spring4, spring5],\n  );\n\n  useEffect(() => {\n    if (!active || muted || reducedMotion) {\n      levels.forEach((level, index) => {\n        level.set(\n          muted ? voiceInputWaveMutedLevel : voiceInputWaveLevels[index],\n        );\n      });\n      return undefined;\n    }\n\n    let frame = 0;\n    let audioContext: AudioContext | undefined;\n    let source: MediaStreamAudioSourceNode | undefined;\n    let analyser: AnalyserNode | undefined;\n    let data: Uint8Array<ArrayBuffer> | undefined;\n    let disposed = false;\n    const AudioContextConstructor = getAudioContextConstructor();\n    const dispose = () => {\n      if (disposed) return;\n      disposed = true;\n      source?.disconnect();\n      analyser?.disconnect();\n      if (audioContext && audioContext.state !== \"closed\") {\n        void audioContext.close().catch(() => undefined);\n      }\n    };\n    const rest = () =>\n      levels.forEach((level) => level.set(voiceInputWaveIdleLevel));\n\n    if (stream && AudioContextConstructor) {\n      try {\n        audioContext = new AudioContextConstructor();\n        analyser = audioContext.createAnalyser();\n        // 128 bins give each of the six ray groups a distinct frequency band.\n        analyser.fftSize = 128;\n        analyser.smoothingTimeConstant = 0.7;\n        data = new Uint8Array(analyser.frequencyBinCount);\n        source = audioContext.createMediaStreamSource(stream);\n        source.connect(analyser);\n        if (audioContext.state === \"suspended\") {\n          void audioContext.resume().catch(() => {\n            if (disposed) return;\n            window.cancelAnimationFrame(frame);\n            rest();\n            dispose();\n          });\n        }\n      } catch {\n        dispose();\n        audioContext = undefined;\n        source = undefined;\n        analyser = undefined;\n        data = undefined;\n      }\n    }\n\n    if (!analyser || !data) {\n      rest();\n      return dispose;\n    }\n\n    // Voice energy sits in the lower spectrum, so ignore the sparse high bins.\n    const usableBins = data ? Math.max(1, Math.floor(data.length * 0.7)) : 0;\n    // Smoothed loudness envelope: quick to rise, slower to settle into idle.\n    let envelope = 0;\n\n    const readAmplitude = () => {\n      if (analyser && data) {\n        analyser.getByteFrequencyData(data);\n        let total = 0;\n        for (let index = 0; index < usableBins; index += 1) {\n          total += data[index];\n        }\n        return Math.min(1, (total / usableBins / 255) * 2.6);\n      }\n\n      return 0;\n    };\n\n    const readBand = (index: number) => {\n      if (analyser && data) {\n        const from = Math.floor((index / levels.length) * usableBins);\n        const to = Math.max(\n          from + 1,\n          Math.floor(((index + 1) / levels.length) * usableBins),\n        );\n        let sum = 0;\n        for (let bin = from; bin < to; bin += 1) {\n          sum += data[bin];\n        }\n        return sum / (to - from) / 255;\n      }\n\n      return 0;\n    };\n\n    const update = () => {\n      if (disposed) return;\n      const amplitude = readAmplitude();\n      envelope += (amplitude - envelope) * (amplitude > envelope ? 0.4 : 0.12);\n\n      if (envelope <= voiceInputSilenceThreshold) {\n        // Silence: hold the flat idle position with no oscillation.\n        levels.forEach((level) => level.set(voiceInputWaveIdleLevel));\n      } else {\n        levels.forEach((level, index) => {\n          const band = readBand(index);\n          level.set(\n            Math.max(\n              voiceInputWaveMinActive,\n              Math.min(\n                1,\n                voiceInputWaveIdleLevel + band * 1.2 + envelope * 0.2,\n              ),\n            ),\n          );\n        });\n      }\n\n      frame = window.requestAnimationFrame(update);\n    };\n\n    frame = window.requestAnimationFrame(update);\n\n    return () => {\n      window.cancelAnimationFrame(frame);\n      dispose();\n    };\n  }, [active, levels, muted, reducedMotion, stream]);\n\n  return reducedMotion ? levels : springs;\n}\n\nfunction VoiceInputWaveform({\n  active,\n  muted,\n  reducedMotion,\n  stream,\n}: {\n  active: boolean;\n  muted: boolean;\n  reducedMotion: boolean;\n  stream: MediaStream | null;\n}) {\n  const levels = useWaveformMotionValues({\n    active,\n    muted,\n    reducedMotion,\n    stream,\n  });\n\n  return (\n    <span\n      aria-hidden=\"true\"\n      data-slot=\"voice-input-waveform\"\n      className={voiceInputWaveformClasses}\n      style={{ opacity: muted ? 0.5 : 1 }}\n    >\n      {Array.from({ length: 24 }, (_, index) => (\n        <span\n          key={index}\n          className=\"absolute inset-0\"\n          style={{ transform: `rotate(${index * 15}deg)` }}\n        >\n          <motionElement.span\n            data-slot=\"voice-input-wave-bar\"\n            className={voiceInputWaveBarClasses}\n            style={{\n              scaleY: levels[index % levels.length] as MotionValue<number>,\n            }}\n          />\n        </span>\n      ))}\n    </span>\n  );\n}\n\nexport const VoiceInput = forwardRef<HTMLButtonElement, VoiceInputProps>(\n  (\n    {\n      \"aria-busy\": ariaBusy,\n      audio = true,\n      className,\n      controllerRef,\n      getUserMedia,\n      disabled = false,\n      labels,\n      motion = \"standard\",\n      muted = false,\n      onClick,\n      onError,\n      onStateChange,\n      onStop,\n      onStream,\n      size = \"md\",\n      type = \"button\",\n      variant = \"soft\",\n      ...props\n    },\n    ref,\n  ) => {\n    const prefersReducedMotion = useReducedMotionPreference();\n    const reducedMotion = motion === \"none\" || prefersReducedMotion === true;\n    const transition = reducedMotion\n      ? voiceInputTransitions.none\n      : voiceInputTransitions[motion];\n    const [state, setState] = useState<VoiceInputState>(\"idle\");\n    const [stream, setStream] = useState<MediaStream | null>(null);\n    const streamRef = useRef<MediaStream | null>(null);\n    const mountedRef = useRef(false);\n    const requestIdRef = useRef(0);\n    const removeTrackListenersRef = useRef<() => void>(() => undefined);\n    const current = useRef({\n      muted,\n      disabled,\n      audio,\n      onStream,\n      onStop,\n      onError,\n      onStateChange,\n      getUserMedia,\n    });\n    useEffect(() => {\n      current.current = {\n        muted,\n        disabled,\n        audio,\n        onStream,\n        onStop,\n        onError,\n        onStateChange,\n        getUserMedia,\n      };\n    });\n    const stateRef = useRef<VoiceInputState>(\"idle\");\n    const resolvedLabels = { ...defaultVoiceInputLabels, ...labels };\n    const active = isActiveState(state);\n    const requesting = state === \"permission-request\";\n    const activationDisabled = disabled;\n    const motionState = getVoiceInputMotionState(state);\n\n    const reportError = useCallback((error: unknown, next: VoiceInputState) => {\n      // A consumer error handler must not create an unhandled async rejection.\n      try {\n        current.current.onError?.(error, next);\n      } catch (callbackError) {\n        console.error(\"VoiceInput onError callback failed\", callbackError);\n      }\n    }, []);\n\n    const updateState = useCallback((next: VoiceInputState) => {\n      if (stateRef.current === next) return;\n      stateRef.current = next;\n      if (mountedRef.current) setState(next);\n      current.current.onStateChange?.(next);\n    }, []);\n\n    const release = useCallback(\n      (reason: VoiceInputStopReason) => {\n        requestIdRef.current += 1;\n        removeTrackListenersRef.current();\n        removeTrackListenersRef.current = () => undefined;\n        const owned = streamRef.current;\n        streamRef.current = null;\n        if (mountedRef.current) setStream(null);\n        if (owned) {\n          stopMediaStream(owned);\n          try {\n            current.current.onStop?.(owned, reason);\n          } catch (error) {\n            reportError(error, \"error\");\n          }\n        }\n      },\n      [reportError],\n    );\n\n    const fail = useCallback(\n      (error: unknown, next: VoiceInputState = \"error\") => {\n        release(\"error\");\n        try {\n          updateState(next);\n        } catch (callbackError) {\n          reportError(callbackError, \"error\");\n        }\n        reportError(error, next);\n      },\n      [release, reportError, updateState],\n    );\n\n    const stopOwnedStream = useCallback(\n      (reason: VoiceInputStopReason) => {\n        release(reason);\n        try {\n          updateState(\"idle\");\n        } catch (error) {\n          fail(error);\n        }\n      },\n      [fail, release, updateState],\n    );\n\n    useImperativeHandle(\n      controllerRef,\n      () => ({\n        stop: () => stopOwnedStream(\"user\"),\n        cancel: () => stopOwnedStream(\"cancel\"),\n      }),\n      [stopOwnedStream],\n    );\n\n    const startStream = useCallback(async () => {\n      if (\n        current.current.disabled ||\n        streamRef.current ||\n        stateRef.current === \"permission-request\"\n      )\n        return;\n      const requestId = ++requestIdRef.current;\n      const mediaDevices = getMediaDevices();\n      const acquire =\n        current.current.getUserMedia ??\n        mediaDevices?.getUserMedia.bind(mediaDevices);\n      if (!acquire) {\n        fail(\n          new Error(\n            \"Microphone access requires a supported browser and a secure context.\",\n          ),\n          \"unsupported\",\n        );\n        return;\n      }\n      try {\n        updateState(\"permission-request\");\n      } catch (error) {\n        fail(error);\n        return;\n      }\n      if (requestId !== requestIdRef.current) return;\n      let nextStream: MediaStream;\n      // Only browser acquisition errors belong to the permission/device taxonomy.\n      try {\n        nextStream = await acquire({\n          audio: current.current.audio,\n          video: false,\n        });\n      } catch (error) {\n        if (mountedRef.current && requestId === requestIdRef.current) {\n          const name =\n            typeof error === \"object\" && error !== null && \"name\" in error\n              ? error.name\n              : \"\";\n          const next: VoiceInputState =\n            name === \"NotAllowedError\" || name === \"SecurityError\"\n              ? \"permission-denied\"\n              : name === \"NotSupportedError\"\n                ? \"unsupported\"\n                : name === \"NotFoundError\"\n                  ? \"device-not-found\"\n                  : name === \"NotReadableError\" || name === \"AbortError\"\n                    ? \"device-busy\"\n                    : name === \"OverconstrainedError\" || name === \"TypeError\"\n                      ? \"constraints-error\"\n                      : \"error\";\n          fail(error, next);\n        }\n        return;\n      }\n      if (\n        !mountedRef.current ||\n        requestId !== requestIdRef.current ||\n        current.current.disabled\n      ) {\n        stopMediaStream(nextStream);\n        return;\n      }\n      streamRef.current = nextStream;\n      try {\n        if (\n          !nextStream\n            .getAudioTracks()\n            .some((track) => track.readyState !== \"ended\")\n        ) {\n          throw new Error(\"The microphone returned no live audio track.\");\n        }\n        setTrackMuted(nextStream, current.current.muted);\n        const ended = () => stopOwnedStream(\"track-ended\");\n        const tracks = nextStream.getTracks();\n        tracks.forEach((track) => track.addEventListener(\"ended\", ended));\n        removeTrackListenersRef.current = () =>\n          tracks.forEach((track) => track.removeEventListener(\"ended\", ended));\n        setStream(nextStream);\n        updateState(current.current.muted ? \"muted\" : \"recording\");\n        // State callbacks may synchronously stop/cancel the newly acquired stream.\n        if (streamRef.current === nextStream)\n          current.current.onStream?.(nextStream);\n      } catch (error) {\n        fail(error);\n      }\n    }, [fail, stopOwnedStream, updateState]);\n\n    useEffect(() => {\n      mountedRef.current = true;\n      return () => {\n        mountedRef.current = false;\n        release(\"unmount\");\n        stateRef.current = \"idle\";\n      };\n    }, [release]);\n\n    useEffect(() => {\n      if (disabled) {\n        stopOwnedStream(\"disabled\");\n        return;\n      }\n      const owned = streamRef.current;\n      if (owned) {\n        setTrackMuted(owned, muted);\n        try {\n          updateState(muted ? \"muted\" : \"recording\");\n        } catch (error) {\n          fail(error);\n        }\n      }\n    }, [disabled, muted, fail, stopOwnedStream, updateState]);\n\n    const handleClick: MouseEventHandler<HTMLButtonElement> = (event) => {\n      if (activationDisabled) {\n        event.preventDefault();\n        return;\n      }\n      onClick?.(event);\n      if (event.defaultPrevented) return;\n      if (streamRef.current || stateRef.current === \"permission-request\") {\n        stopOwnedStream(\n          stateRef.current === \"permission-request\" ? \"cancel\" : \"user\",\n        );\n      } else {\n        void startStream();\n      }\n    };\n\n    return (\n      <motionElement.button\n        {...(props as HTMLMotionProps<\"button\">)}\n        ref={ref}\n        type={type}\n        disabled={disabled}\n        aria-label={resolvedLabels[state]}\n        aria-busy={requesting ? true : ariaBusy}\n        aria-pressed={active ? true : undefined}\n        data-slot=\"voice-input\"\n        data-state={state}\n        data-motion-state={motionState}\n        data-variant={variant}\n        data-size={size}\n        data-motion={motion}\n        data-active={active ? \"true\" : undefined}\n        data-disabled={disabled ? \"true\" : undefined}\n        data-muted={state === \"muted\" ? \"true\" : undefined}\n        data-reduced-motion={reducedMotion ? \"true\" : undefined}\n        data-requesting={requesting ? \"true\" : undefined}\n        className={voiceInputClassNames({ className, size, variant })}\n        initial={false}\n        whileTap={\n          activationDisabled || reducedMotion ? undefined : { scale: 0.96 }\n        }\n        transition={transition}\n        onClick={handleClick}\n      >\n        <span\n          aria-hidden=\"true\"\n          data-slot=\"voice-input-icon-wrap\"\n          className={voiceInputIconWrapClasses}\n        >\n          <motionElement.span\n            data-slot=\"voice-input-icon\"\n            className={voiceInputIconClasses}\n            animate={{\n              opacity: active && state !== \"muted\" ? 0.92 : 1,\n              scale: active ? 0.65 : 1,\n            }}\n            transition={transition}\n          >\n            <DefaultMicrophoneIcon\n              muted={state === \"muted\"}\n              reducedMotion={reducedMotion}\n              transition={transition}\n            />\n          </motionElement.span>\n        </span>\n        <motionElement.span\n          data-slot=\"voice-input-waveform-motion\"\n          className=\"pointer-events-none absolute inset-0\"\n          animate={{\n            opacity: active ? 1 : 0,\n          }}\n          transition={transition}\n        >\n          <VoiceInputWaveform\n            active={active}\n            muted={state === \"muted\"}\n            reducedMotion={reducedMotion}\n            stream={stream}\n          />\n        </motionElement.span>\n      </motionElement.button>\n    );\n  },\n);\n\nVoiceInput.displayName = \"VoiceInput\";\n"
    },
    {
      "path": "packages/components/src/components/voice-input/use-voice-recorder.ts",
      "type": "registry:file",
      "target": "~/components/dethink/components/voice-input/use-voice-recorder.ts",
      "content": "import { useCallback, useEffect, useRef, useState } from \"react\";\nimport type { VoiceInputStopReason } from \"./voice-input\";\n\nexport type VoiceRecorderState =\n  \"idle\" | \"recording\" | \"paused\" | \"finalizing\" | \"ready\" | \"error\";\nexport type VoiceRecorderStopReason = VoiceInputStopReason | \"duration-limit\";\nexport interface VoiceRecording {\n  blob: Blob;\n  url: string;\n  mimeType: string;\n  durationMs: number;\n  reason: VoiceRecorderStopReason;\n}\nexport interface UseVoiceRecorderOptions {\n  /** Active recording time, excluding pauses. Changes apply to the next take. */\n  maxDurationMs?: number;\n  mimeType?: string;\n  onRecording?: (recording: VoiceRecording) => void;\n  onError?: (error: unknown) => void;\n  /** Synchronize a VoiceInput controller after this hook releases its tracks. */\n  onRelease?: () => void;\n}\nexport interface VoiceRecorder {\n  state: VoiceRecorderState;\n  elapsedMs: number;\n  recording: VoiceRecording | null;\n  error: unknown;\n  start: (stream: MediaStream) => void;\n  stop: (reason?: VoiceRecorderStopReason) => void;\n  pause: () => void;\n  resume: () => void;\n  discard: () => void;\n}\n\ninterface Session {\n  recorder: MediaRecorder;\n  stream: MediaStream;\n  chunks: Blob[];\n  elapsed: number;\n  since: number | null;\n  limit: number;\n  finishing: boolean;\n  released: boolean;\n  reason: VoiceRecorderStopReason;\n  timer?: ReturnType<typeof setInterval>;\n  deadline?: ReturnType<typeof setTimeout>;\n  detach: () => void;\n}\n\nfunction clearClock(session: Session) {\n  clearInterval(session.timer);\n  clearTimeout(session.deadline);\n}\nfunction elapsed(session: Session) {\n  return (\n    session.elapsed +\n    (session.since === null ? 0 : performance.now() - session.since)\n  );\n}\nfunction pauseClock(session: Session) {\n  session.elapsed = elapsed(session);\n  session.since = null;\n  clearClock(session);\n}\n\n/** Local recording only. Owns handed-in tracks until stop, discard, or unmount. */\nexport function useVoiceRecorder(\n  options: UseVoiceRecorderOptions = {},\n): VoiceRecorder {\n  const [state, setState] = useState<VoiceRecorderState>(\"idle\");\n  const [elapsedMs, setElapsedMs] = useState(0);\n  const [recording, setRecording] = useState<VoiceRecording | null>(null);\n  const [error, setError] = useState<unknown>(null);\n  const current = useRef(options);\n  const sessionRef = useRef<Session | null>(null);\n  const urlRef = useRef<string | null>(null);\n  const mounted = useRef(false);\n  useEffect(() => {\n    current.current = options;\n  });\n\n  const revoke = useCallback(() => {\n    if (urlRef.current) URL.revokeObjectURL(urlRef.current);\n    urlRef.current = null;\n  }, []);\n  const report = useCallback((cause: unknown) => {\n    if (mounted.current) {\n      setError(cause);\n      setState(\"error\");\n    }\n    try {\n      current.current.onError?.(cause);\n    } catch (callbackError) {\n      console.error(\"useVoiceRecorder onError callback failed\", callbackError);\n    }\n  }, []);\n  const release = useCallback(\n    (session: Session) => {\n      if (session.released) return;\n      session.released = true;\n      session.stream.getTracks().forEach((track) => track.stop());\n      try {\n        current.current.onRelease?.();\n      } catch (cause) {\n        report(cause);\n      }\n    },\n    [report],\n  );\n  const dispose = useCallback(() => {\n    const session = sessionRef.current;\n    sessionRef.current = null;\n    if (!session) return;\n    clearClock(session);\n    session.detach();\n    try {\n      if (session.recorder.state !== \"inactive\") session.recorder.stop();\n    } finally {\n      release(session);\n    }\n  }, [release]);\n  const fail = useCallback(\n    (cause: unknown) => {\n      try {\n        dispose();\n      } finally {\n        report(cause);\n      }\n    },\n    [dispose, report],\n  );\n\n  const stop = useCallback(\n    (reason: VoiceRecorderStopReason = \"user\") => {\n      const session = sessionRef.current;\n      if (!session || session.finishing) return;\n      session.finishing = true;\n      session.reason = reason;\n      pauseClock(session);\n      setElapsedMs(Math.min(session.elapsed, session.limit));\n      setState(\"finalizing\");\n      try {\n        if (session.recorder.state !== \"inactive\") session.recorder.stop();\n        release(session);\n      } catch (cause) {\n        fail(cause);\n      }\n    },\n    [fail, release],\n  );\n\n  const runClock = useCallback(\n    (session: Session) => {\n      session.since = performance.now();\n      session.timer = setInterval(() => {\n        if (sessionRef.current === session)\n          setElapsedMs(Math.min(elapsed(session), session.limit));\n      }, 100);\n      if (Number.isFinite(session.limit)) {\n        session.deadline = setTimeout(\n          () => {\n            if (sessionRef.current === session) stop(\"duration-limit\");\n          },\n          Math.max(0, session.limit - session.elapsed),\n        );\n      }\n    },\n    [stop],\n  );\n\n  const start = useCallback(\n    (stream: MediaStream) => {\n      if (!mounted.current) {\n        stream.getTracks().forEach((track) => track.stop());\n        return;\n      }\n      // Dispose first: queued final events from the previous take cannot publish.\n      dispose();\n      revoke();\n      setRecording(null);\n      setElapsedMs(0);\n      setError(null);\n      let session: Session | null = null;\n      try {\n        if (typeof MediaRecorder === \"undefined\")\n          throw new Error(\"Audio recording is not supported in this browser.\");\n        const requested = current.current.mimeType;\n        if (requested && !MediaRecorder.isTypeSupported(requested))\n          throw new Error(\"The requested recording format is not supported.\");\n        const mimeType =\n          requested ??\n          [\"audio/webm;codecs=opus\", \"audio/mp4\", \"audio/ogg;codecs=opus\"].find(\n            (type) => MediaRecorder.isTypeSupported(type),\n          );\n        const recorder = new MediaRecorder(\n          stream,\n          mimeType ? { mimeType } : undefined,\n        );\n        const max = current.current.maxDurationMs;\n        if (\n          max !== undefined &&\n          (!Number.isFinite(max) || max <= 0 || max > 2_147_483_647)\n        )\n          throw new Error(\n            \"maxDurationMs must be a positive finite duration below 2^31 milliseconds.\",\n          );\n        session = {\n          recorder,\n          stream,\n          chunks: [],\n          elapsed: 0,\n          since: null,\n          limit: max ?? Infinity,\n          finishing: false,\n          released: false,\n          reason: \"user\",\n          detach: () => undefined,\n        };\n        const take = session;\n        const data = (event: BlobEvent) => {\n          if (sessionRef.current === take && event.data.size)\n            take.chunks.push(event.data);\n        };\n        const ended = () => stop(\"track-ended\");\n        const failed = () =>\n          fail(\n            new Error(\"Recording failed. Check your microphone and try again.\"),\n          );\n        const done = () => {\n          if (!mounted.current || sessionRef.current !== take) return;\n          if (!take.finishing) {\n            take.reason = \"track-ended\";\n            pauseClock(take);\n          }\n          take.detach();\n          sessionRef.current = null;\n          release(take);\n          try {\n            const blob = new Blob(take.chunks, {\n              type:\n                recorder.mimeType ||\n                take.chunks[0]?.type ||\n                \"application/octet-stream\",\n            });\n            if (!blob.size)\n              throw new Error(\"No audio was recorded. Try a longer recording.\");\n            revoke();\n            const url = URL.createObjectURL(blob);\n            urlRef.current = url;\n            const result: VoiceRecording = {\n              blob,\n              url,\n              mimeType: blob.type,\n              durationMs: Math.min(take.elapsed, take.limit),\n              reason: take.reason,\n            };\n            setElapsedMs(result.durationMs);\n            setRecording(result);\n            setState(\"ready\");\n            current.current.onRecording?.(result);\n          } catch (cause) {\n            report(cause);\n          }\n        };\n        take.detach = () => {\n          recorder.removeEventListener(\"dataavailable\", data);\n          recorder.removeEventListener(\"stop\", done);\n          recorder.removeEventListener(\"error\", failed);\n          stream\n            .getTracks()\n            .forEach((track) => track.removeEventListener(\"ended\", ended));\n        };\n        sessionRef.current = take;\n        recorder.addEventListener(\"dataavailable\", data);\n        recorder.addEventListener(\"stop\", done);\n        recorder.addEventListener(\"error\", failed);\n        stream\n          .getTracks()\n          .forEach((track) => track.addEventListener(\"ended\", ended));\n        recorder.start(1000);\n        setState(\"recording\");\n        runClock(take);\n      } catch (cause) {\n        if (!session) {\n          stream.getTracks().forEach((track) => track.stop());\n          try {\n            current.current.onRelease?.();\n          } catch (releaseError) {\n            report(releaseError);\n          }\n        }\n        fail(cause);\n      }\n    },\n    [dispose, fail, release, report, revoke, runClock, stop],\n  );\n\n  const pause = useCallback(() => {\n    const take = sessionRef.current;\n    if (!take || take.finishing || take.recorder.state !== \"recording\") return;\n    try {\n      take.recorder.pause();\n      pauseClock(take);\n      setElapsedMs(take.elapsed);\n      setState(\"paused\");\n    } catch (cause) {\n      fail(cause);\n    }\n  }, [fail]);\n  const resume = useCallback(() => {\n    const take = sessionRef.current;\n    if (!take || take.finishing || take.recorder.state !== \"paused\") return;\n    try {\n      take.recorder.resume();\n      runClock(take);\n      setState(\"recording\");\n    } catch (cause) {\n      fail(cause);\n    }\n  }, [fail, runClock]);\n  const discard = useCallback(() => {\n    dispose();\n    revoke();\n    setRecording(null);\n    setElapsedMs(0);\n    setError(null);\n    setState(\"idle\");\n  }, [dispose, revoke]);\n\n  useEffect(() => {\n    mounted.current = true;\n    return () => {\n      mounted.current = false;\n      dispose();\n      revoke();\n    };\n  }, [dispose, revoke]);\n\n  return {\n    state,\n    elapsedMs,\n    recording,\n    error,\n    start,\n    stop,\n    pause,\n    resume,\n    discard,\n  };\n}\n"
    },
    {
      "path": "packages/components/src/components/voice-input/index.ts",
      "type": "registry:file",
      "target": "~/components/dethink/components/voice-input/index.ts",
      "content": "export {\n  VoiceInput,\n  getVoiceInputMotionState,\n  voiceInputClassNames,\n  type VoiceInputController,\n  type VoiceInputMediaRequest,\n  type VoiceInputMotion,\n  type VoiceInputProps,\n  type VoiceInputSize,\n  type VoiceInputState,\n  type VoiceInputStopReason,\n  type VoiceInputVariant,\n} from \"./voice-input\";\n\nexport {\n  useVoiceRecorder,\n  type VoiceRecorder,\n  type VoiceRecorderState,\n  type VoiceRecorderStopReason,\n  type VoiceRecording,\n  type UseVoiceRecorderOptions,\n} from \"./use-voice-recorder\";\n"
    },
    {
      "path": "packages/components/src/utils/cn.ts",
      "type": "registry:file",
      "target": "~/components/dethink/utils/cn.ts",
      "content": "import { clsx, type ClassValue } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n"
    }
  ]
}
