{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ai-speech-input",
  "title": "AI: Speech Input",
  "description": "Speech capture with Web Speech or MediaRecorder support, explicit activation and capability detection.",
  "dependencies": [
    "@untitledui/icons@^0.0.23"
  ],
  "registryDependencies": [
    "@nx-ui/utils",
    "@nx-ui/button",
    "@nx-ui/spinner"
  ],
  "files": [
    {
      "path": "components/ai/speech-input.tsx",
      "content": "// Ported from vercel/ai-elements@6a9d5b1822ffb10bba4bd97175f01edd7d8651cd.\n\"use client\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport { cn } from \"@/lib/utils\";\nimport { Microphone01 as MicIcon, Square as SquareIcon } from \"@untitledui/icons\";\nimport type { ComponentProps } from \"react\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\n\ninterface SpeechRecognition extends EventTarget {\n  continuous: boolean;\n  interimResults: boolean;\n  lang: string;\n  start(): void;\n  stop(): void;\n  onstart: ((this: SpeechRecognition, ev: Event) => void) | null;\n  onend: ((this: SpeechRecognition, ev: Event) => void) | null;\n  onresult:\n    | ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => void)\n    | null;\n  onerror:\n    | ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => void)\n    | null;\n}\n\ninterface SpeechRecognitionEvent extends Event {\n  results: SpeechRecognitionResultList;\n  resultIndex: number;\n}\n\ninterface SpeechRecognitionResultList {\n  readonly length: number;\n  item(index: number): SpeechRecognitionResult;\n  [index: number]: SpeechRecognitionResult;\n}\n\ninterface SpeechRecognitionResult {\n  readonly length: number;\n  item(index: number): SpeechRecognitionAlternative;\n  [index: number]: SpeechRecognitionAlternative;\n  isFinal: boolean;\n}\n\ninterface SpeechRecognitionAlternative {\n  transcript: string;\n  confidence: number;\n}\n\ninterface SpeechRecognitionErrorEvent extends Event {\n  error: string;\n}\n\ntype SpeechInputMode = \"speech-recognition\" | \"media-recorder\" | \"none\";\n\nexport type SpeechInputProps = ComponentProps<typeof Button> & {\n  onTranscriptionChange?: (text: string) => void;\n  /**\n   * Callback for when audio is recorded using MediaRecorder fallback.\n   * This is called in browsers that don't support the Web Speech API (Firefox, Safari).\n   * The callback receives an audio Blob that should be sent to a transcription service.\n   * Return the transcribed text, which will be passed to onTranscriptionChange.\n   */\n  onAudioRecorded?: (audioBlob: Blob) => Promise<string>;\n  lang?: string;\n};\n\nconst detectSpeechInputMode = (): SpeechInputMode => {\n  if (typeof window === \"undefined\") {\n    return \"none\";\n  }\n\n  const speechWindow = window as Window & {\n    SpeechRecognition?: new () => SpeechRecognition;\n    webkitSpeechRecognition?: new () => SpeechRecognition;\n  };\n  if (typeof speechWindow.SpeechRecognition === \"function\" || typeof speechWindow.webkitSpeechRecognition === \"function\") {\n    return \"speech-recognition\";\n  }\n\n  if (typeof window.MediaRecorder === \"function\" && typeof navigator.mediaDevices?.getUserMedia === \"function\") {\n    return \"media-recorder\";\n  }\n\n  return \"none\";\n};\n\nconst speechErrorMessage = (error: unknown): string => {\n  const code = error instanceof DOMException ? error.name :\n    error && typeof error === \"object\" && \"error\" in error ? String(error.error) : \"\";\n  if (code === \"NotAllowedError\" || code === \"PermissionDeniedError\" || code === \"not-allowed\" || code === \"service-not-allowed\") {\n    return \"Microphone permission was denied.\";\n  }\n  if (code === \"NotFoundError\" || code === \"audio-capture\") {\n    return \"No microphone is available.\";\n  }\n  return error instanceof Error && error.message ? error.message : \"Speech input failed. Please try again.\";\n};\n\nexport const SpeechInput = ({\n  className,\n  onTranscriptionChange,\n  onAudioRecorded,\n  lang = \"en-US\",\n  \"aria-label\": ariaLabel,\n  onClick,\n  disabled,\n  ...props\n}: SpeechInputProps) => {\n  const [isListening, setIsListening] = useState(false);\n  const [isProcessing, setIsProcessing] = useState(false);\n  const [isStarting, setIsStarting] = useState(false);\n  const [mode, setMode] = useState<SpeechInputMode>(\"none\");\n  const [modeResolved, setModeResolved] = useState(false);\n  const [error, setError] = useState<string | null>(null);\n  const [isRecognitionReady, setIsRecognitionReady] = useState(false);\n  const recognitionRef = useRef<SpeechRecognition | null>(null);\n  const mediaRecorderRef = useRef<MediaRecorder | null>(null);\n  const streamRef = useRef<MediaStream | null>(null);\n  const mountedRef = useRef(false);\n  const recordingGenerationRef = useRef(0);\n  const pendingStartRef = useRef(false);\n  const audioChunksRef = useRef<Blob[]>([]);\n  const onTranscriptionChangeRef = useRef<\n    SpeechInputProps[\"onTranscriptionChange\"]\n  >(onTranscriptionChange);\n  const onAudioRecordedRef =\n    useRef<SpeechInputProps[\"onAudioRecorded\"]>(onAudioRecorded);\n\n  // Keep refs in sync\n  onTranscriptionChangeRef.current = onTranscriptionChange;\n  onAudioRecordedRef.current = onAudioRecorded;\n\n  useEffect(() => {\n    setMode(detectSpeechInputMode());\n    setModeResolved(true);\n  }, []);\n\n  // Initialize Speech Recognition when mode is speech-recognition\n  useEffect(() => {\n    if (mode !== \"speech-recognition\") {\n      return;\n    }\n\n    const speechWindow = window as Window & {\n      SpeechRecognition?: new () => SpeechRecognition;\n      webkitSpeechRecognition?: new () => SpeechRecognition;\n    };\n    const SpeechRecognition =\n      speechWindow.SpeechRecognition || speechWindow.webkitSpeechRecognition;\n    if (!SpeechRecognition) return;\n    const speechRecognition = new SpeechRecognition();\n\n    speechRecognition.continuous = true;\n    speechRecognition.interimResults = true;\n    speechRecognition.lang = lang;\n\n    const handleStart = () => {\n      setIsListening(true);\n      setError(null);\n    };\n\n    const handleEnd = () => {\n      setIsListening(false);\n    };\n\n    const handleResult = (event: Event) => {\n      const speechEvent = event as SpeechRecognitionEvent;\n      let finalTranscript = \"\";\n\n      for (\n        let i = speechEvent.resultIndex;\n        i < speechEvent.results.length;\n        i += 1\n      ) {\n        const result = speechEvent.results[i];\n        if (result?.isFinal) {\n          finalTranscript += result[0]?.transcript ?? \"\";\n        }\n      }\n\n      if (finalTranscript) {\n        onTranscriptionChangeRef.current?.(finalTranscript);\n      }\n    };\n\n    const handleError = (event: Event) => {\n      setIsListening(false);\n      setError(speechErrorMessage(event));\n    };\n\n    speechRecognition.addEventListener(\"start\", handleStart);\n    speechRecognition.addEventListener(\"end\", handleEnd);\n    speechRecognition.addEventListener(\"result\", handleResult);\n    speechRecognition.addEventListener(\"error\", handleError);\n\n    recognitionRef.current = speechRecognition;\n    setIsRecognitionReady(true);\n\n    return () => {\n      speechRecognition.removeEventListener(\"start\", handleStart);\n      speechRecognition.removeEventListener(\"end\", handleEnd);\n      speechRecognition.removeEventListener(\"result\", handleResult);\n      speechRecognition.removeEventListener(\"error\", handleError);\n      speechRecognition.stop();\n      recognitionRef.current = null;\n      setIsRecognitionReady(false);\n    };\n  }, [mode, lang]);\n\n  // Invalidate any pending permission request on unmount. Strict Mode's\n  // setup/cleanup/setup cycle also gets a fresh generation before interaction.\n  useEffect(() => {\n    mountedRef.current = true;\n    return () => {\n      mountedRef.current = false;\n      recordingGenerationRef.current += 1;\n      pendingStartRef.current = false;\n      if (mediaRecorderRef.current?.state === \"recording\") {\n        mediaRecorderRef.current.stop();\n      }\n      mediaRecorderRef.current = null;\n      if (streamRef.current) {\n        for (const track of streamRef.current.getTracks()) {\n          track.stop();\n        }\n        streamRef.current = null;\n      }\n    };\n  }, []);\n\n  // Start MediaRecorder recording\n  const startMediaRecorder = useCallback(async () => {\n    if (!onAudioRecordedRef.current || pendingStartRef.current || !mountedRef.current) {\n      return;\n    }\n\n    const generation = ++recordingGenerationRef.current;\n    const isCurrent = () => mountedRef.current && recordingGenerationRef.current === generation;\n    pendingStartRef.current = true;\n    setIsStarting(true);\n    let stream: MediaStream | null = null;\n    let mediaRecorder: MediaRecorder | null = null;\n\n    try {\n      setError(null);\n      stream = await navigator.mediaDevices.getUserMedia({ audio: true });\n      if (!isCurrent()) {\n        for (const track of stream.getTracks()) track.stop();\n        return;\n      }\n      const activeStream = stream;\n      streamRef.current = activeStream;\n      mediaRecorder = new MediaRecorder(activeStream);\n      audioChunksRef.current = [];\n\n      const handleDataAvailable = (event: BlobEvent) => {\n        if (event.data.size > 0) {\n          audioChunksRef.current.push(event.data);\n        }\n      };\n\n      const handleStop = async () => {\n        for (const track of activeStream.getTracks()) {\n          track.stop();\n        }\n        if (streamRef.current === activeStream) streamRef.current = null;\n        if (mediaRecorderRef.current === mediaRecorder) mediaRecorderRef.current = null;\n        if (!isCurrent()) return;\n\n        const mimeType = mediaRecorder?.mimeType ||\n          audioChunksRef.current.find((chunk) => chunk.type)?.type;\n        const audioBlob = new Blob(\n          audioChunksRef.current,\n          mimeType ? { type: mimeType } : {}\n        );\n\n        if (audioBlob.size > 0 && onAudioRecordedRef.current) {\n          setIsProcessing(true);\n          try {\n            const transcript = await onAudioRecordedRef.current(audioBlob);\n            if (transcript && isCurrent()) {\n              onTranscriptionChangeRef.current?.(transcript);\n            }\n          } catch (caughtError) {\n            if (isCurrent()) setError(speechErrorMessage(caughtError));\n          } finally {\n            if (isCurrent()) setIsProcessing(false);\n          }\n        }\n      };\n\n      const handleError = (event: Event) => {\n        if (isCurrent()) {\n          setIsListening(false);\n          setError(speechErrorMessage(event));\n        }\n        for (const track of activeStream.getTracks()) {\n          track.stop();\n        }\n        if (streamRef.current === activeStream) streamRef.current = null;\n        if (mediaRecorderRef.current === mediaRecorder) mediaRecorderRef.current = null;\n      };\n\n      mediaRecorder.addEventListener(\"dataavailable\", handleDataAvailable);\n      mediaRecorder.addEventListener(\"stop\", handleStop);\n      mediaRecorder.addEventListener(\"error\", handleError);\n\n      mediaRecorderRef.current = mediaRecorder;\n      mediaRecorder.start();\n      setIsListening(true);\n    } catch (caughtError) {\n      if (stream) {\n        for (const track of stream.getTracks()) track.stop();\n        if (streamRef.current === stream) streamRef.current = null;\n      }\n      if (mediaRecorderRef.current === mediaRecorder) mediaRecorderRef.current = null;\n      if (isCurrent()) {\n        setIsListening(false);\n        setError(speechErrorMessage(caughtError));\n      }\n    } finally {\n      if (isCurrent()) {\n        pendingStartRef.current = false;\n        setIsStarting(false);\n      }\n    }\n  }, []);\n\n  // Stop MediaRecorder recording\n  const stopMediaRecorder = useCallback(() => {\n    if (mediaRecorderRef.current?.state === \"recording\") {\n      mediaRecorderRef.current.stop();\n    }\n    setIsListening(false);\n  }, []);\n\n  const toggleListening = useCallback(() => {\n    if (mode === \"speech-recognition\" && recognitionRef.current) {\n      if (isListening) {\n        recognitionRef.current.stop();\n      } else {\n        setError(null);\n        try {\n          recognitionRef.current.start();\n        } catch (caughtError) {\n          setError(speechErrorMessage(caughtError));\n        }\n      }\n    } else if (mode === \"media-recorder\") {\n      if (isListening) {\n        stopMediaRecorder();\n      } else {\n        startMediaRecorder();\n      }\n    }\n  }, [mode, isListening, startMediaRecorder, stopMediaRecorder]);\n\n  const handleClick = useCallback(\n    (event: Parameters<NonNullable<SpeechInputProps[\"onClick\"]>>[0]) => {\n      onClick?.(event);\n      if (!event.defaultPrevented) toggleListening();\n    },\n    [onClick, toggleListening]\n  );\n\n  // Determine if button should be disabled\n  const isDisabled =\n    mode === \"none\" ||\n    (mode === \"speech-recognition\" && !isRecognitionReady) ||\n    (mode === \"media-recorder\" && !onAudioRecorded) ||\n    isStarting ||\n    isProcessing;\n\n  return (\n    <div className=\"inline-flex flex-col items-center gap-2\">\n      <div className=\"relative inline-flex items-center justify-center\">\n      {/* Animated pulse rings */}\n      {isListening &&\n        [0, 1, 2].map((index) => (\n          <div\n            className=\"absolute inset-0 animate-ping rounded-full border-2 border-red-400/30\"\n            key={index}\n            style={{\n              animationDelay: `${index * 0.3}s`,\n              animationDuration: \"2s\",\n            }}\n          />\n        ))}\n\n      {/* Main record button */}\n      <Button\n        className={cn(\n          \"relative z-10 rounded-full transition-all duration-300\",\n          isListening\n            ? \"bg-destructive text-white hover:bg-destructive/80 hover:text-white\"\n            : \"bg-primary text-primary-foreground hover:bg-primary/80 hover:text-primary-foreground\",\n          className\n        )}\n        {...props}\n        disabled={isDisabled || disabled}\n        onClick={handleClick}\n        aria-label={\n          ariaLabel ?? (isListening ? \"Stop speech input\" : \"Start speech input\")\n        }\n      >\n        {isProcessing && <Spinner />}\n        {!isProcessing && isListening && <SquareIcon className=\"size-4\" />}\n        {!(isProcessing || isListening) && <MicIcon className=\"size-4\" />}\n      </Button>\n      </div>\n      {(error || (modeResolved && mode === \"none\")) && (\n        <p className=\"text-xs text-destructive\" role={error ? \"alert\" : \"status\"}>\n          {error ?? \"Speech input is unavailable in this browser.\"}\n        </p>\n      )}\n    </div>\n  );\n};\n",
      "type": "registry:ui",
      "target": "components/ai/speech-input.tsx"
    }
  ],
  "type": "registry:ui"
}