{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "spectrum",
  "title": "Spectrum",
  "description": "A frequency spectrum analyser with axes, grid and peak hold.",
  "dependencies": [
    "solid-js@^1.9.15"
  ],
  "registryDependencies": [
    "@audiocn-solid/core",
    "@audiocn-solid/use-frame-source",
    "@audiocn-solid/use-reduced-motion",
    "@audiocn-solid/use-visibility"
  ],
  "files": [
    {
      "path": "components/ui/spectrum.tsx",
      "content": "import { For, createContext, createMemo, useContext } from \"solid-js\";\n\nimport { useFrameSource } from \"@/hooks/use-frame-source\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { useVisibility } from \"@/hooks/use-visibility\";\nimport { clamp } from \"@/lib/audio/decibels\";\nimport { subscribeFrame } from \"@/lib/audio/frame-loop\";\nimport { linearTaper, logTaper } from \"@/lib/audio/taper\";\nimport type { FrameSource, Taper, VisualFrame } from \"@/lib/audio/types\";\nimport { provideContext } from \"@/lib/solid/context\";\nimport { createCompatEffect } from \"@/lib/solid/effect\";\nimport type {\n  CanvasDOMProps,\n  DivDOMProps,\n  JSXElement,\n} from \"@/lib/solid/jsx-types\";\nimport { omitProps } from \"@/lib/solid/props\";\nimport { setRefValue } from \"@/lib/solid/ref\";\nimport type { RefTarget } from \"@/lib/solid/ref\";\nimport { cn } from \"@/lib/utils\";\n\nconst DEFAULT_FREQUENCY_TICKS = [100, 1000, 10_000];\n\nconst LEVEL_TICK_DB = 12;\n\ninterface SpectrumContextValue {\n  minDb: number;\n  maxDb: number;\n  minHz: number;\n  maxHz: number;\n  frequencyTaper: Taper;\n  variant: \"bars\" | \"line\" | \"area\";\n  peakHold: boolean;\n  grid: boolean;\n  frame: VisualFrame | null;\n  version: number;\n}\n\nconst SpectrumContext = createContext<SpectrumContextValue>();\n\nconst useSpectrum = (part: string) => {\n  const value = useContext(SpectrumContext);\n\n  if (!value) throw new Error(`${part} must be used inside Spectrum.`);\n\n  return value;\n};\n\ninterface Size {\n  width: number;\n  height: number;\n  ratio: number;\n}\n\ninterface Colors {\n  grid: string;\n  line: string;\n  peak: string;\n}\n\nconst readColors = (canvas: HTMLCanvasElement): Colors => {\n  const style = getComputedStyle(canvas);\n\n  return {\n    grid: style.getPropertyValue(\"--spectrum-grid\").trim() || style.color,\n    line: style.getPropertyValue(\"--spectrum\").trim() || style.color,\n    peak: style.getPropertyValue(\"--spectrum-peak\").trim() || style.color,\n  };\n};\n\nconst drawGrid = (\n  context: CanvasRenderingContext2D,\n  size: Size,\n  settings: SpectrumContextValue,\n  color: string\n) => {\n  const span = settings.maxDb - settings.minDb;\n  context.strokeStyle = color;\n  context.lineWidth = 1;\n  context.beginPath();\n\n  for (const db of [\n    settings.maxDb,\n    settings.maxDb - span / 3,\n    settings.maxDb - (span * 2) / 3,\n  ]) {\n    const y =\n      Math.round((1 - (db - settings.minDb) / span) * size.height) + 0.5;\n\n    context.moveTo(0, y);\n    context.lineTo(size.width, y);\n  }\n\n  for (const hz of DEFAULT_FREQUENCY_TICKS) {\n    if (hz > settings.minHz && hz < settings.maxHz) {\n      const x =\n        Math.round(settings.frequencyTaper.toPosition(hz) * size.width) + 0.5;\n\n      context.moveTo(x, 0);\n      context.lineTo(x, size.height);\n    }\n  }\n\n  context.stroke();\n};\n\nconst drawBands = (\n  context: CanvasRenderingContext2D,\n  size: Size,\n  bands: Float32Array,\n  variant: SpectrumContextValue[\"variant\"]\n) => {\n  const slot = size.width / bands.length;\n\n  if (variant === \"bars\") {\n    const gap = Math.min(2, slot * 0.25);\n\n    for (const [index, band] of bands.entries()) {\n      const height = clamp(band, 0, 1) * size.height;\n      context.fillRect(\n        index * slot + gap / 2,\n        size.height - height,\n        Math.max(1, slot - gap),\n        height\n      );\n    }\n\n    return;\n  }\n\n  context.lineWidth = 1.5;\n  context.lineJoin = \"round\";\n  context.beginPath();\n\n  for (const [index, band] of bands.entries()) {\n    const x = (index + 0.5) * slot;\n    const y = size.height - clamp(band, 0, 1) * size.height;\n\n    if (index === 0) context.moveTo(x, y);\n    else context.lineTo(x, y);\n  }\n\n  if (variant === \"area\") {\n    context.lineTo((bands.length - 0.5) * slot, size.height);\n    context.lineTo(0.5 * slot, size.height);\n    context.closePath();\n    context.globalAlpha = 0.3;\n    context.fill();\n    context.globalAlpha = 1;\n  }\n\n  context.stroke();\n};\n\nconst drawPeaks = (\n  context: CanvasRenderingContext2D,\n  size: Size,\n  bands: Float32Array,\n  peaks: Float32Array,\n  release: number\n) => {\n  const slot = size.width / bands.length;\n  let falling = false;\n\n  for (const [index, band] of bands.entries()) {\n    const level = clamp(band, 0, 1);\n    const held = Math.max(level, (peaks[index] ?? 0) * release);\n    peaks[index] = held;\n\n    if (held > level + 0.001) falling = true;\n    context.fillRect(\n      index * slot,\n      size.height - held * size.height - 1,\n      Math.max(1, slot - 1),\n      2\n    );\n  }\n\n  return falling;\n};\n\ninterface CanvasProps extends Omit<CanvasDOMProps, \"ref\"> {\n  ref?: RefTarget<HTMLCanvasElement>;\n  className?: string;\n}\n\nexport const SpectrumCanvas = (props: CanvasProps) => {\n  const settings = useSpectrum(\"SpectrumCanvas\");\n  const reducedMotion = useReducedMotion();\n  let canvas: HTMLCanvasElement | undefined;\n  const visible = useVisibility(() => canvas ?? null);\n\n  createCompatEffect(\n    () => ({\n      variant: settings.variant,\n      grid: settings.grid,\n      peakHold: settings.peakHold,\n      minDb: settings.minDb,\n      maxDb: settings.maxDb,\n      minHz: settings.minHz,\n      maxHz: settings.maxHz,\n      taper: settings.frequencyTaper,\n      reduced: reducedMotion(),\n    }),\n    (options) => {\n      const element = canvas;\n      const context = element?.getContext(\"2d\");\n\n      if (!(element && context)) return;\n      const size: Size = { width: 0, height: 0, ratio: 1 };\n      let colors = readColors(element);\n      let sinceColor = 0;\n      let peaks = new Float32Array(0);\n      let lastPaint = 0;\n      let dirty = true;\n      let paintedVersion = -1;\n\n      const resize = () => {\n        const rect = element.getBoundingClientRect();\n        size.ratio = window.devicePixelRatio || 1;\n        size.width = rect.width;\n        size.height = rect.height;\n        element.width = Math.max(1, Math.round(rect.width * size.ratio));\n        element.height = Math.max(1, Math.round(rect.height * size.ratio));\n        dirty = true;\n      };\n\n      resize();\n      const observer = new ResizeObserver(resize);\n      observer.observe(element);\n\n      const unsubscribe = subscribeFrame((nowMs) => {\n        if (++sinceColor >= 30) {\n          sinceColor = 0;\n          const next = readColors(element);\n\n          if (\n            next.grid !== colors.grid ||\n            next.line !== colors.line ||\n            next.peak !== colors.peak\n          ) {\n            colors = next;\n            dirty = true;\n          }\n        }\n\n        if (\n          (!dirty && settings.version === paintedVersion) ||\n          size.width === 0 ||\n          !visible.current\n        )\n          return;\n\n        if (options.reduced && nowMs - lastPaint < 250) return;\n        const elapsed = lastPaint === 0 ? 0 : nowMs - lastPaint;\n        lastPaint = nowMs;\n        dirty = false;\n        paintedVersion = settings.version;\n        context.setTransform(size.ratio, 0, 0, size.ratio, 0, 0);\n        context.clearRect(0, 0, size.width, size.height);\n\n        if (options.grid) drawGrid(context, size, settings, colors.grid);\n        const bands = settings.frame?.bands;\n\n        if (!bands?.length) return;\n        context.fillStyle = colors.line;\n        context.strokeStyle = colors.line;\n        drawBands(context, size, bands, options.variant);\n\n        if (options.peakHold) {\n          if (peaks.length !== bands.length)\n            peaks = new Float32Array(bands.length);\n          context.fillStyle = colors.peak;\n          dirty = drawPeaks(\n            context,\n            size,\n            bands,\n            peaks,\n            0.985 ** (elapsed / (1000 / 60))\n          );\n        }\n      });\n\n      return () => {\n        unsubscribe();\n        observer.disconnect();\n      };\n    }\n  );\n  const rest = omitProps(props, [\"ref\", \"class\", \"className\"]);\n\n  return (\n    <canvas\n      aria-hidden=\"true\"\n      class={cn(\n        \"[grid-column:2] [grid-row:1] size-full min-h-0\",\n        props.class,\n        props.className\n      )}\n      data-slot=\"spectrum-canvas\"\n      {...rest}\n      ref={(node: HTMLCanvasElement) => {\n        canvas = node;\n        setRefValue(props.ref, node);\n      }}\n    />\n  );\n};\n\nexport interface SpectrumFrequencyAxisProps extends DivDOMProps {\n  className?: string;\n  ticks?: number[];\n  format?: (hz: number) => string;\n}\n\nexport const SpectrumFrequencyAxis = (props: SpectrumFrequencyAxisProps) => {\n  const settings = useSpectrum(\"SpectrumFrequencyAxis\");\n\n  const ticks = createMemo(() =>\n    (props.ticks ?? DEFAULT_FREQUENCY_TICKS).filter(\n      (hz) => hz >= settings.minHz && hz <= settings.maxHz\n    )\n  );\n\n  const rest = omitProps(props, [\"class\", \"className\", \"ticks\", \"format\"]);\n\n  return (\n    <div\n      aria-hidden=\"true\"\n      class={cn(\n        \"text-muted-foreground relative [grid-column:2] [grid-row:2] h-4 text-[0.625rem] tabular-nums\",\n        props.class,\n        props.className\n      )}\n      data-slot=\"spectrum-frequency-axis\"\n      {...rest}\n    >\n      <For each={ticks()}>\n        {(hz) => (\n          <span\n            class=\"absolute top-0 left-(--tick-position) -translate-x-1/2\"\n            style={{\n              \"--tick-position\": `${settings.frequencyTaper.toPosition(hz) * 100}%`,\n            }}\n          >\n            {props.format?.(hz) ?? (hz >= 1000 ? `${hz / 1000}k` : String(hz))}\n          </span>\n        )}\n      </For>\n    </div>\n  );\n};\n\nexport interface SpectrumLevelAxisProps extends DivDOMProps {\n  className?: string;\n  ticks?: number[];\n  format?: (db: number) => string;\n}\n\nexport const SpectrumLevelAxis = (props: SpectrumLevelAxisProps) => {\n  const settings = useSpectrum(\"SpectrumLevelAxis\");\n\n  const ticks = createMemo(\n    () =>\n      props.ticks ??\n      Array.from(\n        {\n          length:\n            Math.floor((settings.maxDb - settings.minDb) / LEVEL_TICK_DB) + 1,\n        },\n        (_, index) => settings.maxDb - index * LEVEL_TICK_DB\n      )\n  );\n\n  const rest = omitProps(props, [\"class\", \"className\", \"ticks\", \"format\"]);\n\n  return (\n    <div\n      aria-hidden=\"true\"\n      class={cn(\n        \"text-muted-foreground relative [grid-column:1] [grid-row:1] w-7 text-[0.625rem] tabular-nums\",\n        props.class,\n        props.className\n      )}\n      data-slot=\"spectrum-level-axis\"\n      {...rest}\n    >\n      <For each={ticks()}>\n        {(db) => (\n          <span\n            class=\"absolute right-0 bottom-(--tick-position) translate-y-1/2\"\n            style={{\n              \"--tick-position\": `${((db - settings.minDb) / (settings.maxDb - settings.minDb)) * 100}%`,\n            }}\n          >\n            {props.format?.(db) ?? String(Math.round(db))}\n          </span>\n        )}\n      </For>\n    </div>\n  );\n};\n\nexport interface SpectrumProps extends DivDOMProps {\n  className?: string;\n  source?: FrameSource<VisualFrame> | null;\n  variant?: \"bars\" | \"line\" | \"area\";\n  minDb?: number;\n  maxDb?: number;\n  minHz?: number;\n  maxHz?: number;\n  scale?: \"log\" | \"linear\";\n  peakHold?: boolean;\n  grid?: boolean;\n  children?: JSXElement;\n}\n\nexport const Spectrum = (props: SpectrumProps) => {\n  let frame: VisualFrame | null = null;\n  let version = 0;\n  useFrameSource(\n    () => props.source,\n    (next) => {\n      frame = next;\n      version++;\n    }\n  );\n\n  const taper = createMemo(() =>\n    props.scale === \"linear\"\n      ? linearTaper(props.minHz ?? 40, props.maxHz ?? 16_000)\n      : logTaper(props.minHz ?? 40, props.maxHz ?? 16_000)\n  );\n\n  const settings: SpectrumContextValue = {\n    get minDb() {\n      return props.minDb ?? -100;\n    },\n    get maxDb() {\n      return props.maxDb ?? -30;\n    },\n    get minHz() {\n      return props.minHz ?? 40;\n    },\n    get maxHz() {\n      return props.maxHz ?? 16_000;\n    },\n    get frequencyTaper() {\n      return taper();\n    },\n    get variant() {\n      return props.variant ?? \"bars\";\n    },\n    get peakHold() {\n      return props.peakHold ?? false;\n    },\n    get grid() {\n      return props.grid ?? true;\n    },\n    get frame() {\n      return frame;\n    },\n    get version() {\n      return version;\n    },\n  };\n\n  const rest = omitProps(props, [\n    \"class\",\n    \"className\",\n    \"source\",\n    \"variant\",\n    \"minDb\",\n    \"maxDb\",\n    \"minHz\",\n    \"maxHz\",\n    \"scale\",\n    \"peakHold\",\n    \"grid\",\n    \"children\",\n  ]);\n\n  return provideContext(SpectrumContext, settings, () => (\n    <div\n      aria-label=\"Frequency spectrum\"\n      class={cn(\n        \"grid h-40 w-full grid-cols-[auto_minmax(0,1fr)] grid-rows-[minmax(0,1fr)_auto] gap-1 [--spectrum-grid:var(--border)] [--spectrum-peak:var(--foreground)] [--spectrum:var(--primary)]\",\n        props.class,\n        props.className\n      )}\n      data-slot=\"spectrum\"\n      data-variant={settings.variant}\n      role=\"img\"\n      {...rest}\n    >\n      {props.children ?? (\n        <>\n          <SpectrumLevelAxis />\n          <SpectrumCanvas />\n          <SpectrumFrequencyAxis />\n        </>\n      )}\n    </div>\n  ));\n};\n",
      "type": "registry:ui"
    },
    {
      "path": "lib/audiocn-license.txt",
      "content": "Audio component source adapted from https://github.com/audiocn/ui.\nMIT License — Copyright (c) 2026 OrcDev\n\nRegular SVG icon geometry adapted from @phosphor-icons/react 2.1.10.\nMIT License — Copyright (c) 2020 Phosphor Icons\n\nThe following permission and warranty terms apply to both notices.\nRetain this file with copies or substantial portions of the source.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n",
      "type": "registry:file",
      "target": "@lib/audiocn-license.txt"
    }
  ],
  "type": "registry:ui"
}
