{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "audio-recorder",
  "type": "registry:component",
  "title": "AudioRecorder",
  "description": "Voice recorder with realtime waveform, duration, cancel, and submit controls.",
  "dependencies": [
    "lucide-react@^1.31.0"
  ],
  "registryDependencies": [
    "https://control-ui.dev/r/audio-visualizer.json",
    "https://control-ui.dev/r/button.json",
    "https://control-ui.dev/r/core.json"
  ],
  "files": [
    {
      "path": "src/registry/hooks/use-audio-recorder.ts",
      "target": "@components/control-ui/hooks/use-audio-recorder.ts",
      "type": "registry:hook",
      "content": "import { useEffect, useEffectEvent, useRef, useState, useSyncExternalStore } from \"react\";\n\nexport type AudioRecorderState = \"idle\" | \"requesting\" | \"recording\" | \"recorded\" | \"submitting\" | \"error\";\n\nexport type AudioRecording = {\n  blob: Blob;\n  url: string;\n  durationMs: number;\n  mimeType: string;\n};\n\nexport type UseAudioRecorderOptions = {\n  onRecordingComplete?: (recording: AudioRecording) => void | Promise<void>;\n  onCancel?: () => void;\n  maxDurationMs?: number;\n  disabled?: boolean;\n  barCount?: number;\n  barDurationMs?: number;\n  deviceId?: string;\n};\n\nexport type AudioRecorderSnapshot = {\n  state: AudioRecorderState;\n  durationMs: number;\n  levels: number[];\n  error: Error | null;\n  recording?: AudioRecording;\n};\n\nexport type UseAudioRecorderResult = AudioRecorderSnapshot & {\n  isDisabled: boolean;\n  canStart: boolean;\n  canCancel: boolean;\n  canSubmit: boolean;\n  start: () => Promise<boolean>;\n  stop: () => Promise<AudioRecording | null>;\n  cancel: () => void;\n  submit: () => Promise<AudioRecording | null>;\n};\n\nexport type UseAudioInputDevicesResult = {\n  devices: MediaDeviceInfo[];\n  loading: boolean;\n  error: Error | null;\n  hasPermission: boolean;\n  refresh: () => Promise<void>;\n  requestPermission: () => Promise<void>;\n};\n\n// structural, so tests can inject plain mocks while real DOM constructors stay assignable\nexport type AudioRecorderStreamLike = {\n  getTracks: () => readonly { stop: () => void }[];\n};\n\ntype MediaRecorderLike = {\n  state: string;\n  mimeType: string;\n  ondataavailable: ((event: BlobEvent) => void) | null;\n  onstop: ((event: Event) => void) | null;\n  start(timeslice?: number): void;\n  stop(): void;\n};\n\ntype MediaRecorderConstructor<TStream extends AudioRecorderStreamLike> = {\n  new (stream: TStream, options?: MediaRecorderOptions): MediaRecorderLike;\n  isTypeSupported?: (mimeType: string) => boolean;\n};\n\ntype AnalyserLike = {\n  fftSize: number;\n  smoothingTimeConstant: number;\n  getByteTimeDomainData(samples: Uint8Array<ArrayBuffer>): void;\n};\n\ntype AudioSourceLike = {\n  // `unknown` keeps overloaded AudioNode.connect assignable; controller only ever passes analyser\n  connect(destination: unknown): unknown;\n  disconnect(): void;\n};\n\ntype AudioContextLike<TStream extends AudioRecorderStreamLike> = {\n  resume?(): Promise<void>;\n  close(): Promise<void>;\n  createMediaStreamSource(stream: TStream): AudioSourceLike;\n  createAnalyser(): AnalyserLike;\n};\n\ntype AudioContextConstructor<TStream extends AudioRecorderStreamLike> = new () => AudioContextLike<TStream>;\n\nexport type AudioRecorderControllerEnvironment<TStream extends AudioRecorderStreamLike = AudioRecorderStreamLike> = {\n  mediaDevices?: { getUserMedia: (constraints: MediaStreamConstraints) => Promise<TStream> };\n  MediaRecorder?: MediaRecorderConstructor<TStream>;\n  AudioContext?: AudioContextConstructor<TStream>;\n  requestAnimationFrame?: (callback: FrameRequestCallback) => number;\n  cancelAnimationFrame?: (handle: number) => void;\n  createObjectURL?: (blob: Blob) => string;\n  revokeObjectURL?: (url: string) => void;\n  now?: () => number;\n};\n\ntype CalibrationState = {\n  noiseFloor: number;\n  gain: number;\n  smoothedLevel: number;\n};\n\nconst DEFAULT_LEVEL_COUNT = 28;\nconst DEFAULT_BAR_DURATION_MS = 80;\nconst MIN_BAR_DURATION_MS = 50;\nconst MIME_TYPE_CANDIDATES = [\"audio/webm;codecs=opus\", \"audio/mp4\"] as const;\nconst CALM_LEVEL = 0.1;\nconst LEVEL_COMPRESSION = 2.2;\nconst PEAK_BLEND = 0.35;\n\nexport function preferredAudioMimeType(MediaRecorderImpl?: { isTypeSupported?: (mimeType: string) => boolean }) {\n  if (!MediaRecorderImpl?.isTypeSupported) return undefined;\n  return MIME_TYPE_CANDIDATES.find((mimeType) => MediaRecorderImpl.isTypeSupported?.(mimeType));\n}\n\nconst deviceIdRegex = /\\(([\\da-fA-F]{4}:[\\da-fA-F]{4})\\)$/;\n\n// exported so device-picker UIs share one label format instead of reimplementing parsing\nexport function formatAudioInputDeviceLabel(device: MediaDeviceInfo, fallback = \"Microphone\") {\n  const label = device.label.trim();\n  const matches = label.match(deviceIdRegex);\n\n  if (!matches) return label || fallback;\n\n  const [, id] = matches;\n  const name = label.replace(deviceIdRegex, \"\").trim();\n  return `${name || fallback} (${id})`;\n}\n\nasync function enumerateAudioInputDevices() {\n  if (typeof navigator === \"undefined\" || !navigator.mediaDevices?.enumerateDevices) {\n    throw new Error(\"Microphone device selection is not available in this browser.\");\n  }\n\n  const devices = await navigator.mediaDevices.enumerateDevices();\n  return devices.filter((device) => device.kind === \"audioinput\");\n}\n\nexport function useAudioInputDevices(): UseAudioInputDevicesResult {\n  const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);\n  const [loading, setLoading] = useState(false);\n  const [error, setError] = useState<Error | null>(null);\n  const [hasPermission, setHasPermission] = useState(false);\n\n  async function refresh() {\n    setLoading(true);\n    setError(null);\n\n    try {\n      const nextDevices = await enumerateAudioInputDevices();\n      setDevices(nextDevices);\n      // labels only populate once mic permission granted, so presence = permission signal\n      if (nextDevices.some((device) => device.label)) setHasPermission(true);\n    } catch (nextError) {\n      setError(asError(nextError));\n    } finally {\n      setLoading(false);\n    }\n  }\n\n  async function requestPermission() {\n    if (typeof navigator === \"undefined\" || !navigator.mediaDevices?.getUserMedia) {\n      setError(new Error(\"Microphone device selection is not available in this browser.\"));\n      return;\n    }\n\n    setLoading(true);\n    setError(null);\n\n    try {\n      const stream = await navigator.mediaDevices.getUserMedia({ audio: true });\n      for (const track of stream.getTracks()) track.stop();\n      setHasPermission(true);\n      setDevices(await enumerateAudioInputDevices());\n    } catch (nextError) {\n      setError(microphoneError(nextError));\n    } finally {\n      setLoading(false);\n    }\n  }\n\n  const refreshFromEffect = useEffectEvent(refresh);\n\n  useEffect(() => {\n    queueMicrotask(() => void refreshFromEffect());\n  }, []);\n\n  useEffect(() => {\n    if (typeof navigator === \"undefined\" || !navigator.mediaDevices?.addEventListener) return;\n\n    const handleDeviceChange = () => {\n      void refreshFromEffect();\n    };\n\n    navigator.mediaDevices.addEventListener(\"devicechange\", handleDeviceChange);\n    return () => navigator.mediaDevices.removeEventListener(\"devicechange\", handleDeviceChange);\n  }, []);\n\n  return { devices, loading, error, hasPermission, refresh, requestPermission };\n}\n\nfunction browserEnvironment(): AudioRecorderControllerEnvironment<MediaStream> {\n  // structural view of globalThis: Safari's prefixed webkitAudioContext is not declared in lib.dom\n  const scope: { AudioContext?: AudioContextConstructor<MediaStream>; webkitAudioContext?: AudioContextConstructor<MediaStream> } =\n    globalThis;\n\n  return {\n    mediaDevices: typeof navigator !== \"undefined\" ? navigator.mediaDevices : undefined,\n    MediaRecorder: typeof MediaRecorder !== \"undefined\" ? MediaRecorder : undefined,\n    AudioContext: typeof AudioContext !== \"undefined\" ? AudioContext : scope.webkitAudioContext,\n    // wrapped so host fns aren't called with env object as `this` (\"Illegal invocation\")\n    requestAnimationFrame:\n      typeof requestAnimationFrame !== \"undefined\" ? (callback: FrameRequestCallback) => requestAnimationFrame(callback) : undefined,\n    cancelAnimationFrame: typeof cancelAnimationFrame !== \"undefined\" ? (handle: number) => cancelAnimationFrame(handle) : undefined,\n    createObjectURL: typeof URL !== \"undefined\" ? URL.createObjectURL?.bind(URL) : undefined,\n    revokeObjectURL: typeof URL !== \"undefined\" ? URL.revokeObjectURL?.bind(URL) : undefined,\n    now: typeof performance !== \"undefined\" ? () => performance.now() : () => Date.now(),\n  };\n}\n\nfunction asError(error: unknown) {\n  return error instanceof Error ? error : new Error(String(error));\n}\n\nfunction microphoneError(error: unknown) {\n  const nextError = asError(error);\n\n  if (nextError.name === \"NotAllowedError\" || nextError.name === \"SecurityError\") {\n    return new Error(\"Microphone permission was denied.\");\n  }\n\n  if (nextError.name === \"NotFoundError\" || nextError.name === \"OverconstrainedError\") {\n    return new Error(\"Selected microphone is not available.\");\n  }\n\n  return nextError;\n}\n\nfunction clampLevel(value: number) {\n  return Math.min(1, Math.max(0.06, value));\n}\n\nfunction rmsFromSamples(samples: Uint8Array<ArrayBuffer>, start: number, end: number) {\n  let total = 0;\n  let count = 0;\n\n  for (const sampleValue of samples.subarray(start, end)) {\n    const sample = (sampleValue - 128) / 128;\n    total += sample * sample;\n    count += 1;\n  }\n\n  return count === 0 ? 0 : Math.sqrt(total / count);\n}\n\nfunction nextAudioLevel(samples: Uint8Array<ArrayBuffer>, calibration: CalibrationState) {\n  const wholeSignal = rmsFromSamples(samples, 0, samples.length);\n  calibration.noiseFloor =\n    wholeSignal < calibration.noiseFloor * 1.6 ? calibration.noiseFloor * 0.96 + wholeSignal * 0.04 : calibration.noiseFloor;\n\n  const signal = Math.max(0, wholeSignal - calibration.noiseFloor);\n  const targetGain = Math.min(5, Math.max(1.8, 0.36 / Math.max(signal, 0.08)));\n  calibration.gain = calibration.gain * 0.88 + targetGain * 0.12;\n  const compressedSignal = 1 - Math.exp(-signal * calibration.gain * LEVEL_COMPRESSION);\n  const target = clampLevel(CALM_LEVEL + compressedSignal * (1 - CALM_LEVEL));\n  const smoothing = target > calibration.smoothedLevel ? 0.5 : 0.14;\n  calibration.smoothedLevel = clampLevel(calibration.smoothedLevel * (1 - smoothing) + target * smoothing);\n  return calibration.smoothedLevel;\n}\n\nfunction recordingStateCanCancel(state: AudioRecorderState) {\n  return state === \"requesting\" || state === \"recording\" || state === \"recorded\" || state === \"error\";\n}\n\nfunction audioTrackConstraints(deviceId?: string): MediaTrackConstraints {\n  const constraints: MediaTrackConstraints = {\n    echoCancellation: true,\n    noiseSuppression: true,\n    autoGainControl: true,\n  };\n\n  if (deviceId) constraints.deviceId = { exact: deviceId };\n  return constraints;\n}\n\nfunction stopMediaStream(mediaStream: AudioRecorderStreamLike) {\n  for (const track of mediaStream.getTracks()) track.stop();\n}\n\nexport function createAudioRecorderController<TStream extends AudioRecorderStreamLike = AudioRecorderStreamLike>(\n  initialOptions: UseAudioRecorderOptions = {},\n  environment?: AudioRecorderControllerEnvironment<TStream>,\n) {\n  // separate calls keep TStream bound to whichever environment runs (injected vs browser default)\n  return environment\n    ? createControllerForEnvironment(initialOptions, environment)\n    : createControllerForEnvironment(initialOptions, browserEnvironment());\n}\n\nfunction createControllerForEnvironment<TStream extends AudioRecorderStreamLike>(\n  initialOptions: UseAudioRecorderOptions,\n  environment: AudioRecorderControllerEnvironment<TStream>,\n) {\n  let options = { ...initialOptions };\n  let snapshot: AudioRecorderSnapshot = {\n    state: \"idle\",\n    durationMs: 0,\n    levels: [],\n    error: null,\n  };\n  const listeners = new Set<() => void>();\n  let stream: TStream | null = null;\n  let recorder: MediaRecorderLike | null = null;\n  let chunks: BlobPart[] = [];\n  let audioContext: AudioContextLike<TStream> | null = null;\n  let audioSource: AudioSourceLike | null = null;\n  let analyser: AnalyserLike | null = null;\n  let audioSamples: Uint8Array<ArrayBuffer> | null = null;\n  let animationFrame: number | null = null;\n  let startedAt = 0;\n  let selectedMimeType = \"\";\n  let activeRecording: AudioRecording | undefined;\n  let stopPromise: Promise<AudioRecording | null> | null = null;\n  let resolveStop: ((recording: AudioRecording | null) => void) | null = null;\n  let discardOnStop = false;\n  let requestId = 0;\n  let destroyed = false;\n  let bucketStartedAt = 0;\n  let bucketLevelTotal = 0;\n  let bucketPeakLevel = 0;\n  let bucketSampleCount = 0;\n  const calibration: CalibrationState = { noiseFloor: 0.018, gain: 4, smoothedLevel: CALM_LEVEL };\n\n  function emit() {\n    for (const listener of listeners) listener();\n  }\n\n  function setSnapshot(next: Partial<AudioRecorderSnapshot>) {\n    snapshot = { ...snapshot, ...next };\n    emit();\n  }\n\n  function now() {\n    return environment.now?.() ?? Date.now();\n  }\n\n  function resolvedBarCount() {\n    return Math.max(1, Math.floor(options.barCount ?? DEFAULT_LEVEL_COUNT));\n  }\n\n  function resolvedBarDurationMs() {\n    return Math.max(MIN_BAR_DURATION_MS, Math.floor(options.barDurationMs ?? DEFAULT_BAR_DURATION_MS));\n  }\n\n  function resetWaveform(startTime = now()) {\n    calibration.noiseFloor = 0.018;\n    calibration.gain = 4;\n    calibration.smoothedLevel = CALM_LEVEL;\n    bucketStartedAt = startTime;\n    bucketLevelTotal = 0;\n    bucketPeakLevel = 0;\n    bucketSampleCount = 0;\n    return [];\n  }\n\n  function clearActiveRecording() {\n    if (activeRecording?.url) environment.revokeObjectURL?.(activeRecording.url);\n    activeRecording = undefined;\n  }\n\n  function stopLiveInput() {\n    if (animationFrame !== null) {\n      environment.cancelAnimationFrame?.(animationFrame);\n      animationFrame = null;\n    }\n\n    try {\n      audioSource?.disconnect();\n    } catch {}\n\n    try {\n      void audioContext?.close();\n    } catch {}\n\n    for (const track of stream?.getTracks() ?? []) track.stop();\n\n    stream = null;\n    audioContext = null;\n    audioSource = null;\n    analyser = null;\n    audioSamples = null;\n  }\n\n  function releaseRecorder() {\n    recorder = null;\n    chunks = [];\n    selectedMimeType = \"\";\n    startedAt = 0;\n  }\n\n  function resetToIdle() {\n    setSnapshot({\n      state: \"idle\",\n      durationMs: 0,\n      levels: resetWaveform(),\n      error: null,\n      recording: undefined,\n    });\n  }\n\n  function finishStop(recording: AudioRecording | null) {\n    const done = resolveStop;\n    resolveStop = null;\n    stopPromise = null;\n    done?.(recording);\n  }\n\n  function finalizeRecording() {\n    const discard = discardOnStop;\n    discardOnStop = false;\n    stopLiveInput();\n\n    if (discard) {\n      releaseRecorder();\n      resetToIdle();\n      finishStop(null);\n      return;\n    }\n\n    const stoppedAt = now();\n    const durationMs = Math.max(snapshot.durationMs, stoppedAt - startedAt);\n    const mimeType = recorder?.mimeType || selectedMimeType || \"audio/webm\";\n    const blob = new Blob(chunks, { type: mimeType });\n    clearActiveRecording();\n    activeRecording = {\n      blob,\n      url: environment.createObjectURL?.(blob) ?? \"\",\n      durationMs,\n      mimeType: blob.type || mimeType,\n    };\n    releaseRecorder();\n    setSnapshot({\n      state: \"recorded\",\n      durationMs,\n      recording: activeRecording,\n      error: null,\n    });\n    finishStop(activeRecording);\n  }\n\n  function scheduleFrame() {\n    if (!environment.requestAnimationFrame || animationFrame !== null || snapshot.state !== \"recording\") return;\n    animationFrame = environment.requestAnimationFrame(readFrame);\n  }\n\n  function readAudioLevel() {\n    if (!analyser || !audioSamples) return CALM_LEVEL;\n    analyser.getByteTimeDomainData(audioSamples);\n    return nextAudioLevel(audioSamples, calibration);\n  }\n\n  function collectAudioLevel() {\n    const level = readAudioLevel();\n    bucketLevelTotal += level;\n    bucketPeakLevel = Math.max(bucketPeakLevel, level);\n    bucketSampleCount += 1;\n  }\n\n  function flushLevelBucket(timestamp: number, force = false) {\n    if (bucketSampleCount === 0) return snapshot.levels;\n    if (!force && timestamp - bucketStartedAt < resolvedBarDurationMs()) return snapshot.levels;\n\n    const averageLevel = bucketLevelTotal / bucketSampleCount;\n    const level = clampLevel(averageLevel + (bucketPeakLevel - averageLevel) * PEAK_BLEND);\n    const levels = [...snapshot.levels, level].slice(-resolvedBarCount());\n    bucketStartedAt = timestamp;\n    bucketLevelTotal = 0;\n    bucketPeakLevel = 0;\n    bucketSampleCount = 0;\n    return levels;\n  }\n\n  function readFrame() {\n    animationFrame = null;\n    if (snapshot.state !== \"recording\") return;\n\n    const timestamp = now();\n    collectAudioLevel();\n    const levels = flushLevelBucket(timestamp);\n    const durationMs = Math.max(0, timestamp - startedAt);\n    setSnapshot({ durationMs, levels });\n\n    if (options.maxDurationMs && durationMs >= options.maxDurationMs) {\n      void stop();\n      return;\n    }\n\n    scheduleFrame();\n  }\n\n  function attachAnalyser(nextStream: TStream) {\n    if (!environment.AudioContext) return;\n\n    try {\n      audioContext = new environment.AudioContext();\n      const resume = audioContext.resume?.();\n      void resume?.catch(() => {});\n      audioSource = audioContext.createMediaStreamSource(nextStream);\n      analyser = audioContext.createAnalyser();\n      analyser.fftSize = 1024;\n      analyser.smoothingTimeConstant = 0.18;\n      audioSamples = new Uint8Array(analyser.fftSize);\n      audioSource.connect(analyser);\n    } catch {\n      audioContext = null;\n      audioSource = null;\n      analyser = null;\n      audioSamples = null;\n    }\n  }\n\n  function discardStaleStream(nextStream: TStream, currentRequest: number) {\n    if (!destroyed && currentRequest === requestId) return false;\n\n    stopMediaStream(nextStream);\n    if (currentRequest === requestId) resetToIdle();\n    return true;\n  }\n\n  function beginRecording(nextStream: TStream) {\n    const MediaRecorderImpl = environment.MediaRecorder;\n    if (!MediaRecorderImpl) throw new Error(\"Microphone recording is not available in this browser.\");\n\n    selectedMimeType = preferredAudioMimeType(MediaRecorderImpl) ?? \"\";\n    const nextRecorder = new MediaRecorderImpl(nextStream, selectedMimeType ? { mimeType: selectedMimeType } : undefined);\n\n    chunks = [];\n    nextRecorder.ondataavailable = (event) => {\n      if (event.data.size > 0) chunks.push(event.data);\n    };\n    nextRecorder.onstop = finalizeRecording;\n\n    stream = nextStream;\n    recorder = nextRecorder;\n    attachAnalyser(nextStream);\n    startedAt = now();\n    resetWaveform(startedAt);\n    nextRecorder.start(Math.min(1000, Math.max(250, resolvedBarDurationMs())));\n    setSnapshot({ state: \"recording\", durationMs: 0, levels: [], error: null });\n    scheduleFrame();\n  }\n\n  async function start() {\n    if (options.disabled || snapshot.state === \"requesting\" || snapshot.state === \"recording\") return false;\n\n    clearActiveRecording();\n    requestId += 1;\n    const currentRequest = requestId;\n    setSnapshot({\n      state: \"requesting\",\n      durationMs: 0,\n      levels: resetWaveform(),\n      error: null,\n      recording: undefined,\n    });\n\n    if (!environment.mediaDevices?.getUserMedia || !environment.MediaRecorder) {\n      setSnapshot({\n        state: \"error\",\n        error: new Error(\"Microphone recording is not available in this browser.\"),\n      });\n      return false;\n    }\n\n    try {\n      const nextStream = await environment.mediaDevices.getUserMedia({\n        audio: audioTrackConstraints(options.deviceId),\n      });\n\n      if (discardStaleStream(nextStream, currentRequest)) return false;\n      beginRecording(nextStream);\n      return true;\n    } catch (error) {\n      stopLiveInput();\n      releaseRecorder();\n      setSnapshot({ state: \"error\", error: microphoneError(error), recording: undefined });\n      return false;\n    }\n  }\n\n  function stop() {\n    if (snapshot.state === \"recorded\") return Promise.resolve(snapshot.recording ?? null);\n    if (!recorder || recorder.state === \"inactive\") return Promise.resolve(null);\n    if (stopPromise) return stopPromise;\n\n    if (snapshot.state === \"recording\") {\n      const stoppedAt = now();\n      collectAudioLevel();\n      setSnapshot({\n        durationMs: Math.max(snapshot.durationMs, stoppedAt - startedAt),\n        levels: flushLevelBucket(stoppedAt, true),\n      });\n    }\n\n    discardOnStop = false;\n    stopPromise = new Promise<AudioRecording | null>((resolve) => {\n      resolveStop = resolve;\n      recorder?.stop();\n    });\n    return stopPromise;\n  }\n\n  function cancelInternal(notify: boolean) {\n    const shouldNotify = notify && recordingStateCanCancel(snapshot.state);\n    requestId += 1;\n    clearActiveRecording();\n\n    if (recorder && recorder.state !== \"inactive\") {\n      discardOnStop = true;\n      if (!stopPromise) {\n        stopPromise = new Promise<AudioRecording | null>((resolve) => {\n          resolveStop = resolve;\n        });\n      }\n      stopLiveInput();\n      recorder.stop();\n    } else {\n      stopLiveInput();\n      releaseRecorder();\n      resetToIdle();\n    }\n\n    if (shouldNotify) options.onCancel?.();\n  }\n\n  function cancel() {\n    cancelInternal(true);\n  }\n\n  async function submit() {\n    if (snapshot.state === \"recording\") {\n      const recording = await stop();\n      return recording ? submitRecording(recording) : null;\n    }\n\n    const recording = snapshot.recording;\n    if (snapshot.state !== \"recorded\" || !recording) return null;\n\n    return submitRecording(recording);\n  }\n\n  async function submitRecording(recording: AudioRecording) {\n    setSnapshot({ state: \"submitting\", error: null });\n    try {\n      await options.onRecordingComplete?.(recording);\n      setSnapshot({\n        state: \"idle\",\n        durationMs: 0,\n        levels: resetWaveform(),\n        error: null,\n        recording: undefined,\n      });\n      return recording;\n    } catch (error) {\n      setSnapshot({ state: \"recorded\", error: asError(error), recording });\n      return null;\n    }\n  }\n\n  function destroy() {\n    destroyed = true;\n    cancelInternal(false);\n    clearActiveRecording();\n    listeners.clear();\n  }\n\n  function setOptions(nextOptions: UseAudioRecorderOptions) {\n    const nextBarCount = nextOptions.barCount ?? DEFAULT_LEVEL_COUNT;\n    const currentBarCount = options.barCount ?? DEFAULT_LEVEL_COUNT;\n    options = { ...nextOptions };\n\n    if (nextBarCount !== currentBarCount && snapshot.state === \"idle\") {\n      setSnapshot({ levels: resetWaveform() });\n    }\n\n    if (options.disabled && (snapshot.state === \"requesting\" || snapshot.state === \"recording\")) {\n      cancelInternal(false);\n    }\n  }\n\n  function getResult(): UseAudioRecorderResult {\n    const isDisabled = Boolean(options.disabled);\n\n    return {\n      ...snapshot,\n      isDisabled,\n      canStart: !isDisabled && (snapshot.state === \"idle\" || snapshot.state === \"error\"),\n      canCancel: recordingStateCanCancel(snapshot.state),\n      canSubmit: snapshot.state === \"recording\" || snapshot.state === \"recorded\",\n      start,\n      stop,\n      cancel,\n      submit,\n    };\n  }\n\n  return {\n    getSnapshot: () => snapshot,\n    getResult,\n    setOptions,\n    subscribe(listener: () => void) {\n      listeners.add(listener);\n      return () => {\n        listeners.delete(listener);\n      };\n    },\n    isDestroyed: () => destroyed,\n    start,\n    stop,\n    cancel,\n    submit,\n    destroy,\n  };\n}\n\nexport function useAudioRecorder(options: UseAudioRecorderOptions = {}): UseAudioRecorderResult {\n  const [controller] = useState(() => createAudioRecorderController(options));\n  const snapshot = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);\n  const activeMount = useRef<symbol | null>(null);\n  const { onRecordingComplete, onCancel, maxDurationMs, disabled, barCount, barDurationMs, deviceId } = options;\n\n  useEffect(() => {\n    const mount = Symbol(\"audio-recorder-mount\");\n    activeMount.current = mount;\n    return () => {\n      queueMicrotask(() => {\n        if (activeMount.current === mount) controller.destroy();\n      });\n    };\n  }, [controller]);\n\n  useEffect(() => {\n    controller.setOptions({ onRecordingComplete, onCancel, maxDurationMs, disabled, barCount, barDurationMs, deviceId });\n  }, [controller, onRecordingComplete, onCancel, maxDurationMs, disabled, barCount, barDurationMs, deviceId]);\n\n  const isDisabled = Boolean(disabled);\n\n  return {\n    ...snapshot,\n    isDisabled,\n    canStart: !isDisabled && (snapshot.state === \"idle\" || snapshot.state === \"error\"),\n    canCancel: recordingStateCanCancel(snapshot.state),\n    canSubmit: snapshot.state === \"recording\" || snapshot.state === \"recorded\",\n    start: controller.start,\n    stop: controller.stop,\n    cancel: controller.cancel,\n    submit: controller.submit,\n  };\n}\n"
    },
    {
      "path": "src/registry/knob-contracts/audio-recorder-knobs.ts",
      "target": "@components/control-ui/knob-contracts/audio-recorder-knobs.ts",
      "type": "registry:component",
      "content": "// Generated from src/registry/sources/control-ui/recipes/audio-recorder.css by scripts/gen-knob-contracts.ts — run `bun run sync:knobs`.\nexport const audioRecorderKnobs = [\n  \"--cui-audio-recorder-foreground\",\n  \"--cui-audio-recorder-active-foreground\",\n  \"--cui-audio-recorder-recording-ring-color\",\n  \"--cui-audio-recorder-error-foreground\",\n] as const;\nexport type AudioRecorderKnobStyle = Partial<Record<(typeof audioRecorderKnobs)[number], string>>;\n"
    },
    {
      "path": "src/registry/knob-contracts/audio-visualizer-knobs.ts",
      "target": "@components/control-ui/knob-contracts/audio-visualizer-knobs.ts",
      "type": "registry:component",
      "content": "// Generated from src/registry/sources/control-ui/recipes/audio-visualizer.css by scripts/gen-knob-contracts.ts — run `bun run sync:knobs`.\nexport const audioVisualizerKnobs = [\n  \"--cui-audio-visualizer-radius\",\n  \"--cui-audio-visualizer-bar-background\",\n  \"--cui-audio-visualizer-line-fill\",\n  \"--cui-audio-visualizer-line-stroke\",\n] as const;\nexport type AudioVisualizerKnobStyle = Partial<Record<(typeof audioVisualizerKnobs)[number], string>>;\n"
    },
    {
      "path": "src/registry/knob-contracts/button-knobs.ts",
      "target": "@components/control-ui/knob-contracts/button-knobs.ts",
      "type": "registry:component",
      "content": "// Generated from src/registry/sources/control-ui/recipes/button.css by scripts/gen-knob-contracts.ts — run `bun run sync:knobs`.\nexport const buttonKnobs = [\n  \"--cui-button-radius\",\n  \"--cui-button-gap\",\n  \"--cui-button-icon\",\n  \"--cui-button-bg\",\n  \"--cui-button-bg-image\",\n  \"--cui-button-foreground\",\n  \"--cui-button-hover-bg\",\n  \"--cui-button-hover-foreground\",\n  \"--cui-button-press-bg\",\n  \"--cui-button-press-scale\",\n  \"--cui-button-active-bg\",\n  \"--cui-button-active-foreground\",\n  \"--cui-button-active-hover-bg\",\n  \"--cui-button-shadow\",\n  \"--cui-button-hover-shadow\",\n  \"--cui-button-press-shadow\",\n  \"--cui-button-active-shadow\",\n] as const;\nexport type ButtonKnobStyle = Partial<Record<(typeof buttonKnobs)[number], string>>;\n"
    },
    {
      "path": "src/registry/lib/format-audio-recorder-duration.ts",
      "target": "@components/control-ui/lib/format-audio-recorder-duration.ts",
      "type": "registry:lib",
      "content": "export function formatAudioRecorderDuration(durationMs: number) {\n  const totalSeconds = Math.max(0, Math.floor(durationMs / 1000));\n  const minutes = Math.floor(totalSeconds / 60);\n  const seconds = String(totalSeconds % 60).padStart(2, \"0\");\n  return `${minutes}:${seconds}`;\n}\n"
    },
    {
      "path": "src/registry/sources/control-ui/audio-recorder.tsx",
      "target": "@components/control-ui/audio-recorder.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { CheckIcon, MicIcon, XIcon } from \"lucide-react\";\nimport type { ComponentProps, CSSProperties, MouseEvent } from \"react\";\nimport { createContext, use } from \"react\";\n// point this at ./audio-visualizer-line for line reading — same export, same contract, no call site moves\nimport { AudioVisualizer } from \"@/components/control-ui/audio-visualizer\";\nimport type { UseAudioRecorderOptions, UseAudioRecorderResult } from \"@/components/control-ui/hooks/use-audio-recorder\";\nimport { useAudioRecorder } from \"@/components/control-ui/hooks/use-audio-recorder\";\nimport type { AudioRecorderKnobStyle } from \"@/components/control-ui/knob-contracts/audio-recorder-knobs\";\nimport type { AudioVisualizerKnobStyle } from \"@/components/control-ui/knob-contracts/audio-visualizer-knobs\";\nimport type { ButtonKnobStyle } from \"@/components/control-ui/knob-contracts/button-knobs\";\nimport { cn } from \"@/components/control-ui/lib/cn\";\nimport { formatAudioRecorderDuration } from \"@/components/control-ui/lib/format-audio-recorder-duration\";\nimport { Button } from \"@/components/control-ui/ui/button\";\n\ntype AudioRecorderStyleProps<Props, Style> = Omit<Props, \"style\"> & {\n  style?: CSSProperties & Style;\n};\n\nconst AudioRecorderContext = createContext<UseAudioRecorderResult | null>(null);\n\n// lets call site stand its own part inside <AudioRecorder>, e.g. different visualizer per instance\nexport function useAudioRecorderContext() {\n  const context = use(AudioRecorderContext);\n  if (!context) throw new Error(\"AudioRecorder compound components must be rendered inside <AudioRecorder>.\");\n  return context;\n}\n\nexport type { AudioRecording } from \"@/components/control-ui/hooks/use-audio-recorder\";\n\n// no bundled device picker — pair `deviceId` with useAudioInputDevices and any list UI\nexport type AudioRecorderProps = Omit<ComponentProps<\"div\">, \"style\"> &\n  UseAudioRecorderOptions & {\n    label?: string;\n    style?: CSSProperties & AudioRecorderKnobStyle;\n  };\n\nexport function AudioRecorder({\n  onRecordingComplete,\n  onCancel,\n  maxDurationMs,\n  disabled,\n  barCount,\n  barDurationMs,\n  deviceId,\n  label = \"Voice recorder\",\n  className,\n  children,\n  ...props\n}: AudioRecorderProps) {\n  const recorder = useAudioRecorder({ onRecordingComplete, onCancel, maxDurationMs, disabled, barCount, barDurationMs, deviceId });\n\n  return (\n    <AudioRecorderContext.Provider value={recorder}>\n      {/* biome-ignore lint/a11y/useSemanticElements: toolbar-like group, not a form fieldset */}\n      <div\n        data-control-ui=\"audio-recorder\"\n        data-control-family=\"audio-recorder\"\n        data-slot=\"root\"\n        data-state={recorder.state}\n        data-disabled={recorder.isDisabled ? \"true\" : undefined}\n        data-error={recorder.error ? \"true\" : undefined}\n        role=\"group\"\n        aria-label={label}\n        aria-disabled={recorder.isDisabled || undefined}\n        className={cn(\"flex min-h-8 w-full min-w-0 max-w-full items-center gap-2 pr-1\", className)}\n        {...props}\n      >\n        {children ?? <AudioRecorderDefaultLayout points={barCount} />}\n      </div>\n    </AudioRecorderContext.Provider>\n  );\n}\n\nfunction AudioRecorderDefaultLayout({ points }: { points?: number }) {\n  return (\n    <>\n      <AudioRecorderTrigger />\n      <div\n        data-control-ui=\"audio-recorder\"\n        data-control-family=\"audio-recorder\"\n        data-slot=\"content\"\n        className=\"grid min-w-0 flex-1 items-center overflow-hidden\"\n      >\n        <AudioRecorderStatus className=\"col-start-1 row-start-1 w-full flex-none\" />\n        <AudioRecorderVisualizer points={points} className=\"col-start-1 row-start-1 w-full\" />\n      </div>\n      <AudioRecorderDuration />\n      <div\n        data-control-ui=\"audio-recorder\"\n        data-control-family=\"audio-recorder\"\n        data-slot=\"actions\"\n        className=\"flex shrink-0 items-center gap-1\"\n      >\n        <AudioRecorderCancel />\n        <AudioRecorderSubmit />\n      </div>\n    </>\n  );\n}\n\nexport type AudioRecorderTriggerProps = AudioRecorderStyleProps<ComponentProps<typeof Button>, ButtonKnobStyle & AudioRecorderKnobStyle>;\n\nexport function AudioRecorderTrigger({ className, children, disabled, onClick, ...props }: AudioRecorderTriggerProps) {\n  const recorder = useAudioRecorderContext();\n  const isActive =\n    recorder.state === \"requesting\" || recorder.state === \"recording\" || recorder.state === \"recorded\" || recorder.state === \"submitting\";\n  const isStatusOnly =\n    !recorder.canStart &&\n    (recorder.state === \"requesting\" || recorder.state === \"recording\" || recorder.state === \"recorded\" || recorder.state === \"submitting\");\n  const triggerVariant = audioRecorderTriggerVariant(recorder, isActive);\n  const triggerTone = audioRecorderTriggerTone(recorder);\n  const ariaLabel = audioRecorderTriggerLabel(recorder);\n\n  async function handleClick(event: MouseEvent<HTMLButtonElement>) {\n    onClick?.(event);\n    if (!event.defaultPrevented && recorder.canStart) await recorder.start();\n  }\n\n  return (\n    <Button\n      data-control-ui=\"audio-recorder\"\n      data-slot=\"trigger\"\n      data-recorder-state={recorder.state}\n      data-status-only={isStatusOnly ? \"true\" : undefined}\n      type=\"button\"\n      variant={triggerVariant}\n      tone={triggerTone}\n      active={isActive}\n      size=\"sm\"\n      iconOnly\n      shape=\"circle\"\n      aria-label={ariaLabel}\n      title={recorder.error?.message}\n      disabled={disabled ?? (recorder.isDisabled || !recorder.canStart)}\n      className={cn(\"data-[status-only=true]:cursor-default\", className)}\n      onClick={handleClick}\n      {...props}\n    >\n      {children ?? <MicIcon aria-hidden=\"true\" className=\"size-3.5\" />}\n    </Button>\n  );\n}\n\nfunction audioRecorderTriggerVariant(recorder: UseAudioRecorderResult, isActive: boolean) {\n  if (recorder.state === \"recording\") return \"solid\";\n  if (isActive) return \"surface\";\n  return \"quiet\";\n}\n\nfunction audioRecorderTriggerTone(recorder: UseAudioRecorderResult) {\n  if (recorder.state === \"recording\" || recorder.state === \"error\") return \"danger\";\n  if (recorder.state === \"requesting\" || recorder.state === \"submitting\") return \"primary\";\n  return \"neutral\";\n}\n\nfunction audioRecorderTriggerLabel(recorder: UseAudioRecorderResult) {\n  switch (recorder.state) {\n    case \"requesting\":\n      return \"Requesting microphone permission\";\n    case \"recording\":\n      return \"Recording voice\";\n    case \"recorded\":\n      return \"Voice recording ready\";\n    case \"submitting\":\n      return \"Sending voice recording\";\n    default:\n      return recorder.error ? \"Retry voice recording\" : \"Start voice recording\";\n  }\n}\nexport type AudioRecorderVisualizerProps = AudioRecorderStyleProps<\n  Omit<ComponentProps<\"div\">, \"children\">,\n  AudioVisualizerKnobStyle & AudioRecorderKnobStyle\n> & {\n  points?: number;\n};\n\n// owns only state gating and recorder anatomy hook, never waveform rendering\nexport function AudioRecorderVisualizer({ points, className, ...props }: AudioRecorderVisualizerProps) {\n  const recorder = useAudioRecorderContext();\n  const isVisible = recorder.state === \"recording\" || recorder.state === \"recorded\";\n\n  return (\n    <AudioVisualizer\n      data-control-ui=\"audio-recorder\"\n      data-slot=\"visualizer\"\n      data-visible={isVisible ? \"true\" : undefined}\n      active={recorder.state === \"recording\"}\n      levels={recorder.levels}\n      points={points}\n      aria-hidden={!isVisible}\n      className={className}\n      {...props}\n    />\n  );\n}\n\nexport type AudioRecorderStatusProps = AudioRecorderStyleProps<ComponentProps<\"span\">, AudioRecorderKnobStyle>;\n\nexport function AudioRecorderStatus({ className, children, ...props }: AudioRecorderStatusProps) {\n  const recorder = useAudioRecorderContext();\n  const message = children ?? audioRecorderStatusMessage(recorder);\n  const isVisible = Boolean(message);\n\n  return (\n    <span\n      data-control-ui=\"audio-recorder\"\n      data-control-family=\"audio-recorder\"\n      data-slot=\"status\"\n      data-visible={isVisible ? \"true\" : undefined}\n      data-tone={recorder.state === \"error\" ? \"error\" : \"neutral\"}\n      aria-hidden={!isVisible}\n      aria-live=\"polite\"\n      className={cn(\"min-w-0 flex-1 truncate\", className)}\n      {...props}\n    >\n      {message}\n    </span>\n  );\n}\n\nfunction audioRecorderStatusMessage(recorder: UseAudioRecorderResult) {\n  if (recorder.state === \"requesting\") return \"Allow microphone access\";\n  if (recorder.state === \"submitting\") return \"Sending voice recording\";\n  if (recorder.state === \"error\") return recorder.error?.message;\n  return null;\n}\n\nexport type AudioRecorderDurationProps = AudioRecorderStyleProps<ComponentProps<\"span\">, AudioRecorderKnobStyle>;\n\nexport function AudioRecorderDuration({ className, children, ...props }: AudioRecorderDurationProps) {\n  const recorder = useAudioRecorderContext();\n  const isVisible = recorder.state === \"recording\" || recorder.state === \"recorded\";\n\n  return (\n    <span\n      data-control-ui=\"audio-recorder\"\n      data-control-family=\"audio-recorder\"\n      data-slot=\"duration\"\n      data-visible={isVisible ? \"true\" : undefined}\n      aria-hidden={!isVisible}\n      className={cn(\"w-10 shrink-0\", className)}\n      {...props}\n    >\n      {children ?? formatAudioRecorderDuration(recorder.durationMs)}\n    </span>\n  );\n}\n\nexport type AudioRecorderCancelProps = AudioRecorderStyleProps<ComponentProps<typeof Button>, ButtonKnobStyle & AudioRecorderKnobStyle>;\n\nexport function AudioRecorderCancel({ className, children, disabled, onClick, tabIndex, ...props }: AudioRecorderCancelProps) {\n  const recorder = useAudioRecorderContext();\n  const isVisible = recorder.canCancel;\n\n  function handleClick(event: MouseEvent<HTMLButtonElement>) {\n    onClick?.(event);\n    if (!event.defaultPrevented && recorder.canCancel) recorder.cancel();\n  }\n\n  return (\n    <Button\n      data-control-ui=\"audio-recorder\"\n      data-slot=\"cancel\"\n      data-visible={isVisible ? \"true\" : undefined}\n      type=\"button\"\n      variant=\"quiet\"\n      tone=\"danger\"\n      size=\"sm\"\n      iconOnly\n      shape=\"circle\"\n      aria-label=\"Cancel voice recording\"\n      aria-hidden={!isVisible}\n      tabIndex={isVisible ? tabIndex : -1}\n      disabled={disabled ?? (recorder.isDisabled || !isVisible)}\n      className={className}\n      onClick={handleClick}\n      {...props}\n    >\n      {children ?? <XIcon aria-hidden=\"true\" className=\"size-3.5\" />}\n    </Button>\n  );\n}\n\nexport type AudioRecorderSubmitProps = AudioRecorderStyleProps<ComponentProps<typeof Button>, ButtonKnobStyle & AudioRecorderKnobStyle>;\n\nexport function AudioRecorderSubmit({ className, children, disabled, onClick, tabIndex, ...props }: AudioRecorderSubmitProps) {\n  const recorder = useAudioRecorderContext();\n  const isVisible = recorder.canSubmit;\n\n  async function handleClick(event: MouseEvent<HTMLButtonElement>) {\n    onClick?.(event);\n    if (!event.defaultPrevented && recorder.canSubmit) await recorder.submit();\n  }\n\n  return (\n    <Button\n      data-control-ui=\"audio-recorder\"\n      data-slot=\"submit\"\n      data-visible={isVisible ? \"true\" : undefined}\n      type=\"button\"\n      variant=\"solid\"\n      tone=\"primary\"\n      size=\"sm\"\n      iconOnly\n      shape=\"circle\"\n      aria-label=\"Send voice recording\"\n      aria-hidden={!isVisible}\n      tabIndex={isVisible ? tabIndex : -1}\n      disabled={disabled ?? (recorder.state === \"submitting\" || !isVisible)}\n      className={className}\n      onClick={handleClick}\n      {...props}\n    >\n      {children ?? <CheckIcon aria-hidden=\"true\" className=\"size-3.5\" />}\n    </Button>\n  );\n}\n"
    },
    {
      "path": "src/registry/sources/control-ui/recipes/audio-recorder.css",
      "target": "@components/control-ui/styles/recipes/audio-recorder.css",
      "type": "registry:file",
      "content": "@layer components {\n  :where([data-control-ui=\"audio-recorder\"][data-slot=\"root\"]) {\n    --cui-audio-recorder-active-foreground: var(--foreground);\n    --cui-audio-recorder-foreground: var(--muted-foreground);\n    --cui-audio-recorder-recording-ring-color: oklch(from var(--destructive) l c h / 0.25);\n    --cui-audio-recorder-error-foreground: var(--destructive-text);\n    color: var(--cui-audio-recorder-foreground);\n    transition: color var(--duration-base) var(--ease-standard);\n  }\n\n  :where([data-control-ui=\"audio-recorder\"][data-slot=\"root\"]:is([data-state=\"recording\"], [data-state=\"recorded\"])) {\n    color: var(--cui-audio-recorder-active-foreground);\n  }\n\n  :where([data-control-ui=\"audio-recorder\"][data-slot=\"trigger\"][data-recorder-state=\"recording\"]) {\n    --cui-button-shadow: 0 0 0 2px var(--cui-audio-recorder-recording-ring-color);\n  }\n\n  :where(\n    [data-control-ui=\"audio-recorder\"]:is(\n      [data-slot=\"visualizer\"],\n      [data-slot=\"status\"],\n      [data-slot=\"duration\"],\n      [data-slot=\"cancel\"],\n      [data-slot=\"submit\"]\n    )\n  ) {\n    --_audio-recorder-content-hidden-scale: 100%;\n    --_audio-recorder-content-visible-scale: 100%;\n    opacity: 0;\n    filter: blur(var(--blur-xs));\n    translate: calc(var(--spacing) * 1) 0;\n    scale: var(--_audio-recorder-content-hidden-scale);\n    visibility: hidden;\n    transition-property: opacity, filter, translate, scale, visibility;\n    transition-duration: var(--duration-base);\n    transition-timing-function: var(--ease-emphasized);\n    transition-behavior: allow-discrete;\n  }\n\n  :where([data-control-ui=\"audio-recorder\"]:is([data-slot=\"cancel\"], [data-slot=\"submit\"])) {\n    --_audio-recorder-content-hidden-scale: 95%;\n  }\n\n  :where([data-control-ui=\"audio-recorder\"][data-visible=\"true\"]) {\n    opacity: 1;\n    filter: blur(0px);\n    translate: 0px 0;\n    scale: var(--_audio-recorder-content-visible-scale);\n    visibility: visible;\n  }\n\n  :where([data-control-ui=\"audio-recorder\"]:is([data-slot=\"cancel\"], [data-slot=\"submit\"]):disabled) {\n    opacity: 0;\n  }\n\n  :where([data-control-ui=\"audio-recorder\"]:is([data-slot=\"status\"], [data-slot=\"duration\"])) {\n    color: var(--muted-foreground);\n    font-size: var(--text-caption);\n  }\n\n  :where([data-control-ui=\"audio-recorder\"][data-slot=\"status\"][data-tone=\"error\"]) {\n    color: var(--cui-audio-recorder-error-foreground);\n  }\n\n  :where([data-control-ui=\"audio-recorder\"][data-slot=\"duration\"]) {\n    text-align: right;\n    font-variant-numeric: tabular-nums;\n  }\n}\n\n@property --cui-audio-recorder-foreground {\n  syntax: \"<color>\";\n  inherits: true;\n  initial-value: transparent;\n}\n\n@property --cui-audio-recorder-active-foreground {\n  syntax: \"<color>\";\n  inherits: true;\n  initial-value: transparent;\n}\n\n@property --cui-audio-recorder-recording-ring-color {\n  syntax: \"<color>\";\n  inherits: true;\n  initial-value: transparent;\n}\n\n@property --cui-audio-recorder-error-foreground {\n  syntax: \"<color>\";\n  inherits: true;\n  initial-value: transparent;\n}\n"
    }
  ],
  "css": {
    "@import \"../components/control-ui/styles/recipes/audio-recorder.css\"": {}
  },
  "meta": {}
}
