{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "audio-waveform",
  "title": "Audio Waveform",
  "description": "A canvas amplitude waveform with a click/drag/keyboard seek slider — takes precomputed peaks or decodes a src itself, highlights regions, and stays a controlled display while your own audio element plays.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/ui/audio-waveform.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cn } from \"@/lib/utils\"\n\n/** A highlighted span of the timeline — chorus, speaker turn, detected keyword… */\nexport interface AudioWaveformRegion {\n  /** Start in seconds. */\n  start: number\n  /** End in seconds. Entries with `end <= start` are ignored. */\n  end: number\n  /** Optional caption pinned to the top-left of the band. */\n  label?: string\n}\n\ninterface AudioWaveformOwnProps {\n  /**\n   * Controlled playhead position in seconds. The component never plays audio —\n   * feed it from your own `<audio>` element (`timeupdate` / rAF) and move that\n   * element's `currentTime` inside `onSeek`.\n   */\n  currentTime: number\n  /** Fires on click, drag and every keyboard step with the requested time in seconds. */\n  onSeek?: (time: number) => void\n  /** Highlighted spans drawn over the wave (clamped to the clip, invalid spans dropped). */\n  regions?: AudioWaveformRegion[]\n  /** Wave area height in CSS px (min 24). Width always fills the container. */\n  height?: number\n  /** Bar thickness in CSS px (min 1). */\n  barWidth?: number\n  /** Gap between bars in CSS px (min 0). */\n  barGap?: number\n  /** Buckets computed when decoding `src` (16…4096). Ignored when `peaks` is given. */\n  resolution?: number\n  /** Seconds per arrow key press (PageUp/PageDown use 10% of the clip, at least this). */\n  keyboardStep?: number\n  /** Renders read-only: no pointer seek, no keyboard seek, no hover cursor. */\n  disabled?: boolean\n  /** Accessible name of the seek slider. */\n  label?: string\n  /** Fires once after a successful decode — cache this and pass it back as `peaks` next time. */\n  onDecoded?: (result: { peaks: number[]; duration: number }) => void\n}\n\n/**\n * Either hand over precomputed `peaks` + `duration` (recommended: no download,\n * no decode, no main-thread work), or hand over a `src` and let the component\n * fetch + `decodeAudioData` it once.\n */\nexport type AudioWaveformProps = React.HTMLAttributes<HTMLDivElement> &\n  AudioWaveformOwnProps &\n  (\n    | { peaks: number[]; duration: number; src?: string }\n    | { peaks?: undefined; duration?: number; src: string }\n  )\n\n/** Bars never collapse to nothing — silence still reads as a baseline, not a hole. */\nconst MIN_BAR_HEIGHT = 2\n/** Un-played bars are the same ink at lower opacity: one token, two lightnesses. */\nconst IDLE_ALPHA = 0.34\n/** Per-bucket sample cap. A 3-minute stereo clip is ~16M samples; scanning every one\n *  of them blocks the main thread for tens of ms and changes the drawn peak by <1px. */\nconst MAX_SAMPLES_PER_BUCKET = 512\nconst DEFAULT_RESOLUTION = 512\n\n/** Explicit locale — `Intl.*(undefined)` would follow the visitor's locale and\n *  render Eastern Arabic digits for some users while the layout assumes 2 glyphs. */\nconst PAD2 = new Intl.NumberFormat(\"en-US\", { minimumIntegerDigits: 2, useGrouping: false })\n\nfunction formatTime(seconds: number) {\n  const safe = Number.isFinite(seconds) && seconds > 0 ? Math.floor(seconds) : 0\n  const h = Math.floor(safe / 3600)\n  const m = Math.floor((safe % 3600) / 60)\n  const s = safe % 60\n  return h > 0 ? `${h}:${PAD2.format(m)}:${PAD2.format(s)}` : `${m}:${PAD2.format(s)}`\n}\n\nfunction clamp(value: number, min: number, max: number) {\n  if (!Number.isFinite(value)) return min\n  return value < min ? min : value > max ? max : value\n}\n\n/**\n * Peak-per-bucket over the first two channels, striding through long buckets.\n * Returns raw 0…1 amplitudes; the display layer is what normalises them.\n */\nfunction computePeaks(buffer: AudioBuffer, buckets: number): number[] {\n  const out = new Array<number>(buckets).fill(0)\n  const channelCount = Math.min(buffer.numberOfChannels, 2)\n  const total = buffer.length\n  if (channelCount === 0 || total === 0) return out\n\n  const channels: Float32Array[] = []\n  for (let c = 0; c < channelCount; c++) channels.push(buffer.getChannelData(c))\n\n  const bucketSize = total / buckets\n  for (let i = 0; i < buckets; i++) {\n    const start = Math.floor(i * bucketSize)\n    const end = Math.min(total, Math.max(start + 1, Math.floor((i + 1) * bucketSize)))\n    const stride = Math.max(1, Math.floor((end - start) / MAX_SAMPLES_PER_BUCKET))\n    let peak = 0\n    for (const channel of channels) {\n      for (let j = start; j < end; j += stride) {\n        const v = channel[j] < 0 ? -channel[j] : channel[j]\n        if (v > peak) peak = v\n      }\n    }\n    out[i] = peak\n  }\n  return out\n}\n\n/** The message the user sees, not the one the console wants. */\nfunction describeFailure(cause: unknown): string {\n  if (cause instanceof Error) {\n    // fetch() rejects with a TypeError for offline / DNS / CORS failures, and\n    // \"Failed to fetch\" is developer-speak for \"the file never arrived\".\n    if (cause.name === \"TypeError\") return \"Couldn't load audio\"\n    // decodeAudioData rejects with an EncodingError DOMException.\n    if (cause.name === \"EncodingError\") return \"Couldn't decode audio\"\n    if (cause.message) return cause.message\n  }\n  return \"Couldn't decode audio\"\n}\n\ntype DecodeState =\n  | { status: \"loading\" }\n  | { status: \"ready\"; peaks: number[]; duration: number }\n  | { status: \"error\"; message: string }\n\nexport const AudioWaveform = React.forwardRef<HTMLDivElement, AudioWaveformProps>(\n  (\n    {\n      peaks,\n      src,\n      duration,\n      currentTime,\n      onSeek,\n      onDecoded,\n      regions,\n      height = 64,\n      barWidth = 3,\n      barGap = 2,\n      resolution = DEFAULT_RESOLUTION,\n      keyboardStep = 5,\n      disabled = false,\n      label = \"Audio position\",\n      className,\n      ...props\n    },\n    ref,\n  ) => {\n    // Numeric props are clamped: a 0 bar width or a 0 keyboard step turns the\n    // draw loop / seek into a no-op (or an infinite one) on a single typo.\n    const safeHeight = Math.max(24, Number.isFinite(height) ? height : 64)\n    const safeBarWidth = Math.max(1, Number.isFinite(barWidth) ? barWidth : 3)\n    const safeBarGap = Math.max(0, Number.isFinite(barGap) ? barGap : 2)\n    const safeStep = Number.isFinite(keyboardStep) && keyboardStep > 0 ? keyboardStep : 5\n    const safeResolution = Math.round(clamp(resolution, 16, 4096))\n\n    const canvasRef = React.useRef<HTMLCanvasElement>(null)\n    const [size, setSize] = React.useState({ cssW: 0, cssH: 0, ratio: 1 })\n    const [hoverRatio, setHoverRatio] = React.useState<number | null>(null)\n    const [dragging, setDragging] = React.useState(false)\n    // Bumped by the theme observer so the canvas re-reads its ink after a dark-mode flip.\n    const [themeTick, setThemeTick] = React.useState(0)\n\n    const [decode, setDecode] = React.useState<DecodeState | null>(null)\n\n    // latest-ref: consumers pass inline arrows, so these must never reach a dep array.\n    const onSeekRef = React.useRef(onSeek)\n    const onDecodedRef = React.useRef(onDecoded)\n    React.useEffect(() => {\n      onSeekRef.current = onSeek\n      onDecodedRef.current = onDecoded\n    })\n\n    // Render-phase adjust-state (not an effect — see react-hooks/set-state-in-effect):\n    // a new src must drop the previous clip's peaks/error in the same render, otherwise\n    // the old waveform stays on screen while the new one downloads.\n    const [prevSrc, setPrevSrc] = React.useState(src)\n    if (prevSrc !== src) {\n      setPrevSrc(src)\n      setDecode(peaks || !src ? null : { status: \"loading\" })\n    }\n\n    /* ---------------------------------------------------------------- decode */\n\n    React.useEffect(() => {\n      if (peaks || !src) return\n      const controller = new AbortController()\n      let context: AudioContext | null = null\n\n      const closeContext = () => {\n        const pending = context\n        context = null\n        // close() rejects on an already-closed context; nothing to recover from.\n        void pending?.close().catch(() => {})\n      }\n\n      const run = async () => {\n        try {\n          const AudioContextCtor =\n            window.AudioContext ??\n            (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext\n          if (!AudioContextCtor) throw new Error(\"Web Audio API unavailable\")\n\n          const response = await fetch(src, { signal: controller.signal })\n          if (!response.ok) throw new Error(`Request failed (${response.status})`)\n          const bytes = await response.arrayBuffer()\n          if (controller.signal.aborted) return\n\n          context = new AudioContextCtor()\n          const buffer = await context.decodeAudioData(bytes)\n          // Re-check after every await: the component may have unmounted while\n          // decoding, and a late resolve must not touch state or leak the context.\n          if (controller.signal.aborted) return\n\n          const computed = computePeaks(buffer, safeResolution)\n          setDecode({ status: \"ready\", peaks: computed, duration: buffer.duration })\n          onDecodedRef.current?.({ peaks: computed, duration: buffer.duration })\n        } catch (cause) {\n          if (controller.signal.aborted) return\n          setDecode({ status: \"error\", message: describeFailure(cause) })\n        } finally {\n          closeContext()\n        }\n      }\n\n      // Dispatched from a microtask so the effect body itself performs no setState\n      // (react-hooks/set-state-in-effect) and StrictMode's mount→cleanup→mount\n      // never fires the request twice.\n      queueMicrotask(() => {\n        if (!controller.signal.aborted) void run()\n      })\n\n      return () => {\n        controller.abort()\n        closeContext()\n      }\n    }, [src, peaks, safeResolution])\n\n    /* ------------------------------------------------------------- geometry */\n\n    const displayPeaks = peaks ?? (decode?.status === \"ready\" ? decode.peaks : null)\n    const displayDuration = peaks\n      ? (duration ?? 0)\n      : decode?.status === \"ready\"\n        ? decode.duration\n        : 0\n\n    const status: \"loading\" | \"ready\" | \"error\" = peaks\n      ? \"ready\"\n      : decode?.status === \"error\"\n        ? \"error\"\n        : decode?.status === \"ready\"\n          ? \"ready\"\n          : src\n            ? \"loading\"\n            : \"error\"\n\n    const errorMessage =\n      decode?.status === \"error\" ? decode.message : !src && !peaks ? \"No audio source\" : null\n\n    // Normalised once per peaks change: server-side peak arrays arrive in every\n    // scale imaginable (0…1, 0…255, dB), and a quiet clip should still be legible.\n    const normalized = React.useMemo(() => {\n      if (!displayPeaks || displayPeaks.length === 0) return null\n      let max = 0\n      for (const value of displayPeaks) {\n        if (!Number.isFinite(value)) continue\n        const abs = value < 0 ? -value : value\n        if (abs > max) max = abs\n      }\n      if (max <= 0) return new Array<number>(displayPeaks.length).fill(0)\n      return displayPeaks.map(value => {\n        if (!Number.isFinite(value)) return 0\n        const abs = value < 0 ? -value : value\n        return abs / max\n      })\n    }, [displayPeaks])\n\n    // A disabled waveform still exposes its position to assistive tech — it just\n    // drops out of the tab order and stops accepting pointer/keyboard seeks.\n    const hasTimeline = status === \"ready\" && displayDuration > 0\n    const seekable = hasTimeline && !disabled\n    const clampedTime = clamp(currentTime, 0, displayDuration)\n    const progress = displayDuration > 0 ? clampedTime / displayDuration : 0\n\n    const safeRegions = React.useMemo(() => {\n      if (!regions || displayDuration <= 0) return []\n      return regions\n        .map(region => ({\n          ...region,\n          start: clamp(region.start, 0, displayDuration),\n          end: clamp(region.end, 0, displayDuration),\n        }))\n        .filter(region => region.end > region.start)\n    }, [regions, displayDuration])\n\n    /* -------------------------------------------------------- measure + paint */\n\n    React.useEffect(() => {\n      const canvas = canvasRef.current\n      if (!canvas || typeof ResizeObserver === \"undefined\") return\n      const observer = new ResizeObserver(entries => {\n        const entry = entries[0]\n        if (!entry) return\n        const cssW = entry.contentRect.width\n        const cssH = entry.contentRect.height\n        const dpr = window.devicePixelRatio || 1\n        // Both measurements lie, in opposite directions, and the harness caught\n        // both: `device-pixel-content-box` reports CSS px under an emulated\n        // device scale factor, while `devicePixelRatio` reports 1 inside a window\n        // whose scale factor is overridden on a real 2x screen. Take the larger —\n        // an under-sized backing store is exactly what \"blurry on Retina\" is.\n        const box = entry.devicePixelContentBoxSize?.[0]\n        const measured = box && cssW > 0 ? box.inlineSize / cssW : 0\n        // Above ~3x the extra fill costs real time and buys nothing visible, and\n        // some mobile engines refuse canvases wider than 4096 device px outright.\n        const capped = cssW > 0 ? Math.min(3, Math.max(1, 4096 / cssW)) : 1\n        const ratio = clamp(Math.min(Math.max(measured, dpr), capped), 1, 3)\n        setSize(prev =>\n          prev.cssW === cssW && prev.cssH === cssH && prev.ratio === ratio\n            ? prev\n            : { cssW, cssH, ratio },\n        )\n      })\n      // Unsupported browsers throw a WebIDL TypeError here instead of ignoring\n      // the option, so the fallback must be a real try/catch.\n      try {\n        observer.observe(canvas, { box: \"device-pixel-content-box\" })\n      } catch {\n        observer.observe(canvas)\n      }\n      return () => observer.disconnect()\n    }, [])\n\n    React.useEffect(() => {\n      const canvas = canvasRef.current\n      if (!canvas || size.cssW <= 0 || size.cssH <= 0) return\n      const devW = Math.max(1, Math.round(size.cssW * size.ratio))\n      const devH = Math.max(1, Math.round(size.cssH * size.ratio))\n      if (canvas.width !== devW) canvas.width = devW\n      if (canvas.height !== devH) canvas.height = devH\n      const ctx = canvas.getContext(\"2d\")\n      if (!ctx) return\n\n      const scaleX = devW / size.cssW\n      const scaleY = devH / size.cssH\n      ctx.setTransform(scaleX, 0, 0, scaleY, 0, 0)\n      ctx.clearRect(0, 0, size.cssW, size.cssH)\n      if (!normalized || normalized.length === 0) return\n\n      // currentColor: the wave inherits whatever text token the container carries,\n      // so retinting is a className away and dark mode is free. The string goes\n      // straight to fillStyle — no hand-rolled color parsing.\n      ctx.fillStyle = getComputedStyle(canvas).color\n\n      // Snap to the device-pixel grid: a bar landing on x.5 device px is drawn as\n      // two half-lit columns, which is exactly the \"blurry on Retina\" look.\n      const snapX = (value: number) => Math.round(value * scaleX) / scaleX\n      const snapY = (value: number) => Math.round(value * scaleY) / scaleY\n\n      const slot = safeBarWidth + safeBarGap\n      const count = Math.max(1, Math.floor((size.cssW + safeBarGap) / slot))\n      const playedX = progress * size.cssW\n      const rounded = typeof ctx.roundRect === \"function\"\n\n      const tracePath = (from: number, to: number) => {\n        ctx.beginPath()\n        for (let i = from; i < to; i++) {\n          const bucketFrom = Math.floor((i * normalized.length) / count)\n          const bucketTo = Math.max(bucketFrom + 1, Math.floor(((i + 1) * normalized.length) / count))\n          let amp = 0\n          for (let k = bucketFrom; k < bucketTo && k < normalized.length; k++) {\n            if (normalized[k] > amp) amp = normalized[k]\n          }\n          const barH = Math.max(MIN_BAR_HEIGHT, amp * size.cssH)\n          const x = snapX(i * slot)\n          const w = Math.max(snapX(safeBarWidth), 1 / scaleX)\n          const h = snapY(barH)\n          const y = snapY((size.cssH - barH) / 2)\n          if (rounded) ctx.roundRect(x, y, w, h, Math.min(w / 2, h / 2))\n          else ctx.rect(x, y, w, h)\n        }\n        ctx.fill()\n      }\n\n      // Pass 1: the whole wave at idle opacity. Pass 2: the same path clipped to\n      // the played span at full opacity, so the bar straddling the playhead is\n      // split mid-bar instead of snapping a whole bar early or late.\n      ctx.globalAlpha = IDLE_ALPHA\n      tracePath(0, count)\n      if (playedX > 0) {\n        ctx.save()\n        ctx.beginPath()\n        ctx.rect(0, 0, playedX, size.cssH)\n        ctx.clip()\n        ctx.globalAlpha = 1\n        tracePath(0, Math.min(count, Math.ceil(playedX / slot)))\n        ctx.restore()\n      }\n      ctx.globalAlpha = 1\n    }, [normalized, progress, size, safeBarWidth, safeBarGap, themeTick])\n\n    // A theme flip lands as a class/style change on <html>; the canvas keeps the\n    // old ink until something repaints it.\n    React.useEffect(() => {\n      if (typeof MutationObserver === \"undefined\") return\n      const observer = new MutationObserver(() => setThemeTick(tick => tick + 1))\n      observer.observe(document.documentElement, {\n        attributes: true,\n        attributeFilter: [\"class\", \"style\", \"data-theme\"],\n      })\n      return () => observer.disconnect()\n    }, [])\n\n    /* ---------------------------------------------------------- interaction */\n\n    const ratioFromEvent = (event: React.PointerEvent<HTMLDivElement>) => {\n      const rect = event.currentTarget.getBoundingClientRect()\n      if (rect.width <= 0) return 0\n      return clamp((event.clientX - rect.left) / rect.width, 0, 1)\n    }\n\n    const emitSeek = (time: number) => {\n      onSeekRef.current?.(clamp(time, 0, displayDuration))\n    }\n\n    const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {\n      if (!seekable || event.button !== 0) return\n      event.currentTarget.setPointerCapture(event.pointerId)\n      setDragging(true)\n      emitSeek(ratioFromEvent(event) * displayDuration)\n    }\n\n    const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {\n      if (!seekable) return\n      const ratio = ratioFromEvent(event)\n      setHoverRatio(ratio)\n      if (dragging) emitSeek(ratio * displayDuration)\n    }\n\n    const endDrag = (event: React.PointerEvent<HTMLDivElement>) => {\n      if (event.currentTarget.hasPointerCapture(event.pointerId)) {\n        event.currentTarget.releasePointerCapture(event.pointerId)\n      }\n      setDragging(false)\n    }\n\n    const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n      if (!seekable) return\n      const page = Math.max(safeStep, displayDuration / 10)\n      let next: number\n      switch (event.key) {\n        case \"ArrowRight\":\n        case \"ArrowUp\":\n          next = clampedTime + safeStep\n          break\n        case \"ArrowLeft\":\n        case \"ArrowDown\":\n          next = clampedTime - safeStep\n          break\n        case \"PageUp\":\n          next = clampedTime + page\n          break\n        case \"PageDown\":\n          next = clampedTime - page\n          break\n        case \"Home\":\n          next = 0\n          break\n        case \"End\":\n          next = displayDuration\n          break\n        default:\n          return\n      }\n      event.preventDefault()\n      emitSeek(next)\n    }\n\n    const hoverTime = hoverRatio === null ? 0 : hoverRatio * displayDuration\n    const showHover = seekable && hoverRatio !== null && !dragging\n\n    return (\n      <div className={cn(\"w-full text-primary\", className)} ref={ref} {...props}>\n        <div className=\"relative w-full\" style={{ height: safeHeight }}>\n          <canvas\n            aria-hidden=\"true\"\n            className=\"absolute inset-0 block size-full\"\n            ref={canvasRef}\n          />\n\n          {safeRegions.map(region => (\n            <div\n              aria-hidden=\"true\"\n              className=\"pointer-events-none absolute inset-y-0 border-x\"\n              key={`${region.start}-${region.end}-${region.label ?? \"\"}`}\n              style={{\n                left: `${(region.start / displayDuration) * 100}%`,\n                width: `${((region.end - region.start) / displayDuration) * 100}%`,\n                backgroundColor: \"color-mix(in oklab, currentColor 12%, transparent)\",\n                borderColor: \"color-mix(in oklab, currentColor 30%, transparent)\",\n              }}\n            >\n              {region.label && (\n                <span className=\"absolute left-1 top-1 max-w-[calc(100%-0.5rem)] truncate rounded bg-background/90 px-1 text-xs text-foreground\">\n                  {region.label}\n                </span>\n              )}\n            </div>\n          ))}\n\n          {status === \"ready\" && displayDuration > 0 && (\n            <div\n              aria-hidden=\"true\"\n              className=\"pointer-events-none absolute inset-y-0 w-0.5 rounded-full bg-current\"\n              style={{ left: `calc(${progress * 100}% - 1px)` }}\n            />\n          )}\n\n          {showHover && (\n            <>\n              <div\n                aria-hidden=\"true\"\n                className=\"pointer-events-none absolute inset-y-0 w-px bg-foreground/50\"\n                style={{ left: `${hoverRatio * 100}%` }}\n              />\n              <span\n                aria-hidden=\"true\"\n                className=\"pointer-events-none absolute top-1 whitespace-nowrap rounded-md border bg-popover px-1.5 py-0.5 text-xs tabular-nums text-popover-foreground shadow-sm\"\n                style={{\n                  left: `${hoverRatio * 100}%`,\n                  transform:\n                    hoverRatio < 0.08\n                      ? \"translateX(0)\"\n                      : hoverRatio > 0.92\n                        ? \"translateX(-100%)\"\n                        : \"translateX(-50%)\",\n                }}\n              >\n                {formatTime(hoverTime)}\n              </span>\n            </>\n          )}\n\n          {status === \"loading\" && (\n            <div className=\"absolute inset-0 flex items-center overflow-hidden\" role=\"status\">\n              {/* One node, not one-per-bar: a repeating gradient comb keeps a\n                  1200px waveform from mounting 240 throwaway spans per clip. */}\n              <span\n                aria-hidden=\"true\"\n                className=\"h-[55%] w-full animate-pulse text-muted-foreground/40 motion-reduce:animate-none\"\n                style={{\n                  backgroundImage: `repeating-linear-gradient(to right, currentColor 0 ${safeBarWidth}px, transparent ${safeBarWidth}px ${safeBarWidth + safeBarGap}px)`,\n                }}\n              />\n              <span className=\"sr-only\">Decoding audio…</span>\n            </div>\n          )}\n\n          {status === \"error\" && (\n            <div className=\"absolute inset-0 flex items-center justify-center\" role=\"status\">\n              <p className=\"truncate px-2 text-xs text-destructive\">\n                {errorMessage ?? \"Couldn't decode audio\"}\n              </p>\n            </div>\n          )}\n\n          {hasTimeline && (\n            <div\n              aria-disabled={disabled || undefined}\n              aria-label={label}\n              aria-orientation=\"horizontal\"\n              aria-valuemax={Math.round(displayDuration * 100) / 100}\n              aria-valuemin={0}\n              aria-valuenow={Math.round(clampedTime * 100) / 100}\n              aria-valuetext={`${formatTime(clampedTime)} of ${formatTime(displayDuration)}`}\n              className={cn(\n                \"absolute inset-0 rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n                disabled ? \"cursor-default\" : \"cursor-pointer touch-none\",\n              )}\n              onKeyDown={handleKeyDown}\n              onPointerCancel={endDrag}\n              onPointerDown={handlePointerDown}\n              onPointerLeave={() => setHoverRatio(null)}\n              onPointerMove={handlePointerMove}\n              onPointerUp={endDrag}\n              role=\"slider\"\n              tabIndex={disabled ? -1 : 0}\n            />\n          )}\n        </div>\n\n        <div className=\"mt-1.5 flex items-center justify-between text-xs tabular-nums text-muted-foreground\">\n          <span>{status === \"loading\" ? \"Decoding…\" : formatTime(clampedTime)}</span>\n          <span>{formatTime(displayDuration)}</span>\n        </div>\n      </div>\n    )\n  },\n)\n\nAudioWaveform.displayName = \"AudioWaveform\"\n\nexport default AudioWaveform\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}