{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "audio-recorder",
  "title": "Audio Recorder",
  "description": "A voice-note recorder: press to record, watch the input level move while it does, stop, play it back, and get a Blob you can upload — and, the part every hand-rolled version gets wrong, the microphone is handed back the moment it is finished with. Reach for it wherever a screen is better off taking someone's voice than their typing: an audio attachment on a support ticket or bug report, a feedback or NPS widget that offers \"say it instead\", voice comments on a design or document review, pronunciation and language practice, a singing or instrument lesson, interview and research recordings, a meeting or consultation memo, a field-service or inspection report dictated on site, a delivery or warehouse note, a clinical or veterinary note, voice messages in a chat or community app, a voice journal or diary, call-centre after-call notes, and anywhere speaking is the accessible alternative for someone who cannot comfortably type. Common asks it answers: \"react audio recorder\", \"record audio in browser react\", \"MediaRecorder react hook\", \"useMediaRecorder\", \"voice note component\", \"voice memo recorder react\", \"audio message component\", \"shadcn audio recorder\", \"shadcn voice recorder\", \"react-media-recorder alternative\", \"recordrtc alternative\", \"react-mic alternative\", \"getUserMedia audio react\", \"microphone input level meter react\", \"audio level meter react\", \"show mic volume while recording\", \"waveform while recording\", \"AnalyserNode rms react\", \"pause and resume MediaRecorder\", \"MediaRecorder mimeType safari\", \"audio/webm is not supported in safari\", \"NotSupportedError MediaRecorder constructor\", \"MediaRecorder.isTypeSupported\", \"record audio that works in safari and chrome\", \"audio duration Infinity MediaRecorder\", \"webm blob duration infinity\", \"how to get the length of a MediaRecorder recording\", \"audio element duration NaN blob\", \"microphone still on after stop\", \"recording indicator stays on react\", \"stop microphone stream react\", \"AudioContext was not allowed to start\", \"too many AudioContexts\", \"ondataavailable only fires once\", \"MediaRecorder empty blob\", \"recorded audio is silent\", \"detect muted microphone browser\", \"microphone permission denied react\", \"allow microphone in an iframe\", \"microphone blocked on http\", \"録音 react コンポーネント\", \"ボイスメモ 入力\", \"マイク 入力レベル 表示\", \"録音したのに無音\". Official shadcn/ui has nothing for this and no combination of its parts reaches it. The measurement, taken today over all 63 catalogue entries (62 are fetchable — questionnaire is listed and 404s on both style tracks) and 223,287 bytes of their source: MediaRecorder, getUserMedia, mediaDevices, AudioContext, AnalyserNode, analyser, ondataavailable, isTypeSupported, webm, microphone, waveform, getTracks, NotAllowedError and even the bare word audio are every one of them a zero hit. There is no component in official shadcn that touches sound at all, so an agent asked for a voice-note field builds it from a bare MediaRecorder and gets the same six things wrong each time. Within this registry it is the first component that records: camera-capture tells the same permission story for video and is the only other thing here that opens a device, and speech-input uses the Web Speech API to return text and deliberately keeps no audio — which is the line to draw when choosing between them. Use speech-input to fill a field with words; use this when the sound itself is the thing being kept, or when the recording has to work offline, or must not be sent to a remote transcription service. Pipe the blob straight into a FormData with form.append(\"audio\", recording.blob, `note${audioFileExtension(recording.type)}`), and upload-list will show it going up. Six things separate it from the thirty-line version. First, and the reason the meter is not decoration: a muted microphone does not fail. Mute it in the operating system, at a switch on the headset, or in a mixer app, and getUserMedia still resolves, the track is still live and unmuted as far as the browser is concerned, MediaRecorder still runs, and what comes out is a perfectly valid file of digital silence. Nothing in the platform reports this; the person finds out when someone tells them the voice note is empty, long after they sent it. An AnalyserNode watches the signal, the level is on screen the whole time, and after a few seconds with nothing arriving the component says so in words. Second, there is no audio format every browser records. Chromium and Firefox write WebM, Safari writes MP4, and `new MediaRecorder(stream, { mimeType })` with a type the browser cannot write throws NotSupportedError out of the constructor immediately — so a recorder hard-coded to audio/webm has never once worked in Safari. A descent through isTypeSupported picks the best type this browser admits to, a constructor that rejects it anyway is retried with the browser's own default, and the type reported back is read off recorder.mimeType rather than from the request, because naming an upload from what you asked for hands the server an MP4 called WebM. Third, the file has no duration. A MediaRecorder writes its container header before it knows how long the recording will be and never goes back to finalise it, so handing the blob to an <audio> element gives duration === Infinity — the broken scrubber behind every half-finished voice-note widget, usually worked around by seeking to a huge offset and waiting to see where it lands. The duration here is measured while recording, from timestamps rather than by counting ticks, so a tab left in the background (where timers are throttled to about once a second) still reports its true length, and paused stretches are subtracted. Fourth, stopping. recorder.stop() ends the recording and nothing else: track.stop() is the only thing that ends the capture, and without it the browser's tab indicator — and on a phone the operating system's own — stays lit over a page that has finished. The AudioContext has to be closed too, or it holds the audio hardware and eats into the small number of contexts a page is allowed, so a recorder mounted a few times stops being able to meter at all. Both happen on stop, on discard, and on unmount, which is the moment a single-page app would otherwise leak them with no control left that could reach them. The analyser is also connected to the analyser and to nothing else, because connecting on to context.destination is the obvious next line and it routes the microphone into the speakers: an immediate feedback howl on any device without headphones, recorded. Fifth, the chunks. The specification does not promise a single dataavailable at the end, so event.data saved over a variable yields a file holding whichever piece arrived last; they are accumulated, and a stop that produced no bytes at all is reported as a failure rather than handed back as a zero-byte clip to upload. A microphone that goes away mid-sentence — a headset unplugged, the device seized by another app, the permission revoked from the browser's own UI — ends the recording and keeps what was captured, because discarding four minutes of an interview over the last second of it is the one outcome nobody forgives. Sixth, permissions. NotAllowedError means four unrelated things — an http origin, an iframe without allow=\"microphone\", a stored block, and a prompt closed without an answer — and only the stored block is worth sending somebody to their site settings for, so the other three say something true instead and are not offered a retry that cannot work. A permission fixed in those settings is picked up live through the Permissions API change event rather than staying dead until a reload, and because MediaDevices is [SecureContext] the whole object is missing on http — where the obvious feature detect fires and produces the one wrong answer, \"this browser has no microphone support\". The two halves of support are detected separately for the same reason: a browser that can reach a microphone but cannot record one needs different words, and Safari was that browser for four releases. Video is explicitly refused so the camera indicator stays dark and only one permission is asked for, and there is deliberately no autoStart — a component that opens the microphone on mount records the room before anybody agreed to it, and does so without a prompt on the second visit. Ships as a hook (useAudioRecorder) plus a component, with echoCancellation, noiseSuppression and autoGainControl exposed because the platform defaults are right for speech and ruinous for music, plus maxDuration so a forgotten recording cannot grow until the tab dies. pickAudioMimeType, audioFileExtension and formatDuration are exported as pure functions. The level meter carries role=meter and is deliberately not a live region, so it is read when asked for rather than narrating every syllable, while the silence warning goes through a permanently mounted polite live region; controls are aria-disabled rather than disabled so a refusal stays reachable and can explain itself. Styled entirely with shadcn tokens (input, accent, ring, primary, muted, destructive, muted-foreground), so it follows light and dark mode, and its only dependency is lucide-react for the icons.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/ui/audio-recorder.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Loader2, Mic, Pause, Play, Square, Trash2, TriangleAlert } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/**\n * The MediaDevices entry point, or null where there isn't one.\n *\n * Hand-written because TypeScript declares `readonly mediaDevices: MediaDevices` on `Navigator` —\n * not optional — so `navigator.mediaDevices.getUserMedia(...)` type-checks everywhere and then\n * throws a TypeError on the browsers, and the origins, that haven't got it.\n */\nfunction mediaDevicesOf(): MediaDevices | null {\n  if (typeof navigator === \"undefined\" || !(\"mediaDevices\" in navigator)) return null\n  const api: MediaDevices | undefined = navigator.mediaDevices\n  return api && typeof api.getUserMedia === \"function\" ? api : null\n}\n\n/** The `MediaRecorder` constructor, with `isTypeSupported` marked for what it is: not always there. */\ntype MediaRecorderCtor = {\n  new (stream: MediaStream, options?: MediaRecorderOptions): MediaRecorder\n  isTypeSupported?: (type: string) => boolean\n}\n\n/**\n * The `MediaRecorder` constructor, or null.\n *\n * A separate detect from `mediaDevicesOf`, and the separation is load-bearing: the two features\n * shipped years apart, so \"can reach the microphone\" and \"can record what comes out of it\" are\n * genuinely different questions. Safari had `getUserMedia` for four releases before it had this.\n * Collapsing them produces a component that asks for the microphone, lights the recording\n * indicator, and only then discovers it cannot record — a permission prompt spent on nothing.\n */\nfunction mediaRecorderCtor(): MediaRecorderCtor | null {\n  if (typeof window === \"undefined\") return null\n  const holder = window as unknown as Record<string, unknown>\n  const ctor = holder.MediaRecorder\n  return typeof ctor === \"function\" ? (ctor as MediaRecorderCtor) : null\n}\n\n/** An `AudioContext` constructor, including the spelling older Safari shipped. */\ntype AudioContextCtor = new (options?: AudioContextOptions) => AudioContext\n\nfunction audioContextCtor(): AudioContextCtor | null {\n  if (typeof window === \"undefined\") return null\n  const holder = window as unknown as Record<string, unknown>\n  const ctor = holder.AudioContext ?? holder.webkitAudioContext\n  return typeof ctor === \"function\" ? (ctor as AudioContextCtor) : null\n}\n\n/** Whether this browser can record audio at all. Both halves are required. */\nexport function isAudioRecorderSupported(): boolean {\n  return mediaDevicesOf() !== null && mediaRecorderCtor() !== null\n}\n\n/**\n * Whether this page is a non-secure context, where the microphone is unavailable and always will be.\n */\nfunction insecureContext(): boolean {\n  // A browser old enough to lack `isSecureContext` must not be read as insecure — that would\n  // refuse to work on the very browsers this is meant to degrade for.\n  return typeof window !== \"undefined\" && window.isSecureContext === false\n}\n\n/**\n * Whether Permissions Policy forbids the microphone in this document, where that can be asked.\n *\n * One of the four producers of an unexplained `NotAllowedError`. The default allowlist for\n * `microphone` is `'self'`, so an `<iframe>` embedding this page without `allow=\"microphone\"` is\n * refused, as is any origin serving `Permissions-Policy: microphone=()`. Neither is something the\n * person looking at the screen can do anything about, and neither shows a prompt.\n *\n * Best effort: the accessor is absent outside Chromium and is not in the DOM typings, so false\n * means \"not known to be blocked\", never \"allowed\".\n */\nfunction blockedByPermissionsPolicy(): boolean {\n  if (typeof document === \"undefined\") return false\n  type Policy = { allowsFeature?: (feature: string) => boolean }\n  const doc = document as Document & { permissionsPolicy?: Policy; featurePolicy?: Policy }\n  const policy = doc.permissionsPolicy ?? doc.featurePolicy\n  if (!policy || typeof policy.allowsFeature !== \"function\") return false\n  try {\n    return !policy.allowsFeature(\"microphone\")\n  } catch {\n    return false\n  }\n}\n\n/**\n * Reads the stored microphone permission without prompting, or null where that cannot be asked.\n *\n * The only other way to learn the permission state is to call `getUserMedia`, and that *acts* — it\n * can put a prompt in front of somebody who never asked for one, and it opens the microphone. This\n * answers the same question silently, which is what makes it possible to tell the four different\n * things `NotAllowedError` means apart.\n *\n * Guarded twice: `navigator.permissions` is declared non-optional by TypeScript and is genuinely\n * absent in older browsers, and a browser that has the Permissions API may still not recognise the\n * `microphone` descriptor — Safari does not — in which case `query` rejects instead of answering.\n * Not knowing is an ordinary answer here, and every path below works without it.\n */\nasync function queryMicrophonePermission(): Promise<PermissionStatus | null> {\n  if (typeof navigator === \"undefined\" || !(\"permissions\" in navigator)) return null\n  const permissions: Permissions | undefined = navigator.permissions\n  if (!permissions || typeof permissions.query !== \"function\") return null\n  try {\n    return await permissions.query({ name: \"microphone\" as PermissionName })\n  } catch {\n    return null\n  }\n}\n\n/**\n * Container and codec spellings to try, best first.\n *\n * This list is the second reason the component exists. There is no format every browser records:\n * Chromium and Firefox produce WebM, and Safari produces MP4, and `new MediaRecorder(stream, {\n * mimeType })` with a type the browser cannot write throws `NotSupportedError` from the\n * constructor — not later, not as an event, but immediately, which is why a recorder hard-coded to\n * `audio/webm` is a feature that has never once worked in Safari. Opus is first because it is\n * dramatically smaller than AAC at speech bitrates, and the bare container spellings come after\n * the codec-qualified ones so a browser that recognises the container but not the codec string\n * still gets a type it can use.\n *\n * Export is deliberate: a backend that only accepts one container wants to pass its own list.\n */\nexport const AUDIO_MIME_CANDIDATES: readonly string[] = [\n  \"audio/webm;codecs=opus\",\n  \"audio/ogg;codecs=opus\",\n  \"audio/mp4;codecs=mp4a.40.2\",\n  \"audio/webm\",\n  \"audio/mp4\",\n  \"audio/ogg\",\n]\n\n/**\n * The first candidate this browser says it can record, or null to let it choose for itself.\n *\n * Null is a real answer and not a failure. `new MediaRecorder(stream)` with no options always\n * works where `MediaRecorder` exists, and a browser whose `isTypeSupported` is missing — which is\n * every implementation old enough to need the help most — cannot be interrogated, so asking it to\n * pick beats guessing on its behalf. What comes back is read off `recorder.mimeType` afterwards\n * either way, because the type that was requested and the type that was written are not reliably\n * the same thing.\n */\nexport function pickAudioMimeType(\n  candidates: readonly string[] = AUDIO_MIME_CANDIDATES\n): string | null {\n  const ctor = mediaRecorderCtor()\n  if (!ctor || typeof ctor.isTypeSupported !== \"function\") return null\n  for (const type of candidates) {\n    try {\n      if (ctor.isTypeSupported(type)) return type\n    } catch {\n      // A browser that throws rather than answering has answered.\n    }\n  }\n  return null\n}\n\n/**\n * A file extension for a recorded type, leading dot included.\n *\n * Needed because the extension has to match what the browser actually wrote, and that is only\n * known after recording. Naming the upload `.webm` on a Safari recording hands the server an MP4\n * called WebM, which is the kind of mismatch that survives every test on the developer's own\n * machine and fails on half the users' — ffmpeg and most server-side probes trust the bytes, but\n * plenty of storage layers, players and content-type sniffers trust the name.\n */\nexport function audioFileExtension(type: string | null | undefined): string {\n  const base = (type ?? \"\").split(\";\")[0]?.trim().toLowerCase() ?? \"\"\n  switch (base) {\n    case \"audio/webm\":\n      return \".webm\"\n    // `.m4a` rather than `.mp4`: the container is MP4 either way, but a file named `.mp4` with no\n    // video track is offered to video players and opens as a black rectangle.\n    case \"audio/mp4\":\n    case \"audio/aac\":\n    case \"audio/x-m4a\":\n      return \".m4a\"\n    case \"audio/ogg\":\n      return \".ogg\"\n    case \"audio/mpeg\":\n      return \".mp3\"\n    case \"audio/wav\":\n    case \"audio/wave\":\n    case \"audio/x-wav\":\n      return \".wav\"\n    default:\n      // Not a guess dressed up as an answer: an unknown container gets the generic extension\n      // rather than one that would mislabel it.\n      return \".bin\"\n  }\n}\n\n/**\n * A byte buffer for the analyser, typed so it fits `getByteTimeDomainData` on every TypeScript.\n *\n * TypeScript 5.7 made the typed arrays generic in their backing buffer, and the DOM signature asks\n * for `Uint8Array<ArrayBuffer>` specifically, so a plain `new Uint8Array(n)` — inferred as\n * `ArrayBufferLike`, which `SharedArrayBuffer` also satisfies — no longer type-checks there.\n * Allocating the buffer explicitly fixes that, and taking the type from this function rather than\n * writing `Uint8Array<ArrayBuffer>` keeps the file compiling on the versions before 5.7, where\n * `Uint8Array` takes no type argument at all. A component that only builds on the newest compiler\n * is a component half this registry's consumers cannot install.\n */\nfunction makeByteBuffer(size: number) {\n  return new Uint8Array(new ArrayBuffer(size))\n}\n\ntype ByteBuffer = ReturnType<typeof makeByteBuffer>\n\n/** `0:07`, or `1:02:03` once it runs past an hour. For a duration in milliseconds. */\nexport function formatDuration(ms: number): string {\n  const total = Math.max(0, Math.floor(ms / 1000))\n  const seconds = total % 60\n  const minutes = Math.floor(total / 60) % 60\n  const hours = Math.floor(total / 3600)\n  const pad = (n: number) => String(n).padStart(2, \"0\")\n  return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}`\n}\n\n/**\n * Why recording failed — worked out, not guessed.\n *\n * Four of these arrive as the identical `NotAllowedError`, and they need four different things\n * from four different people: `denied` is the user's own stored choice, `dismissed` is a prompt\n * closed without an answer, `insecure-context` and `blocked-by-policy` are the developer's to fix\n * and are invisible to the user.\n */\nexport type AudioFailureCause =\n  | \"unsupported\"\n  | \"recorder-unsupported\"\n  | \"insecure-context\"\n  | \"blocked-by-policy\"\n  | \"denied\"\n  | \"dismissed\"\n  | \"no-microphone\"\n  | \"in-use\"\n  | \"encoding-unsupported\"\n  | \"interrupted\"\n  | \"record-failed\"\n  | \"empty\"\n  | \"unknown\"\n\nexport interface AudioFailure {\n  cause: AudioFailureCause\n  /** The `DOMException.name` the browser used, or null when this was settled before any call. */\n  code: string | null\n  /** Wording safe to show as-is. Override per cause with the `messages` prop. */\n  message: string\n  /**\n   * Whether trying again, right now, can produce a different answer.\n   *\n   * False for `denied` is the point of the component. Once the microphone is blocked for an origin\n   * the browser refuses without prompting, so a \"Try again\" is a button that cannot work: it\n   * returns the same error instantly for as long as the page is open. The only way out runs\n   * through the browser's own site settings, which is what the copy for that case says — and why\n   * the permission `change` listener below exists, so that coming back from those settings costs\n   * nothing.\n   */\n  retryable: boolean\n}\n\nconst RETRYABLE: Record<AudioFailureCause, boolean> = {\n  unsupported: false,\n  \"recorder-unsupported\": false,\n  \"insecure-context\": false,\n  \"blocked-by-policy\": false,\n  denied: false,\n  dismissed: true,\n  \"no-microphone\": true,\n  \"in-use\": true,\n  // Nothing on this device can write any container this asked for. A second attempt with the same\n  // list fails identically; the way out is a different `mimeCandidates`, not a retry.\n  \"encoding-unsupported\": false,\n  interrupted: true,\n  \"record-failed\": true,\n  empty: true,\n  unknown: true,\n}\n\nconst DEFAULT_MESSAGES: Record<AudioFailureCause, string> = {\n  unsupported: \"This browser can't use a microphone.\",\n  \"recorder-unsupported\": \"This browser can reach the microphone but can't record audio.\",\n  \"insecure-context\":\n    \"The microphone needs a secure (https) connection, so the browser refused without asking.\",\n  \"blocked-by-policy\": \"This page isn't permitted to use the microphone.\",\n  denied:\n    \"The microphone is blocked for this site. Allow it in your browser's site settings — this page can't ask again.\",\n  dismissed: \"The microphone request was dismissed.\",\n  \"no-microphone\": \"No microphone was found on this device.\",\n  \"in-use\": \"The microphone is being used by another app.\",\n  \"encoding-unsupported\": \"This browser can't record any of the audio formats this page asked for.\",\n  \"interrupted\": \"Recording stopped — the microphone may have been unplugged or taken by another app.\",\n  \"record-failed\": \"The recording couldn't be completed.\",\n  empty: \"Nothing was recorded.\",\n  unknown: \"The recording couldn't be started.\",\n}\n\n/**\n * Maps a `getUserMedia` rejection to a cause. `NotAllowedError` is resolved separately.\n *\n * The legacy spellings are not decoration: `PermissionDeniedError`, `DevicesNotFoundError` and\n * `TrackStartError` are what older Chrome and the prefixed implementations threw, and a browser\n * old enough to use the prefixed entry point is exactly the one that will not be updated.\n */\nfunction causeForError(name: string): AudioFailureCause | null {\n  switch (name) {\n    case \"NotAllowedError\":\n    case \"PermissionDeniedError\":\n      return null\n    case \"NotFoundError\":\n    case \"DevicesNotFoundError\":\n      return \"no-microphone\"\n    // The microphone exists, the permission is fine, and the operating system will not hand it\n    // over — almost always because a call in another app or another tab already holds it.\n    case \"NotReadableError\":\n    case \"TrackStartError\":\n      return \"in-use\"\n    case \"OverconstrainedError\":\n    case \"ConstraintNotSatisfiedError\":\n      return \"no-microphone\"\n    case \"AbortError\":\n      return \"interrupted\"\n    // Thrown where media support has been disabled at the browser level.\n    case \"SecurityError\":\n      return \"blocked-by-policy\"\n    default:\n      return \"unknown\"\n  }\n}\n\n/** Stops every track on a stream. The only thing that turns the recording indicator off. */\nfunction stopTracks(stream: MediaStream | null | undefined): void {\n  for (const track of stream?.getTracks?.() ?? []) {\n    track.onended = null\n    track.stop()\n  }\n}\n\n/** Where the recorder has got to. */\nexport type RecorderPhase =\n  | \"idle\"\n  | \"prompting\"\n  | \"starting\"\n  | \"recording\"\n  | \"paused\"\n  | \"stopping\"\n  | \"recorded\"\n  | \"error\"\n\n/** A finished recording. */\nexport interface AudioRecording {\n  /** The encoded audio. This is the thing to upload. See `audioFileExtension` for the name. */\n  blob: Blob\n  /**\n   * An object URL for playing it back, owned by this component.\n   *\n   * Revoked when the next recording starts and on unmount, because an object URL pins its blob in\n   * memory until somebody releases it and a voice-note field is used over and over. Anything that\n   * has to outlive the component — a player elsewhere on the page, a value kept in form state —\n   * should make its own from `blob` and revoke that itself.\n   */\n  url: string\n  /**\n   * How long was recorded, in milliseconds, measured while it happened.\n   *\n   * Measured here because the file cannot be asked. A `MediaRecorder` writes its container header\n   * before it knows how long the recording will be and never goes back to finalise it, so a WebM\n   * from this API carries no duration: hand the blob to an `<audio>` element and `duration` reads\n   * `Infinity`, which is why so many voice-note widgets show a broken scrubber and a timeline of\n   * nothing. The usual workaround — seek to a huge offset and wait for the element to report where\n   * it landed — is a second asynchronous dance to recover a number that was in hand all along.\n   * This is that number: wall-clock between start and stop, with any paused stretches subtracted.\n   */\n  duration: number\n  /** The container actually written, read off the recorder rather than the type requested. */\n  type: string\n  /** Bytes. */\n  size: number\n}\n\nexport interface UseAudioRecorderOptions {\n  /**\n   * Container and codec spellings to try, best first. Default `AUDIO_MIME_CANDIDATES`.\n   *\n   * Pass a narrower list when the receiving end only accepts one — but pass a list, not a string,\n   * because a single hard-coded type is the thing this exists to avoid.\n   */\n  mimeCandidates?: readonly string[]\n  /**\n   * Browser audio processing, all on by default — which is right for speech and wrong for music.\n   *\n   * These are on by the platform's own defaults because the overwhelming majority of recordings\n   * made in a browser are somebody talking, and for talking they are a large improvement: the echo\n   * canceller stops a laptop recording its own speaker output, and the gain control keeps a quiet\n   * speaker audible. For anything musical they are destructive in a way that cannot be undone\n   * afterwards — the noise suppressor is tuned to discard what is not a voice, so a guitar loses\n   * its decay and a cymbal turns to gravel, and the gain control pumps the volume between phrases.\n   * A recorder for instrument practice or a singing teacher wants all three off.\n   */\n  echoCancellation?: boolean\n  noiseSuppression?: boolean\n  autoGainControl?: boolean\n  /** Pin one specific input by id, from `enumerateDevices`. */\n  deviceId?: string\n  /**\n   * Stop automatically after this many milliseconds. Undefined records until stopped.\n   *\n   * Worth setting for anything that gets uploaded. Every chunk is held in memory until the\n   * recording is finished, so an interview left running on a forgotten tab grows until the tab\n   * dies — and the recording dies with it, because nothing was ever handed back.\n   */\n  maxDuration?: number\n  /**\n   * How long the input can stay silent before `isSilent` goes true. Default 4000ms.\n   *\n   * This is the component's first reason to exist. A microphone muted in the operating system, or\n   * at a hardware switch on the headset, or by a mixer application, does not fail: `getUserMedia`\n   * resolves, the track is live and unmuted as far as the browser is concerned, `MediaRecorder`\n   * runs happily, and what comes out is a perfectly valid file of digital silence. Nothing in the\n   * API reports this. The person finds out when somebody tells them the voice note is empty, which\n   * is after they have stopped, sent it and closed the tab.\n   */\n  silenceTimeout?: number\n  /**\n   * The RMS level below which the input counts as silent, 0 to 1. Default 0.01.\n   *\n   * 0.01 is about -40 dBFS, which is below a quiet room's noise floor as the microphone hears it\n   * but comfortably above the exact zeros a muted device delivers. Raising it turns \"quiet\" into\n   * \"silent\" and will cry wolf at a soft speaker.\n   */\n  silenceThreshold?: number\n  onStart?: () => void\n  /** Called with the finished recording. */\n  onRecording?: (recording: AudioRecording) => void\n  /**\n   * Called when something fails.\n   *\n   * Not `onError`: that is a native DOM attribute React defines on every element, so a prop by\n   * that name collides with it as soon as these options are spread onto an element.\n   */\n  onFailure?: (failure: AudioFailure) => void\n}\n\nexport interface UseAudioRecorderResult {\n  phase: RecorderPhase\n  /** The last failure, or null. Cleared when recording starts. */\n  failure: AudioFailure | null\n  /** The finished recording, or null. */\n  recording: AudioRecording | null\n  /** Milliseconds recorded so far, paused stretches excluded. Ticks while recording. */\n  elapsed: number\n  /** Input level, 0 to 1, smoothed for display. 0 unless recording. */\n  level: number\n  /** True while the input is at or past full scale, where the recording is being clipped. */\n  isClipping: boolean\n  /** True once the input has been silent for `silenceTimeout`. See that option. */\n  isSilent: boolean\n  /** Whether the Web Audio API was available to measure the level at all. */\n  isMetered: boolean\n  /** The stored permission, or \"unknown\" where the Permissions API can't say. */\n  permission: PermissionState | \"unknown\"\n  /** Whether the APIs exist. Starts true so the server and first client render agree. */\n  isSupported: boolean\n  start: () => void\n  /** Stop and finish. The recording arrives through `recording` and `onRecording`. */\n  stop: () => void\n  pause: () => void\n  resume: () => void\n  /** Throw the recording away and go back to idle, releasing its object URL. */\n  discard: () => void\n}\n\n/**\n * The whole behaviour, for a recorder you lay out yourself.\n */\nexport function useAudioRecorder(options: UseAudioRecorderOptions = {}): UseAudioRecorderResult {\n  const [phase, setPhase] = React.useState<RecorderPhase>(\"idle\")\n  const [failure, setFailure] = React.useState<AudioFailure | null>(null)\n  const [recording, setRecording] = React.useState<AudioRecording | null>(null)\n  const [elapsed, setElapsed] = React.useState(0)\n  const [level, setLevel] = React.useState(0)\n  const [isClipping, setIsClipping] = React.useState(false)\n  const [isSilent, setIsSilent] = React.useState(false)\n  const [isMetered, setIsMetered] = React.useState(false)\n  const [permission, setPermission] = React.useState<PermissionState | \"unknown\">(\"unknown\")\n  const [isSupported, setIsSupported] = React.useState(true)\n\n  const streamRef = React.useRef<MediaStream | null>(null)\n  const recorderRef = React.useRef<MediaRecorder | null>(null)\n  const chunksRef = React.useRef<Blob[]>([])\n  const contextRef = React.useRef<AudioContext | null>(null)\n  const analyserRef = React.useRef<AnalyserNode | null>(null)\n  const sourceRef = React.useRef<MediaStreamAudioSourceNode | null>(null)\n  const bufferRef = React.useRef<ByteBuffer | null>(null)\n  const frameRef = React.useRef<number | null>(null)\n  const timerRef = React.useRef<ReturnType<typeof setInterval> | null>(null)\n  const recordingRef = React.useRef<AudioRecording | null>(null)\n  const mountedRef = React.useRef(true)\n  const failureRef = React.useRef<AudioFailure | null>(null)\n  const permissionRef = React.useRef<PermissionState | \"unknown\">(\"unknown\")\n\n  // Elapsed time is kept as two numbers rather than counted up, and that is not a style choice. A\n  // counter incremented on a timer is wrong by however much the browser throttled that timer, and\n  // a background tab is throttled to roughly once a second — so a two-minute voice note recorded\n  // while the user read something in another tab would be handed back claiming to be forty\n  // seconds, and the number is what the receiving end shows forever. These two are timestamps, so\n  // the duration is correct no matter how rarely anything got to run.\n  const startedAtRef = React.useRef(0)\n  const bankedRef = React.useRef(0)\n\n  const levelRef = React.useRef(0)\n  const silentForRef = React.useRef(0)\n  const lastFrameRef = React.useRef(0)\n  // Every start gets a number, and a continuation that is not the current one is dropped. There is\n  // no way to cancel a `getUserMedia` already in flight, so without this a stream from an abandoned\n  // attempt lands on top of a fresh one — and, worse, lands with nothing holding it, which is a\n  // microphone left open for the life of the page with no control that can reach it.\n  const sessionRef = React.useRef(0)\n  const pendingRef = React.useRef(false)\n\n  // Options are read through a ref rather than closed over, so `start` and `stop` keep stable\n  // identities. A callback that changes when an inline `onRecording` changes would otherwise\n  // restart the microphone on an unrelated re-render, and a recording that stops and starts\n  // mid-sentence is a worse bug than any it could fix.\n  const optionsRef = React.useRef(options)\n  React.useEffect(() => {\n    optionsRef.current = options\n  })\n\n  // Declared before everything that reads it: under StrictMode the cleanups run and the effects run\n  // again, and if this came last the second pass would see `false` and quietly refuse to work.\n  React.useEffect(() => {\n    mountedRef.current = true\n    return () => {\n      mountedRef.current = false\n    }\n  }, [])\n\n  const active = React.useCallback(\n    (id: number) => mountedRef.current && sessionRef.current === id,\n    []\n  )\n\n  const fail = React.useCallback(\n    (cause: AudioFailureCause, code: string | null, keepPhase?: RecorderPhase) => {\n      const next: AudioFailure = {\n        cause,\n        code,\n        message: DEFAULT_MESSAGES[cause],\n        retryable: RETRYABLE[cause],\n      }\n      failureRef.current = next\n      setFailure(next)\n      setPhase(keepPhase ?? \"error\")\n      optionsRef.current.onFailure?.(next)\n    },\n    []\n  )\n\n  /**\n   * Settles support on mount, with the reason rather than a bare boolean.\n   *\n   * Reporting only `isSupported: false` is what produces the thing this component argues against\n   * everywhere else: a greyed-out button with nothing next to it saying why. Nobody has clicked\n   * anything yet, so there is no failure to describe, and the one person who cannot record is the\n   * one told the least.\n   */\n  React.useEffect(() => {\n    if (isAudioRecorderSupported()) return\n    // Reported once. `fail` mints a fresh failure object every call, so an unguarded version\n    // re-announces itself through `onFailure` on every StrictMode remount — and a consumer that\n    // shows a toast per failure gets two of them for a browser that was never going to work.\n    if (failureRef.current) return\n    setIsSupported(false)\n    if (insecureContext()) {\n      fail(\"insecure-context\", null)\n      return\n    }\n    // Which half is missing decides the copy. \"This browser can't use a microphone\" is false, and\n    // misleading, on a browser whose microphone works and whose recorder does not.\n    fail(mediaDevicesOf() === null ? \"unsupported\" : \"recorder-unsupported\", null)\n  }, [fail])\n\n  /** Stops the meter loop. */\n  const stopMeter = React.useCallback(() => {\n    const frame = frameRef.current\n    frameRef.current = null\n    if (frame !== null && typeof cancelAnimationFrame === \"function\") cancelAnimationFrame(frame)\n  }, [])\n\n  /** Stops the elapsed-time ticker. */\n  const stopTimer = React.useCallback(() => {\n    const timer = timerRef.current\n    timerRef.current = null\n    if (timer !== null) clearInterval(timer)\n  }, [])\n\n  /**\n   * Hands back the microphone and everything attached to it.\n   *\n   * Four separate releases, and every one of them has been left out of a hand-rolled version. The\n   * tracks are what hold the device: a recorder that only calls `recorder.stop()` leaves the\n   * browser's tab indicator lit and, on a phone, the operating system's own — a red dot over a page\n   * that is not recording anything, which is the single complaint this component is built to\n   * avoid. The `AudioContext` is the second: it keeps the audio hardware spun up, and browsers cap\n   * how many a page may have open at once, so a recorder mounted and unmounted a few times stops\n   * being able to meter at all. The source node is disconnected before the context closes so the\n   * graph is not left holding the stream, and the recorder's own handlers are detached so a late\n   * `stop` event cannot land on a component that has moved on.\n   */\n  const release = React.useCallback(() => {\n    stopMeter()\n    stopTimer()\n\n    const recorder = recorderRef.current\n    recorderRef.current = null\n    if (recorder) {\n      recorder.ondataavailable = null\n      recorder.onstop = null\n      recorder.onerror = null\n      // Only if it is still running: `stop()` on an inactive recorder throws InvalidStateError,\n      // and this path runs on unmount, where it usually is inactive.\n      if (recorder.state !== \"inactive\") {\n        try {\n          recorder.stop()\n        } catch {\n          // Nothing left to do about it here.\n        }\n      }\n    }\n\n    const source = sourceRef.current\n    sourceRef.current = null\n    try {\n      source?.disconnect()\n    } catch {\n      // Already disconnected.\n    }\n    analyserRef.current = null\n    bufferRef.current = null\n\n    const context = contextRef.current\n    contextRef.current = null\n    if (context && context.state !== \"closed\") {\n      // Returns a promise that nothing waits for; a rejection here is not actionable. The second\n      // `?.` is on the *result* of the call, not just the method: an implementation whose `close`\n      // returns nothing would otherwise throw reading `.catch` of undefined, from inside the one\n      // function whose whole job is to hand the microphone back — and the lines below it, which\n      // stop the tracks, would never run. That is the recording indicator left lit with no control\n      // able to reach it. `resume` below is written this way for the same reason.\n      void context.close?.()?.catch?.(() => {})\n    }\n\n    const stream = streamRef.current\n    streamRef.current = null\n    stopTracks(stream)\n\n    levelRef.current = 0\n    silentForRef.current = 0\n    if (mountedRef.current) {\n      setLevel(0)\n      setIsClipping(false)\n    }\n  }, [stopMeter, stopTimer])\n\n  /** Releases the object URL of the recording we are holding. */\n  const releaseRecording = React.useCallback(() => {\n    const held = recordingRef.current\n    recordingRef.current = null\n    if (held && typeof URL !== \"undefined\" && typeof URL.revokeObjectURL === \"function\") {\n      URL.revokeObjectURL(held.url)\n    }\n  }, [])\n\n  /** Milliseconds recorded, derived rather than counted. See `startedAtRef`. */\n  const measure = React.useCallback(() => {\n    const running = startedAtRef.current > 0 ? Date.now() - startedAtRef.current : 0\n    return bankedRef.current + Math.max(0, running)\n  }, [])\n\n  /**\n   * Reads the input level once per frame and keeps the silence account.\n   *\n   * Time-domain data, not frequency: the level of a signal is how far it swings from the midpoint,\n   * and `getByteFrequencyData` answers a different question entirely — it would report a strong\n   * reading for a steady hum nobody can hear and a weak one for a crisp consonant.\n   *\n   * The silence account is kept in sampled time rather than wall-clock time, deliberately. A\n   * background tab stops being given frames, so wall-clock would conclude \"silent for thirty\n   * seconds\" about a tab that was simply not being painted, and warn the user that a perfectly\n   * good recording is empty. Frames are the only evidence of silence there is, so only frames\n   * count towards it.\n   */\n  const sample = React.useCallback(\n    (now: number) => {\n      const analyser = analyserRef.current\n      const buffer = bufferRef.current\n      if (!analyser || !buffer) return\n\n      analyser.getByteTimeDomainData(buffer)\n      let sum = 0\n      let peak = 0\n      for (let i = 0; i < buffer.length; i += 1) {\n        const value = ((buffer[i] ?? 128) - 128) / 128\n        sum += value * value\n        const magnitude = Math.abs(value)\n        if (magnitude > peak) peak = magnitude\n      }\n      const rms = Math.sqrt(sum / buffer.length)\n\n      // Fast attack, slow release. A meter that follows the signal down as fast as it follows it up\n      // flickers on every syllable gap and reads as a fault rather than as speech.\n      const smoothed = Math.max(rms, levelRef.current * 0.82)\n      levelRef.current = smoothed\n\n      const opts = optionsRef.current\n      const threshold = opts.silenceThreshold ?? 0.01\n      const timeout = opts.silenceTimeout ?? 4000\n      const delta = lastFrameRef.current > 0 ? Math.min(250, now - lastFrameRef.current) : 0\n      lastFrameRef.current = now\n\n      if (rms >= threshold) {\n        silentForRef.current = 0\n        if (mountedRef.current) setIsSilent(false)\n      } else {\n        silentForRef.current += delta\n        if (silentForRef.current >= timeout && mountedRef.current) setIsSilent(true)\n      }\n\n      if (!mountedRef.current) return\n      // Quantised to 32 steps before it reaches state. Un-quantised, this sets state sixty times a\n      // second and re-renders the whole subtree with it, for a bar whose width nobody can tell\n      // apart at that resolution. Quantised, a silent input re-renders not at all.\n      const step = Math.round(smoothed * 32) / 32\n      setLevel((current) => (current === step ? current : step))\n      setIsClipping(peak >= 0.98)\n    },\n    []\n  )\n\n  /** Starts the per-frame meter, where the browser has frames to give. */\n  const startMeter = React.useCallback(() => {\n    if (typeof requestAnimationFrame !== \"function\") return\n    const loop = (now: number) => {\n      if (!analyserRef.current) return\n      sample(now)\n      frameRef.current = requestAnimationFrame(loop)\n    }\n    lastFrameRef.current = 0\n    frameRef.current = requestAnimationFrame(loop)\n  }, [sample])\n\n  // Set when the source ends under us, read by the `stop` handler. The two are separate events and\n  // the handler cannot tell from the recorder alone whether it was asked to stop or had to.\n  const interruptedRef = React.useRef(false)\n\n  /**\n   * Builds the recording out of the chunks and hands the microphone back.\n   *\n   * Runs from the recorder's own `stop` event rather than from the `stop()` call, because `stop()`\n   * is a request: the encoder still has audio buffered, that audio arrives as one more\n   * `dataavailable`, and only then does `stop` fire. A version that builds the blob where the user\n   * clicked loses the last fraction of a second of every recording — reliably, but by too little\n   * to notice until somebody records a short word.\n   */\n  const finalize = React.useCallback(\n    (id: number) => {\n      // Guarded before anything is touched, and that order is the whole of it. `stop()` returns\n      // immediately and the `stop` event arrives whenever the encoder has finished, so an event\n      // for a recording that was already abandoned — `start` pressed again, discarded, the\n      // component unmounted — lands at a moment when the session that replaced it owns the\n      // microphone. Releasing first and checking afterwards therefore hands back the *live*\n      // recording's stream and closes its context, which looks from the outside like a recorder\n      // that dies a second after it starts. Nothing is leaked by returning here: the tracks of an\n      // abandoned session were released by whatever abandoned it.\n      if (!active(id)) return\n\n      const duration = measure()\n      const interrupted = interruptedRef.current\n      interruptedRef.current = false\n      const chunks = chunksRef.current\n      chunksRef.current = []\n      // Read before `release` nulls it, and taken from the recorder rather than from the request:\n      // a browser handed a type it half-recognises may write a different one, and the first chunk's\n      // own type is empty on some implementations.\n      const written = recorderRef.current?.mimeType || chunks[0]?.type || \"\"\n\n      release()\n\n      const size = chunks.reduce((total, chunk) => total + (chunk.size ?? 0), 0)\n      if (chunks.length === 0 || size === 0) {\n        // Not the muted-microphone case — silence still encodes to a valid file with a header. This\n        // is the recorder having produced nothing at all, which a caller must not be handed as a\n        // zero-byte \"recording\" to upload.\n        fail(\"empty\", null)\n        return\n      }\n\n      const blob = new Blob(chunks, written ? { type: written } : undefined)\n      releaseRecording()\n      const next: AudioRecording = {\n        blob,\n        url: URL.createObjectURL(blob),\n        duration,\n        type: blob.type || written,\n        size: blob.size,\n      }\n      recordingRef.current = next\n      setRecording(next)\n      setElapsed(duration)\n      if (interrupted) {\n        // Both at once, on purpose. The microphone went away mid-recording, which is worth saying,\n        // and what was captured before that is a real recording — discarding four minutes of an\n        // interview because the headset was unplugged at the end is the one outcome nobody forgives.\n        fail(\"interrupted\", \"ended\", \"recorded\")\n      } else {\n        failureRef.current = null\n        setFailure(null)\n        setPhase(\"recorded\")\n      }\n      optionsRef.current.onRecording?.(next)\n    },\n    [active, fail, measure, release, releaseRecording]\n  )\n\n  /** Asks the recorder to finish. The recording is built in `finalize`, from the `stop` event. */\n  const finishRecorder = React.useCallback(() => {\n    const recorder = recorderRef.current\n    if (!recorder || recorder.state === \"inactive\") return\n    // Banked before asking, not measured in the handler: `stop()` returns immediately and the\n    // `stop` event arrives once the encoder has flushed, so measuring there adds the flush to the\n    // duration of every recording.\n    bankedRef.current = measure()\n    startedAtRef.current = 0\n    stopTimer()\n    stopMeter()\n    if (mountedRef.current) {\n      setElapsed(bankedRef.current)\n      setLevel(0)\n      setIsClipping(false)\n      setPhase(\"stopping\")\n    }\n    try {\n      recorder.stop()\n    } catch {\n      // InvalidStateError from a recorder that stopped between the check and here. The `stop` event\n      // for that stop is already on its way.\n    }\n  }, [measure, stopMeter, stopTimer])\n\n  /** Ticks the displayed time and enforces `maxDuration`. */\n  const startTimer = React.useCallback(() => {\n    stopTimer()\n    timerRef.current = setInterval(() => {\n      if (!mountedRef.current) return\n      const ms = measure()\n      setElapsed(ms)\n      const max = optionsRef.current.maxDuration\n      // Checked against the measured time rather than a tick count, so a throttled background tab\n      // stops at the right recorded length instead of at four times it.\n      if (max !== undefined && max > 0 && ms >= max) finishRecorder()\n    }, 250)\n  }, [finishRecorder, measure, stopTimer])\n\n  const start = React.useCallback(() => {\n    const opts = optionsRef.current\n    const api = mediaDevicesOf()\n    const Recorder = mediaRecorderCtor()\n    if (!api || !Recorder) {\n      setIsSupported(false)\n      // Asked in this order so the http case says the true thing instead of blaming the browser:\n      // `MediaDevices` is [SecureContext], so the whole object is missing on a plain-http origin\n      // and the obvious feature detect fires on a browser whose microphone works perfectly well.\n      if (insecureContext()) fail(\"insecure-context\", null)\n      else fail(!api ? \"unsupported\" : \"recorder-unsupported\", null)\n      return\n    }\n    if (insecureContext()) {\n      fail(\"insecure-context\", null)\n      return\n    }\n    if (blockedByPermissionsPolicy()) {\n      fail(\"blocked-by-policy\", null)\n      return\n    }\n    // A second request while the first is still acquiring is never useful and is actively harmful:\n    // the first already holds the device, so the duplicate comes back `NotReadableError` and\n    // somebody who double-tapped Record is told another app has their microphone.\n    if (pendingRef.current) return\n\n    release()\n    releaseRecording()\n\n    const id = sessionRef.current + 1\n    sessionRef.current = id\n    pendingRef.current = true\n    interruptedRef.current = false\n    chunksRef.current = []\n    bankedRef.current = 0\n    startedAtRef.current = 0\n    silentForRef.current = 0\n    failureRef.current = null\n    setFailure(null)\n    setRecording(null)\n    setElapsed(0)\n    setIsSilent(false)\n    // \"prompt\" is the one state that reliably means a dialog is about to appear. Where the\n    // Permissions API could not answer, claiming to know would be the lie — and the wait is\n    // unbounded either way, because a person deciding whether to hand over their microphone is not\n    // a fault.\n    setPhase(permissionRef.current === \"prompt\" ? \"prompting\" : \"starting\")\n\n    const constraints: MediaTrackConstraints = {}\n    if (opts.deviceId) constraints.deviceId = { exact: opts.deviceId }\n    if (opts.echoCancellation !== undefined) constraints.echoCancellation = opts.echoCancellation\n    if (opts.noiseSuppression !== undefined) constraints.noiseSuppression = opts.noiseSuppression\n    if (opts.autoGainControl !== undefined) constraints.autoGainControl = opts.autoGainControl\n    const audio = Object.keys(constraints).length > 0 ? constraints : true\n\n    const settle = (stream: MediaStream) => {\n      pendingRef.current = false\n      if (!active(id)) {\n        // Abandoned while in flight. Nothing is holding this stream and nothing else ever will, so\n        // it has to be stopped here or the microphone stays open for the life of the page.\n        stopTracks(stream)\n        return\n      }\n      streamRef.current = stream\n\n      const Context = audioContextCtor()\n      if (Context) {\n        try {\n          const context = new Context()\n          const source = context.createMediaStreamSource(stream)\n          const analyser = context.createAnalyser()\n          // Small on purpose: this is a level meter, not a spectrum analyser, and 1024 samples is\n          // a sixth of a frame's worth of audio — enough for a stable RMS and cheap enough to do\n          // sixty times a second on a phone.\n          analyser.fftSize = 1024\n          source.connect(analyser)\n          // Connected to the analyser and to nothing else. Connecting on to\n          // `context.destination` is the obvious next line and it routes the microphone into the\n          // speakers, which on any device without headphones is an immediate feedback howl — and\n          // then records it.\n          contextRef.current = context\n          sourceRef.current = source\n          analyserRef.current = analyser\n          bufferRef.current = makeByteBuffer(analyser.fftSize)\n          // A context created outside a user gesture starts suspended and measures nothing. The\n          // click that called `start` is enough activation for this to be allowed; it is awaited by\n          // nobody because the meter appearing a frame late is not worth a branch.\n          if (context.state === \"suspended\") void context.resume?.()?.catch?.(() => {})\n          setIsMetered(true)\n          startMeter()\n        } catch {\n          // Metering is a bonus and the recording is the job. A browser that will not give us an\n          // AudioContext — or has hit its limit on them — can still record perfectly well, and\n          // failing the recording over the level meter would be the tail wagging the dog.\n          setIsMetered(false)\n        }\n      }\n\n      const wanted = pickAudioMimeType(opts.mimeCandidates)\n      let recorder: MediaRecorder\n      try {\n        recorder = wanted ? new Recorder(stream, { mimeType: wanted }) : new Recorder(stream)\n      } catch (error) {\n        // `NotSupportedError`, thrown by the constructor rather than reported later. Reached when\n        // `isTypeSupported` claimed a type the implementation cannot actually write, which happens.\n        // The browser's own default is the one type it is guaranteed to manage, so that is the\n        // retry — a recording in an unexpected container beats no recording at all, and `type`\n        // reports what arrived.\n        try {\n          recorder = new Recorder(stream)\n        } catch {\n          const name = (error as DOMException | undefined)?.name ?? \"unknown\"\n          release()\n          fail(\"encoding-unsupported\", name)\n          return\n        }\n      }\n\n      recorderRef.current = recorder\n      // Accumulated rather than kept as one blob. The specification does not promise a single\n      // `dataavailable` at the end — an implementation may deliver the recording in pieces for its\n      // own reasons, and one that does turns `event.data` saved over a variable into a file holding\n      // the last few seconds of a long recording.\n      recorder.ondataavailable = (event: BlobEvent) => {\n        if (event.data && event.data.size > 0) chunksRef.current.push(event.data)\n      }\n      recorder.onerror = () => {\n        if (!active(id)) return\n        // Whatever was captured before the fault is still worth keeping, so this goes through the\n        // same stop path rather than discarding it.\n        interruptedRef.current = true\n        finishRecorder()\n      }\n      recorder.onstop = () => finalize(id)\n\n      // The source ending on its own — a headset unplugged, the microphone seized by the operating\n      // system, the permission revoked from the browser's own UI while the page is open. Nothing\n      // else reports it, and without this the timer keeps counting up over a recording of nothing.\n      const [track] = stream.getAudioTracks?.() ?? []\n      if (track) {\n        track.onended = () => {\n          if (!active(id)) return\n          interruptedRef.current = true\n          finishRecorder()\n        }\n      }\n\n      try {\n        // No timeslice. One is only needed to get chunks during recording, which nothing here\n        // wants, and on some implementations it changes how the container is written.\n        recorder.start()\n      } catch (error) {\n        const name = (error as DOMException | undefined)?.name ?? \"unknown\"\n        release()\n        fail(\"record-failed\", name)\n        return\n      }\n\n      startedAtRef.current = Date.now()\n      setPhase(\"recording\")\n      startTimer()\n      opts.onStart?.()\n    }\n\n    const reject = (error: unknown) => {\n      pendingRef.current = false\n      if (!active(id)) return\n      const name = (error as DOMException | undefined)?.name ?? \"unknown\"\n      const mapped = causeForError(name)\n      if (mapped) {\n        fail(mapped, name)\n        return\n      }\n\n      // NotAllowedError, which is four different things.\n      //\n      // The two checked before the call are checked again, because a document can be moved into a\n      // frame that forbids the feature between the two moments, and because a browser reaching here\n      // when the pre-checks could not run still deserves the right answer. Then the stored state\n      // decides the rest, read now rather than taken from React state: pressing Block both fails\n      // this call and fires `change`, and there is no guarantee the event has arrived.\n      if (insecureContext()) {\n        fail(\"insecure-context\", name)\n        return\n      }\n      if (blockedByPermissionsPolicy()) {\n        fail(\"blocked-by-policy\", name)\n        return\n      }\n      void queryMicrophonePermission().then((status) => {\n        if (!active(id)) return\n        if (!status) {\n          // Nothing can tell these apart here, so the copy for `denied` carries the day — it is the\n          // only one of the two that stays broken, and advice to check site settings is harmless to\n          // somebody who merely closed the dialog. This is the ordinary path in Safari, which has\n          // no `microphone` permission descriptor.\n          fail(\"denied\", name)\n          return\n        }\n        setPermission(status.state)\n        permissionRef.current = status.state\n        if (status.state === \"denied\") {\n          fail(\"denied\", name)\n          return\n        }\n        // Refused while the stored answer is \"granted\" can only come from above the user — a frame\n        // or a header — so sending them to their own settings would be wrong.\n        if (status.state === \"granted\") {\n          fail(\"blocked-by-policy\", name)\n          return\n        }\n        // Still \"prompt\": nothing was stored, so the dialog was closed rather than answered. This\n        // is the one refusal worth offering a retry for, and asking again really re-prompts.\n        fail(\"dismissed\", name)\n      })\n    }\n\n    try {\n      // `video: false`, not omitted. Asking for video as well lights the camera indicator and puts\n      // a second permission in front of somebody who wanted to record a voice note.\n      const request = api.getUserMedia({ audio, video: false })\n      request.then(settle, reject)\n    } catch (error) {\n      // A browser that throws synchronously rather than rejecting — the prefixed implementations\n      // did, and a TypeError for malformed constraints still does.\n      reject(error)\n    }\n  }, [active, fail, finalize, finishRecorder, release, releaseRecording, startMeter, startTimer])\n\n  const pause = React.useCallback(() => {\n    const recorder = recorderRef.current\n    if (!recorder || recorder.state !== \"recording\") return\n    try {\n      // Asked first, and the clock only stopped once it agreed. `pause` is the one method here that\n      // an implementation may simply not have, and banking the time before finding that out would\n      // leave a recorder that is still running with a clock that has stopped.\n      recorder.pause()\n    } catch {\n      return\n    }\n    bankedRef.current = measure()\n    startedAtRef.current = 0\n    stopTimer()\n    stopMeter()\n    levelRef.current = 0\n    setLevel(0)\n    setIsClipping(false)\n    setElapsed(bankedRef.current)\n    setPhase(\"paused\")\n  }, [measure, stopMeter, stopTimer])\n\n  const resume = React.useCallback(() => {\n    const recorder = recorderRef.current\n    if (!recorder || recorder.state !== \"paused\") return\n    try {\n      recorder.resume()\n    } catch {\n      return\n    }\n    startedAtRef.current = Date.now()\n    silentForRef.current = 0\n    setIsSilent(false)\n    setPhase(\"recording\")\n    startMeter()\n    startTimer()\n  }, [startMeter, startTimer])\n\n  const discard = React.useCallback(() => {\n    // Bumping the id first orphans anything still in flight, and `release` detaches the recorder's\n    // own handlers, so a `stop` event for a recording that was thrown away cannot arrive later and\n    // hand it back.\n    sessionRef.current += 1\n    pendingRef.current = false\n    interruptedRef.current = false\n    release()\n    releaseRecording()\n    chunksRef.current = []\n    bankedRef.current = 0\n    startedAtRef.current = 0\n    failureRef.current = null\n    setRecording(null)\n    setFailure(null)\n    setElapsed(0)\n    setIsSilent(false)\n    setPhase(\"idle\")\n  }, [release, releaseRecording])\n\n  /**\n   * Reads the stored permission and keeps the dead end honest.\n   *\n   * The `change` event is the reason this is a subscription rather than one read. A user sent to\n   * site settings by the `denied` message comes back to a page that is still open, and the browser\n   * fires `change` the moment they flip the switch. Handling it turns \"allow it in your settings\"\n   * into advice that visibly works; ignoring it leaves the button dead until a reload, which is the\n   * point at which people conclude the site is broken.\n   */\n  React.useEffect(() => {\n    let status: PermissionStatus | null = null\n    let cancelled = false\n\n    const handleChange = () => {\n      if (!status) return\n      const next = status.state\n      setPermission(next)\n      permissionRef.current = next\n      if (next === \"denied\") return\n      if (failureRef.current?.cause === \"denied\") {\n        failureRef.current = null\n        setFailure(null)\n        setPhase(\"idle\")\n      }\n    }\n\n    void queryMicrophonePermission().then((result) => {\n      if (cancelled || !result) return\n      status = result\n      setPermission(result.state)\n      permissionRef.current = result.state\n      result.addEventListener(\"change\", handleChange)\n    })\n\n    return () => {\n      cancelled = true\n      status?.removeEventListener(\"change\", handleChange)\n    }\n  }, [])\n\n  // The microphone, the audio context and the object URL all belong to the browser, not to this\n  // component, so navigating away inside a single-page app is exactly the moment they would be\n  // leaked: the tracks keep the recording indicator lit with no control left that could turn it\n  // off, the context holds the audio hardware, and the blob stays in memory.\n  React.useEffect(\n    () => () => {\n      sessionRef.current += 1\n      release()\n      releaseRecording()\n    },\n    [release, releaseRecording]\n  )\n\n  return {\n    phase,\n    failure,\n    recording,\n    elapsed,\n    level,\n    isClipping,\n    isSilent,\n    isMetered,\n    permission,\n    isSupported,\n    start,\n    stop: finishRecorder,\n    pause,\n    resume,\n    discard,\n  }\n}\n\n/** How many segments the level meter draws. */\nconst METER_BARS = 20\n\nexport interface AudioRecorderProps\n  extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"children\">,\n    UseAudioRecorderOptions {\n  /** Accessible name for the whole recorder. */\n  label?: string\n  /** Resting label on the primary button. */\n  recordLabel?: string\n  stopLabel?: string\n  pauseLabel?: string\n  resumeLabel?: string\n  discardLabel?: string\n  againLabel?: string\n  retryLabel?: string\n  /** Accessible name for the level meter. */\n  meterLabel?: string\n  /** Accessible name for the playback element. */\n  playbackLabel?: string\n  /** Per-cause wording, merged over the defaults. */\n  messages?: Partial<Record<AudioFailureCause, string>>\n  /** Status wording. Each one is announced through the live region as it becomes true. */\n  promptingMessage?: string\n  startingMessage?: string\n  recordingMessage?: string\n  pausedMessage?: string\n  stoppingMessage?: string\n  recordedMessage?: string\n  /**\n   * Shown and announced once the input has been silent for `silenceTimeout`.\n   *\n   * The whole reason the meter is here. Say what is actually wrong — nothing is reaching the\n   * microphone — rather than \"check your microphone\", because the usual cause is a mute switch\n   * somewhere outside the browser and the browser is reporting everything as fine.\n   */\n  silentMessage?: string\n  /** Hide the message under the controls. It stays in the live region either way. */\n  hideMessage?: boolean\n  /** Hide the `<audio>` player shown after recording. */\n  hidePlayback?: boolean\n}\n\n/**\n * A voice-note recorder that shows the level while it records, and hands the microphone back when\n * it stops.\n *\n * There is deliberately no `autoStart`. A component that opens the microphone on mount records\n * whatever is being said in the room before anybody has agreed to it, and on a repeat visit it\n * does so without a prompt, because the grant is already stored. A camera at least shows you its\n * own preview; a microphone gives no sign at all beyond an indicator most people have never\n * noticed. Recording starts on a press.\n */\nexport function AudioRecorder({\n  label = \"Voice recorder\",\n  recordLabel = \"Record\",\n  stopLabel = \"Stop\",\n  pauseLabel = \"Pause\",\n  resumeLabel = \"Resume\",\n  discardLabel = \"Discard recording\",\n  againLabel = \"Record again\",\n  retryLabel = \"Try again\",\n  meterLabel = \"Input level\",\n  playbackLabel = \"The recording you just made\",\n  messages,\n  promptingMessage = \"Waiting for microphone permission…\",\n  startingMessage = \"Starting the microphone…\",\n  recordingMessage = \"Recording.\",\n  pausedMessage = \"Paused.\",\n  stoppingMessage = \"Finishing the recording…\",\n  recordedMessage = \"Recording finished.\",\n  silentMessage = \"No sound is reaching the microphone — check that it isn't muted.\",\n  hideMessage = false,\n  hidePlayback = false,\n  mimeCandidates,\n  echoCancellation,\n  noiseSuppression,\n  autoGainControl,\n  deviceId,\n  maxDuration,\n  silenceTimeout,\n  silenceThreshold,\n  onStart,\n  onRecording,\n  onFailure,\n  className,\n  ...props\n}: AudioRecorderProps) {\n  const {\n    phase,\n    failure,\n    recording,\n    elapsed,\n    level,\n    isClipping,\n    isSilent,\n    isMetered,\n    start,\n    stop,\n    pause,\n    resume,\n    discard,\n  } = useAudioRecorder({\n    mimeCandidates,\n    echoCancellation,\n    noiseSuppression,\n    autoGainControl,\n    deviceId,\n    maxDuration,\n    silenceTimeout,\n    silenceThreshold,\n    onStart,\n    onRecording,\n    onFailure,\n  })\n\n  // The message is referenced by id from the primary button, so it needs one that survives\n  // hydration.\n  const messageId = React.useId()\n\n  const busy = phase === \"prompting\" || phase === \"starting\" || phase === \"stopping\"\n  const live = phase === \"recording\" || phase === \"paused\"\n\n  const message = failure\n    ? (messages?.[failure.cause] ?? failure.message)\n    : phase === \"prompting\"\n      ? promptingMessage\n      : phase === \"starting\"\n        ? startingMessage\n        : phase === \"stopping\"\n          ? stoppingMessage\n          : phase === \"recording\"\n            ? // The silence warning displaces the ordinary \"Recording.\" rather than sitting\n              // underneath it. Both at once reads as a page describing two different situations,\n              // and the one that matters is the one that means the file will be empty.\n              isSilent\n              ? silentMessage\n              : recordingMessage\n            : phase === \"paused\"\n              ? isSilent\n                ? silentMessage\n                : pausedMessage\n              : phase === \"recorded\"\n                ? recordedMessage\n                : \"\"\n\n  // The only states where pressing the primary button is a real offer. A failure marked\n  // non-retryable keeps the control reachable and says why, rather than dangling an action that\n  // cannot work.\n  const actionable = !busy && failure?.retryable !== false\n\n  const primaryLabel = live\n    ? stopLabel\n    : recording\n      ? againLabel\n      : failure?.retryable\n        ? retryLabel\n        : recordLabel\n  const PrimaryIcon = busy ? Loader2 : live ? Square : failure && !failure.retryable ? TriangleAlert : Mic\n\n  function handlePrimary() {\n    if (!actionable) return\n    if (live) {\n      stop()\n      return\n    }\n    start()\n  }\n\n  const lit = Math.round(level * METER_BARS)\n\n  return (\n    <div\n      role=\"group\"\n      aria-label={label}\n      className={cn(\"flex w-full max-w-sm flex-col gap-3\", className)}\n      {...props}\n    >\n      <div className=\"flex items-center gap-3\">\n        <button\n          type=\"button\"\n          onClick={handlePrimary}\n          // `aria-disabled` rather than `disabled`, so the control keeps its place in the tab order\n          // and can still be reached and read. A real `disabled` button is skipped entirely, which\n          // means the one explanation of why recording is unavailable is delivered to everyone\n          // except the people who most need it.\n          aria-disabled={actionable ? undefined : true}\n          aria-busy={busy || undefined}\n          aria-describedby={message ? messageId : undefined}\n          data-phase={phase}\n          data-cause={failure?.cause}\n          className={cn(\n            \"inline-flex h-9 shrink-0 items-center justify-center gap-2 rounded-md border border-input bg-transparent px-4 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring aria-disabled:pointer-events-none aria-disabled:opacity-50\",\n            live && \"border-destructive/60 text-destructive\"\n          )}\n        >\n          <PrimaryIcon className={cn(\"h-4 w-4\", busy && \"animate-spin\")} aria-hidden=\"true\" />\n          {primaryLabel}\n        </button>\n\n        {live ? (\n          <button\n            type=\"button\"\n            onClick={phase === \"paused\" ? resume : pause}\n            aria-label={phase === \"paused\" ? resumeLabel : pauseLabel}\n            className=\"inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md border border-input bg-transparent transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n          >\n            {phase === \"paused\" ? (\n              <Play className=\"h-4 w-4\" aria-hidden=\"true\" />\n            ) : (\n              <Pause className=\"h-4 w-4\" aria-hidden=\"true\" />\n            )}\n          </button>\n        ) : null}\n\n        {/*\n          The level meter, and the component's own subject. `role=\"meter\"` rather than a bare\n          decorated div because \"is any sound arriving\" is precisely the thing somebody who cannot\n          see the bars most needs, and a meter is not a live region, so reporting it costs nothing:\n          it is read when asked for, never announced. The silence warning below is the part that\n          does speak, because by then there is something to say.\n        */}\n        <div\n          role=\"meter\"\n          aria-label={meterLabel}\n          aria-valuemin={0}\n          aria-valuemax={100}\n          aria-valuenow={Math.round(level * 100)}\n          aria-valuetext={isSilent ? silentMessage : undefined}\n          className=\"flex h-9 min-w-0 flex-1 items-center gap-[2px]\"\n        >\n          {Array.from({ length: METER_BARS }, (_, index) => (\n            <span\n              key={index}\n              aria-hidden=\"true\"\n              className={cn(\n                \"h-3 flex-1 rounded-[1px] bg-muted transition-colors duration-75\",\n                index < lit && \"bg-primary\",\n                // Only the top of the meter turns red, and only while it is actually there. A\n                // recorder that paints the whole bar on a transient peak teaches people to ignore\n                // it.\n                index < lit && isClipping && index >= METER_BARS - 3 && \"bg-destructive\"\n              )}\n            />\n          ))}\n        </div>\n\n        <span\n          className={cn(\n            \"shrink-0 text-sm tabular-nums\",\n            phase === \"recording\" ? \"text-foreground\" : \"text-muted-foreground\"\n          )}\n        >\n          {formatDuration(elapsed)}\n        </span>\n\n        {recording && !live ? (\n          <button\n            type=\"button\"\n            onClick={discard}\n            aria-label={discardLabel}\n            className=\"inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md border border-input bg-transparent text-muted-foreground transition-colors hover:bg-accent hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n          >\n            <Trash2 className=\"h-4 w-4\" aria-hidden=\"true\" />\n          </button>\n        ) : null}\n      </div>\n\n      {recording && !hidePlayback ? (\n        /*\n          The duration is printed beside the player rather than left to it. A browser's own audio\n          controls read the length out of the file, and a MediaRecorder file has not got one — the\n          header is written before the length is known and never revisited — so the native timeline\n          shows `Infinity`, or nothing, or counts up past the end. The number here was measured\n          while the recording happened, which is the only place it was ever available.\n        */\n        <div className=\"flex items-center gap-2\">\n          <audio\n            src={recording.url}\n            controls\n            preload=\"metadata\"\n            aria-label={playbackLabel}\n            className=\"h-9 min-w-0 flex-1\"\n          />\n          <span className=\"shrink-0 text-xs text-muted-foreground tabular-nums\">\n            {formatDuration(recording.duration)}\n          </span>\n        </div>\n      ) : null}\n\n      {/*\n        Mounted from the start and left empty, never conditionally rendered, and never `hidden`.\n        A live region inserted into the document already holding its text is not reliably announced,\n        and `hidden`, `display:none` and `aria-hidden` all take it out of the accessibility tree,\n        which is the same silence as not rendering it. `sr-only` is the one way to hide it visually\n        and keep it speaking.\n      */}\n      <p\n        id={messageId}\n        role=\"status\"\n        aria-live=\"polite\"\n        data-silent={isSilent || undefined}\n        data-metered={isMetered || undefined}\n        className={cn(\n          \"text-sm\",\n          isSilent || failure ? \"text-destructive\" : \"text-muted-foreground\",\n          (hideMessage || !message) && \"sr-only\"\n        )}\n      >\n        {message}\n      </p>\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui",
  "docs": "Install any pulld component by name: add \"@pulld\": \"https://pulld.pages.dev/r/{name}.json\" to the registries block in components.json, then `npx shadcn add @pulld/<name>`. All 140 components: https://pulld.pages.dev/?utm_source=cli"
}
