{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "core-spectrum",
  "type": "registry:ui",
  "title": "Spectrum",
  "description": "An FFT magnitude display as bars or a filled curve, on a logarithmic frequency axis — because a linear one spends half its width above 10 kHz and crushes the bass into nothing.",
  "dependencies": [
    "maruaudio@>=0.0.3 <0.1.0"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "components/ui/spectrum.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/spectrum\n * Copyright © [Maru](https://maru.audio)\n *\n * Maru UI Kit — https://maru.audio/ui-kit\n *\n * Installed from the Maru UI registry. This file is yours now: edit it\n * freely, it will not be overwritten unless you re-run the add command.\n *\n * Keep this item’s companion files and CSS imports together. Dependencies\n * declared by the registry are installed with the source.\n */\n/**\n * Spectrum — an FFT magnitude display, as bars or a filled curve.\n *\n * Feed it an `AnalyserNode` (or a `getData` callback returning dB values) and\n * it plots magnitude against frequency. The x axis is logarithmic by default,\n * because a linear one spends half its width on 10–20 kHz — where almost\n * nothing musically interesting happens — and crushes the bass into a few\n * pixels.\n *\n * Styling comes from CSS custom properties — see `lib/theme.ts`.\n */\n\nimport { useCallback, useRef } from \"react\"\nimport type { CSSProperties } from \"react\"\nimport {\n  canvasColor,\n  token,\n} from \"maruaudio/ui/shared/theme\"\nimport {\n  useAudioCanvas,\n  type AudioSourceProps,\n} from \"maruaudio/ui/shared/use-audio-source\"\n\nexport interface SpectrumProps extends AudioSourceProps {\n  width?: number | string\n  height?: number\n  /** Bars, or a filled line across the bins. */\n  variant?: \"bars\" | \"curve\"\n  /** Number of bars. Ignored by the curve variant. */\n  bars?: number\n  /** Frequency axis scaling. Log is the musically useful one. */\n  scale?: \"log\" | \"linear\"\n  /** Lowest frequency shown, in Hz. */\n  minFrequency?: number\n  /** Highest frequency shown, in Hz. Defaults to the Nyquist limit. */\n  maxFrequency?: number\n  /** Bottom of the magnitude range, in dB. */\n  minDecibels?: number\n  /** Top of the magnitude range, in dB. */\n  maxDecibels?: number\n  /** Sample rate, needed for the frequency axis when using `getData`. */\n  sampleRate?: number\n  /** Draw the frequency gridlines. */\n  showGrid?: boolean\n  color?: string\n  className?: string\n  style?: CSSProperties\n  \"aria-label\"?: string\n}\n\n/** Decade gridlines a listener actually reads the axis by. */\nconst GRID_FREQUENCIES = [100, 1000, 10000]\n\nexport function Spectrum({\n  analyser,\n  getData,\n  dataSize = 1024,\n  paused = false,\n  width = \"100%\",\n  height = 140,\n  variant = \"bars\",\n  bars = 48,\n  scale = \"log\",\n  minFrequency = 20,\n  maxFrequency,\n  minDecibels = -90,\n  maxDecibels = -10,\n  sampleRate,\n  showGrid = true,\n  color,\n  className,\n  style,\n  \"aria-label\": ariaLabel,\n}: SpectrumProps) {\n  const canvasRef = useRef<HTMLCanvasElement | null>(null)\n\n  const draw = useCallback(\n    ({\n      ctx,\n      width: w,\n      height: h,\n      data,\n    }: {\n      ctx: CanvasRenderingContext2D\n      width: number\n      height: number\n      data: Float32Array | null\n    }) => {\n      const styles = getComputedStyle(ctx.canvas)\n      const accent = canvasColor(ctx.canvas, color ?? token.accent)\n      const accent2 = canvasColor(\n        ctx.canvas,\n        styles.getPropertyValue(\"--uikit-accent-2\").trim() || accent,\n      )\n      const grid = styles.getPropertyValue(\"--uikit-border\").trim() || \"rgba(148,163,184,0.35)\"\n\n      ctx.clearRect(0, 0, w, h)\n\n      const rate = sampleRate ?? analyser?.context.sampleRate ?? 48000\n      const nyquist = rate / 2\n      const topFrequency = Math.min(maxFrequency ?? nyquist, nyquist)\n\n      /** Frequency → x, honouring the chosen axis scaling. */\n      const freqToX = (frequency: number): number => {\n        if (scale === \"linear\") {\n          return ((frequency - minFrequency) / (topFrequency - minFrequency)) * w\n        }\n        const low = Math.log10(Math.max(minFrequency, 1))\n        const high = Math.log10(topFrequency)\n        return ((Math.log10(Math.max(frequency, 1)) - low) / (high - low)) * w\n      }\n\n      if (showGrid) {\n        ctx.strokeStyle = grid\n        ctx.globalAlpha = 0.3\n        ctx.lineWidth = 1\n        for (const frequency of GRID_FREQUENCIES) {\n          if (frequency <= minFrequency || frequency >= topFrequency) continue\n          const x = Math.round(freqToX(frequency)) + 0.5\n          ctx.beginPath()\n          ctx.moveTo(x, 0)\n          ctx.lineTo(x, h)\n          ctx.stroke()\n        }\n        ctx.globalAlpha = 1\n      }\n\n      if (!data || data.length === 0) return\n\n      /** dB magnitude → 0..1 of the display height. */\n      const magnitude = (db: number): number => {\n        const unit = (db - minDecibels) / (maxDecibels - minDecibels)\n        return unit < 0 ? 0 : unit > 1 ? 1 : unit\n      }\n\n      const binCount = data.length\n      const binFrequency = (index: number) => (index / binCount) * nyquist\n\n      const gradient = ctx.createLinearGradient(0, h, 0, 0)\n      gradient.addColorStop(0, accent)\n      gradient.addColorStop(1, accent2)\n\n      if (variant === \"bars\") {\n        const gap = w / bars > 3 ? 1 : 0\n        const barWidth = w / bars\n\n        ctx.fillStyle = gradient\n        for (let i = 0; i < bars; i += 1) {\n          // Each bar spans a frequency band; take the loudest bin inside it so\n          // a narrow peak isn't averaged into invisibility.\n          const x0 = (i / bars) * w\n          const x1 = ((i + 1) / bars) * w\n          let loudest = -Infinity\n          for (let bin = 0; bin < binCount; bin += 1) {\n            const x = freqToX(binFrequency(bin))\n            if (x < x0 || x >= x1) continue\n            if (data[bin] > loudest) loudest = data[bin]\n          }\n          if (loudest === -Infinity) continue\n          const barHeight = magnitude(loudest) * h\n          if (barHeight <= 0) continue\n          ctx.fillRect(x0, h - barHeight, Math.max(1, barWidth - gap), barHeight)\n        }\n        return\n      }\n\n      // Curve: one point per bin, filled down to the baseline.\n      ctx.beginPath()\n      ctx.moveTo(0, h)\n      for (let bin = 0; bin < binCount; bin += 1) {\n        const frequency = binFrequency(bin)\n        if (frequency < minFrequency || frequency > topFrequency) continue\n        ctx.lineTo(freqToX(frequency), h - magnitude(data[bin]) * h)\n      }\n      ctx.lineTo(w, h)\n      ctx.closePath()\n\n      ctx.globalAlpha = 0.35\n      ctx.fillStyle = gradient\n      ctx.fill()\n      ctx.globalAlpha = 1\n      ctx.strokeStyle = accent\n      ctx.lineWidth = 1.5\n      ctx.stroke()\n    },\n    [\n      color,\n      showGrid,\n      variant,\n      bars,\n      scale,\n      minFrequency,\n      maxFrequency,\n      minDecibels,\n      maxDecibels,\n      sampleRate,\n      analyser,\n    ],\n  )\n\n  useAudioCanvas(canvasRef, {\n    analyser,\n    getData,\n    dataSize,\n    paused,\n    kind: \"frequency\",\n    draw,\n  })\n\n  return (\n    <canvas\n      ref={canvasRef}\n      className={`uikit-control${className ? ` ${className}` : \"\"}`}\n      role=\"img\"\n      aria-label={ariaLabel ?? \"Frequency spectrum\"}\n      style={{\n        display: \"block\",\n        width,\n        height,\n        background: token.surface,\n        borderRadius: token.radius,\n        border: `1px solid ${token.border}`,\n        ...style,\n      }}\n    />\n  )\n}\n",
      "type": "registry:component",
      "target": "@ui/spectrum.tsx"
    }
  ]
}
