{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "core-level-meter",
  "type": "registry:ui",
  "title": "Level Meter",
  "description": "Volume bars with peak hold: RMS for the bar, a decaying tick for the recent peak, in either orientation. Takes an analyser, a data callback, or levels you already computed.",
  "dependencies": [
    "maruaudio@>=0.0.3 <0.1.0"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "components/ui/level-meter.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/level-meter\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 * LevelMeter — volume bars with peak hold.\n *\n * One bar per channel, in either orientation. The bar follows RMS (what you\n * hear as loudness) while a thin tick marks the recent peak and bleeds back\n * down, because on percussive material the peak is gone long before the eye\n * catches it.\n *\n * Feed it an `AnalyserNode`, a `getData` callback, or — when your engine\n * already computes levels — a plain `value` array, which skips the analysis\n * entirely.\n *\n * Styling comes from CSS custom properties — see `lib/theme.ts`.\n */\n\nimport { useCallback, useEffect, useRef, useState } from \"react\"\nimport type { CSSProperties } from \"react\"\nimport { token } from \"maruaudio/ui/shared/theme\"\nimport {\n  amplitudeToDb,\n  createPeakHold,\n  dbToUnit,\n  peak as peakOf,\n  rms,\n  useDocumentVisible,\n  type AudioSourceProps,\n} from \"maruaudio/ui/shared/use-audio-source\"\n\nexport interface LevelMeterProps extends AudioSourceProps {\n  /**\n   * Pre-computed levels, one per channel, as amplitude 0..1. Supplying this\n   * bypasses `analyser`/`getData` — for engines that already meter internally.\n   */\n  value?: ReadonlyArray<number>\n  /** Number of bars when metering from an analyser. */\n  channels?: number\n\n  orientation?: \"vertical\" | \"horizontal\"\n  /** Long axis in pixels, or a CSS length such as `100%`. */\n  length?: number | string\n  /** Width of each bar in pixels. */\n  thickness?: number\n  /**\n   * Visual treatment for the meter body. `squares` uses discrete glowing LED\n   * cells, with the final cells taking the secondary accent colour.\n   */\n  variant?: \"bar\" | \"squares\"\n\n  /** Show a dB scale beside the bars. */\n  showScale?: boolean\n  /** Bottom of the scale in dB. */\n  minDecibels?: number\n  /** Top of the scale in dB. */\n  maxDecibels?: number\n  /**\n   * Display exponent applied to the dB-scaled fraction. 1 is linear in dB;\n   * below 1 lifts quiet signal so a low-level source still reads as moving\n   * (0.6 puts -40 dB of a -60 dB scale near the middle). Scale marks follow.\n   */\n  curve?: number\n  /** Segment count for the LED treatment, or a mask count for the bar. */\n  segments?: number\n  /** Peak-hold decay, in units of full scale per second. 0 disables the tick. */\n  peakDecay?: number\n  /** Channel captions, e.g. [\"L\", \"R\"]. */\n  labels?: ReadonlyArray<string>\n\n  className?: string\n  style?: CSSProperties\n  \"aria-label\"?: string\n}\n\nexport function LevelMeter({\n  analyser,\n  getData,\n  dataSize = 1024,\n  paused = false,\n  value,\n  channels,\n  orientation = \"vertical\",\n  length = 120,\n  thickness = 10,\n  variant = \"bar\",\n  showScale = false,\n  minDecibels = -60,\n  maxDecibels = 0,\n  curve = 1,\n  segments = 0,\n  peakDecay = 0.6,\n  labels,\n  className,\n  style,\n  \"aria-label\": ariaLabel,\n}: LevelMeterProps) {\n  const vertical = orientation === \"vertical\"\n  const channelCount = value?.length ?? channels ?? 1\n  const visible = useDocumentVisible()\n\n  // Levels are animation state, not value state: they change every frame and\n  // no consumer needs to be notified.\n  const [levels, setLevels] = useState<ReadonlyArray<number>>(() =>\n    Array.from({ length: channelCount }, () => 0),\n  )\n  const [peaks, setPeaks] = useState<ReadonlyArray<number>>(() =>\n    Array.from({ length: channelCount }, () => 0),\n  )\n\n  const holdsRef = useRef(\n    Array.from({ length: channelCount }, () => createPeakHold(peakDecay)),\n  )\n  if (holdsRef.current.length !== channelCount) {\n    holdsRef.current = Array.from({ length: channelCount }, () => createPeakHold(peakDecay))\n  }\n\n  const getDataRef = useRef(getData)\n  getDataRef.current = getData\n\n  // Driven mode: levels come straight from the prop, but the peak tick still\n  // needs a clock to decay against.\n  const driven = value !== undefined\n\n  useEffect(() => {\n    if (!driven) return\n    setLevels(value)\n    const now = typeof performance !== \"undefined\" ? performance.now() : Date.now()\n    setPeaks(value.map((level, i) => holdsRef.current[i]?.push(level, now) ?? level))\n  }, [driven, value])\n\n  const analyse = useCallback(\n    (buffer: Float32Array, now: number) => {\n      const level = rms(buffer)\n      // A single analyser is one signal; mirror it across the bars rather than\n      // pretending to have per-channel data we were never given.\n      const next = Array.from({ length: channelCount }, () => level)\n      setLevels(next)\n      setPeaks(\n        next.map((_, i) => holdsRef.current[i]?.push(peakOf(buffer), now) ?? 0),\n      )\n    },\n    [channelCount],\n  )\n\n  useEffect(() => {\n    if (driven || paused || !visible) return\n    if (typeof window === \"undefined\") return\n    if (!analyser && !getDataRef.current) return\n\n    const size = analyser ? analyser.fftSize : dataSize\n    const buffer = new Float32Array(size)\n    let raf = 0\n\n    const frame = () => {\n      raf = requestAnimationFrame(frame)\n      if (analyser) analyser.getFloatTimeDomainData(buffer)\n      else if (getDataRef.current?.(buffer) === false) return\n      analyse(buffer, performance.now())\n    }\n\n    raf = requestAnimationFrame(frame)\n    return () => cancelAnimationFrame(raf)\n  }, [driven, paused, visible, analyser, dataSize, analyse])\n\n  /** Amplitude → the fraction of the bar to light. */\n  const exponent = Number.isFinite(curve) && curve > 0 ? curve : 1\n  const toUnit = (amplitude: number) =>\n    dbToUnit(amplitudeToDb(amplitude, minDecibels), minDecibels, maxDecibels) **\n    exponent\n\n  // The middle mark sits at the bar's midpoint, so under a curve it names the\n  // level that actually lights half the bar.\n  const scaleMarks = showScale\n    ? [\n        maxDecibels,\n        minDecibels + (maxDecibels - minDecibels) * 0.5 ** (1 / exponent),\n        minDecibels,\n      ]\n    : []\n  const squareCount = Math.max(1, segments || 20)\n\n  return (\n    <div\n      className={className}\n      role=\"meter\"\n      aria-label={ariaLabel ?? \"Level meter\"}\n      aria-valuemin={minDecibels}\n      aria-valuemax={maxDecibels}\n      aria-valuenow={Math.round(amplitudeToDb(Math.max(...levels, 0), minDecibels))}\n      style={{\n        display: \"inline-flex\",\n        flexDirection: vertical ? \"row\" : \"column\",\n        alignItems: vertical ? \"flex-end\" : \"flex-start\",\n        gap: 6,\n        fontFamily: token.font,\n        ...style,\n      }}\n    >\n      <div\n        style={{\n          display: \"flex\",\n          flexDirection: vertical ? \"row\" : \"column\",\n          gap: 4,\n          // A horizontal meter is commonly placed in a flex row with\n          // `length=\"100%\"`. Give the channel wrapper a real containing\n          // width so that the percentage track resolves instead of\n          // collapsing to zero.\n          ...(vertical ? {} : { width: \"100%\" }),\n        }}\n      >\n        {levels.map((level, index) => {\n          const unit = toUnit(level)\n          const peakUnit = toUnit(peaks[index] ?? 0)\n\n          return (\n            <div\n              key={index}\n              style={{\n                display: \"flex\",\n                flexDirection: vertical ? \"column\" : \"row\",\n                alignItems: \"center\",\n                gap: 4,\n              }}\n            >\n              <div\n                style={{\n                  position: \"relative\",\n                  width: vertical ? thickness : length,\n                  height: vertical ? length : thickness,\n                  background: token.track,\n                  borderRadius: 3,\n                  overflow: \"hidden\",\n                  boxShadow:\n                    \"var(--uikit-meter-inset, inset 0 1px 3px rgba(0,0,0,0.45))\",\n                }}\n              >\n                {variant === \"squares\" ? (\n                  <div\n                    aria-hidden\n                    style={{\n                      position: \"absolute\",\n                      inset: 0,\n                      display: \"flex\",\n                      flexDirection: vertical ? \"column-reverse\" : \"row\",\n                      gap: 2,\n                      padding: 1,\n                    }}\n                  >\n                    {Array.from({ length: squareCount }, (_, squareIndex) => {\n                      const lit = Math.max(\n                        0,\n                        Math.min(1, unit * squareCount - squareIndex),\n                      )\n                      const hot = squareIndex >= squareCount - Math.min(4, squareCount)\n                      const glow = hot ? token.accent2 : token.accent\n                      return (\n                        <div\n                          key={squareIndex}\n                          style={{\n                            flex: \"1 1 0\",\n                            minWidth: 0,\n                            minHeight: 0,\n                            background:\n                              lit > 0\n                                ? `linear-gradient(${vertical ? \"to top\" : \"to right\"}, ${glow} ${lit * 100}%, ${token.track} ${lit * 100}%)`\n                                : token.track,\n                            opacity: lit > 0 ? 0.45 + lit * 0.55 : 1,\n                            boxShadow:\n                              lit > 0\n                                ? `0 0 ${2 + lit * 5}px ${glow}, inset 0 1px 2px rgba(255,255,255,0.12)`\n                                : \"inset 0 1px 3px rgba(0,0,0,0.45)\",\n                            transition: \"none\",\n                          }}\n                        />\n                      )\n                    })}\n                  </div>\n                ) : (\n                  <>\n                    {/* Lit portion */}\n                    <div\n                      style={{\n                        position: \"absolute\",\n                        background: `linear-gradient(${vertical ? \"to top\" : \"to right\"}, ${token.accent}, ${token.accent2})`,\n                        ...(vertical\n                          ? { left: 0, right: 0, bottom: 0, height: `${unit * 100}%` }\n                          : { top: 0, bottom: 0, left: 0, width: `${unit * 100}%` }),\n                        transition: \"none\",\n                      }}\n                    />\n\n                    {/* Segment mask: hairlines punched over the fill read as LEDs\n                        without needing a second element per segment. */}\n                    {segments > 0 && (\n                      <div\n                        aria-hidden\n                        style={{\n                          position: \"absolute\",\n                          inset: 0,\n                          backgroundImage: `repeating-linear-gradient(${\n                            vertical ? \"to top\" : \"to right\"\n                          }, transparent 0, transparent calc(100% / ${segments} - 1.5px), ${token.surface} calc(100% / ${segments} - 1.5px), ${token.surface} calc(100% / ${segments}))`,\n                        }}\n                      />\n                    )}\n                  </>\n                )}\n\n                {/* Peak-hold tick */}\n                {peakDecay > 0 && peakUnit > 0 && (\n                  <div\n                    aria-hidden\n                    style={{\n                      position: \"absolute\",\n                      background: token.text,\n                      ...(vertical\n                        ? {\n                            left: 0,\n                            right: 0,\n                            height: 2,\n                            bottom: `calc(${peakUnit * 100}% - 1px)`,\n                          }\n                        : {\n                            top: 0,\n                            bottom: 0,\n                            width: 2,\n                            left: `calc(${peakUnit * 100}% - 1px)`,\n                          }),\n                    }}\n                  />\n                )}\n              </div>\n\n              {labels?.[index] && (\n                <span\n                  style={{\n                    fontSize: 9,\n                    fontWeight: 600,\n                    letterSpacing: \"0.08em\",\n                    color: token.textMuted,\n                  }}\n                >\n                  {labels[index]}\n                </span>\n              )}\n            </div>\n          )\n        })}\n      </div>\n\n      {showScale && (\n        <div\n          aria-hidden\n          style={{\n            display: \"flex\",\n            flexDirection: vertical ? \"column\" : \"row\",\n            justifyContent: \"space-between\",\n            height: vertical ? length : undefined,\n            width: vertical ? undefined : length,\n            fontFamily: token.fontMono,\n            fontSize: 8,\n            color: token.textMuted,\n          }}\n        >\n          {scaleMarks.map((db) => (\n            <span key={db}>{db > -100 ? `${Math.round(db)}` : \"-inf\"}</span>\n          ))}\n        </div>\n      )}\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "@ui/level-meter.tsx"
    }
  ]
}
