{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "core-dynamics-display",
  "type": "registry:ui",
  "title": "Dynamics Display",
  "description": "A compressor or gate's transfer curve — computed, so it is true before a single sample plays — with the knee shaded, the threshold marked, a gate's hysteresis band drawn as a band, and a live gain-reduction meter beside it. The curve says what the processor would do at any level; the meter says what it is doing to this one.",
  "dependencies": [
    "maruaudio@>=0.0.3 <0.1.0"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "components/ui/dynamics-display.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/dynamics-display\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 * DynamicsDisplay — a compressor or gate's transfer curve, plus its live\n * gain-reduction meter.\n *\n * The curve is the one picture that says what a dynamics processor does: input\n * level across, output level up, and wherever the line leaves the diagonal the\n * processor is pulling on the signal. It is *computed*, not measured, so it is\n * true the moment the page paints — no analyser, no AudioContext, and it\n * renders on the server. SVG rather than canvas for exactly that reason: the\n * shape changes only when a parameter moves, so there is nothing to animate and\n * no ref to wait for.\n *\n * The meter is the other half and answers a different question. The curve is\n * what the processor WOULD do at any level; the meter is what it IS doing to\n * this signal, right now. Both belong on the face, and the meter reading zero\n * in silence is honest rather than broken.\n *\n * `mode` switches the curve between the two shapes the family needs:\n *\n *   - `\"compressor\"` — threshold, ratio and a soft knee, the standard\n *     formulation the Web Audio `DynamicsCompressorNode` is specified against.\n *   - `\"gate\"` — threshold, a `range` attenuation floor below it, and an\n *     optional `hysteresis` band drawn as the region where the gate's state\n *     depends on which way the level is travelling.\n *\n * All the arithmetic lives in `lib/dynamics-math`, which is pure and tested in\n * Node; this file is the drawing and the animation frame.\n *\n * Styling comes from CSS custom properties — see `lib/theme.ts` — with a\n * `color` prop to override the curve's accent for one instance.\n */\n\nimport { useCallback, useEffect, useId, useMemo, useRef, useState } from \"react\"\nimport type { CSSProperties } from \"react\"\nimport { token } from \"maruaudio/ui/shared/theme\"\nimport {\n  createDynamicsScale,\n  dynamicsReduction,\n  gateThresholds,\n  gridDecibels,\n  kneeBand,\n  reductionToUnit,\n  transferPath,\n  type DynamicsCurve,\n  type DynamicsMode,\n  type GatePhase,\n} from \"maruaudio/ui/shared/dynamics-math\"\nimport {\n  createPeakHold,\n  useDocumentVisible,\n} from \"maruaudio/ui/shared/use-audio-source\"\n\nexport interface DynamicsDisplayProps {\n  /** Which curve to draw. */\n  mode?: DynamicsMode\n  /** Threshold in dB — where the curve bends, in both modes. */\n  threshold: number\n\n  /** Compressor only: compression ratio, n:1. */\n  ratio?: number\n  /** Total knee width in dB, centred on the threshold. 0 is a hard corner. */\n  knee?: number\n  /** Gate only: attenuation while shut, in dB. Negative. */\n  range?: number\n  /** Gate only: how far below the threshold the gate holds open, in dB. */\n  hysteresis?: number\n  /**\n   * Gate only: which edge of the hysteresis band the solid curve draws. The\n   * other edge is drawn faintly whenever `hysteresis` is non-zero, so the band\n   * reads as a band rather than as two unrelated settings.\n   */\n  phase?: GatePhase\n\n  /** Bottom of both axes, in dB. */\n  minDecibels?: number\n  /** Top of both axes, in dB. */\n  maxDecibels?: number\n  /** Gridline spacing in dB. */\n  gridStep?: number\n\n  width?: number | string\n  /** Plot height in pixels. The plot itself stays square inside it. */\n  height?: number\n  showGrid?: boolean\n  /** Draw the dB numbers along both axes. */\n  showScale?: boolean\n  /** Draw the \"Input dB\" / \"Output dB\" axis titles. */\n  showAxisTitles?: boolean\n  /** Draw the dashed unity diagonal — the signal untouched. */\n  showUnity?: boolean\n  /** Shade the knee's footprint on the input axis. */\n  showKnee?: boolean\n  /** Mark these input levels on the curve with a dot. */\n  probeLevels?: ReadonlyArray<number>\n\n  /** Show the live gain-reduction meter under the plot. */\n  showMeter?: boolean\n  /**\n   * Gain reduction in dB, supplied directly — the driven form, and the exact\n   * analogue of `LevelMeter`'s `value`. Sign-agnostic: pass\n   * `DynamicsCompressorNode.reduction` (negative) or a positive depth.\n   */\n  reduction?: number\n  /**\n   * Fill `target[0]` with the current gain reduction in dB and return true —\n   * the `getData` form from the kit's audio-source contract, kept so a worklet,\n   * a test harness or a plain number source can drive the meter with no Web\n   * Audio involved. Returning false means \"no data this frame\".\n   *\n   * A Float32Array rather than a bare number return so the callback matches\n   * every other live display in the kit and allocates nothing per frame.\n   */\n  getReduction?: (target: Float32Array) => boolean | void\n  /** Reduction in dB that fills the meter. */\n  meterRange?: number\n  /** Stop the meter's loop and hold the last value. */\n  paused?: boolean\n  /** Meter peak-hold decay, in units of full scale per second. 0 disables it. */\n  peakDecay?: number\n\n  /** Override the curve's accent colour for this instance. */\n  color?: string\n  className?: string\n  style?: CSSProperties\n  \"aria-label\"?: string\n}\n\n/** Reduction depths read better without a trailing \".0\" at rest. */\nfunction formatReduction(db: number): string {\n  const depth = Math.abs(db)\n  return depth < 0.05 ? \"--\" : `-${depth.toFixed(1)} dB`\n}\n\nexport function DynamicsDisplay({\n  mode = \"compressor\",\n  threshold,\n  ratio = 4,\n  knee = 0,\n  range = -60,\n  hysteresis = 0,\n  phase = \"opening\",\n  minDecibels = -60,\n  maxDecibels = 0,\n  gridStep = 12,\n  width = \"100%\",\n  height = 220,\n  showGrid = true,\n  showScale = true,\n  showAxisTitles = true,\n  showUnity = true,\n  showKnee = true,\n  probeLevels,\n  showMeter = false,\n  reduction,\n  getReduction,\n  meterRange = 40,\n  paused = false,\n  peakDecay = 0.6,\n  color,\n  className,\n  style,\n  \"aria-label\": ariaLabel,\n}: DynamicsDisplayProps) {\n  const rawId = useId()\n  const meterId = `uikit-dyn-${rawId.replace(/[^a-zA-Z0-9]/g, \"\")}`\n  const accent = color ?? token.accent\n\n  const scale = useMemo(\n    () => createDynamicsScale({ minDecibels, maxDecibels }),\n    [minDecibels, maxDecibels],\n  )\n\n  const curve = useMemo<DynamicsCurve>(\n    () =>\n      mode === \"gate\"\n        ? { mode: \"gate\", threshold, range, knee, hysteresis }\n        : { mode: \"compressor\", threshold, ratio, knee },\n    [mode, threshold, range, knee, hysteresis, ratio],\n  )\n\n  const curvePath = useMemo(\n    () => transferPath(curve, scale, { phase }),\n    [curve, scale, phase],\n  )\n\n  // The other edge of a hysteresis band, drawn faintly behind the solid curve.\n  // Without it the band is an empty rectangle and the second threshold is a\n  // number nobody can see.\n  const ghostPath = useMemo(() => {\n    if (mode !== \"gate\" || !(hysteresis > 0)) return null\n    return transferPath(curve, scale, {\n      phase: phase === \"closing\" ? \"opening\" : \"closing\",\n    })\n  }, [mode, hysteresis, curve, scale, phase])\n\n  const band = showKnee ? kneeBand(curve, scale, phase) : null\n  const thresholds = mode === \"gate\" ? gateThresholds({ threshold, hysteresis }) : null\n  const marks = showGrid || showScale ? gridDecibels(scale, gridStep) : []\n  const probes = probeLevels ?? []\n\n  const { padding: pad, innerWidth: innerW, innerHeight: innerH } = scale\n  const { inputToX, outputToY } = scale\n\n  /* ------------------------------------------------------------- the meter */\n\n  const visible = useDocumentVisible()\n  // Meter depth is animation state, not value state: it changes every frame and\n  // no consumer needs to be told about it.\n  const [live, setLive] = useState(0)\n  const [peak, setPeak] = useState(0)\n  const holdRef = useRef(createPeakHold(peakDecay))\n  const getReductionRef = useRef(getReduction)\n  getReductionRef.current = getReduction\n\n  const driven = reduction !== undefined\n\n  const push = useCallback(\n    (db: number, now: number) => {\n      const unit = reductionToUnit(db, meterRange)\n      setLive(db)\n      setPeak(holdRef.current.push(unit, now))\n    },\n    [meterRange],\n  )\n\n  useEffect(() => {\n    if (!driven) return\n    const now = typeof performance !== \"undefined\" ? performance.now() : Date.now()\n    push(reduction, now)\n  }, [driven, reduction, push])\n\n  // Whether a callback is attached, as a dependency rather than the callback\n  // itself: a fresh closure every render must not restart the loop, but a\n  // source appearing (the app's audio finally starting) must.\n  const hasSource = getReduction !== undefined\n\n  useEffect(() => {\n    if (!showMeter || driven || paused || !visible || !hasSource) return\n    if (typeof window === \"undefined\") return\n\n    // One frame's worth of scalar. Allocated once for the life of the loop so\n    // the animation frame allocates nothing.\n    const buffer = new Float32Array(1)\n    let raf = 0\n\n    const frame = () => {\n      raf = requestAnimationFrame(frame)\n      buffer[0] = 0\n      if (getReductionRef.current?.(buffer) === false) return\n      push(buffer[0], performance.now())\n    }\n\n    raf = requestAnimationFrame(frame)\n    return () => cancelAnimationFrame(raf)\n  }, [showMeter, driven, paused, visible, hasSource, push])\n\n  const liveUnit = reductionToUnit(live, meterRange)\n\n  /* ------------------------------------------------------------ the drawing */\n\n  const label =\n    ariaLabel ??\n    (mode === \"gate\"\n      ? `Gate transfer curve: threshold ${threshold.toFixed(1)} dB, range ${range.toFixed(\n          0,\n        )} dB, knee ${knee.toFixed(0)} dB${\n          hysteresis > 0 ? `, hysteresis ${hysteresis.toFixed(0)} dB` : \"\"\n        }`\n      : `Compressor transfer curve: threshold ${threshold.toFixed(1)} dB, ratio ${ratio.toFixed(\n          1,\n        )} to 1, knee ${knee.toFixed(0)} dB`)\n\n  return (\n    <div className={className} style={{ width, fontFamily: token.font, ...style }}>\n      <svg\n        viewBox={`0 0 ${scale.width} ${scale.height}`}\n        width=\"100%\"\n        height={height}\n        role=\"img\"\n        aria-label={label}\n        className=\"uikit-control\"\n        style={{\n          display: \"block\",\n          margin: \"0 auto\",\n          background: token.surface,\n          borderRadius: token.radius,\n          border: `1px solid ${token.border}`,\n        }}\n      >\n        {/* plot ground */}\n        <rect\n          x={pad}\n          y={pad}\n          width={innerW}\n          height={innerH}\n          fill={token.text}\n          opacity={0.03}\n        />\n\n        {/* hysteresis band: the levels where the gate's state depends on\n            history rather than on the signal alone. */}\n        {thresholds && hysteresis > 0 && (\n          <rect\n            x={inputToX(Math.max(scale.minDecibels, thresholds.close))}\n            y={pad}\n            width={\n              inputToX(Math.min(scale.maxDecibels, thresholds.open)) -\n              inputToX(Math.max(scale.minDecibels, thresholds.close))\n            }\n            height={innerH}\n            fill={accent}\n            opacity={0.16}\n          />\n        )}\n\n        {/* knee footprint — the \"soft\" in soft-knee, as width rather than a\n            number sitting under a knob. */}\n        {band && (\n          <rect\n            x={inputToX(band.low)}\n            y={pad}\n            width={inputToX(band.high) - inputToX(band.low)}\n            height={innerH}\n            fill={accent}\n            opacity={0.1}\n          />\n        )}\n\n        {showGrid && (\n          <g stroke={token.border} strokeWidth={0.75} opacity={0.55}>\n            {marks.map((db) => (\n              <g key={db}>\n                <line x1={inputToX(db)} y1={pad} x2={inputToX(db)} y2={pad + innerH} />\n                <line x1={pad} y1={outputToY(db)} x2={pad + innerW} y2={outputToY(db)} />\n              </g>\n            ))}\n          </g>\n        )}\n\n        {showScale && (\n          <g fill={token.textMuted} fontSize={7} fontFamily={token.fontMono}>\n            {marks.map((db) => (\n              <g key={db}>\n                <text x={inputToX(db)} y={pad + innerH + 11} textAnchor=\"middle\">\n                  {db}\n                </text>\n                <text x={pad - 5} y={outputToY(db) + 2.5} textAnchor=\"end\">\n                  {db}\n                </text>\n              </g>\n            ))}\n          </g>\n        )}\n\n        {/* unity reference: what the output would be with nothing applied */}\n        {showUnity && (\n          <line\n            x1={inputToX(scale.minDecibels)}\n            y1={outputToY(scale.minDecibels)}\n            x2={inputToX(scale.maxDecibels)}\n            y2={outputToY(scale.maxDecibels)}\n            stroke={token.text}\n            strokeWidth={1}\n            strokeDasharray=\"3 3\"\n            opacity={0.3}\n          />\n        )}\n\n        {/* threshold marker(s) */}\n        {(thresholds ? [thresholds.open, thresholds.close] : [threshold])\n          .filter((db, index, all) => all.indexOf(db) === index)\n          .map((db) => (\n            <line\n              key={db}\n              x1={inputToX(db)}\n              y1={pad}\n              x2={inputToX(db)}\n              y2={pad + innerH}\n              stroke={accent}\n              strokeWidth={1}\n              strokeDasharray=\"2 2\"\n            />\n          ))}\n\n        {ghostPath && (\n          <path\n            d={ghostPath}\n            fill=\"none\"\n            stroke={accent}\n            strokeWidth={1.2}\n            strokeDasharray=\"3 2\"\n            opacity={0.5}\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n          />\n        )}\n\n        <path\n          d={curvePath}\n          fill=\"none\"\n          stroke={accent}\n          strokeWidth={2}\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n\n        {probes.map((db) => (\n          <circle\n            key={db}\n            cx={inputToX(db)}\n            cy={outputToY(\n              Math.max(scale.minDecibels, db - dynamicsReduction(db, curve, phase)),\n            )}\n            r={2.6}\n            fill={token.cap}\n            stroke={accent}\n            strokeWidth={1.2}\n          />\n        ))}\n\n        {showAxisTitles && (\n          <g fill={token.textMuted} fontSize={7} letterSpacing=\"0.08em\">\n            <text x={scale.width / 2} y={scale.height - 3} textAnchor=\"middle\">\n              INPUT dB\n            </text>\n            <text\n              x={9}\n              y={scale.height / 2}\n              textAnchor=\"middle\"\n              transform={`rotate(-90 9 ${scale.height / 2})`}\n            >\n              OUTPUT dB\n            </text>\n          </g>\n        )}\n      </svg>\n\n      {showMeter && (\n        <div\n          role=\"meter\"\n          aria-labelledby={meterId}\n          aria-valuemin={0}\n          aria-valuemax={meterRange}\n          aria-valuenow={Math.round(Math.abs(live) * 10) / 10}\n          aria-valuetext={formatReduction(live)}\n          style={{\n            display: \"flex\",\n            alignItems: \"center\",\n            gap: 8,\n            marginTop: 8,\n          }}\n        >\n          <span\n            id={meterId}\n            style={{\n              fontSize: 9,\n              fontWeight: 600,\n              letterSpacing: \"0.08em\",\n              color: token.textMuted,\n            }}\n          >\n            GR\n          </span>\n          <div\n            style={{\n              position: \"relative\",\n              flex: 1,\n              height: 10,\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            <div\n              style={{\n                position: \"absolute\",\n                top: 0,\n                bottom: 0,\n                left: 0,\n                width: `${liveUnit * 100}%`,\n                background: `linear-gradient(to right, ${accent}, ${token.accent2})`,\n              }}\n            />\n            {peakDecay > 0 && peak > 0 && (\n              <div\n                aria-hidden\n                style={{\n                  position: \"absolute\",\n                  top: 0,\n                  bottom: 0,\n                  width: 2,\n                  left: `calc(${peak * 100}% - 1px)`,\n                  background: token.text,\n                }}\n              />\n            )}\n          </div>\n          <span\n            style={{\n              minWidth: 54,\n              textAlign: \"right\",\n              fontFamily: token.fontMono,\n              fontSize: 10,\n              fontVariantNumeric: \"tabular-nums\",\n              color: token.text,\n            }}\n          >\n            {formatReduction(live)}\n          </span>\n        </div>\n      )}\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "@ui/dynamics-display.tsx"
    }
  ]
}
