{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "voice-dictation",
  "type": "registry:ui",
  "title": "Voice Dictation",
  "description": "Mic button that expands into a live waveform pill with a timer and live transcript; speech-to-text with recording fallback, toggle or hold-to-talk.",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://kuro.abstergo.fyi/r/kura-theme.json",
    "https://kuro.abstergo.fyi/r/audio-waveform.json",
    "https://kuro.abstergo.fyi/r/kura-keys.json",
    "https://kuro.abstergo.fyi/r/kura-motion.json",
    "https://kuro.abstergo.fyi/r/shimmer-text.json",
    "https://kuro.abstergo.fyi/r/spinner.json",
    "https://kuro.abstergo.fyi/r/tooltip.json",
    "https://kuro.abstergo.fyi/r/use-audio-analyser.json",
    "https://kuro.abstergo.fyi/r/use-hotkey.json",
    "https://kuro.abstergo.fyi/r/use-media-recorder.json",
    "https://kuro.abstergo.fyi/r/use-reduced-motion.json",
    "https://kuro.abstergo.fyi/r/use-speech-recognition.json",
    "utils"
  ],
  "files": [
    {
      "path": "registry/kura/voice-dictation/index.ts",
      "type": "registry:ui",
      "target": "components/kura/voice-dictation/index.ts",
      "content": "export {\n  VoiceDictation,\n  type VoiceDictationProps,\n  type VoiceDictationStatus,\n} from \"./voice-dictation\";\n"
    },
    {
      "path": "registry/kura/voice-dictation/voice-dictation.tsx",
      "type": "registry:ui",
      "target": "components/kura/voice-dictation/voice-dictation.tsx",
      "content": "\"use client\";\n\nimport { Mic, MicOff, X } from \"lucide-react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport * as React from \"react\";\nimport { useAudioAnalyser } from \"@/hooks/kura/use-audio-analyser\";\nimport { useHotkey } from \"@/hooks/kura/use-hotkey\";\nimport { useMediaRecorder } from \"@/hooks/kura/use-media-recorder\";\nimport { useReducedMotion } from \"@/hooks/kura/use-reduced-motion\";\nimport { useSpeechRecognition } from \"@/hooks/kura/use-speech-recognition\";\nimport { cn } from \"@/lib/utils\";\nimport type { KeyName } from \"@/lib/kura/keys\";\nimport { springs } from \"@/lib/kura/motion\";\nimport { AudioWaveform } from \"@/components/kura/audio-waveform\";\nimport { ShimmerText } from \"@/components/kura/shimmer-text\";\nimport { Spinner } from \"@/components/kura/spinner\";\nimport { Tooltip } from \"@/components/kura/tooltip\";\n\nexport type VoiceDictationStatus =\n  | \"idle\"\n  | \"requesting\"\n  | \"listening\"\n  | \"processing\"\n  | \"denied\"\n  | \"unsupported\"\n  | \"error\";\n\nexport interface VoiceDictationProps extends Omit<React.ComponentProps<\"div\">, \"children\"> {\n  /** Called once per dictation with the final, trimmed text (not called when cancelled or empty). */\n  onTranscript?: (text: string) => void;\n  /** Live transcript while listening (final + interim), e.g. to preview in a field. */\n  onInterim?: (text: string) => void;\n  onStatusChange?: (status: VoiceDictationStatus) => void;\n  /** `toggle` = click to start and stop. `hold` = press and hold (pointer, Space/Enter or the hotkey). Default \"toggle\". */\n  mode?: \"toggle\" | \"hold\";\n  /** Global shortcut. Default [\"mod\", \"shift\", \"d\"]. Pass null to disable. */\n  hotkey?: readonly KeyName[] | null;\n  /** BCP-47 language for speech recognition, e.g. \"en-US\". Default: the page language. */\n  lang?: string;\n  /**\n   * Fallback when the browser has no Web Speech API: the recording is passed here and the\n   * returned text is used (status \"processing\" meanwhile). E.g. post to a Whisper endpoint.\n   */\n  transcribe?: (audio: Blob) => Promise<string>;\n  /**\n   * Demo mode: fake permission, a simulated waveform and a scripted transcript. `true` uses a\n   * built-in script; a string is typed out word by word.\n   */\n  simulate?: boolean | string;\n  /** Show the live transcript card under the control. Default true. */\n  showTranscript?: boolean;\n  /** Placeholder in the transcript card before any words arrive. Default \"Listening…\". */\n  transcriptPlaceholder?: string;\n  disabled?: boolean;\n}\n\nconst DEFAULT_SCRIPT =\n  \"Draft a short note to the team: the launch moves to Thursday, and the release notes need one more pass on the pricing section.\";\n\nfunction formatElapsed(ms: number): string {\n  const s = Math.floor(ms / 1000);\n  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, \"0\")}`;\n}\n\nconst STATUS_TEXT: Record<VoiceDictationStatus, string> = {\n  idle: \"\",\n  requesting: \"Waiting for microphone permission\",\n  listening: \"Listening\",\n  processing: \"Transcribing\",\n  denied: \"Microphone access is blocked\",\n  unsupported: \"Voice input is not supported in this browser\",\n  error: \"Voice input failed\",\n};\n\n/**\n * Microphone dictation: a mic button that expands into a live waveform pill with a timer, plus\n * a live transcript. Uses the Web Speech API when available and falls back to recording +\n * your `transcribe` function. Toggle or hold-to-talk, with a global shortcut.\n */\nexport function VoiceDictation({\n  onTranscript,\n  onInterim,\n  onStatusChange,\n  mode = \"toggle\",\n  hotkey = [\"mod\", \"shift\", \"d\"],\n  lang,\n  transcribe,\n  simulate,\n  showTranscript = true,\n  transcriptPlaceholder = \"Listening…\",\n  disabled = false,\n  className,\n  ...props\n}: VoiceDictationProps) {\n  const reduced = useReducedMotion();\n  const simulating = simulate !== undefined && simulate !== false;\n  const script = typeof simulate === \"string\" ? simulate : DEFAULT_SCRIPT;\n\n  const [status, setStatusState] = React.useState<VoiceDictationStatus>(\"idle\");\n  const statusRef = React.useRef(status);\n  const setStatus = React.useCallback(\n    (next: VoiceDictationStatus) => {\n      if (statusRef.current === next) return;\n      statusRef.current = next;\n      setStatusState(next);\n      onStatusChange?.(next);\n    },\n    [onStatusChange],\n  );\n\n  const [finalText, setFinalText] = React.useState(\"\");\n  const [interimText, setInterimText] = React.useState(\"\");\n  const [elapsed, setElapsed] = React.useState(0);\n  const cancelled = React.useRef(false);\n  const timers = React.useRef<ReturnType<typeof setTimeout>[]>([]);\n  const startedAt = React.useRef(0);\n\n  const emit = React.useCallback(\n    (text: string) => {\n      const clean = text.replace(/\\s+/g, \" \").trim();\n      if (clean && !cancelled.current) onTranscript?.(clean);\n    },\n    [onTranscript],\n  );\n\n  const speech = useSpeechRecognition({\n    lang,\n    continuous: true,\n    interimResults: true,\n    onEnd: (final) => {\n      if (simulating) return;\n      if (recorderRef.current?.isRecording) void recorderRef.current.stop();\n      if (statusRef.current === \"listening\" || statusRef.current === \"processing\") {\n        emit(final);\n        setStatus(\"idle\");\n      }\n    },\n    onError: (error) => {\n      if (simulating || error.name === \"aborted\") return;\n      if (recorderRef.current?.isRecording) void recorderRef.current.stop();\n      if (error.name === \"no-speech\") setStatus(\"idle\");\n      else\n        setStatus(\n          error.name === \"not-allowed\" || error.name === \"service-not-allowed\" ? \"denied\" : \"error\",\n        );\n    },\n  });\n  const recorderError = React.useRef<Error | null>(null);\n  const recorder = useMediaRecorder({\n    onError: (error) => {\n      recorderError.current = error;\n    },\n  });\n  const recorderRef = React.useRef(recorder);\n  recorderRef.current = recorder;\n  const statusId = React.useId();\n  const analyser = useAudioAnalyser({\n    source: simulating ? null : recorder.stream,\n    simulate: simulating && status === \"listening\" ? \"speech\" : false,\n    bands: 24,\n  });\n\n  const supported = simulating || speech.isSupported || recorder.isSupported;\n\n  // Mirror real recognition text while listening.\n  React.useEffect(() => {\n    if (simulating) return;\n    setFinalText(speech.finalTranscript);\n    setInterimText(speech.interimTranscript);\n  }, [simulating, speech.finalTranscript, speech.interimTranscript]);\n\n  React.useEffect(() => {\n    if (status === \"listening\") onInterim?.(`${finalText}${interimText}`.trim());\n  }, [status, finalText, interimText, onInterim]);\n\n  // Timer.\n  React.useEffect(() => {\n    if (status !== \"listening\") return;\n    startedAt.current = Date.now();\n    setElapsed(0);\n    const id = setInterval(() => setElapsed(Date.now() - startedAt.current), 250);\n    return () => clearInterval(id);\n  }, [status]);\n\n  const clearTimers = React.useCallback(() => {\n    for (const t of timers.current) clearTimeout(t);\n    timers.current = [];\n  }, []);\n  React.useEffect(() => clearTimers, [clearTimers]);\n\n  const start = React.useCallback(async () => {\n    if (disabled) return;\n    const s = statusRef.current;\n    if (s === \"listening\" || s === \"requesting\" || s === \"processing\") return;\n    cancelled.current = false;\n    setFinalText(\"\");\n    setInterimText(\"\");\n    clearTimers();\n\n    if (simulating) {\n      setStatus(\"requesting\");\n      timers.current.push(\n        setTimeout(() => {\n          setStatus(\"listening\");\n          const words = script.split(/\\s+/);\n          words.forEach((_, i) => {\n            timers.current.push(\n              setTimeout(\n                () => {\n                  const done = words.slice(0, Math.max(0, i - 2)).join(\" \");\n                  const live = words.slice(Math.max(0, i - 2), i + 1).join(\" \");\n                  setFinalText(done ? `${done} ` : \"\");\n                  setInterimText(live);\n                  if (i === words.length - 1) {\n                    timers.current.push(\n                      setTimeout(() => {\n                        setFinalText(script);\n                        setInterimText(\"\");\n                      }, 500),\n                    );\n                  }\n                },\n                450 + i * 190,\n              ),\n            );\n          });\n        }, 450),\n      );\n      return;\n    }\n\n    if (!supported) {\n      setStatus(\"unsupported\");\n      return;\n    }\n    setStatus(\"requesting\");\n    recorderError.current = null;\n    if (recorder.isSupported) {\n      const stream = await recorder.start();\n      if (!stream) {\n        const name = (recorderError.current as { name?: string } | null)?.name;\n        setStatus(name === \"NotAllowedError\" || name === \"SecurityError\" ? \"denied\" : \"error\");\n        return;\n      }\n    }\n    if (cancelled.current) {\n      recorder.reset();\n      setStatus(\"idle\");\n      return;\n    }\n    if (speech.isSupported) {\n      speech.reset();\n      speech.start();\n    }\n    setStatus(\"listening\");\n  }, [disabled, simulating, script, supported, recorder, speech, setStatus, clearTimers]);\n\n  const stop = React.useCallback(async () => {\n    const s = statusRef.current;\n    if (s === \"requesting\") {\n      cancelled.current = true;\n      clearTimers();\n      recorder.reset();\n      setStatus(\"idle\");\n      return;\n    }\n    if (s !== \"listening\") return;\n\n    if (simulating) {\n      clearTimers();\n      setStatus(\"processing\");\n      timers.current.push(\n        setTimeout(() => {\n          emit(`${finalText}${interimText}`);\n          setInterimText(\"\");\n          setStatus(\"idle\");\n        }, 650),\n      );\n      return;\n    }\n\n    setStatus(\"processing\");\n    if (speech.isSupported) {\n      speech.stop(); // onEnd emits the final transcript\n      if (recorder.isRecording) void recorder.stop();\n      return;\n    }\n    const blob = await recorder.stop();\n    if (blob && transcribe) {\n      try {\n        emit(await transcribe(blob));\n        setStatus(\"idle\");\n      } catch {\n        setStatus(\"error\");\n      }\n    } else {\n      setStatus(\"idle\");\n    }\n  }, [\n    simulating,\n    finalText,\n    interimText,\n    emit,\n    speech,\n    recorder,\n    transcribe,\n    setStatus,\n    clearTimers,\n  ]);\n\n  const cancel = React.useCallback(() => {\n    cancelled.current = true;\n    clearTimers();\n    if (!simulating) {\n      speech.abort();\n      recorder.reset();\n    }\n    setFinalText(\"\");\n    setInterimText(\"\");\n    setStatus(\"idle\");\n  }, [simulating, speech, recorder, setStatus, clearTimers]);\n\n  const toggle = () => {\n    const s = statusRef.current;\n    if (s === \"listening\" || s === \"requesting\") void stop();\n    else void start();\n  };\n\n  useHotkey(\n    hotkey,\n    (e) => {\n      if (e.repeat) return;\n      if (mode === \"hold\") void start();\n      else toggle();\n    },\n    {\n      enabled: !disabled && hotkey !== null,\n      allowInInputs: true,\n      onKeyUp: mode === \"hold\" ? () => void stop() : undefined,\n    },\n  );\n\n  React.useEffect(() => {\n    if (status !== \"listening\") return;\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key === \"Escape\") {\n        e.preventDefault();\n        cancel();\n      }\n    };\n    document.addEventListener(\"keydown\", onKey);\n    return () => document.removeEventListener(\"keydown\", onKey);\n  }, [status, cancel]);\n\n  const live = status === \"listening\" || status === \"processing\" || status === \"requesting\";\n  const blocked = status === \"denied\" || status === \"unsupported\" || status === \"error\";\n  const effective: VoiceDictationStatus = !supported && status === \"idle\" ? \"unsupported\" : status;\n  const transcript = `${finalText}${interimText}`;\n\n  const holdHandlers =\n    mode === \"hold\"\n      ? {\n          onPointerDown: (e: React.PointerEvent<HTMLButtonElement>) => {\n            if (e.button !== 0) return;\n            e.currentTarget.setPointerCapture?.(e.pointerId);\n            void start();\n          },\n          onPointerUp: () => void stop(),\n          onPointerCancel: () => void stop(),\n          onKeyDown: (e: React.KeyboardEvent) => {\n            if ((e.key === \" \" || e.key === \"Enter\") && !e.repeat) {\n              e.preventDefault();\n              void start();\n            }\n          },\n          onKeyUp: (e: React.KeyboardEvent) => {\n            if (e.key === \" \" || e.key === \"Enter\") {\n              e.preventDefault();\n              void stop();\n            }\n          },\n        }\n      : { onClick: toggle };\n\n  const tooltipText =\n    effective === \"unsupported\"\n      ? \"Voice input isn't supported here\"\n      : effective === \"denied\"\n        ? \"Microphone blocked\"\n        : mode === \"hold\"\n          ? \"Hold to dictate\"\n          : status === \"listening\"\n            ? \"Stop dictation\"\n            : \"Dictate\";\n\n  const layoutTransition = reduced ? { duration: 0 } : springs.smooth;\n  const swap = reduced\n    ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } }\n    : {\n        initial: { opacity: 0, filter: \"blur(2px)\", scale: 0.9 },\n        animate: { opacity: 1, filter: \"blur(0px)\", scale: 1 },\n        exit: { opacity: 0, filter: \"blur(2px)\", scale: 0.9 },\n      };\n\n  return (\n    <div\n      data-slot=\"voice-dictation\"\n      data-status={effective}\n      data-mode={mode}\n      className={cn(\"flex min-w-0 flex-col items-start gap-3 font-kura-sans\", className)}\n      {...props}\n    >\n      <motion.div\n        layout\n        transition={layoutTransition}\n        data-slot=\"voice-dictation-control\"\n        style={{ borderRadius: 999 }}\n        className={cn(\n          \"relative flex h-8 items-center\",\n          live && \"gap-1 bg-kura-accent-subtle p-0.5 pr-3 text-kura-accent-text\",\n        )}\n      >\n        <Tooltip content={tooltipText} shortcut={hotkey ?? undefined}>\n          <motion.button\n            layout=\"position\"\n            transition={layoutTransition}\n            type=\"button\"\n            data-slot=\"voice-dictation-button\"\n            aria-label={mode === \"hold\" ? \"Hold to dictate\" : \"Dictate\"}\n            aria-pressed={status === \"listening\"}\n            aria-describedby={blocked ? statusId : undefined}\n            disabled={disabled || effective === \"unsupported\" || status === \"processing\"}\n            {...holdHandlers}\n            className={cn(\n              \"relative flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-full outline-none\",\n              \"transition-[background-color,color,box-shadow] duration-(--kura-duration-fast) ease-kura-out\",\n              \"focus-visible:shadow-kura-focus disabled:cursor-not-allowed disabled:opacity-50\",\n              live\n                ? \"bg-kura-accent text-kura-accent-fg hover:bg-kura-accent-hover\"\n                : blocked\n                  ? \"text-kura-danger-text hover:bg-kura-hover\"\n                  : \"text-kura-fg-muted hover:bg-kura-hover hover:text-kura-fg\",\n              effective === \"unsupported\" && \"text-kura-fg-subtle\",\n              mode === \"hold\" && \"touch-none select-none\",\n            )}\n          >\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              <motion.span\n                key={live ? \"stop\" : blocked ? \"off\" : \"mic\"}\n                className=\"flex\"\n                transition={springs.snappy}\n                {...swap}\n              >\n                {status === \"requesting\" ? (\n                  <Spinner size=\"sm\" label={null} />\n                ) : live && mode === \"toggle\" ? (\n                  <span aria-hidden className=\"size-2.5 rounded-[2.5px] bg-current\" />\n                ) : blocked ? (\n                  <MicOff aria-hidden className=\"size-4\" />\n                ) : (\n                  <Mic aria-hidden className=\"size-4\" />\n                )}\n              </motion.span>\n            </AnimatePresence>\n          </motion.button>\n        </Tooltip>\n        <AnimatePresence initial={false}>\n          {live && (\n            <motion.div\n              key=\"live\"\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1, transition: { delay: reduced ? 0 : 0.08, duration: 0.2 } }}\n              exit={{ opacity: 0, transition: { duration: 0.09 } }}\n              className=\"flex items-center gap-2\"\n            >\n              {status === \"processing\" ? (\n                <ShimmerText className=\"px-1 text-[13px]\">Transcribing…</ShimmerText>\n              ) : (\n                <>\n                  <AudioWaveform\n                    analyser={analyser}\n                    variant=\"mirrored\"\n                    tone=\"accent\"\n                    barWidth={2}\n                    barGap={2}\n                    active={status === \"listening\"}\n                    className=\"h-5 w-16\"\n                    aria-hidden\n                  />\n                  <span className=\"w-8 text-right font-medium text-xs tabular-nums\">\n                    {formatElapsed(elapsed)}\n                  </span>\n                  <button\n                    type=\"button\"\n                    data-slot=\"voice-dictation-cancel\"\n                    aria-label=\"Cancel dictation\"\n                    onClick={cancel}\n                    className=\"-mr-1.5 flex size-6 cursor-pointer items-center justify-center rounded-full opacity-70 outline-none transition-[opacity,background-color] duration-(--kura-duration-fast) hover:bg-kura-hover hover:opacity-100 focus-visible:shadow-kura-focus\"\n                  >\n                    <X aria-hidden className=\"size-3.5\" />\n                  </button>\n                </>\n              )}\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </motion.div>\n\n      <span id={statusId} role=\"status\" className=\"sr-only\">\n        {STATUS_TEXT[effective]}\n      </span>\n\n      {blocked && (\n        <p\n          data-slot=\"voice-dictation-message\"\n          className=\"max-w-xs animate-kura-fade-in text-kura-danger-text text-xs\"\n        >\n          {status === \"denied\"\n            ? \"Microphone access is blocked. Allow it in your browser's site settings and try again.\"\n            : status === \"unsupported\"\n              ? \"This browser can't record audio. Try Chrome, Edge or Safari.\"\n              : \"Something went wrong with voice input. Try again.\"}\n        </p>\n      )}\n\n      {showTranscript && live && (\n        <div\n          data-slot=\"voice-dictation-transcript\"\n          className={cn(\n            \"w-full min-w-0 animate-kura-fade-in rounded-kura-lg bg-kura-surface px-3.5 py-3 text-[15px] leading-6 shadow-kura-sm\",\n          )}\n        >\n          {transcript ? (\n            <p className=\"text-pretty\">\n              <span className=\"text-kura-fg\">{finalText}</span>\n              <span className=\"text-kura-fg-subtle\">{interimText}</span>\n              {status === \"listening\" && (\n                <span\n                  aria-hidden\n                  className=\"ml-0.5 inline-block h-[1.1em] w-[2px] translate-y-[3px] animate-kura-pulse rounded-full bg-kura-accent motion-reduce:animate-none\"\n                />\n              )}\n            </p>\n          ) : (\n            <p className=\"text-kura-fg-subtle\">\n              {status === \"requesting\" ? \"Allow microphone access…\" : transcriptPlaceholder}\n            </p>\n          )}\n        </div>\n      )}\n    </div>\n  );\n}\n"
    }
  ],
  "categories": [
    "voice",
    "voice",
    "dictation",
    "speech-to-text",
    "microphone"
  ],
  "docs": "Docs: https://kuro.abstergo.fyi/components/voice-dictation · Markdown for agents: https://kuro.abstergo.fyi/components/voice-dictation.md",
  "meta": {
    "kura": {
      "status": "experimental",
      "whenToUse": [
        "Let users dictate a prompt instead of typing — standalone or in a composer toolbar.",
        "Push-to-talk input (mode=\"hold\") for voice notes or quick commands.",
        "Browsers without the Web Speech API: record audio and transcribe on your server via `transcribe`."
      ],
      "whenNotToUse": [
        "A full-duplex conversation with a voice agent (barge-in, spoken replies) — use agent-orb with your realtime session.",
        "Only visualising audio you already have — use audio-waveform.",
        "Typing prompts — use composer."
      ]
    }
  }
}
