{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "voice-chat",
  "title": "Voice Chat",
  "description": "A realtime voice session panel — speaking orb, word-by-word live captions, mute, mic picker, connection meter, session clock, and an end-of-call summary.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "alert-dialog",
    "button",
    "dropdown-menu",
    "https://ui.zyeon.ai/r/use-controllable-state.json",
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/blocks/voice-chat.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { AudioLines, Check, ChevronDown, LoaderCircle, Mic, MicOff, PhoneOff, RotateCcw } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n} from \"@/components/ui/alert-dialog\"\nimport { Button } from \"@/components/ui/button\"\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuLabel,\n  DropdownMenuRadioGroup,\n  DropdownMenuRadioItem,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\"\nimport { useControllableState } from \"@/hooks/use-controllable-state\"\n\n/* -------------------------------------------------------------------------- *\n * Types\n * -------------------------------------------------------------------------- */\n\n/**\n * The session's own lifecycle — deliberately not the transport's. A peer\n * connection has a dozen states; a person in a conversation has four: am I\n * getting in, am I in, am I about to lose it, is it over.\n */\nexport type VoiceSessionState = \"connecting\" | \"active\" | \"reconnecting\" | \"ended\"\n/** Who currently holds the floor. `\"none\"` is a real answer, not a null. */\nexport type VoiceSpeaker = \"user\" | \"assistant\" | \"none\"\nexport type ConnectionQuality = \"excellent\" | \"good\" | \"poor\" | \"lost\"\n\nexport interface VoiceInputDevice {\n  /** Stable id — `MediaDeviceInfo.deviceId` in a browser app. */\n  id: string\n  /** What the picker shows. Browsers only fill this in after mic permission. */\n  label: string\n}\n\n/* -------------------------------------------------------------------------- *\n * Copy\n *\n * One record per surface at module scope: translating the panel means editing a\n * handful of strings in one place instead of threading a `labels` object through\n * the props.\n * -------------------------------------------------------------------------- */\n\nconst STATE_LABELS: Record<VoiceSessionState, string> = {\n  connecting: \"Connecting\",\n  active: \"Live\",\n  reconnecting: \"Reconnecting\",\n  ended: \"Ended\",\n}\n\nconst STATE_ANNOUNCEMENTS: Record<VoiceSessionState, string> = {\n  connecting: \"Connecting to the voice session\",\n  active: \"Voice session live\",\n  reconnecting: \"Connection lost, reconnecting\",\n  ended: \"Voice session ended\",\n}\n\nconst QUALITY_LABELS: Record<ConnectionQuality, string> = {\n  excellent: \"Excellent\",\n  good: \"Good\",\n  poor: \"Weak\",\n  lost: \"No signal\",\n}\n\nconst DEFAULT_DEVICE_LABEL = \"System microphone\"\n\n/* -------------------------------------------------------------------------- *\n * Tuning constants\n * -------------------------------------------------------------------------- */\n\n/** Sub-second, so the visible mm:ss never stalls on a slow frame. */\nconst CLOCK_TICK_MS = 250\nconst NOTICE_MS = 4500\nconst ANNOUNCE_MS = 4000\n/** The caption keeps the newest characters; older words scroll out of the line. */\nconst CAPTION_TAIL = 260\n/** How far the halo travels at level 1. */\nconst HALO_GAIN = 0.5\n/** The inner ring moves less, so the two never read as one blob. */\nconst RING_GAIN = 0.2\n/** Quantisation of a measured level under reduced motion. */\nconst REDUCED_STEPS = 4\n/** Static level used while somebody speaks and motion is turned off. */\nconst REDUCED_LEVEL = 0.55\n/** Filled bars per quality grade. */\nconst QUALITY_BARS: Record<ConnectionQuality, number> = { excellent: 4, good: 3, poor: 2, lost: 0 }\nconst BAR_HEIGHTS = [\"h-1.5\", \"h-2\", \"h-2.5\", \"h-3\"]\n\nconst KEYFRAMES = `@keyframes vc-word{from{opacity:0;transform:translateY(0.18em)}to{opacity:1;transform:none}}@keyframes vc-ripple{0%{opacity:.45;transform:scale(.92)}100%{opacity:0;transform:scale(1.55)}}@keyframes vc-beat{0%,100%{opacity:1}50%{opacity:.3}}`\n\n/* -------------------------------------------------------------------------- *\n * Helpers\n * -------------------------------------------------------------------------- */\n\nfunction clamp01(value: number): number {\n  if (!Number.isFinite(value)) return 0\n  return Math.min(1, Math.max(0, value))\n}\n\n/** `7:04`, and `1:07:04` once a session runs past an hour. */\nexport function formatDuration(ms: number): string {\n  const total = Math.max(0, Math.floor(ms / 1000))\n  const seconds = String(total % 60).padStart(2, \"0\")\n  const minutes = Math.floor(total / 60) % 60\n  const hours = Math.floor(total / 3600)\n  if (hours > 0) return `${hours}:${String(minutes).padStart(2, \"0\")}:${seconds}`\n  return `${minutes}:${seconds}`\n}\n\n/** What a screen reader should say — \"7 minutes 4 seconds\", never \"7 colon 04\". */\nexport function spokenDuration(ms: number): string {\n  const total = Math.max(0, Math.floor(ms / 1000))\n  const minutes = Math.floor(total / 60)\n  const seconds = total % 60\n  const parts: string[] = []\n  if (minutes > 0) parts.push(`${minutes} minute${minutes === 1 ? \"\" : \"s\"}`)\n  parts.push(`${seconds} second${seconds === 1 ? \"\" : \"s\"}`)\n  return parts.join(\" \")\n}\n\n/**\n * Keep the END of a growing transcript, cut at a word boundary. A caption line\n * is a window on the newest words; the archive belongs in the transcript.\n */\nexport function tailText(text: string, max = CAPTION_TAIL): string {\n  if (text.length <= max) return text\n  const cut = text.length - max\n  const space = text.indexOf(\" \", cut)\n  const from = space === -1 || space > cut + 24 ? cut : space + 1\n  return `…${text.slice(from)}`\n}\n\n/**\n * A deterministic, speech-shaped envelope: three detuned sines instead of a\n * random walk. The orb reads as a voice rather than as noise, and it is\n * reproducible frame for frame — which is what makes a screenshot of a live\n * session stable.\n *\n * The two voices are shaped differently on purpose: synthesised speech runs\n * steadier than a person, who breathes between phrases.\n */\nexport function simulateLevel(elapsedMs: number, speaker: VoiceSpeaker): number {\n  if (speaker === \"assistant\") {\n    const carrier = Math.sin(elapsedMs / 92) * 0.5 + 0.5\n    const phrase = Math.sin(elapsedMs / 430 + 0.8) * 0.5 + 0.5\n    return clamp01(0.24 + 0.52 * carrier * (0.45 + 0.55 * phrase))\n  }\n  if (speaker === \"user\") {\n    const syllable = Math.sin(elapsedMs / 74) * 0.5 + 0.5\n    const word = Math.sin(elapsedMs / 205 + 1.3) * 0.5 + 0.5\n    const breath = Math.sin(elapsedMs / 1290 + 0.4) * 0.5 + 0.5\n    return clamp01(0.08 + 0.68 * syllable * word + 0.22 * breath * word)\n  }\n  // Nobody is talking: a slow breath, so an open line never looks frozen.\n  return clamp01(0.05 + 0.1 * (Math.sin(elapsedMs / 1400) * 0.5 + 0.5))\n}\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n  mq.addEventListener(\"change\", callback)\n  return () => mq.removeEventListener(\"change\", callback)\n}\n\nfunction useReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  )\n}\n\ntype ClearableTimer = ReturnType<typeof setTimeout> | null\n\nfunction clearTimer(ref: React.RefObject<ClearableTimer>) {\n  if (ref.current === null) return\n  clearTimeout(ref.current)\n  ref.current = null\n}\n\n/* -------------------------------------------------------------------------- *\n * The orb's palette\n *\n * The accent is an inline CSS color rather than a utility class so it can be a\n * chart token: the two voices have to be told apart at a glance, and `primary`\n * alone cannot do that.\n * -------------------------------------------------------------------------- */\n\ninterface OrbTone {\n  accent: string\n  /** Fill the core with the accent, or leave it on `bg-muted`. */\n  solid: boolean\n  glyph: string\n}\n\nconst TONE_ASSISTANT: OrbTone = { accent: \"var(--primary)\", solid: true, glyph: \"text-primary-foreground\" }\nconst TONE_USER: OrbTone = { accent: \"var(--chart-2)\", solid: true, glyph: \"text-background\" }\nconst TONE_QUIET: OrbTone = { accent: \"var(--muted-foreground)\", solid: false, glyph: \"text-muted-foreground\" }\n\nfunction pickTone(floor: VoiceSpeaker, muted: boolean, running: boolean): OrbTone {\n  if (floor === \"assistant\") return TONE_ASSISTANT\n  // A muted mic is the loudest thing on the panel: it outranks the live accent.\n  if (muted) return TONE_QUIET\n  if (floor === \"user\") return TONE_USER\n  return running ? TONE_ASSISTANT : TONE_QUIET\n}\n\n/* -------------------------------------------------------------------------- *\n * Connection quality\n * -------------------------------------------------------------------------- */\n\nfunction QualityMeter({ pulsing, quality }: { pulsing: boolean; quality: ConnectionQuality }) {\n  const filled = QUALITY_BARS[quality]\n  const weak = quality === \"poor\" || quality === \"lost\"\n\n  return (\n    <span\n      aria-label={`Connection ${QUALITY_LABELS[quality].toLowerCase()}`}\n      className={cn(\n        \"inline-flex items-end gap-0.5\",\n        pulsing && \"[animation:vc-beat_1.4s_ease-in-out_infinite] motion-reduce:[animation:none]\",\n      )}\n      role=\"img\"\n    >\n      {BAR_HEIGHTS.map((height, index) => (\n        <span\n          aria-hidden=\"true\"\n          className={cn(\n            \"w-1 rounded-full transition-colors\",\n            height,\n            index < filled\n              ? weak\n                ? \"bg-destructive\"\n                : \"bg-foreground\"\n              : quality === \"lost\"\n                ? \"bg-destructive/25\"\n                : \"bg-muted-foreground/25\",\n          )}\n          key={height}\n        />\n      ))}\n    </span>\n  )\n}\n\n/* -------------------------------------------------------------------------- *\n * Props\n * -------------------------------------------------------------------------- */\n\nexport interface VoiceChatProps extends React.HTMLAttributes<HTMLDivElement> {\n  /** Controlled session state. Omit to let the panel own it. */\n  state?: VoiceSessionState\n  /** Initial state when uncontrolled. Default `\"connecting\"`. */\n  defaultState?: VoiceSessionState\n  /** Fires on every transition, whoever caused it — user, transport, or timeout. */\n  onStateChange?: (state: VoiceSessionState) => void\n  /** Name of the other side of the call. Default `\"Assistant\"`. */\n  assistantName?: string\n  /**\n   * Who is talking right now. Only honoured while `state === \"active\"`: a\n   * connecting or reconnecting session has nobody on the floor by definition.\n   */\n  speaker?: VoiceSpeaker\n  /** Caption text the recognizer has already settled. Rendered solid. */\n  transcript?: string\n  /** The unsettled tail. Renders muted, and every new word fades in. */\n  interim?: string\n  /**\n   * Input amplitude, 0..1, for the orb. Omit and the orb runs the built-in\n   * envelope for whoever holds the floor.\n   */\n  level?: number\n  /** Controlled mute. */\n  muted?: boolean\n  /** Initial mute when uncontrolled. Default `false`. */\n  defaultMuted?: boolean\n  onMutedChange?: (muted: boolean) => void\n  /** Selectable microphones. An empty list disables the picker rather than hiding it. */\n  devices?: VoiceInputDevice[]\n  /** Controlled device id. */\n  deviceId?: string\n  /** Initial device id when uncontrolled; falls back to the first device. */\n  defaultDeviceId?: string\n  onDeviceChange?: (deviceId: string) => void\n  /** Transport health for the four-bar meter. Default `\"excellent\"`. */\n  quality?: ConnectionQuality\n  /**\n   * Controlled session length in ms. Omit and the panel runs its own clock,\n   * counting only while the session is `active` or `reconnecting`.\n   */\n  durationMs?: number\n  /** Ask before hanging up. Default `true`. A connecting session never asks. */\n  confirmEnd?: boolean\n  /** The user ended the session. Fires at most once per session, with its length. */\n  onEnd?: (durationMs: number) => void\n  /**\n   * The user asked for another session from the summary. Omit it on a controlled\n   * panel and the button is not rendered at all — a dead \"start again\" is worse\n   * than no button.\n   */\n  onRestart?: () => void\n  /** Slot in the ended summary: a transcript link, a share button, a rating. */\n  transcriptSlot?: React.ReactNode\n  /** Extra content for the ended summary, under the duration / mic / connection rows. */\n  summaryExtra?: React.ReactNode\n}\n\n/* -------------------------------------------------------------------------- *\n * Component\n * -------------------------------------------------------------------------- */\n\n/**\n * A realtime voice session panel: orb, live caption, mic controls, and a summary\n * once the call is over.\n *\n * Three decisions shape it:\n *\n * 1. **The session state machine is the layout.** `connecting`, `active`,\n *    `reconnecting` and `ended` are four first-class branches, not opacity\n *    tweaks on one layout — an ended session shows a summary, not a greyed-out\n *    live panel with a dead End button.\n * 2. **Ending is one-shot.** The confirm dialog, a connecting-state cancel and a\n *    controlled owner can all land on the same hang-up; `onEnd` fires once per\n *    session or not at all, and never from a cleanup.\n * 3. **The clock is wall time, not render time.** Elapsed comes from\n *    `performance.now()` spans folded into an accumulator, so a throttled\n *    background tab, a re-render storm or a pause at `ended` can never make a\n *    session look shorter than it was.\n */\nexport const VoiceChat = React.forwardRef<HTMLDivElement, VoiceChatProps>(function VoiceChat(\n  {\n    state: stateProp,\n    defaultState = \"connecting\",\n    onStateChange,\n    assistantName = \"Assistant\",\n    speaker = \"none\",\n    transcript,\n    interim,\n    level,\n    muted: mutedProp,\n    defaultMuted = false,\n    onMutedChange,\n    devices = [],\n    deviceId: deviceIdProp,\n    defaultDeviceId,\n    onDeviceChange,\n    quality = \"excellent\",\n    durationMs,\n    confirmEnd = true,\n    onEnd,\n    onRestart,\n    transcriptSlot,\n    summaryExtra,\n    className,\n    onKeyDown: onKeyDownProp,\n    ...rest\n  },\n  ref,\n) {\n  const reduced = useReducedMotion()\n\n  const [state, setState] = useControllableState<VoiceSessionState>({\n    value: stateProp,\n    defaultValue: defaultState,\n    onChange: onStateChange,\n  })\n  const [muted, setMuted] = useControllableState<boolean>({\n    value: mutedProp,\n    defaultValue: defaultMuted,\n    onChange: onMutedChange,\n  })\n  const [deviceId, setDeviceId] = useControllableState<string>({\n    value: deviceIdProp,\n    defaultValue: defaultDeviceId ?? devices[0]?.id ?? \"\",\n    onChange: onDeviceChange,\n  })\n\n  const [elapsed, setElapsed] = React.useState(0)\n  const [confirmOpen, setConfirmOpen] = React.useState(false)\n  const [notice, setNotice] = React.useState<string | null>(null)\n  const [announcement, setAnnouncement] = React.useState(\"\")\n  const [captionAnnouncement, setCaptionAnnouncement] = React.useState(\"\")\n\n  const running = state === \"active\" || state === \"reconnecting\"\n  const ended = state === \"ended\"\n\n  // A session that ended somewhere else — a controlled owner, a transport\n  // hang-up — leaves the confirm REQUEST behind while `open` is merely derived\n  // away. Dropped during render, not in an effect, so a stale\n  // \"End this session?\" can never reopen on the next session.\n  if (confirmOpen && ended) setConfirmOpen(false)\n\n  /** Session length already banked by finished running spans. */\n  const accumulatedRef = React.useRef(0)\n  /** Start of the span in flight, or `null` while the clock is parked. */\n  const spanStartRef = React.useRef<number | null>(null)\n  /** The one-shot hang-up lock. */\n  const endedRef = React.useRef(false)\n  const noticeTimer = React.useRef<ClearableTimer>(null)\n  const announceTimer = React.useRef<ClearableTimer>(null)\n  const haloRef = React.useRef<HTMLSpanElement | null>(null)\n  const ringRef = React.useRef<HTMLSpanElement | null>(null)\n\n  const duration = durationMs ?? elapsed\n\n  const announce = React.useCallback((message: string) => {\n    setAnnouncement(message)\n    clearTimer(announceTimer)\n    announceTimer.current = setTimeout(() => {\n      announceTimer.current = null\n      // Emptying the region is what lets the SAME message be announced twice in\n      // a row: a screen reader stays silent on unchanged text.\n      setAnnouncement(\"\")\n    }, ANNOUNCE_MS)\n  }, [])\n\n  const notify = React.useCallback((message: string | null) => {\n    setNotice(message)\n    clearTimer(noticeTimer)\n    if (!message) return\n    noticeTimer.current = setTimeout(() => {\n      noticeTimer.current = null\n      setNotice(null)\n    }, NOTICE_MS)\n  }, [])\n\n  /* -- Session boundaries ---------------------------------------------------- *\n   * A transition OUT of `ended` is a new session: the hang-up lock lifts and the\n   * clock goes back to zero, whether the restart came from this panel's own\n   * button or from a controlled owner swapping the prop. */\n  const prevStateRef = React.useRef(state)\n  React.useEffect(() => {\n    const previous = prevStateRef.current\n    if (previous === state) return\n    prevStateRef.current = state\n    if (previous === \"ended\") {\n      endedRef.current = false\n      accumulatedRef.current = 0\n      setElapsed(0)\n    }\n    announce(STATE_ANNOUNCEMENTS[state])\n  }, [announce, state])\n\n  /* -- The clock ------------------------------------------------------------- *\n   * Every running span is measured with performance.now() and folded into the\n   * accumulator when it closes, so the total survives pauses, reconnects and a\n   * tab the browser has stopped painting. The interval only decides how often\n   * the number on screen is refreshed — it never counts. */\n  React.useEffect(() => {\n    if (durationMs !== undefined) return\n    if (!running) {\n      spanStartRef.current = null\n      setElapsed(accumulatedRef.current)\n      return\n    }\n    const startedAt = performance.now()\n    spanStartRef.current = startedAt\n    const tick = () => setElapsed(accumulatedRef.current + (performance.now() - startedAt))\n    const id = setInterval(tick, CLOCK_TICK_MS)\n    tick()\n    return () => {\n      clearInterval(id)\n      spanStartRef.current = null\n      accumulatedRef.current += performance.now() - startedAt\n    }\n  }, [durationMs, running])\n\n  /* -- Cleanup ---------------------------------------------------------------- *\n   * Timers only. No callback fires from an unmount: `onEnd` means \"the user hung\n   * up\", and a component leaving the tree is not the user hanging up. */\n  React.useEffect(\n    () => () => {\n      clearTimer(noticeTimer)\n      clearTimer(announceTimer)\n    },\n    [],\n  )\n\n  /* -- Caption ---------------------------------------------------------------- */\n\n  const settled = React.useMemo(() => tailText((transcript ?? \"\").trim()), [transcript])\n  const interimWords = React.useMemo(() => {\n    const text = (interim ?? \"\").trim()\n    return text.length > 0 ? text.split(/\\s+/) : []\n  }, [interim])\n\n  /**\n   * Only SETTLED phrases are announced, and only the part that is new. The\n   * visible line is deliberately not a live region: announcing every partial\n   * restarts a screen reader mid-word several times a second.\n   */\n  const lastSettledRef = React.useRef(settled)\n  React.useEffect(() => {\n    const previous = lastSettledRef.current\n    if (settled === previous) return\n    lastSettledRef.current = settled\n    if (!settled) return\n    const delta = settled.startsWith(previous) ? settled.slice(previous.length).trim() : settled\n    if (delta) setCaptionAnnouncement(delta)\n  }, [settled])\n\n  /* -- Derived presentation ---------------------------------------------------- */\n\n  /**\n   * A muted user cannot be the one speaking, whatever the transport reports —\n   * and only an active session has a floor to hold.\n   */\n  const floor: VoiceSpeaker = state === \"active\" ? (muted && speaker === \"user\" ? \"none\" : speaker) : \"none\"\n  const tone = pickTone(floor, muted, running)\n  const rippling = floor !== \"none\"\n\n  const activeDevice = devices.find(device => device.id === deviceId)\n  const deviceLabel = activeDevice?.label ?? DEFAULT_DEVICE_LABEL\n\n  const speakerLabel =\n    state === \"connecting\"\n      ? \"Connecting\"\n      : state === \"reconnecting\"\n        ? \"Reconnecting\"\n        : muted && floor !== \"assistant\"\n          ? \"Microphone off\"\n          : floor === \"user\"\n            ? \"You\"\n            : floor === \"assistant\"\n              ? assistantName\n              : \"Listening\"\n\n  const placeholder =\n    state === \"connecting\"\n      ? `Connecting to ${assistantName}…`\n      : state === \"reconnecting\"\n        ? \"Audio is paused while the connection is restored.\"\n        : muted\n          ? \"Your microphone is off — unmute to keep talking.\"\n          : \"Listening — start speaking whenever you're ready.\"\n\n  /* -- The orb ----------------------------------------------------------------- *\n   * Written straight to the nodes' style instead of through state: a 60 fps\n   * setState would re-render the caption, the clock and the controls sixty times\n   * a second for a purely visual effect. */\n  React.useEffect(() => {\n    const halo = haloRef.current\n    const ring = ringRef.current\n    if (!halo || !ring) return\n\n    const apply = (value: number) => {\n      halo.style.transform = `scale(${(1 + value * HALO_GAIN).toFixed(3)})`\n      ring.style.transform = `scale(${(1 + value * RING_GAIN).toFixed(3)})`\n    }\n\n    if (!running) {\n      apply(0)\n      return\n    }\n    if (level !== undefined) {\n      // Measured amplitude is information, not decoration, so it keeps driving\n      // the orb under reduced motion — quantised, so it steps instead of\n      // shimmering.\n      apply(reduced ? Math.round(clamp01(level) * REDUCED_STEPS) / REDUCED_STEPS : clamp01(level))\n      return\n    }\n    if (reduced) {\n      // No real signal and no motion allowed: a static, clearly larger orb still\n      // says \"somebody is talking\".\n      apply(floor === \"none\" ? 0 : REDUCED_LEVEL)\n      return\n    }\n    const started = performance.now()\n    let raf = 0\n    const tick = (now: number) => {\n      apply(simulateLevel(now - started, floor))\n      raf = requestAnimationFrame(tick)\n    }\n    raf = requestAnimationFrame(tick)\n    return () => cancelAnimationFrame(raf)\n  }, [floor, level, reduced, running])\n\n  /* -- Actions ------------------------------------------------------------------ */\n\n  const toggleMute = React.useCallback(() => {\n    const next = !muted\n    setMuted(next)\n    announce(next ? \"Microphone muted\" : \"Microphone live\")\n  }, [announce, muted, setMuted])\n\n  const selectDevice = React.useCallback(\n    (id: string) => {\n      setDeviceId(id)\n      const label = devices.find(device => device.id === id)?.label\n      if (!label) return\n      // A picker that changes nothing visible is a dead affordance: the switch\n      // says so in the panel, and once in the announcement region.\n      notify(`Microphone switched to ${label}.`)\n      announce(`Microphone switched to ${label}`)\n    },\n    [announce, devices, notify, setDeviceId],\n  )\n\n  /**\n   * Hang up, once. Ending can arrive from the confirm dialog, from a\n   * connecting-state cancel or from a keyboard activation of either, and the\n   * payload has to be the exact session length — the span in flight is closed by\n   * hand rather than read off the last render.\n   */\n  const endSession = React.useCallback(() => {\n    if (endedRef.current) return\n    endedRef.current = true\n    const start = spanStartRef.current\n    const exact = durationMs ?? accumulatedRef.current + (start === null ? 0 : performance.now() - start)\n    setConfirmOpen(false)\n    notify(null)\n    setState(\"ended\")\n    onEnd?.(exact)\n  }, [durationMs, notify, onEnd, setState])\n\n  const requestEnd = React.useCallback(() => {\n    // Nothing has been said yet, so there is nothing to lose: joining is\n    // cancelled outright instead of behind an \"are you sure\".\n    if (!confirmEnd || state === \"connecting\") {\n      endSession()\n      return\n    }\n    setConfirmOpen(true)\n  }, [confirmEnd, endSession, state])\n\n  const canRestart = onRestart !== undefined || stateProp === undefined\n\n  const restartSession = React.useCallback(() => {\n    endedRef.current = false\n    accumulatedRef.current = 0\n    setElapsed(0)\n    notify(null)\n    onRestart?.()\n    setState(\"connecting\")\n  }, [notify, onRestart, setState])\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    onKeyDownProp?.(event)\n    if (event.defaultPrevented) return\n    if (event.key !== \"m\" && event.key !== \"M\") return\n    if (event.metaKey || event.ctrlKey || event.altKey) return\n    // Somebody typing \"m\" into a field inside the panel means the letter m.\n    const target = event.target\n    if (target instanceof HTMLElement && target.closest(\"input, textarea, select, [contenteditable='true']\")) return\n    if (ended) return\n    event.preventDefault()\n    toggleMute()\n  }\n\n  /* -- Rendering ---------------------------------------------------------------- */\n\n  const statePill = (\n    <span\n      className={cn(\n        \"inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium\",\n        state === \"active\" && \"border-primary/30 bg-primary/10 text-foreground\",\n        state === \"reconnecting\" && \"border-destructive/30 bg-destructive/10 text-destructive\",\n        (state === \"connecting\" || state === \"ended\") && \"text-muted-foreground\",\n      )}\n    >\n      {state === \"connecting\" || state === \"reconnecting\" ? (\n        <LoaderCircle aria-hidden=\"true\" className=\"size-3 animate-spin motion-reduce:animate-none\" />\n      ) : (\n        <span\n          aria-hidden=\"true\"\n          className={cn(\n            \"size-1.5 rounded-full\",\n            state === \"active\"\n              ? \"bg-primary [animation:vc-beat_1.6s_ease-in-out_infinite] motion-reduce:[animation:none]\"\n              : \"bg-muted-foreground\",\n          )}\n        />\n      )}\n      {STATE_LABELS[state]}\n    </span>\n  )\n\n  return (\n    <div\n      aria-label={`Voice session with ${assistantName}`}\n      className={cn(\"flex w-full flex-col gap-6 rounded-xl border bg-card p-5 text-card-foreground sm:p-6\", className)}\n      data-quality={quality}\n      data-speaker={floor}\n      data-state={state}\n      onKeyDown={handleKeyDown}\n      ref={ref}\n      role=\"group\"\n      {...rest}\n    >\n      <style href=\"zyeon-voice-chat\" precedence=\"medium\">\n        {KEYFRAMES}\n      </style>\n\n      {/* Header: who, how healthy the line is, how long it has been open. */}\n      <div className=\"flex flex-wrap items-center justify-between gap-3\">\n        <div className=\"flex min-w-0 items-center gap-2.5\">\n          <span className=\"truncate text-sm font-medium\">{assistantName}</span>\n          {statePill}\n        </div>\n        <div className=\"flex items-center gap-3\">\n          {!ended && (\n            <span className=\"flex items-center gap-1.5\">\n              <QualityMeter pulsing={state === \"reconnecting\"} quality={quality} />\n              <span\n                className={cn(\n                  \"text-xs\",\n                  quality === \"poor\" || quality === \"lost\" ? \"text-destructive\" : \"text-muted-foreground\",\n                )}\n              >\n                {QUALITY_LABELS[quality]}\n              </span>\n            </span>\n          )}\n          <span\n            aria-label={`Session length ${spokenDuration(duration)}`}\n            className=\"text-sm text-muted-foreground tabular-nums\"\n            role=\"timer\"\n          >\n            {formatDuration(duration)}\n          </span>\n        </div>\n      </div>\n\n      {ended ? (\n        /* ---- Summary ------------------------------------------------------- */\n        <div className=\"flex flex-col items-center gap-5 py-2 text-center\">\n          <span\n            aria-hidden=\"true\"\n            className=\"flex size-12 items-center justify-center rounded-full bg-muted text-muted-foreground\"\n          >\n            <Check className=\"size-6\" />\n          </span>\n\n          <div className=\"flex flex-col gap-1\">\n            <p className=\"text-sm font-medium\">Session ended</p>\n            <p className=\"text-sm text-muted-foreground\">\n              You spoke with {assistantName} for {spokenDuration(duration)}.\n            </p>\n          </div>\n\n          <dl className=\"grid w-full max-w-lg grid-cols-1 gap-px overflow-hidden rounded-lg border bg-border text-left sm:grid-cols-3\">\n            {[\n              { label: \"Duration\", value: formatDuration(duration) },\n              { label: \"Microphone\", value: deviceLabel },\n              { label: \"Connection\", value: QUALITY_LABELS[quality] },\n            ].map(row => (\n              <div className=\"flex min-w-0 flex-col gap-0.5 bg-card p-3\" key={row.label}>\n                <dt className=\"text-xs text-muted-foreground\">{row.label}</dt>\n                <dd className=\"truncate text-sm font-medium\">{row.value}</dd>\n              </div>\n            ))}\n          </dl>\n\n          {summaryExtra}\n\n          {(Boolean(transcriptSlot) || canRestart) && (\n            <div className=\"flex flex-wrap items-center justify-center gap-2\">\n              {transcriptSlot}\n              {canRestart && (\n                <Button onClick={restartSession} size=\"lg\" variant=\"outline\">\n                  <RotateCcw aria-hidden=\"true\" />\n                  Start a new session\n                </Button>\n              )}\n            </div>\n          )}\n        </div>\n      ) : (\n        /* ---- Live panel ---------------------------------------------------- */\n        <>\n          <div className=\"flex flex-col items-center gap-5\">\n            <div aria-hidden=\"true\" className=\"relative flex size-40 shrink-0 items-center justify-center\">\n              <span\n                className={cn(\n                  \"absolute inset-0 rounded-full opacity-20 blur-xl will-change-transform\",\n                  level !== undefined && \"transition-transform duration-100 ease-out motion-reduce:transition-none\",\n                )}\n                ref={haloRef}\n                style={{ backgroundColor: tone.accent }}\n              />\n              <span\n                className={cn(\n                  \"absolute inset-6 rounded-full border opacity-50 will-change-transform\",\n                  level !== undefined && \"transition-transform duration-100 ease-out motion-reduce:transition-none\",\n                )}\n                ref={ringRef}\n                style={{ borderColor: tone.accent }}\n              />\n              {rippling && (\n                <>\n                  <span\n                    className=\"absolute inset-6 rounded-full border [animation:vc-ripple_2.6s_ease-out_infinite] motion-reduce:hidden\"\n                    style={{ borderColor: tone.accent }}\n                  />\n                  <span\n                    className=\"absolute inset-6 rounded-full border [animation:vc-ripple_2.6s_ease-out_1.3s_infinite] motion-reduce:hidden\"\n                    style={{ borderColor: tone.accent }}\n                  />\n                </>\n              )}\n              <span\n                className={cn(\n                  \"relative flex size-24 items-center justify-center rounded-full transition-colors duration-300 motion-reduce:transition-none\",\n                  tone.solid ? \"shadow-sm\" : \"border bg-muted\",\n                  tone.glyph,\n                )}\n                style={tone.solid ? { backgroundColor: tone.accent } : undefined}\n              >\n                {state === \"connecting\" || state === \"reconnecting\" ? (\n                  <LoaderCircle className=\"size-8 animate-spin motion-reduce:animate-none\" />\n                ) : muted && floor !== \"assistant\" ? (\n                  <MicOff className=\"size-8\" />\n                ) : floor === \"assistant\" ? (\n                  <AudioLines className=\"size-8\" />\n                ) : (\n                  <Mic className=\"size-8\" />\n                )}\n              </span>\n            </div>\n\n            {/* The caption line. Not a live region — see the two sr-only spans\n                at the bottom of the panel. */}\n            <div className=\"flex min-h-24 w-full max-w-xl flex-col items-center gap-1.5 text-center\">\n              <span className=\"text-xs font-medium tracking-wide text-muted-foreground uppercase\">{speakerLabel}</span>\n              <p className=\"text-sm leading-relaxed wrap-anywhere\">\n                {settled.length > 0 && <span>{settled} </span>}\n                {interimWords.map((word, index) => (\n                  <span\n                    className=\"text-muted-foreground [animation:vc-word_260ms_ease-out_both] motion-reduce:[animation:none]\"\n                    key={`${index}:${word}`}\n                  >\n                    {word}{\" \"}\n                  </span>\n                ))}\n                {settled.length === 0 && interimWords.length === 0 && (\n                  <span className=\"text-muted-foreground\">{placeholder}</span>\n                )}\n              </p>\n            </div>\n          </div>\n\n          {state === \"reconnecting\" && (\n            <div\n              className=\"flex items-center justify-center gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-center text-xs text-destructive\"\n              role=\"status\"\n            >\n              <LoaderCircle aria-hidden=\"true\" className=\"size-3.5 shrink-0 animate-spin motion-reduce:animate-none\" />\n              Audio is paused while the connection is restored — the session clock keeps running.\n            </div>\n          )}\n\n          <div className=\"flex flex-col items-center gap-2\">\n            <div className=\"flex flex-wrap items-center justify-center gap-2\">\n              {/* The LABEL states the action, so this must not also carry\n                  aria-pressed: \"Unmute, pressed\" is exactly the confusion the\n                  APG warns about. Pick one — this one picks the label. */}\n              <Button\n                aria-keyshortcuts=\"M\"\n                onClick={toggleMute}\n                size=\"lg\"\n                variant={muted ? \"default\" : \"outline\"}\n              >\n                {muted ? <MicOff aria-hidden=\"true\" /> : <Mic aria-hidden=\"true\" />}\n                {muted ? \"Unmute\" : \"Mute\"}\n              </Button>\n\n              <DropdownMenu>\n                <DropdownMenuTrigger asChild>\n                  <Button\n                    aria-label={`Microphone: ${deviceLabel}`}\n                    className=\"max-w-56\"\n                    disabled={devices.length === 0}\n                    size=\"lg\"\n                    variant=\"outline\"\n                  >\n                    <Mic aria-hidden=\"true\" />\n                    <span className=\"truncate\">{deviceLabel}</span>\n                    <ChevronDown aria-hidden=\"true\" className=\"opacity-60\" />\n                  </Button>\n                </DropdownMenuTrigger>\n                <DropdownMenuContent align=\"center\" className=\"min-w-56\">\n                  <DropdownMenuLabel>Microphone</DropdownMenuLabel>\n                  <DropdownMenuSeparator />\n                  <DropdownMenuRadioGroup onValueChange={selectDevice} value={deviceId}>\n                    {devices.map(device => (\n                      <DropdownMenuRadioItem key={device.id} value={device.id}>\n                        {device.label}\n                      </DropdownMenuRadioItem>\n                    ))}\n                  </DropdownMenuRadioGroup>\n                </DropdownMenuContent>\n              </DropdownMenu>\n\n              <Button onClick={requestEnd} size=\"lg\" variant=\"destructive\">\n                <PhoneOff aria-hidden=\"true\" />\n                {state === \"connecting\" ? \"Cancel\" : \"End session\"}\n              </Button>\n            </div>\n\n            <p className=\"min-h-4 text-center text-xs text-muted-foreground\">\n              {notice ?? (running ? \"Press M to mute while the panel has focus.\" : null)}\n            </p>\n          </div>\n        </>\n      )}\n\n      {/* Derived, not synced: a session that ends anywhere else — a controlled\n          owner, a transport hang-up — closes this dialog on the same render,\n          with no effect chasing it. */}\n      <AlertDialog onOpenChange={setConfirmOpen} open={confirmOpen && !ended}>\n        <AlertDialogContent>\n          <AlertDialogHeader>\n            <AlertDialogTitle>End this voice session?</AlertDialogTitle>\n            <AlertDialogDescription>\n              {formatDuration(duration)} of conversation with {assistantName}. The line closes immediately; the\n              transcript stays in the summary.\n            </AlertDialogDescription>\n          </AlertDialogHeader>\n          <AlertDialogFooter>\n            <AlertDialogCancel>Keep talking</AlertDialogCancel>\n            <AlertDialogAction onClick={endSession} variant=\"destructive\">\n              End session\n            </AlertDialogAction>\n          </AlertDialogFooter>\n        </AlertDialogContent>\n      </AlertDialog>\n\n      {/* Two quiet regions: session and control transitions in one, settled\n          caption phrases in the other, so a caption never talks over a\n          \"microphone muted\". */}\n      <span aria-live=\"polite\" className=\"sr-only\" role=\"status\">\n        {announcement}\n      </span>\n      <span aria-live=\"polite\" className=\"sr-only\">\n        {captionAnnouncement}\n      </span>\n    </div>\n  )\n})\n\nexport default VoiceChat\n",
      "type": "registry:block"
    }
  ],
  "type": "registry:block"
}
