{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "audio-waveform",
  "type": "registry:ui",
  "title": "Audio Waveform",
  "description": "Canvas audio visualiser — live bars, mirrored history or oscilloscope line from a mic or media element, or recorded peaks with a seek scrubber.",
  "dependencies": [],
  "registryDependencies": [
    "https://kuro.abstergo.fyi/r/kura-theme.json",
    "https://kuro.abstergo.fyi/r/kura-merge-refs.json",
    "https://kuro.abstergo.fyi/r/use-audio-analyser.json",
    "https://kuro.abstergo.fyi/r/use-reduced-motion.json",
    "utils"
  ],
  "files": [
    {
      "path": "registry/kura/audio-waveform/audio-waveform.tsx",
      "type": "registry:ui",
      "target": "components/kura/audio-waveform/audio-waveform.tsx",
      "content": "// The root's role is dynamic: role=\"img\" for display, role=\"slider\" (focusable, with pointer and\n// key handlers) when `onSeek` is set — static analysis cannot see that.\n// biome-ignore-all lint/a11y/noStaticElementInteractions: handlers are only attached with role=\"slider\".\n// biome-ignore-all lint/a11y/useAriaPropsSupportedByRole: aria-label/value props match the runtime role.\n\"use client\";\n\nimport * as React from \"react\";\nimport {\n  type AudioAnalyser,\n  type AudioFrame,\n  useAudioAnalyser,\n} from \"@/hooks/kura/use-audio-analyser\";\nimport { useReducedMotion } from \"@/hooks/kura/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\nimport { mergeRefs } from \"@/lib/kura/merge-refs\";\n\nexport interface AudioWaveformProps\n  extends Omit<React.ComponentProps<\"div\">, \"children\" | \"onChange\"> {\n  /** A live analyser from `useAudioAnalyser` (share one between several visuals). */\n  analyser?: AudioAnalyser;\n  /** Mic stream or media element to analyse internally when no `analyser` is given. */\n  source?: MediaStream | HTMLMediaElement | null;\n  /** Generate synthetic audio data (docs, previews, before permission). */\n  simulate?: boolean | \"speech\" | \"music\" | \"silence\";\n  /**\n   * Static/recorded mode: amplitudes 0..1 (any length; resampled to the bar count). When set,\n   * live data is ignored. Use `peaksFromSamples` to build them from decoded audio.\n   */\n  peaks?: readonly number[];\n  /** Playback position 0..1 for static mode. Bars before it are drawn solid, after it faded. */\n  progress?: number;\n  /** Make the waveform a seek slider (pointer drag + arrow keys). Receives 0..1. */\n  onSeek?: (progress: number) => void;\n  /** Total duration in seconds, used for the slider's spoken value (\"0:12 of 1:04\"). */\n  duration?: number;\n  /** `bars` = bottom-aligned, `mirrored` = symmetric around the centre, `line` = oscilloscope/envelope. Default \"mirrored\". */\n  variant?: \"bars\" | \"mirrored\" | \"line\";\n  /**\n   * Live data shown by `bars`/`mirrored`: `history` scrolls recent loudness from right to left\n   * (voice memo), `spectrum` shows frequency bands. Default: history for mirrored, spectrum for bars.\n   */\n  display?: \"history\" | \"spectrum\";\n  /** Bar width in CSS px. Default 3. */\n  barWidth?: number;\n  /** Gap between bars in CSS px. Default 2. */\n  barGap?: number;\n  /** Minimum bar height in CSS px so silence still reads as a line of dots. Default 2. */\n  minBarHeight?: number;\n  /** `default` = ink, `muted` = subtle ink, `accent` = the signal colour (only for live AI listening). Default \"default\". */\n  tone?: \"default\" | \"muted\" | \"accent\";\n  /** Pause the live animation (the last frame stays). Default true. */\n  active?: boolean;\n  /** Accessible name. Default \"Audio waveform\" (or \"Seek\" when `onSeek` is set). */\n  label?: string;\n}\n\n/** Downsample raw PCM samples (-1..1) to `count` peak amplitudes 0..1, normalised to the loudest. */\nexport function peaksFromSamples(samples: ArrayLike<number>, count: number): number[] {\n  const out = new Array<number>(Math.max(0, count)).fill(0);\n  if (!samples.length || count <= 0) return out;\n  const size = samples.length / count;\n  let max = 0;\n  for (let i = 0; i < count; i++) {\n    const start = Math.floor(i * size);\n    const end = Math.max(start + 1, Math.floor((i + 1) * size));\n    let peak = 0;\n    for (let j = start; j < end && j < samples.length; j++) {\n      const v = Math.abs(samples[j] ?? 0);\n      if (v > peak) peak = v;\n    }\n    out[i] = peak;\n    if (peak > max) max = peak;\n  }\n  return max > 0 ? out.map((v) => v / max) : out;\n}\n\nfunction resample(values: ArrayLike<number>, count: number): Float32Array {\n  const out = new Float32Array(count);\n  const n = values.length;\n  if (!n) return out;\n  for (let i = 0; i < count; i++) {\n    const a = Math.floor((i * n) / count);\n    const b = Math.max(a + 1, Math.floor(((i + 1) * n) / count));\n    let peak = 0;\n    for (let j = a; j < b && j < n; j++) peak = Math.max(peak, values[j] ?? 0);\n    out[i] = peak;\n  }\n  return out;\n}\n\nfunction formatTime(s: number): string {\n  const m = Math.floor(s / 60);\n  const sec = Math.floor(s % 60);\n  return `${m}:${sec.toString().padStart(2, \"0\")}`;\n}\n\nconst TONES = {\n  default: \"text-kura-fg\",\n  muted: \"text-kura-fg-subtle\",\n  accent: \"text-kura-accent\",\n} as const;\n\nconst HISTORY_INTERVAL = 50; // ms per history sample\n\n/**\n * Canvas waveform for live audio (mic, playback, simulated) or recorded peaks, crisp at any\n * device pixel ratio. Static mode can be a seek slider with a played/unplayed split.\n */\nexport function AudioWaveform({\n  analyser: analyserProp,\n  source,\n  simulate,\n  peaks,\n  progress,\n  onSeek,\n  duration,\n  variant = \"mirrored\",\n  display,\n  barWidth = 3,\n  barGap = 2,\n  minBarHeight = 2,\n  tone = \"default\",\n  active = true,\n  label,\n  className,\n  ref,\n  onKeyDown,\n  ...props\n}: AudioWaveformProps) {\n  const isStatic = peaks !== undefined;\n  const internal = useAudioAnalyser({\n    source,\n    simulate,\n    enabled: !analyserProp && !isStatic && (!!source || !!simulate),\n  });\n  const analyser = analyserProp ?? internal;\n  const reduced = useReducedMotion();\n  const mode = display ?? (variant === \"bars\" ? \"spectrum\" : \"history\");\n\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const canvasRef = React.useRef<HTMLCanvasElement>(null);\n  const sizeRef = React.useRef({ w: 0, h: 0, dpr: 1 });\n  const colorRef = React.useRef(\"currentColor\");\n  const historyRef = React.useRef<number[]>([]);\n  const lastPush = React.useRef(0);\n  const hoverRef = React.useRef<number | null>(null);\n  const draggingRef = React.useRef(false);\n  const lastFrame = React.useRef<AudioFrame | null>(null);\n  const [scrubbing, setScrubbing] = React.useState(false);\n\n  const draw = React.useCallback(\n    (frame: AudioFrame | null, now: number) => {\n      const canvas = canvasRef.current;\n      const ctx = canvas?.getContext(\"2d\");\n      const { w, h, dpr } = sizeRef.current;\n      if (!canvas || !ctx || w === 0 || h === 0) return;\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      ctx.clearRect(0, 0, w, h);\n      ctx.fillStyle = colorRef.current;\n      ctx.strokeStyle = colorRef.current;\n\n      const step = barWidth + barGap;\n      const count = Math.max(1, Math.floor((w + barGap) / step));\n      const usable = h - 2;\n      const radius = barWidth / 2;\n      const align = variant === \"bars\" ? \"bottom\" : \"center\";\n      const playedX = typeof progress === \"number\" ? Math.min(1, Math.max(0, progress)) * w : null;\n      const hoverX = hoverRef.current;\n\n      const bar = (x: number, v: number, alpha: number) => {\n        const bh = Math.max(minBarHeight, v * usable);\n        const y = align === \"bottom\" ? h - 1 - bh : (h - bh) / 2;\n        ctx.globalAlpha = alpha;\n        ctx.beginPath();\n        if (typeof ctx.roundRect === \"function\")\n          ctx.roundRect(x, y, barWidth, bh, Math.min(radius, bh / 2));\n        else ctx.rect(x, y, barWidth, bh);\n        ctx.fill();\n      };\n\n      const alphaFor = (x: number) => {\n        if (playedX === null) return 1;\n        if (x + barWidth / 2 <= playedX) return 1;\n        if (hoverX !== null && x <= hoverX) return 0.5;\n        return 0.24;\n      };\n\n      // Static peaks.\n      if (isStatic) {\n        const values = resample(peaks ?? [], count);\n        if (variant === \"line\") {\n          drawEnvelope(ctx, values, w, h, minBarHeight);\n          if (playedX !== null) {\n            ctx.save();\n            ctx.globalCompositeOperation = \"destination-in\";\n            ctx.globalAlpha = 1;\n            ctx.fillRect(0, 0, playedX, h);\n            ctx.globalAlpha = 0.24;\n            ctx.fillRect(playedX, 0, w - playedX, h);\n            ctx.restore();\n          }\n        } else {\n          const offset = (w - (count * step - barGap)) / 2;\n          for (let i = 0; i < count; i++) {\n            const x = Math.round(offset + i * step);\n            bar(x, values[i] ?? 0, alphaFor(x));\n          }\n        }\n        if (playedX !== null && (hoverX !== null || draggingRef.current)) {\n          ctx.globalAlpha = 0.9;\n          ctx.fillRect(Math.round(hoverX ?? playedX), 0, 1, h);\n        }\n        ctx.globalAlpha = 1;\n        return;\n      }\n\n      // Live.\n      const f = frame;\n      if (variant === \"line\") {\n        ctx.globalAlpha = 1;\n        ctx.lineWidth = 1.5;\n        ctx.lineJoin = \"round\";\n        ctx.beginPath();\n        const data = f?.waveform;\n        const points = Math.min(256, Math.max(32, Math.floor(w / 3)));\n        for (let i = 0; i <= points; i++) {\n          const x = (i / points) * w;\n          const idx = data ? Math.floor((i / points) * (data.length - 1)) : 0;\n          const v = data ? (data[idx] ?? 0) : 0;\n          // taper the ends so the line breathes out of a flat baseline\n          const taper = Math.sin((i / points) * Math.PI);\n          const y = h / 2 + v * (usable / 2) * taper * 1.6;\n          if (i === 0) ctx.moveTo(x, y);\n          else ctx.lineTo(x, y);\n        }\n        ctx.stroke();\n        return;\n      }\n\n      if (mode === \"spectrum\") {\n        const values = resample(f?.bands ?? [], count);\n        const offset = (w - (count * step - barGap)) / 2;\n        for (let i = 0; i < count; i++) {\n          bar(Math.round(offset + i * step), values[i] ?? 0, 1);\n        }\n        return;\n      }\n\n      // history: newest on the right, scrolling left.\n      const hist = historyRef.current;\n      const elapsed = reduced ? 1 : Math.min(1, (now - lastPush.current) / HISTORY_INTERVAL);\n      const shift = elapsed * step;\n      const fade = Math.min(w * 0.18, 64);\n      for (let i = 0; i < count + 1; i++) {\n        const v = hist[hist.length - 1 - i] ?? 0;\n        const x = Math.round(w - barWidth - i * step - shift + step);\n        if (x + barWidth < 0) break;\n        if (x > w - barWidth) continue;\n        const edge = x < fade ? Math.max(0, x / fade) : 1;\n        bar(x, v, 0.25 + 0.75 * edge);\n      }\n      ctx.globalAlpha = 1;\n    },\n    [barWidth, barGap, minBarHeight, variant, mode, isStatic, peaks, progress, reduced],\n  );\n\n  const redraw = React.useCallback(() => {\n    draw(lastFrame.current, typeof performance !== \"undefined\" ? performance.now() : 0);\n  }, [draw]);\n\n  // Size the canvas to the element at device-pixel resolution.\n  React.useEffect(() => {\n    const root = rootRef.current;\n    const canvas = canvasRef.current;\n    if (!root || !canvas) return;\n    const readColor = () => {\n      colorRef.current = window.getComputedStyle(root).color || \"currentColor\";\n    };\n    const resize = () => {\n      const dpr = Math.min(3, window.devicePixelRatio || 1);\n      const w = root.clientWidth;\n      const h = root.clientHeight;\n      sizeRef.current = { w, h, dpr };\n      canvas.width = Math.round(w * dpr);\n      canvas.height = Math.round(h * dpr);\n      readColor();\n      redraw();\n    };\n    resize();\n    const ro = typeof ResizeObserver !== \"undefined\" ? new ResizeObserver(resize) : null;\n    ro?.observe(root);\n    // Theme switches change the token colour without resizing.\n    const mo =\n      typeof MutationObserver !== \"undefined\"\n        ? new MutationObserver(() => {\n            readColor();\n            redraw();\n          })\n        : null;\n    mo?.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\", \"data-theme\", \"style\"],\n    });\n    return () => {\n      ro?.disconnect();\n      mo?.disconnect();\n    };\n  }, [redraw]);\n\n  // Re-read the colour when the tone changes.\n  // biome-ignore lint/correctness/useExhaustiveDependencies: tone changes the CSS colour we sample.\n  React.useEffect(() => {\n    const root = rootRef.current;\n    if (!root) return;\n    colorRef.current = window.getComputedStyle(root).color || \"currentColor\";\n    redraw();\n  }, [tone, redraw]);\n\n  // Live loop.\n  React.useEffect(() => {\n    if (isStatic || !active) return;\n    let lastDraw = 0;\n    return analyser.subscribe((frame) => {\n      lastFrame.current = frame;\n      const now = frame.time;\n      if (now - lastPush.current >= HISTORY_INTERVAL) {\n        const hist = historyRef.current;\n        hist.push(Math.min(1, frame.level * 0.85 + frame.peak * 0.25));\n        if (hist.length > 600) hist.splice(0, hist.length - 600);\n        lastPush.current = now;\n      }\n      // Reduced motion: a calm meter that updates a few times per second.\n      if (reduced && now - lastDraw < 160) return;\n      lastDraw = now;\n      draw(frame, now);\n    });\n  }, [analyser, isStatic, active, draw, reduced]);\n\n  // Static redraws.\n  React.useEffect(() => {\n    if (isStatic) redraw();\n  }, [isStatic, redraw]);\n\n  const seekable = isStatic && !!onSeek;\n  const posFromEvent = (e: React.PointerEvent) => {\n    const rect = e.currentTarget.getBoundingClientRect();\n    return rect.width > 0 ? Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width)) : 0;\n  };\n\n  const pct =\n    typeof progress === \"number\" ? Math.round(Math.min(1, Math.max(0, progress)) * 100) : 0;\n  const valueText =\n    duration !== undefined\n      ? `${formatTime((pct / 100) * duration)} of ${formatTime(duration)}`\n      : `${pct}%`;\n\n  return (\n    <div\n      ref={mergeRefs(rootRef, ref)}\n      data-slot=\"audio-waveform\"\n      data-variant={variant}\n      data-mode={isStatic ? \"static\" : \"live\"}\n      data-status={isStatic ? undefined : analyser.status}\n      data-scrubbing={scrubbing || undefined}\n      role={seekable ? \"slider\" : \"img\"}\n      aria-label={label ?? (seekable ? \"Seek\" : \"Audio waveform\")}\n      aria-valuemin={seekable ? 0 : undefined}\n      aria-valuemax={seekable ? 100 : undefined}\n      aria-valuenow={seekable ? pct : undefined}\n      aria-valuetext={seekable ? valueText : undefined}\n      tabIndex={seekable ? 0 : undefined}\n      onPointerDown={\n        seekable\n          ? (e) => {\n              e.currentTarget.setPointerCapture?.(e.pointerId);\n              draggingRef.current = true;\n              setScrubbing(true);\n              onSeek?.(posFromEvent(e));\n            }\n          : undefined\n      }\n      onPointerMove={\n        seekable\n          ? (e) => {\n              const p = posFromEvent(e);\n              hoverRef.current = p * sizeRef.current.w;\n              if (draggingRef.current) onSeek?.(p);\n              else redraw();\n            }\n          : undefined\n      }\n      onPointerUp={\n        seekable\n          ? () => {\n              draggingRef.current = false;\n              setScrubbing(false);\n            }\n          : undefined\n      }\n      onPointerLeave={\n        seekable\n          ? () => {\n              hoverRef.current = null;\n              if (!draggingRef.current) redraw();\n            }\n          : undefined\n      }\n      onKeyDown={(e) => {\n        onKeyDown?.(e);\n        if (!seekable || e.defaultPrevented) return;\n        const current = typeof progress === \"number\" ? progress : 0;\n        const delta = e.shiftKey ? 0.1 : 0.05;\n        let next: number | null = null;\n        if (e.key === \"ArrowRight\" || e.key === \"ArrowUp\") next = current + delta;\n        else if (e.key === \"ArrowLeft\" || e.key === \"ArrowDown\") next = current - delta;\n        else if (e.key === \"Home\") next = 0;\n        else if (e.key === \"End\") next = 1;\n        if (next !== null) {\n          e.preventDefault();\n          onSeek?.(Math.min(1, Math.max(0, next)));\n        }\n      }}\n      className={cn(\n        \"relative h-10 w-full min-w-0 select-none\",\n        TONES[tone],\n        seekable &&\n          \"cursor-pointer touch-none rounded-kura-sm outline-none focus-visible:shadow-kura-focus\",\n        className,\n      )}\n      {...props}\n    >\n      <canvas ref={canvasRef} aria-hidden className=\"absolute inset-0 size-full\" />\n    </div>\n  );\n}\n\nfunction drawEnvelope(\n  ctx: CanvasRenderingContext2D,\n  values: Float32Array,\n  w: number,\n  h: number,\n  min: number,\n) {\n  const n = values.length;\n  if (!n) return;\n  const mid = h / 2;\n  const amp = (v: number) => Math.max(min / 2, (v * (h - 2)) / 2);\n  ctx.globalAlpha = 1;\n  ctx.beginPath();\n  ctx.moveTo(0, mid);\n  for (let i = 0; i < n; i++) {\n    const x = (i / Math.max(1, n - 1)) * w;\n    ctx.lineTo(x, mid - amp(values[i] ?? 0));\n  }\n  for (let i = n - 1; i >= 0; i--) {\n    const x = (i / Math.max(1, n - 1)) * w;\n    ctx.lineTo(x, mid + amp(values[i] ?? 0));\n  }\n  ctx.closePath();\n  ctx.fill();\n}\n"
    },
    {
      "path": "registry/kura/audio-waveform/index.ts",
      "type": "registry:ui",
      "target": "components/kura/audio-waveform/index.ts",
      "content": "export { AudioWaveform, type AudioWaveformProps, peaksFromSamples } from \"./audio-waveform\";\n"
    }
  ],
  "categories": [
    "voice",
    "audio",
    "waveform",
    "visualizer",
    "canvas"
  ],
  "docs": "Docs: https://kuro.abstergo.fyi/components/audio-waveform · Markdown for agents: https://kuro.abstergo.fyi/components/audio-waveform.md",
  "meta": {
    "kura": {
      "status": "beta",
      "whenToUse": [
        "Show that the mic is live while dictating or talking to a voice agent (mirrored history).",
        "Visualise playback of a model's spoken reply or an <audio> element (spectrum bars or line).",
        "A recorded voice note / audio attachment with a played/unplayed split and drag-to-seek."
      ],
      "whenNotToUse": [
        "A complete dictation control with permissions, transcript and states — use voice-dictation (it renders this).",
        "An ambient “the agent is speaking” blob — use agent-orb.",
        "A non-audio progress indicator — use a progress bar or context-meter."
      ]
    }
  }
}
