{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"voice-assistant","type":"registry:block","title":"AI Voice Assistant","description":"A voice-first conversation — live waveform states, a recording input, a spoken transcript and a typed fallback.","author":"Scrim UI (https://scrimui.dev)","categories":["pattern"],"docs":"https://scrimui.dev/patterns/voice-assistant","dependencies":[],"registryDependencies":["https://scrimui.dev/r/voice-input.json","https://scrimui.dev/r/voice-waveform.json","https://scrimui.dev/r/voice-conversation.json","https://scrimui.dev/r/streaming-message.json","https://scrimui.dev/r/prompt-input.json"],"files":[{"path":"src/showcase/patterns/voice-assistant/voice-assistant.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { VoiceInput } from \"@/components/ui/voice-input\";\nimport { VoiceWaveform, type WaveformState } from \"@/components/ui/voice-waveform\";\nimport { VoiceConversation, type VoiceTurn } from \"@/components/ui/voice-conversation\";\nimport { StreamingMessage } from \"@/components/ui/streaming-message\";\nimport { PromptInput } from \"@/components/ui/prompt-input\";\n\n/* ------------------------------------------------------------------ */\n/* Copy / data                                                         */\n/* ------------------------------------------------------------------ */\n\ntype Stage = \"idle\" | \"listening\" | \"recording\";\n\nconst SPOKEN =\n  \"What does streaming versus waiting for the full reply do for perceived latency?\";\nconst SPOKEN_WORDS = SPOKEN.split(\" \");\n\nconst REPLIES = [\n  \"Streaming lands the first token in milliseconds, which makes a reply feel instant. Keep the reveal smooth, offer a stop control, and only surface citations once the claim is actually grounded.\",\n  \"A voice-first interface should mirror its state out loud: listening, recording, speaking. The waveform is the visual echo of what the assistant is doing right now.\",\n];\n\nconst STAGE_TEXT: Record<Stage, string> = {\n  idle: \"Tap the mic to start talking\",\n  listening: \"Listening…\",\n  recording: \"Recording — tap stop when you are done\",\n};\n\nconst SPEAKING_TEXT = \"Speaking…\";\n\n/* ------------------------------------------------------------------ */\n/* Icons                                                               */\n/* ------------------------------------------------------------------ */\n\nfunction MicIcon() {\n  return (\n    <svg\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      width=\"15\"\n      height=\"15\"\n    >\n      <path d=\"M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z\" />\n      <path d=\"M19 10v2a7 7 0 0 1-14 0v-2\" />\n      <path d=\"M12 19v3\" />\n    </svg>\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/* Helpers                                                             */\n/* ------------------------------------------------------------------ */\n\nfunction waveState(stage: Stage, streaming: boolean): WaveformState {\n  if (streaming) return \"speaking\";\n  if (stage === \"recording\") return \"recording\";\n  if (stage === \"listening\") return \"listening\";\n  return \"idle\";\n}\n\nfunction stageText(stage: Stage, streaming: boolean) {\n  return streaming ? SPEAKING_TEXT : STAGE_TEXT[stage];\n}\n\nfunction StatusChip({ stage, streaming }: { stage: Stage; streaming: boolean }) {\n  let label = \"Idle\";\n  /* zinc-600, not zinc-500: at 11px on the zinc-100 chip that measured\n     4.39:1 and failed AA. The three active states below already pair a -100\n     background with -700 text; this keeps idle the quietest of the four while\n     still clearing the floor (7.03:1). */\n  let cls = \"bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400\";\n  if (streaming) {\n    label = \"Speaking\";\n    cls = \"bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-300\";\n  } else if (stage === \"recording\") {\n    label = \"Recording\";\n    cls = \"bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300\";\n  } else if (stage === \"listening\") {\n    label = \"Listening\";\n    cls = \"bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-300\";\n  }\n  return (\n    <span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${cls}`}>{label}</span>\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/* VoiceAssistantPattern                                               */\n/* ------------------------------------------------------------------ */\n\nexport function VoiceAssistantPattern() {\n  const [turns, setTurns] = React.useState<VoiceTurn[]>([\n    {\n      id: \"1\",\n      role: \"assistant\",\n      text: \"Hi, I’m your voice assistant. Tap the mic and talk, or type below — I’ll answer out loud.\",\n      time: \"Now\",\n    },\n  ]);\n  const [stage, setStage] = React.useState<Stage>(\"idle\");\n  const [transcript, setTranscript] = React.useState(\"\");\n  const [reply, setReply] = React.useState<string | null>(null);\n  const [streaming, setStreaming] = React.useState(false);\n\n  const scrollRef = React.useRef<HTMLDivElement>(null);\n  const idRef = React.useRef(2);\n  const replyRef = React.useRef(0);\n  const wordRef = React.useRef(0);\n  const typingRef = React.useRef<number | null>(null);\n\n  React.useEffect(() => {\n    const el = scrollRef.current;\n    if (el) el.scrollTop = el.scrollHeight;\n  }, [turns, reply, stage]);\n\n  const recordingTime = `0:0${Math.min(3 + Math.ceil(transcript.length / 14), 9)}`;\n\n  function clearTyping() {\n    if (typingRef.current !== null) {\n      window.clearInterval(typingRef.current);\n      typingRef.current = null;\n    }\n  }\n\n  function startListening() {\n    if (streaming) {\n      setReply(null);\n      setStreaming(false);\n    }\n    clearTyping();\n    setTranscript(\"\");\n    setStage(\"listening\");\n    window.setTimeout(() => {\n      setStage(\"recording\");\n      wordRef.current = 0;\n      typingRef.current = window.setInterval(() => {\n        wordRef.current += 1;\n        setTranscript(SPOKEN_WORDS.slice(0, wordRef.current).join(\" \"));\n        if (wordRef.current >= SPOKEN_WORDS.length) clearTyping();\n      }, 230);\n    }, 900);\n  }\n\n  function cancelRecording() {\n    clearTyping();\n    setTranscript(\"\");\n    setStage(\"idle\");\n  }\n\n  function stopRecording() {\n    clearTyping();\n    const text = transcript.trim();\n    setTranscript(\"\");\n    if (!text) {\n      setStage(\"idle\");\n      return;\n    }\n    setStage(\"idle\");\n    setTurns((t) => [...t, { id: String(idRef.current++), role: \"user\", text, time: recordingTime }]);\n    beginReply();\n  }\n\n  function beginReply() {\n    const text = REPLIES[replyRef.current % REPLIES.length];\n    replyRef.current += 1;\n    setReply(text);\n    setStreaming(true);\n  }\n\n  function finishReply() {\n    if (reply) {\n      setTurns((t) => [\n        ...t,\n        { id: String(idRef.current++), role: \"assistant\", text: reply, time: \"Now\" },\n      ]);\n    }\n    setReply(null);\n    setStreaming(false);\n  }\n\n  function stopReply() {\n    setReply(null);\n    setStreaming(false);\n  }\n\n  function submitText(value: string) {\n    setTurns((t) => [...t, { id: String(idRef.current++), role: \"user\", text: value, time: \"Now\" }]);\n    beginReply();\n  }\n\n  return (\n    <div className=\"flex h-[560px] flex-col overflow-hidden rounded-2xl border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900\">\n      {/* Header */}\n      <div className=\"flex items-center justify-between border-b border-zinc-200 px-4 py-3 dark:border-zinc-800\">\n        <div className=\"flex items-center gap-2.5\">\n          <span className=\"flex h-8 w-8 items-center justify-center rounded-lg bg-violet-100 text-violet-600 dark:bg-violet-900/40 dark:text-violet-400\">\n            <MicIcon />\n          </span>\n          <div>\n            <p className=\"text-sm font-semibold text-zinc-900 dark:text-zinc-100\">\n              Voice Assistant\n            </p>\n            <p className=\"text-xs text-zinc-500 dark:text-zinc-400\">Hands-free answers</p>\n          </div>\n        </div>\n        <StatusChip stage={stage} streaming={streaming} />\n      </div>\n\n      {/* Live waveform strip */}\n      <div className=\"flex items-center gap-3 border-b border-zinc-100 px-4 py-2.5 dark:border-zinc-800\">\n        <VoiceWaveform\n          state={waveState(stage, streaming)}\n          bars={22}\n          className=\"h-7 w-40 shrink-0 text-violet-500\"\n        />\n        <p className=\"truncate text-xs text-zinc-500 dark:text-zinc-400\">{stageText(stage, streaming)}</p>\n      </div>\n\n      {/* Messages */}\n      <div ref={scrollRef} className=\"flex-1 space-y-5 overflow-y-auto px-4 py-5 sm:px-6\">\n        <VoiceConversation turns={turns} />\n\n        {reply && (\n          <div className=\"flex items-start gap-3\">\n            <span className=\"mt-3 shrink-0 rounded-full border border-violet-200 bg-violet-50 px-2.5 py-1 text-[11px] font-medium text-violet-600 dark:border-violet-800 dark:bg-violet-950/50 dark:text-violet-400\">\n              Speaking\n            </span>\n            <div className=\"min-w-0 flex-1\">\n              <StreamingMessage\n                text={reply}\n                isStreaming={streaming}\n                speed={2}\n                showActions={false}\n                onStop={stopReply}\n                onComplete={finishReply}\n              />\n            </div>\n          </div>\n        )}\n      </div>\n\n      {/* Controls */}\n      <div className=\"space-y-2 border-t border-zinc-200 px-4 py-3 dark:border-zinc-800\">\n        <VoiceInput\n          state={stage === \"recording\" ? \"recording\" : \"idle\"}\n          recordingTime={recordingTime}\n          transcript={transcript}\n          onStart={startListening}\n          onStop={stopRecording}\n          onCancel={cancelRecording}\n        />\n        <PromptInput\n          placeholder=\"Type instead…\"\n          onSubmit={submitText}\n          showWebSearch={false}\n          showTools={false}\n          disabled={streaming}\n        />\n      </div>\n    </div>\n  );\n}\n","type":"registry:block","target":"components/blocks/voice-assistant.tsx"}]}