{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "audio-visualizer",
  "title": "Audio Visualizer",
  "description": "A real-time AnalyserNode visualizer — spectrum bars, oscilloscope, radial ring or segmented level meter, driven by a stream, an audio element or an analyser you own.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/ui/audio-visualizer.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type AudioVisualizerVariant = \"bars\" | \"wave\" | \"ring\" | \"level\"\n\nconst TAU = Math.PI * 2\n/** 3x screens quadruple fill cost for no visible gain on 2px bars. */\nconst MAX_DPR = 2\n/** Ink opacity of the idle baseline — visible while silent, never competing with a live value. */\nconst TRACK_ALPHA = 0.45\n/**\n * Display gain for the oscilloscope trace, applied through `tanh`: at 1x,\n * conversational speech (~-30 dBFS) draws as a flat line, and a hard clamp\n * would give a hot signal an obviously fake flat-topped ceiling instead.\n */\nconst WAVE_GAIN = 2.4\n/** dBFS window the RMS readout is mapped over (-60 dBFS ≈ silence, 0 dBFS ≈ full scale). */\nconst LEVEL_MIN_DB = -60\nconst LEVEL_MAX_DB = 0\n/** Peak-hold fallback for the `level` variant, in level units per second. */\nconst PEAK_DECAY = 0.4\n/** The readout snaps to this percentage step, so a polite live region isn't re-announced on every tick. */\nconst LEVEL_STEP = 5\n/** A backgrounded tab resumes with one huge gap — clamp it so peak-hold never falls off a cliff. */\nconst MAX_DT = 1 / 15\n\ninterface WebkitWindow {\n  webkitAudioContext?: typeof AudioContext\n}\n\n/**\n * An `<audio>`/`<video>` element can be handed to `createMediaElementSource`\n * exactly once, ever: after that the element is permanently bound to that\n * AudioContext, and closing the context would silence the element for good.\n * So element graphs are cached per element and deliberately never torn down —\n * each mounted visualizer only adds/removes its own AnalyserNode branch.\n */\nconst elementGraphs = new WeakMap<HTMLMediaElement, { context: AudioContext; source: MediaElementAudioSourceNode }>()\n\n/** Null when Web Audio is missing *or* refused — browsers cap how many contexts one page may hold open. */\nfunction createAudioContext(): AudioContext | null {\n  const Ctor = window.AudioContext ?? (window as unknown as WebkitWindow).webkitAudioContext\n  if (!Ctor) return null\n  try {\n    return new Ctor()\n  } catch {\n    return null\n  }\n}\n\nfunction clampNumber(value: number | undefined, min: number, max: number, fallback: number) {\n  if (typeof value !== \"number\" || !Number.isFinite(value)) return fallback\n  return Math.min(max, Math.max(min, value))\n}\n\n/** fftSize must be a power of two in 32..32768 or the setter throws — round instead of trusting the caller. */\nfunction clampFftSize(value: number | undefined, fallback: number) {\n  if (typeof value !== \"number\" || !Number.isFinite(value) || value <= 0) return fallback\n  const exponent = Math.round(Math.log2(Math.min(32768, Math.max(32, value))))\n  return 2 ** Math.min(15, Math.max(5, exponent))\n}\n\nfunction levelFromTimeDomain(data: Uint8Array<ArrayBuffer>) {\n  let sum = 0\n  for (let i = 0; i < data.length; i++) {\n    const v = (data[i] - 128) / 128\n    sum += v * v\n  }\n  const rms = Math.sqrt(sum / Math.max(1, data.length))\n  if (rms <= 0) return 0\n  const db = 20 * Math.log10(rms)\n  return Math.min(1, Math.max(0, (db - LEVEL_MIN_DB) / (LEVEL_MAX_DB - LEVEL_MIN_DB)))\n}\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n  mq.addEventListener(\"change\", callback)\n  return () => mq.removeEventListener(\"change\", callback)\n}\n\n/**\n * `matchMedia` can't be read while rendering (the server has no window, and a\n * render-time read would mismatch hydration). Server snapshot is `false`, so\n * the first paint animates and the real preference lands right after hydration.\n */\nfunction useReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  )\n}\n\nexport interface AudioVisualizerProps extends React.HTMLAttributes<HTMLDivElement> {\n  /**\n   * A MediaStream **you** already own — a microphone capture, a WebRTC remote\n   * track, anything. This component never calls `getUserMedia` and never stops\n   * your tracks; it only taps the stream and disconnects its own nodes.\n   */\n  stream?: MediaStream | null\n  /** An `<audio>`/`<video>` element you already own. Playback stays audible; the element is tapped, not re-routed. */\n  audioElement?: HTMLMediaElement | null\n  /** An AnalyserNode you already own. Treated as read-only: `fftSize`, `smoothing` and the decibel props are ignored for it. */\n  analyser?: AnalyserNode | null\n  /** Spectrum bars (`bars`), oscilloscope trace (`wave`), radial spokes (`ring`), or a segmented level meter (`level`). */\n  variant?: AudioVisualizerVariant\n  /** Spectrum bars / radial spokes / meter segments. Ignored by `wave`. Clamped to 4..128. */\n  barCount?: number\n  /** `fftSize` of the analyser this component creates. Rounded to a power of two in 32..32768. */\n  fftSize?: number\n  /** `smoothingTimeConstant` of the analyser this component creates. Clamped to 0..0.95 (1 would freeze the data). */\n  smoothing?: number\n  /** Lower edge of the spectrum's dynamic range, in dB. Clamped to -120..-10 and kept at least 10 dB under `maxDecibels`. */\n  minDecibels?: number\n  /** Upper edge of the spectrum's dynamic range, in dB. Clamped to -110..0. */\n  maxDecibels?: number\n  /** Canvas height in CSS px. Clamped to 24..640. */\n  height?: number\n  /** Prefix of the `role=\"status\"` readout. */\n  label?: string\n  /** Show the level readout as visible text. Forced on under reduced motion, where it is the functional fallback. */\n  showLevel?: boolean\n  /** How often the readout and `onLevelChange` publish, in ms. Clamped to 200..10000 — never per frame. */\n  levelIntervalMs?: number\n  /** Throttled to `levelIntervalMs` and only fired when the 5%-quantized level actually changes. */\n  onLevelChange?: (level: number) => void\n}\n\n/**\n * AudioVisualizer — a canvas spectrum / waveform / level meter driven by a real\n * AnalyserNode.\n *\n * Ownership is the whole point: the consumer supplies the input (`stream`,\n * `audioElement` or `analyser`) and keeps owning it. Graphs this component\n * builds are disconnected and closed on unmount or input change; the\n * consumer's MediaStream tracks are never stopped, and a consumer-supplied\n * AnalyserNode is never mutated.\n *\n * With no input it paints an identifiable static baseline (bar stubs, a flat\n * trace, an empty ring, unlit segments) instead of an empty box. Under\n * `prefers-reduced-motion` the animation loop never starts: the spectrum\n * variants hold that baseline and the numeric readout — refreshed on the\n * publish tick, not per frame — carries the level instead. The `level` variant\n * keeps repainting on the same tick, so its meter can't contradict the number.\n */\nexport const AudioVisualizer = React.forwardRef<HTMLDivElement, AudioVisualizerProps>(\n  (\n    {\n      stream = null,\n      audioElement = null,\n      analyser: providedAnalyser = null,\n      variant = \"bars\",\n      barCount = 40,\n      fftSize = 1024,\n      smoothing = 0.75,\n      minDecibels = -85,\n      maxDecibels = -25,\n      height = 96,\n      label = \"Input level\",\n      showLevel = true,\n      levelIntervalMs = 1000,\n      onLevelChange,\n      className,\n      ...props\n    },\n    ref,\n  ) => {\n    const canvasRef = React.useRef<HTMLCanvasElement>(null)\n    const reduced = useReducedMotion()\n\n    const [levelPercent, setLevelPercent] = React.useState(0)\n    const [errorMessage, setErrorMessage] = React.useState<string | null>(null)\n\n    // Consumer callbacks arrive as fresh inline closures on every render —\n    // keeping them out of the effect deps stops the whole audio graph from\n    // being torn down and rebuilt on each parent render.\n    const onLevelChangeRef = React.useRef(onLevelChange)\n    React.useEffect(() => {\n      onLevelChangeRef.current = onLevelChange\n    })\n\n    const safeBarCount = Math.round(clampNumber(barCount, 4, 128, 40))\n    const safeFftSize = clampFftSize(fftSize, 1024)\n    const safeSmoothing = clampNumber(smoothing, 0, 0.95, 0.75)\n    const safeMaxDb = clampNumber(maxDecibels, -110, 0, -25)\n    // The setter throws unless minDecibels < maxDecibels; 10 dB of headroom also\n    // keeps the spectrum from collapsing into a two-tone on/off display.\n    const safeMinDb = Math.min(clampNumber(minDecibels, -120, -10, -85), safeMaxDb - 10)\n    const safeHeight = Math.round(clampNumber(height, 24, 640, 96))\n    const safeInterval = clampNumber(levelIntervalMs, 200, 10_000, 1000)\n\n    const hasSource = Boolean(stream || audioElement || providedAnalyser)\n\n    React.useEffect(() => {\n      const canvas = canvasRef.current\n      if (!canvas) return\n      const context2d = canvas.getContext(\"2d\")\n      if (!context2d) return\n      const ctx = context2d\n\n      let width = 0\n      let height2d = 0\n      let analyser: AnalyserNode | null = null\n      let releaseGraph: (() => void) | null = null\n      let freqData: Uint8Array<ArrayBuffer> | null = null\n      let timeData: Uint8Array<ArrayBuffer> | null = null\n      let rafId: number | null = null\n      let lastFrame = 0\n      let peak = 0\n      let publishedPercent = -1\n      let failure: string | null = null\n      const ink = { active: \"\", track: \"\" }\n\n      const applySettings = (node: AnalyserNode) => {\n        try {\n          node.fftSize = safeFftSize\n          node.smoothingTimeConstant = safeSmoothing\n          // Assignment order matters: the setter rejects a max that isn't above\n          // the *current* min, so widen from whichever end is safe first.\n          if (safeMaxDb > node.minDecibels) {\n            node.maxDecibels = safeMaxDb\n            node.minDecibels = safeMinDb\n          } else {\n            node.minDecibels = safeMinDb\n            node.maxDecibels = safeMaxDb\n          }\n        } catch {\n          // A hostile value slipped past the clamps — keep the analyser's defaults.\n        }\n      }\n\n      if (providedAnalyser) {\n        // Borrowed, not owned: no settings are written and nothing is disconnected.\n        analyser = providedAnalyser\n      } else if (stream) {\n        if (stream.getAudioTracks().length === 0) {\n          failure = \"That stream carries no audio track.\"\n        } else {\n          const audioContext = createAudioContext()\n          if (!audioContext) {\n            failure = \"Couldn't open an audio graph in this browser.\"\n          } else {\n            try {\n              const source = audioContext.createMediaStreamSource(stream)\n              const node = audioContext.createAnalyser()\n              applySettings(node)\n              // Analyser only — connecting a microphone to `destination` would\n              // echo the speaker feed straight back out.\n              source.connect(node)\n              analyser = node\n              void audioContext.resume().catch(() => {})\n              releaseGraph = () => {\n                source.disconnect()\n                node.disconnect()\n                if (audioContext.state !== \"closed\") void audioContext.close().catch(() => {})\n                // The stream belongs to the caller: its tracks are left running\n                // on purpose. Stopping them here would kill the caller's mic.\n              }\n            } catch {\n              failure = \"Couldn't tap that stream.\"\n              void audioContext.close().catch(() => {})\n            }\n          }\n        }\n      } else if (audioElement) {\n        let entry = elementGraphs.get(audioElement)\n        if (!entry) {\n          const audioContext = createAudioContext()\n          if (!audioContext) {\n            failure = \"Couldn't open an audio graph in this browser.\"\n          } else {\n            try {\n              const source = audioContext.createMediaElementSource(audioElement)\n              // Keeps the element audible; this branch is never disconnected.\n              source.connect(audioContext.destination)\n              entry = { context: audioContext, source }\n              elementGraphs.set(audioElement, entry)\n            } catch {\n              failure = \"That element is already connected to another audio graph.\"\n              void audioContext.close().catch(() => {})\n            }\n          }\n        }\n        if (entry) {\n          const { context: audioContext, source } = entry\n          const node = audioContext.createAnalyser()\n          applySettings(node)\n          source.connect(node)\n          analyser = node\n          // Autoplay policy: a context built outside a gesture starts suspended,\n          // so retry on every play — the press itself is the gesture.\n          const handlePlay = () => {\n            void audioContext.resume().catch(() => {})\n          }\n          audioElement.addEventListener(\"play\", handlePlay)\n          void audioContext.resume().catch(() => {})\n          releaseGraph = () => {\n            audioElement.removeEventListener(\"play\", handlePlay)\n            try {\n              source.disconnect(node)\n            } catch {\n              // Already detached — the element's own output stays connected.\n            }\n            node.disconnect()\n          }\n        }\n      }\n\n      const readInk = () => {\n        const style = getComputedStyle(canvas)\n        ink.active = style.getPropertyValue(\"--primary\").trim() || style.color\n        ink.track = style.getPropertyValue(\"--muted-foreground\").trim() || style.color\n      }\n\n      const ensureBuffers = (node: AnalyserNode) => {\n        if (!freqData || freqData.length !== node.frequencyBinCount) {\n          freqData = new Uint8Array(node.frequencyBinCount)\n        }\n        if (!timeData || timeData.length !== node.fftSize) {\n          timeData = new Uint8Array(node.fftSize)\n        }\n      }\n\n      const roundedRect = (x: number, y: number, w: number, h: number, r: number) => {\n        ctx.beginPath()\n        if (typeof ctx.roundRect === \"function\") {\n          ctx.roundRect(x, y, w, h, Math.min(r, w / 2, h / 2))\n        } else {\n          ctx.rect(x, y, w, h)\n        }\n        ctx.fill()\n      }\n\n      /**\n       * Bar i averages an exponentially widening slice of bins: linear slices\n       * would spend three quarters of the display on frequencies speech never\n       * reaches, leaving a live mic looking dead on the right-hand side.\n       */\n      const readBars = (node: AnalyserNode) => {\n        const values = new Array<number>(safeBarCount).fill(0)\n        if (!freqData) return values\n        node.getByteFrequencyData(freqData)\n        const bins = freqData.length\n        const minBin = 1\n        const maxBin = Math.max(minBin + 1, Math.floor(bins * 0.72))\n        const ratio = maxBin / minBin\n        for (let i = 0; i < safeBarCount; i++) {\n          const start = Math.floor(minBin * ratio ** (i / safeBarCount))\n          const end = Math.max(start + 1, Math.floor(minBin * ratio ** ((i + 1) / safeBarCount)))\n          let max = 0\n          for (let j = start; j < Math.min(end, bins); j++) {\n            if (freqData[j] > max) max = freqData[j]\n          }\n          values[i] = max / 255\n        }\n        return values\n      }\n\n      const drawBars = (values: number[]) => {\n        const pad = 3\n        const slot = width / values.length\n        const barW = Math.max(1, Math.min(slot - 1, slot * 0.66))\n        const radius = Math.min(barW / 2, 4)\n        const floor = Math.min(3, height2d * 0.08)\n        const usable = Math.max(0, height2d - pad * 2)\n        for (let i = 0; i < values.length; i++) {\n          const x = i * slot + (slot - barW) / 2\n          ctx.globalAlpha = TRACK_ALPHA\n          ctx.fillStyle = ink.track\n          roundedRect(x, height2d - pad - floor, barW, floor, radius)\n          const barH = values[i] * usable\n          if (barH > floor + 0.5) {\n            ctx.globalAlpha = 1\n            ctx.fillStyle = ink.active\n            roundedRect(x, height2d - pad - barH, barW, barH, radius)\n          }\n        }\n      }\n\n      const drawWave = (data: Uint8Array<ArrayBuffer> | null) => {\n        const pad = 4\n        const mid = height2d / 2\n        const amplitude = Math.max(1, mid - pad)\n        ctx.globalAlpha = TRACK_ALPHA\n        ctx.strokeStyle = ink.track\n        ctx.lineWidth = 1\n        ctx.beginPath()\n        ctx.moveTo(0, mid)\n        ctx.lineTo(width, mid)\n        ctx.stroke()\n\n        ctx.globalAlpha = 1\n        ctx.strokeStyle = ink.active\n        ctx.lineWidth = 2\n        ctx.lineJoin = \"round\"\n        ctx.lineCap = \"round\"\n        ctx.beginPath()\n        if (!data || data.length < 2) {\n          ctx.moveTo(0, mid)\n          ctx.lineTo(width, mid)\n        } else {\n          const step = Math.max(1, Math.floor(data.length / Math.max(1, Math.floor(width))))\n          for (let i = 0; i < data.length; i += step) {\n            const x = (i / (data.length - 1)) * width\n            const y = mid - Math.tanh(((data[i] - 128) / 128) * WAVE_GAIN) * amplitude\n            if (i === 0) ctx.moveTo(x, y)\n            else ctx.lineTo(x, y)\n          }\n        }\n        ctx.stroke()\n      }\n\n      const drawRing = (values: number[]) => {\n        const cx = width / 2\n        const cy = height2d / 2\n        const outer = Math.min(width, height2d) / 2 - 2\n        if (outer <= 4) return\n        const inner = outer * 0.5\n        const maxLen = outer - inner\n        const spokeW = Math.max(1.5, ((TAU * inner) / values.length) * 0.5)\n        const stub = Math.max(2, maxLen * 0.12)\n\n        ctx.globalAlpha = TRACK_ALPHA\n        ctx.strokeStyle = ink.track\n        ctx.lineWidth = Math.max(1, maxLen * 0.05)\n        ctx.beginPath()\n        ctx.arc(cx, cy, Math.max(1, inner - ctx.lineWidth), 0, TAU)\n        ctx.stroke()\n\n        ctx.lineCap = \"round\"\n        ctx.lineWidth = spokeW\n        for (let i = 0; i < values.length; i++) {\n          const angle = -Math.PI / 2 + (i / values.length) * TAU\n          const cos = Math.cos(angle)\n          const sin = Math.sin(angle)\n          const len = Math.max(stub, values[i] * maxLen)\n          ctx.globalAlpha = values[i] * maxLen > stub ? 1 : TRACK_ALPHA\n          ctx.strokeStyle = values[i] * maxLen > stub ? ink.active : ink.track\n          ctx.beginPath()\n          ctx.moveTo(cx + cos * inner, cy + sin * inner)\n          ctx.lineTo(cx + cos * (inner + len), cy + sin * (inner + len))\n          ctx.stroke()\n        }\n      }\n\n      const drawLevel = (level: number) => {\n        const segments = Math.min(48, Math.max(8, safeBarCount))\n        const meterH = Math.max(8, Math.min(height2d - 6, height2d * 0.42))\n        const y = (height2d - meterH) / 2\n        const slot = width / segments\n        const segW = Math.max(1, slot - Math.max(1.5, slot * 0.25))\n        const radius = Math.min(segW / 2, 3)\n        const lit = level * segments\n        for (let i = 0; i < segments; i++) {\n          const x = i * slot + (slot - segW) / 2\n          ctx.globalAlpha = TRACK_ALPHA\n          ctx.fillStyle = ink.track\n          roundedRect(x, y, segW, meterH, radius)\n          const fill = Math.min(1, Math.max(0, lit - i))\n          if (fill > 0) {\n            ctx.globalAlpha = fill === 1 ? 1 : 0.4 + 0.6 * fill\n            ctx.fillStyle = ink.active\n            roundedRect(x, y, segW, meterH, radius)\n          }\n        }\n        if (peak > 0.01) {\n          const x = Math.min(width - 2, Math.max(0, peak * width - 1))\n          ctx.globalAlpha = 1\n          ctx.fillStyle = ink.active\n          ctx.fillRect(x, y - 3, 2, meterH + 6)\n        }\n      }\n\n      /**\n       * @param animated false paints the silent baseline (reduced motion, or no\n       *        input at all). The `level` variant still draws the sampled level,\n       *        so its meter can never contradict the number printed under it.\n       */\n      const paint = (animated: boolean, dt: number) => {\n        ctx.clearRect(0, 0, width, height2d)\n        if (!ink.active || width <= 0 || height2d <= 0) return\n        let level = 0\n        if (analyser) {\n          ensureBuffers(analyser)\n          if (timeData) {\n            analyser.getByteTimeDomainData(timeData)\n            level = levelFromTimeDomain(timeData)\n          }\n        }\n        if (variant === \"level\") {\n          peak = animated ? Math.max(level, peak - PEAK_DECAY * dt) : 0\n          drawLevel(level)\n        } else if (variant === \"wave\") {\n          drawWave(animated && analyser ? timeData : null)\n        } else {\n          const values = animated && analyser ? readBars(analyser) : new Array<number>(safeBarCount).fill(0)\n          if (variant === \"ring\") drawRing(values)\n          else drawBars(values)\n        }\n        ctx.globalAlpha = 1\n      }\n\n      const frame = (now: number) => {\n        rafId = requestAnimationFrame(frame)\n        const dt = lastFrame === 0 ? 1 / 60 : Math.min((now - lastFrame) / 1000, MAX_DT)\n        lastFrame = now\n        paint(true, dt)\n      }\n\n      const stopLoop = () => {\n        if (rafId === null) return\n        cancelAnimationFrame(rafId)\n        rafId = null\n      }\n\n      const startLoop = () => {\n        if (rafId !== null || reduced || !analyser || width <= 0) return\n        lastFrame = 0\n        rafId = requestAnimationFrame(frame)\n      }\n\n      /**\n       * The readout is published here, never from the draw loop: a `role=status`\n       * region that changed 60 times a second would make a screen reader\n       * unusable. Quantizing to 5% also skips the churn of a level jittering\n       * one point either side of a boundary.\n       */\n      const publish = () => {\n        let percent = 0\n        if (analyser) {\n          ensureBuffers(analyser)\n          if (timeData) {\n            analyser.getByteTimeDomainData(timeData)\n            percent = Math.round((levelFromTimeDomain(timeData) * 100) / LEVEL_STEP) * LEVEL_STEP\n          }\n        }\n        if (reduced) paint(false, 0)\n        // Assigned unconditionally: a failure raised by the previous input must\n        // clear itself once a working one arrives, not stick around forever.\n        setErrorMessage(failure)\n        if (percent === publishedPercent) return\n        publishedPercent = percent\n        setLevelPercent(percent)\n        onLevelChangeRef.current?.(percent / 100)\n      }\n\n      const resize = (w: number, h: number) => {\n        if (w <= 0 || h <= 0) return\n        const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR)\n        canvas.width = Math.round(w * dpr)\n        canvas.height = Math.round(h * dpr)\n        // Resizing the backing store resets the context — reapply the DPR\n        // transform so everything below keeps drawing in CSS pixels.\n        ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n        width = w\n        height2d = h\n        readInk()\n        if (rafId === null) paint(false, 0)\n        startLoop()\n      }\n\n      let resizeObserver: ResizeObserver | null = null\n      if (typeof ResizeObserver === \"undefined\") {\n        resize(canvas.clientWidth, canvas.clientHeight)\n      } else {\n        // observe() fires once immediately — that first callback is the initial sizing.\n        resizeObserver = new ResizeObserver(entries => {\n          const box = entries[entries.length - 1]?.contentRect\n          if (box) resize(box.width, box.height)\n        })\n        try {\n          resizeObserver.observe(canvas)\n        } catch {\n          resizeObserver = null\n          resize(canvas.clientWidth, canvas.clientHeight)\n        }\n      }\n\n      // A theme flip lands as a class/style change on <html>; re-read the ink and\n      // repaint the still frame when the loop isn't running to do it for us.\n      const themeObserver = new MutationObserver(() => {\n        readInk()\n        if (rafId === null) paint(false, 0)\n      })\n      themeObserver.observe(document.documentElement, {\n        attributes: true,\n        attributeFilter: [\"class\", \"style\", \"data-theme\"],\n      })\n\n      const firstPublish = window.setTimeout(publish, 0)\n      const publishTimer = window.setInterval(publish, safeInterval)\n\n      return () => {\n        stopLoop()\n        window.clearTimeout(firstPublish)\n        window.clearInterval(publishTimer)\n        resizeObserver?.disconnect()\n        themeObserver.disconnect()\n        releaseGraph?.()\n      }\n    }, [\n      stream,\n      audioElement,\n      providedAnalyser,\n      variant,\n      safeBarCount,\n      safeFftSize,\n      safeSmoothing,\n      safeMinDb,\n      safeMaxDb,\n      safeInterval,\n      reduced,\n    ])\n\n    // The suffix explains why the canvas sits still while the number moves —\n    // without it a frozen spectrum reads as \"broken\", not as \"motion reduced\".\n    const statusText = errorMessage\n      ? errorMessage\n      : hasSource\n        ? `${label}: ${levelPercent}%${reduced ? \" · motion reduced\" : \"\"}`\n        : `${label}: no source connected`\n    const levelVisible = showLevel || reduced || errorMessage !== null\n\n    return (\n      <div className={cn(\"flex w-full flex-col gap-2\", className)} ref={ref} {...props}>\n        <div\n          className=\"relative w-full overflow-hidden rounded-lg border bg-muted/30\"\n          style={{ height: `${safeHeight}px` }}\n        >\n          <canvas aria-hidden=\"true\" className=\"block size-full\" ref={canvasRef} />\n        </div>\n        <p\n          aria-live=\"polite\"\n          className={cn(\n            \"font-mono text-xs tabular-nums\",\n            errorMessage ? \"text-destructive\" : \"text-muted-foreground\",\n            !levelVisible && \"sr-only\",\n          )}\n          role=\"status\"\n        >\n          {statusText}\n        </p>\n      </div>\n    )\n  },\n)\n\nAudioVisualizer.displayName = \"AudioVisualizer\"\n\nexport default AudioVisualizer\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}