{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "core-gain-reduction-meter",
  "type": "registry:ui",
  "title": "Gain Reduction Meter",
  "description": "A compressor's metering surface: input and output level bars flanking a gain-reduction bar that fills from the top downward — the hardware convention, because it shows what is being taken away rather than what is there — with a peak-hold tick for the deepest recent bite. Wire it with dB values you already have, a per-frame callback (DynamicsCompressorNode.reduction slots straight in), or a pair of analysers.",
  "dependencies": [
    "maruaudio@>=0.0.3 <0.1.0"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "components/ui/gain-reduction-meter.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/gain-reduction-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 * GainReductionMeter — a compressor's metering surface.\n *\n * Three bars: INPUT and OUTPUT level on the usual dB scale, and between them a\n * GAIN REDUCTION bar on its own scale that fills from the **top downward**\n * (vertical) or from the **right edge inward** (horizontal). That direction is\n * the hardware convention, and it is not decoration: a level bar shows what is\n * there, a GR bar shows what is being *taken away*, so it hangs from the\n * ceiling and grows toward the level bars as the compressor works harder. A\n * thin tick holds the deepest recent reduction and bleeds back up, because on\n * transient material the deepest bite is gone long before the eye catches it.\n *\n * Wiring is deliberately trivial, three ways, per-field precedence\n * `levels` → `getLevels` → analysers:\n *\n *   1. `levels`     — dB values you already computed, set every render.\n *   2. `getLevels`  — polled once per animation frame. This is the natural\n *                     hook for Web Audio's DynamicsCompressorNode, whose\n *                     `reduction` attribute is already the current gain\n *                     reduction in dB:\n *                       getLevels={() => ({ reduction: compressor.reduction })}\n *   3. `inputAnalyser` / `outputAnalyser` — RMS dB is computed per frame.\n *                     Analysers can only know levels, never reduction, so the\n *                     GR bar still wants (1) or (2) — mixing sources is the\n *                     intended use, not an edge case.\n *\n * Each column is optional (`showInput` / `showReduction` / `showOutput`), so a\n * lone GR bar next to your own metering works too.\n *\n * The dB→segment/zone maths is exported as pure functions — `levelZone`,\n * `levelSegments`, `reductionUnit`, `reductionSegments` — testable in Node\n * with no DOM. The peak-hold clock is the shared `createPeakHold` from\n * `lib/use-audio-source`.\n *\n * Styling comes from CSS custom properties — see `lib/theme.ts`. The\n * green→amber→red zone colours read `--uikit-meter-low/-mid/-high` with\n * traffic-light fallbacks, and everything is overridable per part via `colors`.\n */\n\nimport { 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  rms,\n  useDocumentVisible,\n} from \"maruaudio/ui/shared/use-audio-source\"\n\n// ---------------------------------------------------------------------------\n// Pure core — no React, no DOM. Everything below maps decibels to what lights.\n// ---------------------------------------------------------------------------\n\n/** One frame of meter readings, all in dB. `reduction` is ≤ 0, hardware-style —\n * `DynamicsCompressorNode.reduction` can be passed through verbatim. */\nexport interface GainReductionLevels {\n  input?: number\n  output?: number\n  reduction: number\n}\n\n/** The three colour bands of a level bar, quiet to hot. */\nexport type LevelZone = \"low\" | \"mid\" | \"high\"\n\nexport interface LevelZoneThresholds {\n  /** dB at and above which the bar turns amber. */\n  amber: number\n  /** dB at and above which the bar turns red. */\n  red: number\n}\n\n/** Traffic-light boundaries a channel meter usually ships with. */\nexport const LEVEL_ZONE_DEFAULTS: LevelZoneThresholds = { amber: -18, red: -6 }\n\n/** Which colour band a dB value falls in. Boundaries belong to the hotter zone. */\nexport function levelZone(\n  db: number,\n  zones: LevelZoneThresholds = LEVEL_ZONE_DEFAULTS,\n): LevelZone {\n  if (db >= zones.red) return \"high\"\n  if (db >= zones.amber) return \"mid\"\n  return \"low\"\n}\n\nexport interface LevelSegment {\n  lit: boolean\n  zone: LevelZone\n}\n\n/**\n * The LED column for a level, index 0 at the quiet end.\n *\n * Hardware semantics on purpose: a segment's **colour belongs to its position**\n * on the scale (its centre dB), not to the current level — the top LEDs are red\n * whether or not they are lit — and a segment lights when the level reaches\n * past its lower edge.\n */\nexport function levelSegments(\n  db: number,\n  count: number,\n  minDb = -60,\n  maxDb = 0,\n  zones: LevelZoneThresholds = LEVEL_ZONE_DEFAULTS,\n): ReadonlyArray<LevelSegment> {\n  const segments = Math.max(0, Math.floor(count))\n  if (segments === 0) return []\n  const unit = dbToUnit(db, minDb, maxDb)\n  return Array.from({ length: segments }, (_, i) => ({\n    lit: unit > i / segments,\n    zone: levelZone(minDb + (maxDb - minDb) * ((i + 0.5) / segments), zones),\n  }))\n}\n\n/**\n * Gain reduction in dB (≤ 0) → the fraction of the GR bar to fill, 0..1.\n *\n * `minReduction` is the deepest reduction the scale shows (e.g. -24). Positive\n * \"reduction\" is a source that is not reducing at all, and reads as 0 rather\n * than as a negative bar length.\n */\nexport function reductionUnit(reduction: number, minReduction = -24): number {\n  if (minReduction >= 0 || reduction >= 0) return 0\n  const unit = reduction / minReduction\n  return unit > 1 ? 1 : unit\n}\n\n/**\n * The LED column for gain reduction, index 0 nearest 0 dB — the first segment\n * to light, at the top (vertical) or right edge (horizontal) of the bar.\n */\nexport function reductionSegments(\n  reduction: number,\n  count: number,\n  minReduction = -24,\n): ReadonlyArray<boolean> {\n  const segments = Math.max(0, Math.floor(count))\n  if (segments === 0) return []\n  const unit = reductionUnit(reduction, minReduction)\n  return Array.from({ length: segments }, (_, i) => unit > i / segments)\n}\n\n// ---------------------------------------------------------------------------\n// Component\n// ---------------------------------------------------------------------------\n\nexport interface GainReductionMeterColors {\n  /** Level bars' quiet zone. Default `var(--uikit-meter-low, #22c55e)`. */\n  low?: string\n  /** Level bars' amber zone. Default `var(--uikit-meter-mid, #f59e0b)`. */\n  mid?: string\n  /** Level bars' hot zone. Default `var(--uikit-meter-high, #ef4444)`. */\n  high?: string\n  /** The GR bar. Default is the theme accent. */\n  reduction?: string\n  /** The GR peak-hold tick. Default is the theme text colour. */\n  peak?: string\n}\n\nexport interface GainReductionMeterProps {\n  /**\n   * Levels you already computed, in dB. Highest precedence: any field given\n   * here overrides the same field from `getLevels` or the analysers.\n   */\n  levels?: GainReductionLevels\n  /**\n   * Polled once per animation frame. The DynamicsCompressorNode hook:\n   * `getLevels={() => ({ reduction: compressor.reduction })}`.\n   */\n  getLevels?: () => GainReductionLevels\n  /** RMS dB for the IN bar is computed from this each frame. Read-only. */\n  inputAnalyser?: AnalyserNode | null\n  /** RMS dB for the OUT bar is computed from this each frame. Read-only. */\n  outputAnalyser?: AnalyserNode | null\n  /** Stop polling and hold the last frame. */\n  paused?: boolean\n\n  /** Show the INPUT column. */\n  showInput?: boolean\n  /** Show the GAIN REDUCTION column. */\n  showReduction?: boolean\n  /** Show the OUTPUT column. */\n  showOutput?: boolean\n\n  orientation?: \"vertical\" | \"horizontal\"\n  /** Long axis in pixels. */\n  length?: number\n  /** Width of each bar in pixels. */\n  thickness?: number\n  /** Draw as discrete LED segments; 0 is a continuous bar. */\n  segments?: number\n  /** Draw dB tick labels: level scale on one side, GR scale on the other. */\n  showScale?: boolean\n  /** Show the IN / GR / OUT captions. */\n  showLabels?: boolean\n  /** Caption overrides, e.g. `{ reduction: \"COMP\" }`. */\n  labels?: { input?: string; reduction?: string; output?: string }\n\n  /** Bottom of the level scale in dB. */\n  minDecibels?: number\n  /** Top of the level scale in dB. */\n  maxDecibels?: number\n  /** Deepest reduction the GR scale shows, in dB (negative). */\n  minReduction?: number\n  /** Where the level bars turn amber and red. */\n  zones?: Partial<LevelZoneThresholds>\n\n  /** GR peak-hold decay, in units of full scale per second. 0 hides the tick. */\n  peakDecay?: number\n\n  colors?: GainReductionMeterColors\n  className?: string\n  style?: CSSProperties\n  \"aria-label\"?: string\n}\n\ninterface DisplayState {\n  input: number | null\n  output: number | null\n  reduction: number\n  /** Peak-hold, already mapped to 0..1 of the GR scale. */\n  peak: number\n}\n\nconst RESTING: DisplayState = { input: null, output: null, reduction: 0, peak: 0 }\n\n/** RMS of one analyser frame, as dB. */\nfunction analyserDb(\n  analyser: AnalyserNode,\n  buffer: Float32Array<ArrayBuffer>,\n  floor: number,\n): number {\n  analyser.getFloatTimeDomainData(buffer)\n  return amplitudeToDb(rms(buffer), floor)\n}\n\nexport function GainReductionMeter({\n  levels,\n  getLevels,\n  inputAnalyser,\n  outputAnalyser,\n  paused = false,\n  showInput = true,\n  showReduction = true,\n  showOutput = true,\n  orientation = \"vertical\",\n  length = 120,\n  thickness = 10,\n  segments = 0,\n  showScale = false,\n  showLabels = true,\n  labels,\n  minDecibels = -60,\n  maxDecibels = 0,\n  minReduction = -24,\n  zones,\n  peakDecay = 0.6,\n  colors,\n  className,\n  style,\n  \"aria-label\": ariaLabel,\n}: GainReductionMeterProps) {\n  const vertical = orientation === \"vertical\"\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 [display, setDisplay] = useState<DisplayState>(RESTING)\n\n  const holdRef = useRef(createPeakHold(peakDecay))\n  const levelsRef = useRef(levels)\n  levelsRef.current = levels\n  const getLevelsRef = useRef(getLevels)\n  getLevelsRef.current = getLevels\n  const minReductionRef = useRef(minReduction)\n  minReductionRef.current = minReduction\n\n  const polling = Boolean(getLevels || inputAnalyser || outputAnalyser)\n\n  // Driven mode: everything comes straight from the prop, but the peak tick\n  // still gets pushed on every change so it tracks the deepest value seen.\n  useEffect(() => {\n    if (polling || !levels) return\n    const now = typeof performance !== \"undefined\" ? performance.now() : Date.now()\n    const peak = holdRef.current.push(reductionUnit(levels.reduction, minReduction), now)\n    setDisplay({\n      input: levels.input ?? null,\n      output: levels.output ?? null,\n      reduction: levels.reduction,\n      peak,\n    })\n  }, [polling, levels, minReduction])\n\n  // Poll mode: one rAF loop merges the three sources with per-field\n  // precedence — direct `levels` beat the callback, which beats the analysers.\n  useEffect(() => {\n    if (paused || !visible || !polling) return\n    if (typeof window === \"undefined\") return\n\n    const inputBuffer = inputAnalyser ? new Float32Array(inputAnalyser.fftSize) : null\n    const outputBuffer = outputAnalyser ? new Float32Array(outputAnalyser.fftSize) : null\n    let raf = 0\n\n    const frame = () => {\n      raf = requestAnimationFrame(frame)\n      const now = performance.now()\n      const fixed = levelsRef.current\n      const polled = getLevelsRef.current?.()\n\n      const input =\n        fixed?.input ??\n        polled?.input ??\n        (inputAnalyser && inputBuffer ? analyserDb(inputAnalyser, inputBuffer, minDecibels) : null)\n      const output =\n        fixed?.output ??\n        polled?.output ??\n        (outputAnalyser && outputBuffer\n          ? analyserDb(outputAnalyser, outputBuffer, minDecibels)\n          : null)\n      // Reduction can only ever come from the host — an analyser has no idea\n      // what the compressor took away.\n      const reduction = fixed?.reduction ?? polled?.reduction ?? 0\n\n      const peak = holdRef.current.push(\n        reductionUnit(reduction, minReductionRef.current),\n        now,\n      )\n      setDisplay((prev) =>\n        prev.input === input &&\n        prev.output === output &&\n        prev.reduction === reduction &&\n        prev.peak === peak\n          ? prev\n          : { input, output, reduction, peak },\n      )\n    }\n\n    raf = requestAnimationFrame(frame)\n    return () => cancelAnimationFrame(raf)\n  }, [paused, visible, polling, inputAnalyser, outputAnalyser, minDecibels])\n\n  const zoneDb: LevelZoneThresholds = { ...LEVEL_ZONE_DEFAULTS, ...zones }\n  const zoneColors: Record<LevelZone, string> = {\n    low: colors?.low ?? \"var(--uikit-meter-low, #22c55e)\",\n    mid: colors?.mid ?? \"var(--uikit-meter-mid, #f59e0b)\",\n    high: colors?.high ?? \"var(--uikit-meter-high, #ef4444)\",\n  }\n  const reductionColor = colors?.reduction ?? token.accent\n  const peakColor = colors?.peak ?? token.text\n\n  const barShell: CSSProperties = {\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: \"inset 0 1px 3px rgba(0,0,0,0.45)\",\n  }\n\n  const caption = (text: string) =>\n    showLabels ? (\n      <span\n        style={{\n          fontSize: 9,\n          fontWeight: 600,\n          letterSpacing: \"0.08em\",\n          color: token.textMuted,\n        }}\n      >\n        {text}\n      </span>\n    ) : null\n\n  /** A level (IN / OUT) column. */\n  const levelBar = (db: number | null, label: string, name: string) => {\n    const unit = db === null ? 0 : dbToUnit(db, minDecibels, maxDecibels)\n    const amberStop = dbToUnit(zoneDb.amber, minDecibels, maxDecibels) * 100\n    const redStop = dbToUnit(zoneDb.red, minDecibels, maxDecibels) * 100\n\n    return (\n      <div\n        key={name}\n        style={{\n          display: \"flex\",\n          flexDirection: vertical ? \"column\" : \"row\",\n          alignItems: \"center\",\n          gap: 4,\n        }}\n      >\n        <div\n          role=\"meter\"\n          aria-label={name}\n          aria-valuemin={minDecibels}\n          aria-valuemax={maxDecibels}\n          aria-valuenow={Math.round(db ?? minDecibels)}\n          style={barShell}\n        >\n          {segments > 0 ? (\n            <div\n              aria-hidden\n              style={{\n                position: \"absolute\",\n                inset: 0,\n                display: \"flex\",\n                flexDirection: vertical ? \"column-reverse\" : \"row\",\n                gap: 1.5,\n              }}\n            >\n              {levelSegments(db ?? minDecibels, segments, minDecibels, maxDecibels, zoneDb).map(\n                (segment, i) => (\n                  <div\n                    key={i}\n                    style={{\n                      flex: 1,\n                      background: zoneColors[segment.zone],\n                      opacity: segment.lit ? 1 : 0.16,\n                      borderRadius: 1,\n                    }}\n                  />\n                ),\n              )}\n            </div>\n          ) : (\n            // Continuous variant: the full zone gradient lives on a full-length\n            // element and the unlit part is clipped away, so the amber and red\n            // boundaries stay pinned to their dB positions instead of\n            // stretching with the level.\n            <div\n              aria-hidden\n              style={{\n                position: \"absolute\",\n                inset: 0,\n                background: `linear-gradient(${vertical ? \"to top\" : \"to right\"}, ${zoneColors.low} 0 ${amberStop}%, ${zoneColors.mid} ${amberStop}% ${redStop}%, ${zoneColors.high} ${redStop}% 100%)`,\n                clipPath: vertical\n                  ? `inset(${(1 - unit) * 100}% 0 0 0)`\n                  : `inset(0 ${(1 - unit) * 100}% 0 0)`,\n              }}\n            />\n          )}\n        </div>\n        {caption(label)}\n      </div>\n    )\n  }\n\n  /** The gain-reduction column: fills top-down / right-in, plus the peak tick. */\n  const reductionBar = () => {\n    const unit = reductionUnit(display.reduction, minReduction)\n\n    return (\n      <div\n        key=\"reduction\"\n        style={{\n          display: \"flex\",\n          flexDirection: vertical ? \"column\" : \"row\",\n          alignItems: \"center\",\n          gap: 4,\n        }}\n      >\n        <div\n          role=\"meter\"\n          aria-label=\"Gain reduction\"\n          aria-valuemin={minReduction}\n          aria-valuemax={0}\n          aria-valuenow={Math.round(Math.max(minReduction, Math.min(0, display.reduction)))}\n          style={barShell}\n        >\n          {segments > 0 ? (\n            <div\n              aria-hidden\n              style={{\n                position: \"absolute\",\n                inset: 0,\n                display: \"flex\",\n                // Index 0 is nearest 0 dB: the top, or the right edge.\n                flexDirection: vertical ? \"column\" : \"row-reverse\",\n                gap: 1.5,\n              }}\n            >\n              {reductionSegments(display.reduction, segments, minReduction).map((lit, i) => (\n                <div\n                  key={i}\n                  style={{\n                    flex: 1,\n                    background: reductionColor,\n                    opacity: lit ? 1 : 0.16,\n                    borderRadius: 1,\n                  }}\n                />\n              ))}\n            </div>\n          ) : (\n            <div\n              aria-hidden\n              style={{\n                position: \"absolute\",\n                background: reductionColor,\n                ...(vertical\n                  ? { left: 0, right: 0, top: 0, height: `${unit * 100}%` }\n                  : { top: 0, bottom: 0, right: 0, width: `${unit * 100}%` }),\n              }}\n            />\n          )}\n\n          {/* Deepest recent reduction, bleeding back toward 0. */}\n          {peakDecay > 0 && display.peak > 0 && (\n            <div\n              aria-hidden\n              style={{\n                position: \"absolute\",\n                background: peakColor,\n                ...(vertical\n                  ? {\n                      left: 0,\n                      right: 0,\n                      height: 2,\n                      top: `calc(${display.peak * 100}% - 1px)`,\n                    }\n                  : {\n                      top: 0,\n                      bottom: 0,\n                      width: 2,\n                      right: `calc(${display.peak * 100}% - 1px)`,\n                    }),\n              }}\n            />\n          )}\n        </div>\n        {caption(labels?.reduction ?? \"GR\")}\n      </div>\n    )\n  }\n\n  // Scales. Level marks read loud-end-first along the fill direction's far\n  // end; the GR scale reads 0 where the bar starts (top / right), because\n  // that is where no-reduction lives.\n  const levelMarks = vertical\n    ? [maxDecibels, (maxDecibels + minDecibels) / 2, minDecibels]\n    : [minDecibels, (maxDecibels + minDecibels) / 2, maxDecibels]\n  const reductionMarks = vertical\n    ? [0, minReduction / 2, minReduction]\n    : [minReduction, minReduction / 2, 0]\n\n  const scale = (marks: ReadonlyArray<number>) => (\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      {marks.map((db) => (\n        <span key={db}>{Math.round(db)}</span>\n      ))}\n    </div>\n  )\n\n  const columns = [\n    ...(showInput ? [levelBar(display.input, labels?.input ?? \"IN\", \"Input level\")] : []),\n    ...(showReduction ? [reductionBar()] : []),\n    ...(showOutput ? [levelBar(display.output, labels?.output ?? \"OUT\", \"Output level\")] : []),\n  ]\n  const hasLevelBar = showInput || showOutput\n\n  return (\n    <div\n      className={className}\n      role=\"group\"\n      aria-label={ariaLabel ?? \"Gain reduction meter\"}\n      style={{\n        display: \"inline-flex\",\n        flexDirection: vertical ? \"row\" : \"column\",\n        alignItems: vertical ? \"flex-start\" : \"stretch\",\n        gap: 6,\n        fontFamily: token.font,\n        ...style,\n      }}\n    >\n      {showScale && hasLevelBar && scale(levelMarks)}\n\n      <div\n        style={{\n          display: \"flex\",\n          flexDirection: vertical ? \"row\" : \"column\",\n          gap: 4,\n        }}\n      >\n        {columns}\n      </div>\n\n      {showScale && showReduction && scale(reductionMarks)}\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "@ui/gain-reduction-meter.tsx"
    }
  ]
}
