{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "audio-recorder",
  "type": "registry:ui",
  "title": "Audio recorder",
  "description": "A microphone recorder with start, pause and stop, an elapsed timer, live waveform bars, playback with a progress bar and a download button.",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/ui/audio-recorder.tsx",
      "type": "registry:ui",
      "target": "components/ui/audio-recorder.tsx",
      "content": "\"use client\"\nimport * as React from \"react\"\nimport { useReducedMotion } from \"motion/react\"\nimport { DownloadIcon, MicIcon, PauseIcon, PlayIcon, RotateCcwIcon, SquareIcon } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\n\n/** A microphone recorder with start, pause and stop, an elapsed timer, live waveform bars, playback with a progress bar and a download button. */\ntype AudioRecorderProps = {\n  /** Longest recording in seconds. Recording stops by itself when it is reached. */\n  maxDuration?: number\n  /** Called with the finished recording and its object URL. */\n  onRecorded?: (blob: Blob, meta: { duration: number; url: string; mimeType: string }) => void\n  title?: string\n  /** Shown under the title. */\n  description?: string\n  /** Base name of the downloaded file. The extension follows the recording format. */\n  fileName?: string\n  /** Number of waveform bars. */\n  bars?: number\n  className?: string\n}\n\ntype Status = \"idle\" | \"recording\" | \"paused\" | \"done\"\ntype Result = { blob: Blob; url: string; duration: number; mimeType: string }\n\nconst MIMES = [\"audio/webm;codecs=opus\", \"audio/webm\", \"audio/mp4\", \"audio/ogg;codecs=opus\", \"audio/ogg\"]\nconst SAMPLE_EVERY = 50\n/** Resting bars: a quiet, symmetric shape instead of a flat line. */\nconst idle = (n: number) => Array.from({ length: n }, (_, i) => 0.1 + 0.16 * (0.5 + 0.5 * Math.sin(i * 0.42 + 0.6)) * (0.6 + 0.4 * Math.sin(i * 0.11)))\n\nconst DENIED = \"Microphone access was blocked. Allow it in the browser's site settings, then try again.\"\nconst UNSUPPORTED = \"Recording needs a browser with microphone support.\"\n\nfunction clock(ms: number) {\n  const s = Math.max(0, Math.floor(ms / 1000))\n  return `${String(Math.floor(s / 60)).padStart(2, \"0\")}:${String(s % 60).padStart(2, \"0\")}`\n}\n\nfunction extOf(mime: string) {\n  if (mime.includes(\"mp4\")) return \"m4a\"\n  if (mime.includes(\"ogg\")) return \"ogg\"\n  return \"webm\"\n}\n\nconst secondary =\n  \"inline-flex h-9 items-center gap-1.5 rounded-lg border border-border bg-surface px-3 text-[0.8125rem] font-medium text-fg shadow-key outline-none transition-[border-color,background-color] duration-[70ms] hover:border-border-strong hover:bg-sunken/50 focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface active:translate-y-[0.5px] [&_svg]:text-fg-muted\"\n\n/** Average the samples into `n` bins and normalise to the loudest bin. */\nfunction bin(samples: number[], n: number) {\n  if (!samples.length) return Array.from({ length: n }, () => 0)\n  const out: number[] = []\n  for (let i = 0; i < n; i++) {\n    const a = Math.floor((i / n) * samples.length)\n    const b = Math.max(a + 1, Math.floor(((i + 1) / n) * samples.length))\n    let s = 0\n    for (let j = a; j < b; j++) s += samples[j]\n    out.push(s / (b - a))\n  }\n  const peak = Math.max(0.08, ...out)\n  return out.map((v) => v / peak)\n}\n\n/**\n * A voice note recorder. The level bars scroll while you speak, and when\n * you stop they settle into the recording's waveform, which becomes the\n * playback scrubber: bars to the left of the playhead are lit. Without a\n * microphone or permission it stays in a calm idle state with a hint.\n */\nfunction AudioRecorder({\n  maxDuration = 120,\n  onRecorded,\n  title = \"Voice note\",\n  description = \"Attach to invoice INV-2041 for Northwind\",\n  fileName = \"voice-note\",\n  bars = 48,\n  className,\n}: AudioRecorderProps) {\n  const reduce = useReducedMotion()\n  const [status, setStatus] = React.useState<Status>(\"idle\")\n  const [supported, setSupported] = React.useState<boolean | null>(null)\n  const [hint, setHint] = React.useState<string | null>(null)\n  const [elapsed, setElapsed] = React.useState(0)\n  const [levels, setLevels] = React.useState<number[]>(() => idle(bars))\n  const [result, setResult] = React.useState<Result | null>(null)\n  const [playing, setPlaying] = React.useState(false)\n  const [position, setPosition] = React.useState(0)\n  const [mediaDuration, setMediaDuration] = React.useState<number | null>(null)\n  const id = React.useId()\n\n  const stream = React.useRef<MediaStream | null>(null)\n  const ctx = React.useRef<AudioContext | null>(null)\n  const analyser = React.useRef<AnalyserNode | null>(null)\n  const recorder = React.useRef<MediaRecorder | null>(null)\n  const chunks = React.useRef<Blob[]>([])\n  const samples = React.useRef<number[]>([])\n  const raf = React.useRef(0)\n  const timing = React.useRef({ base: 0, since: 0, lastSample: 0, lastPaint: 0 })\n  const audio = React.useRef<HTMLAudioElement>(null)\n  const url = React.useRef<string | null>(null)\n\n  React.useEffect(() => {\n    setSupported(typeof navigator !== \"undefined\" && !!navigator.mediaDevices?.getUserMedia && typeof MediaRecorder !== \"undefined\")\n  }, [])\n\n  const release = React.useCallback(() => {\n    cancelAnimationFrame(raf.current)\n    stream.current?.getTracks().forEach((t) => t.stop())\n    stream.current = null\n    analyser.current = null\n    ctx.current?.close().catch(() => {})\n    ctx.current = null\n  }, [])\n\n  React.useEffect(\n    () => () => {\n      release()\n      const rec = recorder.current\n      if (rec && rec.state !== \"inactive\") {\n        rec.onstop = null\n        rec.stop()\n      }\n      if (url.current) URL.revokeObjectURL(url.current)\n    },\n    [release]\n  )\n\n  const elapsedNow = () => {\n    const t = timing.current\n    return t.base + (t.since ? performance.now() - t.since : 0)\n  }\n\n  const stop = React.useCallback(() => {\n    const rec = recorder.current\n    timing.current.base = elapsedNow()\n    timing.current.since = 0\n    cancelAnimationFrame(raf.current)\n    if (rec && rec.state !== \"inactive\") rec.stop()\n    else release()\n  }, [release])\n\n  const loop = React.useCallback(() => {\n    const a = analyser.current\n    const t = timing.current\n    const now = performance.now()\n    if (a && now - t.lastSample >= SAMPLE_EVERY) {\n      const buf = new Uint8Array(a.fftSize)\n      a.getByteTimeDomainData(buf)\n      let sum = 0\n      for (let i = 0; i < buf.length; i++) {\n        const v = (buf[i] - 128) / 128\n        sum += v * v\n      }\n      samples.current.push(Math.min(1, Math.sqrt(sum / buf.length) * 3.2))\n      t.lastSample = now\n    }\n    const ms = elapsedNow()\n    if (now - t.lastPaint >= (reduce ? 1000 / 12 : 1000 / 30)) {\n      t.lastPaint = now\n      setElapsed(ms)\n      const s = samples.current\n      const tail = s.slice(Math.max(0, s.length - bars))\n      setLevels([...Array.from({ length: bars - tail.length }, () => 0), ...tail])\n    }\n    if (ms >= maxDuration * 1000) {\n      stop()\n      return\n    }\n    raf.current = requestAnimationFrame(loop)\n  }, [bars, maxDuration, reduce, stop])\n\n  const start = async () => {\n    if (status === \"recording\" || status === \"paused\") return\n    setHint(null)\n    if (typeof navigator === \"undefined\" || !navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === \"undefined\") {\n      setHint(UNSUPPORTED)\n      return\n    }\n    try {\n      const s = await navigator.mediaDevices.getUserMedia({ audio: true })\n      stream.current = s\n      const AC = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext\n      if (AC) {\n        const c = new AC()\n        ctx.current = c\n        const an = c.createAnalyser()\n        an.fftSize = 1024\n        c.createMediaStreamSource(s).connect(an)\n        analyser.current = an\n      }\n      const mimeType = MIMES.find((m) => MediaRecorder.isTypeSupported?.(m))\n      const rec = new MediaRecorder(s, mimeType ? { mimeType } : undefined)\n      recorder.current = rec\n      chunks.current = []\n      samples.current = []\n      if (url.current) URL.revokeObjectURL(url.current)\n      url.current = null\n      setResult(null)\n      setMediaDuration(null)\n      setPosition(0)\n      setPlaying(false)\n      rec.ondataavailable = (e) => {\n        if (e.data.size) chunks.current.push(e.data)\n      }\n      rec.onstop = () => {\n        const type = rec.mimeType || mimeType || \"audio/webm\"\n        const blob = new Blob(chunks.current, { type })\n        const objectUrl = URL.createObjectURL(blob)\n        url.current = objectUrl\n        const duration = timing.current.base / 1000\n        release()\n        setLevels(bin(samples.current, bars))\n        setElapsed(timing.current.base)\n        setResult({ blob, url: objectUrl, duration, mimeType: type })\n        setStatus(\"done\")\n        onRecorded?.(blob, { duration, url: objectUrl, mimeType: type })\n      }\n      rec.onerror = () => {\n        release()\n        setStatus(\"idle\")\n        setHint(\"Recording stopped unexpectedly. Try again.\")\n      }\n      timing.current = { base: 0, since: performance.now(), lastSample: 0, lastPaint: 0 }\n      setElapsed(0)\n      setLevels(Array.from({ length: bars }, () => 0))\n      rec.start(250)\n      setStatus(\"recording\")\n      raf.current = requestAnimationFrame(loop)\n    } catch (err) {\n      release()\n      const name = (err as { name?: string })?.name\n      setHint(name === \"NotAllowedError\" || name === \"SecurityError\" ? DENIED : name === \"NotFoundError\" ? \"No microphone was found on this device.\" : UNSUPPORTED)\n      setStatus(\"idle\")\n    }\n  }\n\n  const pause = () => {\n    const rec = recorder.current\n    if (!rec || rec.state !== \"recording\") return\n    rec.pause()\n    timing.current.base = elapsedNow()\n    timing.current.since = 0\n    cancelAnimationFrame(raf.current)\n    setElapsed(timing.current.base)\n    setStatus(\"paused\")\n  }\n\n  const resume = () => {\n    const rec = recorder.current\n    if (!rec || rec.state !== \"paused\") return\n    rec.resume()\n    timing.current.since = performance.now()\n    setStatus(\"recording\")\n    raf.current = requestAnimationFrame(loop)\n  }\n\n  const reset = () => {\n    const a = audio.current\n    if (a) {\n      a.pause()\n      a.currentTime = 0\n    }\n    if (url.current) URL.revokeObjectURL(url.current)\n    url.current = null\n    setResult(null)\n    setMediaDuration(null)\n    setPlaying(false)\n    setPosition(0)\n    setElapsed(0)\n    setLevels(idle(bars))\n    setStatus(\"idle\")\n  }\n\n  const duration = result ? (mediaDuration ?? result.duration) : 0\n\n  const togglePlay = () => {\n    const a = audio.current\n    if (!a || !result) return\n    if (a.paused) a.play().catch(() => setPlaying(false))\n    else a.pause()\n  }\n\n  const seek = (to: number) => {\n    const a = audio.current\n    if (!a || !result) return\n    const t = Math.max(0, Math.min(duration, to))\n    a.currentTime = t\n    setPosition(t)\n  }\n\n  const seekFromPointer = (e: React.PointerEvent<HTMLDivElement>) => {\n    const r = e.currentTarget.getBoundingClientRect()\n    seek(((e.clientX - r.left) / r.width) * duration)\n  }\n\n  const live = status === \"recording\" || status === \"paused\"\n  const progress = result && duration > 0 ? position / duration : 0\n  const litBars = Math.round(progress * bars)\n  const canRecord = supported !== false && !live\n  const remaining = Math.max(0, maxDuration * 1000 - elapsed)\n  const sentence = (s: Status) =>\n    s === \"recording\" ? \"Recording\" : s === \"paused\" ? \"Paused\" : s === \"done\" ? \"Recorded\" : supported === false ? \"Unavailable\" : \"Ready\"\n\n  return (\n    <div data-slot=\"audio-recorder\" className={cn(\"w-full max-w-md rounded-xl border border-border bg-surface p-4 shadow-raised\", className)}>\n      <div className=\"flex items-start justify-between gap-4\">\n        <div className=\"min-w-0\">\n          <p className=\"text-[0.8125rem] font-medium text-fg\">{title}</p>\n          {description ? <p className=\"mt-0.5 truncate text-xs text-fg-muted\">{description}</p> : null}\n        </div>\n        <div className=\"flex shrink-0 items-center gap-2 font-mono text-sm tabular-nums\">\n          {status === \"recording\" ? (\n            <span aria-hidden className=\"relative grid size-2.5 place-items-center\">\n              <span className=\"absolute size-2.5 rounded-full bg-danger opacity-40 motion-safe:animate-ping\" />\n              <span className=\"size-2 rounded-full bg-danger\" />\n            </span>\n          ) : null}\n          <span className={cn(status === \"recording\" ? \"text-danger\" : \"text-fg\")}>{clock(status === \"done\" ? position * 1000 : elapsed)}</span>\n          <span className=\"text-fg-subtle\">/ {clock(status === \"done\" ? duration * 1000 : maxDuration * 1000)}</span>\n        </div>\n      </div>\n\n      <div\n        data-slot=\"audio-recorder-wave\"\n        role={status === \"done\" ? \"slider\" : \"img\"}\n        tabIndex={status === \"done\" ? 0 : undefined}\n        aria-label={status === \"done\" ? \"Playback position\" : live ? \"Live input level\" : \"Waveform\"}\n        aria-valuemin={status === \"done\" ? 0 : undefined}\n        aria-valuemax={status === \"done\" ? Math.round(duration) : undefined}\n        aria-valuenow={status === \"done\" ? Math.round(position) : undefined}\n        aria-valuetext={status === \"done\" ? `${clock(position * 1000)} of ${clock(duration * 1000)}` : undefined}\n        onPointerDown={status === \"done\" ? seekFromPointer : undefined}\n        onPointerMove={status === \"done\" ? (e) => e.buttons === 1 && seekFromPointer(e) : undefined}\n        onKeyDown={\n          status === \"done\"\n            ? (e) => {\n                const step = e.shiftKey ? 5 : 1\n                if (e.key === \"ArrowRight\" || e.key === \"ArrowUp\") { e.preventDefault(); seek(position + step) }\n                if (e.key === \"ArrowLeft\" || e.key === \"ArrowDown\") { e.preventDefault(); seek(position - step) }\n                if (e.key === \"Home\") { e.preventDefault(); seek(0) }\n                if (e.key === \"End\") { e.preventDefault(); seek(duration) }\n                if (e.key === \" \" || e.key === \"Enter\") { e.preventDefault(); togglePlay() }\n              }\n            : undefined\n        }\n        className={cn(\n          \"relative mt-4 flex h-16 items-center gap-[3px] overflow-hidden rounded-lg bg-sunken px-3 outline-none\",\n          status === \"done\" && \"cursor-pointer touch-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface\"\n        )}\n      >\n        {levels.map((v, i) => (\n          <span\n            key={i}\n            aria-hidden\n            className={cn(\n              \"min-w-0 flex-1 rounded-full\",\n              status === \"done\" ? (i < litBars ? \"bg-accent\" : \"bg-fg-subtle/40\") : status === \"recording\" ? \"bg-accent\" : status === \"paused\" ? \"bg-fg-subtle/60\" : \"bg-border-strong\"\n            )}\n            style={{ height: `${Math.max(3, Math.round(v * 56))}px` }}\n          />\n        ))}\n        {status === \"done\" ? (\n          <span aria-hidden className=\"pointer-events-none absolute top-1 bottom-1 w-px bg-fg\" style={{ left: `calc(12px + (100% - 24px) * ${progress})` }} />\n        ) : null}\n        {live ? (\n          <span\n            aria-hidden\n            className=\"absolute inset-x-0 bottom-0 h-0.5 origin-left rounded-full bg-danger/60\"\n            style={{ transform: `scaleX(${Math.min(1, elapsed / (maxDuration * 1000))})` }}\n          />\n        ) : null}\n      </div>\n\n      <div className=\"mt-4 flex items-center gap-2\">\n        {status === \"done\" ? (\n          <button\n            type=\"button\"\n            onClick={togglePlay}\n            aria-label={playing ? \"Pause\" : \"Play\"}\n            className=\"grid size-11 place-items-center rounded-full bg-ink text-on-ink shadow-ink outline-none transition-[background-color] duration-[70ms] hover:bg-ink-hover focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface active:translate-y-[0.5px]\"\n          >\n            {playing ? <PauseIcon className=\"size-4\" /> : <PlayIcon className=\"size-4 translate-x-px\" />}\n          </button>\n        ) : (\n          <button\n            type=\"button\"\n            onClick={live ? stop : start}\n            disabled={!live && !canRecord}\n            aria-label={live ? \"Stop recording\" : \"Start recording\"}\n            className={cn(\n              \"grid size-11 place-items-center rounded-full shadow-ink outline-none transition-[background-color] duration-[70ms] focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface active:translate-y-[0.5px] disabled:cursor-not-allowed disabled:opacity-45\",\n              live ? \"bg-danger text-white hover:bg-danger/90\" : \"bg-ink text-on-ink hover:bg-ink-hover\"\n            )}\n          >\n            {live ? <SquareIcon className=\"size-3.5\" fill=\"currentColor\" /> : <MicIcon className=\"size-4\" />}\n          </button>\n        )}\n\n        {status === \"recording\" ? (\n          <button type=\"button\" onClick={pause} className={secondary}>\n            <PauseIcon className=\"size-3.5\" /> Pause\n          </button>\n        ) : status === \"paused\" ? (\n          <button type=\"button\" onClick={resume} className={secondary}>\n            <PlayIcon className=\"size-3.5\" /> Resume\n          </button>\n        ) : status === \"done\" && result ? (\n          <>\n            <button type=\"button\" onClick={reset} className={secondary}>\n              <RotateCcwIcon className=\"size-3.5\" /> Record again\n            </button>\n            <a href={result.url} download={`${fileName}.${extOf(result.mimeType)}`} className={secondary}>\n              <DownloadIcon className=\"size-3.5\" /> Download\n            </a>\n          </>\n        ) : null}\n      </div>\n      <p id={`${id}-hint`} aria-live=\"polite\" className={cn(\"mt-3 min-h-[1.55em] text-xs\", hint ? \"text-fg\" : \"text-fg-muted\")}>\n          {hint ??\n            (status === \"recording\"\n              ? `${clock(remaining)} left`\n              : status === \"paused\"\n                ? \"Paused. Resume or stop to finish.\"\n                : status === \"done\" && result\n                  ? `${clock(result.duration * 1000)} recorded. Play it back or download it.`\n                  : supported === false\n                    ? UNSUPPORTED\n                    : `Press record to capture a note, up to ${clock(maxDuration * 1000)}.`)}\n      </p>\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {sentence(status)}\n      </span>\n\n      {result ? (\n        <audio\n          ref={audio}\n          src={result.url}\n          preload=\"metadata\"\n          onPlay={() => setPlaying(true)}\n          onPause={() => setPlaying(false)}\n          onTimeUpdate={(e) => setPosition(Math.min(e.currentTarget.currentTime, duration))}\n          onDurationChange={(e) => {\n            const d = e.currentTarget.duration\n            if (Number.isFinite(d) && d > 0) setMediaDuration(d)\n          }}\n          onEnded={() => {\n            setPlaying(false)\n            setPosition(0)\n          }}\n          onLoadedMetadata={(e) => {\n            // Chrome reports Infinity for fresh MediaRecorder blobs until a seek past the end.\n            const a = e.currentTarget\n            if (!Number.isFinite(a.duration)) {\n              const fix = () => {\n                a.removeEventListener(\"timeupdate\", fix)\n                a.currentTime = 0\n              }\n              a.addEventListener(\"timeupdate\", fix)\n              a.currentTime = 1e101\n            }\n          }}\n          className=\"hidden\"\n        />\n      ) : null}\n    </div>\n  )\n}\n\nexport { AudioRecorder }\nexport type { AudioRecorderProps }\n"
    }
  ]
}
