{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "speech-input", "dependencies": [ "motion", "lucide-react" ], "registryDependencies": [ "button", "skeleton", "https://ui.elevenlabs.io/r/use-scribe.json" ], "files": [ { "path": "components/ui/speech-input.tsx", "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\nimport { motion } from \"framer-motion\"\nimport { MicIcon, SquareIcon, XIcon } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport {\n useScribe,\n type AudioFormat,\n type CommitStrategy,\n} from \"@/registry/elevenlabs-ui/hooks/use-scribe\"\nimport { Button } from \"@/components/ui/button\"\nimport { Skeleton } from \"@/components/ui/skeleton\"\n\nconst buttonVariants = cva(\"!px-0\", {\n variants: {\n size: {\n default: \"h-9 w-9\",\n sm: \"h-8 w-8\",\n lg: \"h-10 w-10\",\n },\n },\n defaultVariants: {\n size: \"default\",\n },\n})\n\ntype ButtonSize = VariantProps[\"size\"]\n\nexport interface SpeechInputData {\n /** The current partial (in-progress) transcript */\n partialTranscript: string\n /** Array of all committed (finalized) transcripts */\n committedTranscripts: string[]\n /** Full transcript combining committed and partial transcripts */\n transcript: string\n}\n\ninterface SpeechInputContextValue {\n isConnected: boolean\n isConnecting: boolean\n transcript: string\n partialTranscript: string\n committedTranscripts: string[]\n error: string | null\n start: () => Promise\n stop: () => void\n cancel: () => void\n size: ButtonSize\n}\n\nconst SpeechInputContext = React.createContext(\n null\n)\n\nfunction useSpeechInput() {\n const context = React.useContext(SpeechInputContext)\n if (!context) {\n throw new Error(\n \"SpeechInput compound components must be used within a SpeechInput\"\n )\n }\n return context\n}\n\nfunction buildTranscript({\n partialTranscript,\n committedTranscripts,\n}: {\n partialTranscript: string\n committedTranscripts: string[]\n}): string {\n const committed = committedTranscripts.join(\" \").trim()\n const partial = partialTranscript.trim()\n\n if (committed && partial) {\n return `${committed} ${partial}`\n }\n return committed || partial\n}\n\nfunction buildData({\n partialTranscript,\n committedTranscripts,\n}: {\n partialTranscript: string\n committedTranscripts: string[]\n}): SpeechInputData {\n return {\n partialTranscript,\n committedTranscripts,\n transcript: buildTranscript({ partialTranscript, committedTranscripts }),\n }\n}\n\nexport interface SpeechInputProps {\n children: React.ReactNode\n\n /**\n * Function that returns a token for authenticating with the speech service.\n * This should be an async function that fetches a token from your backend.\n */\n getToken: () => Promise\n\n /**\n * Called whenever the transcript changes (partial or committed)\n */\n onChange?: (data: SpeechInputData) => void\n\n /**\n * Called when recording is cancelled\n */\n onCancel?: (data: SpeechInputData) => void\n\n /**\n * Called when recording starts\n */\n onStart?: (data: SpeechInputData) => void\n\n /**\n * Called when recording stops\n */\n onStop?: (data: SpeechInputData) => void\n\n /**\n * Additional CSS classes for the root container\n */\n className?: string\n\n /**\n * Size variant for the component buttons\n * @default \"default\"\n */\n size?: ButtonSize\n\n /**\n * Model ID for the speech recognition service\n * @default \"scribe_v2_realtime\"\n */\n modelId?: string\n\n /**\n * Base URI for the speech recognition service\n */\n baseUri?: string\n\n /**\n * Strategy for committing transcripts\n */\n commitStrategy?: CommitStrategy\n\n /**\n * Silence threshold in seconds for VAD\n */\n vadSilenceThresholdSecs?: number\n\n /**\n * VAD threshold value\n */\n vadThreshold?: number\n\n /**\n * Minimum speech duration in milliseconds\n */\n minSpeechDurationMs?: number\n\n /**\n * Minimum silence duration in milliseconds\n */\n minSilenceDurationMs?: number\n\n /**\n * Language code for transcription (e.g., \"en\", \"es\", \"fr\")\n */\n languageCode?: string\n\n /**\n * Microphone configuration options\n */\n microphone?: {\n deviceId?: string\n echoCancellation?: boolean\n noiseSuppression?: boolean\n autoGainControl?: boolean\n channelCount?: number\n }\n\n /**\n * Audio format for manual audio mode\n */\n audioFormat?: AudioFormat\n\n /**\n * Sample rate for manual audio mode\n */\n sampleRate?: number\n\n /**\n * Called when an error occurs\n */\n onError?: (error: Error | Event) => void\n\n /**\n * Called when an authentication error occurs\n */\n onAuthError?: (data: { error: string }) => void\n\n /**\n * Called when a quota exceeded error occurs\n */\n onQuotaExceededError?: (data: { error: string }) => void\n}\n\nconst SpeechInput = React.forwardRef(\n function SpeechInput(\n {\n children,\n getToken,\n onChange,\n onCancel,\n onStart,\n onStop,\n className,\n size = \"default\",\n modelId = \"scribe_v2_realtime\",\n baseUri,\n commitStrategy,\n vadSilenceThresholdSecs,\n vadThreshold,\n minSpeechDurationMs,\n minSilenceDurationMs,\n languageCode,\n microphone = {\n echoCancellation: true,\n noiseSuppression: true,\n },\n audioFormat,\n sampleRate,\n onError,\n onAuthError,\n onQuotaExceededError,\n },\n ref\n ) {\n const transcriptsRef = React.useRef({\n partialTranscript: \"\",\n committedTranscripts: [] as string[],\n })\n const startRequestIdRef = React.useRef(0)\n\n const scribe = useScribe({\n modelId,\n baseUri,\n commitStrategy,\n vadSilenceThresholdSecs,\n vadThreshold,\n minSpeechDurationMs,\n minSilenceDurationMs,\n languageCode,\n audioFormat,\n sampleRate,\n microphone,\n onPartialTranscript: (data) => {\n transcriptsRef.current.partialTranscript = data.text\n onChange?.(buildData(transcriptsRef.current))\n },\n onCommittedTranscript: (data) => {\n transcriptsRef.current.committedTranscripts.push(data.text)\n transcriptsRef.current.partialTranscript = \"\"\n onChange?.(buildData(transcriptsRef.current))\n },\n onError,\n onAuthError,\n onQuotaExceededError,\n })\n\n const isConnecting = scribe.status === \"connecting\"\n\n const start = React.useCallback(async () => {\n const requestId = startRequestIdRef.current + 1\n startRequestIdRef.current = requestId\n\n transcriptsRef.current = {\n partialTranscript: \"\",\n committedTranscripts: [],\n }\n scribe.clearTranscripts()\n\n try {\n const token = await getToken()\n if (startRequestIdRef.current !== requestId) {\n return\n }\n\n await scribe.connect({\n token,\n })\n if (startRequestIdRef.current !== requestId) {\n scribe.disconnect()\n return\n }\n onStart?.(buildData(transcriptsRef.current))\n } catch (error) {\n onError?.(error instanceof Error ? error : new Error(String(error)))\n }\n }, [getToken, scribe, onStart, onError])\n\n const stop = React.useCallback(() => {\n startRequestIdRef.current += 1\n scribe.disconnect()\n onStop?.(buildData(transcriptsRef.current))\n }, [scribe, onStop])\n\n const cancel = React.useCallback(() => {\n startRequestIdRef.current += 1\n const data = buildData(transcriptsRef.current)\n scribe.disconnect()\n scribe.clearTranscripts()\n transcriptsRef.current = {\n partialTranscript: \"\",\n committedTranscripts: [],\n }\n onCancel?.(data)\n }, [scribe, onCancel])\n\n const contextValue: SpeechInputContextValue = React.useMemo(\n () => ({\n isConnected: scribe.isConnected,\n isConnecting,\n start,\n stop,\n cancel,\n error: scribe.error,\n size,\n ...buildData({\n partialTranscript: scribe.partialTranscript,\n committedTranscripts: scribe.committedTranscripts.map((t) => t.text),\n }),\n }),\n [\n scribe.isConnected,\n scribe.error,\n scribe.partialTranscript,\n scribe.committedTranscripts,\n isConnecting,\n start,\n stop,\n cancel,\n size,\n ]\n )\n\n React.useEffect(() => {\n return () => {\n startRequestIdRef.current += 1\n scribe.disconnect()\n }\n }, [scribe.disconnect])\n\n return (\n \n \n {children}\n \n \n )\n }\n)\n\nSpeechInput.displayName = \"SpeechInput\"\n\nexport type SpeechInputRecordButtonProps = Omit<\n React.ComponentPropsWithoutRef,\n \"size\"\n>\n\n/**\n * Toggle button for starting/stopping speech recording.\n * Shows a microphone icon when idle and a stop icon when recording.\n */\nconst SpeechInputRecordButton = React.forwardRef<\n HTMLButtonElement,\n SpeechInputRecordButtonProps\n>(function SpeechInputRecordButton(\n { className, onClick, variant = \"ghost\", disabled, ...props },\n ref\n) {\n const speechInput = useSpeechInput()\n\n return (\n {\n if (speechInput.isConnected) {\n speechInput.stop()\n } else {\n speechInput.start()\n }\n onClick?.(e)\n }}\n disabled={disabled ?? speechInput.isConnecting}\n className={cn(\n buttonVariants({ size: speechInput.size }),\n \"relative flex items-center justify-center transition-all\",\n speechInput.isConnected && \"scale-[80%]\",\n className\n )}\n aria-label={\n speechInput.isConnected ? \"Stop recording\" : \"Start recording\"\n }\n {...props}\n >\n \n \n \n \n )\n})\n\nSpeechInputRecordButton.displayName = \"SpeechInputRecordButton\"\n\nexport interface SpeechInputPreviewProps\n extends React.ComponentPropsWithoutRef<\"div\"> {\n /**\n * Text to show when no transcript is available\n * @default \"Listening...\"\n */\n placeholder?: string\n}\n\n/**\n * Displays the current transcript with a placeholder when empty.\n * Only visible when actively recording.\n */\nconst SpeechInputPreview = React.forwardRef<\n HTMLDivElement,\n SpeechInputPreviewProps\n>(function SpeechInputPreview(\n { className, placeholder = \"Listening...\", ...props },\n ref\n) {\n const speechInput = useSpeechInput()\n\n const displayText = speechInput.transcript || placeholder\n const showPlaceholder = !speechInput.transcript.trim()\n\n return (\n \n
\n \n {displayText}\n \n
\n \n )\n})\n\nSpeechInputPreview.displayName = \"SpeechInputPreview\"\n\nexport type SpeechInputCancelButtonProps = Omit<\n React.ComponentPropsWithoutRef,\n \"size\"\n>\n\n/**\n * Button to cancel the current recording and discard the transcript.\n * Only visible when actively recording.\n */\nconst SpeechInputCancelButton = React.forwardRef<\n HTMLButtonElement,\n SpeechInputCancelButtonProps\n>(function SpeechInputCancelButton(\n { className, onClick, variant = \"ghost\", ...props },\n ref\n) {\n const speechInput = useSpeechInput()\n\n return (\n {\n speechInput.cancel()\n onClick?.(e)\n }}\n className={cn(\n buttonVariants({ size: speechInput.size }),\n \"transition-[opacity,transform,width] duration-200 ease-out\",\n speechInput.isConnected\n ? \"scale-[80%] opacity-100\"\n : \"pointer-events-none w-0 scale-100 opacity-0\",\n className\n )}\n aria-label=\"Cancel recording\"\n {...props}\n >\n \n \n )\n})\n\nSpeechInputCancelButton.displayName = \"SpeechInputCancelButton\"\n\nexport {\n SpeechInput,\n SpeechInputRecordButton,\n SpeechInputPreview,\n SpeechInputCancelButton,\n useSpeechInput,\n}\n", "type": "registry:ui" } ], "type": "registry:ui" }