{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "core-transfer-curve",
  "type": "registry:ui",
  "title": "Transfer Curve",
  "description": "What a waveshaper does to a sample, drawn: input amplitude across, output up, and the gap to the identity diagonal is the distortion at true scale. A staircase treatment draws a quantiser's real decision boundaries rather than faking diagonals through them, an optional bar row names which harmonics a Chebyshev order produces, and a live overlay brightens the stretch of the curve the signal is currently exercising.",
  "dependencies": [
    "maruaudio@>=0.0.3 <0.1.0"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "components/ui/transfer-curve.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/transfer-curve\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 * TransferCurve — what a waveshaper does to a sample, drawn.\n *\n * Input amplitude across, output amplitude up. For a memoryless nonlinearity\n * that is not a diagram of the effect, it *is* the effect: the whole of a\n * distortion stage's behaviour is this one line, and the distance between it\n * and the dashed identity diagonal is the distortion, at true scale. Computed\n * from the shaper's parameters rather than measured, so it is right the moment\n * the page paints — no analyser, no AudioContext, and it renders on the server.\n * SVG rather than canvas for the same reason: the shape only changes when a\n * parameter moves.\n *\n * Three treatments over one plot, each earning its place from the family it\n * serves (see `todo/modules/07-ui-surface-catalog.md`):\n *\n *   - **the curve** — drive's soft clip, and any other continuous shaper.\n *   - **`staircase`** — crush. A quantiser's curve is treads and risers, and\n *     drawing it as a polyline through sampled points fakes diagonals that the\n *     arithmetic does not have. The treads here are the quantiser's own\n *     decision boundaries, computed exactly, so the number of steps you can\n *     count is the number of levels the stage actually has.\n *   - **`showHarmonics`** — chebyshev. A bar row under the plot naming which\n *     harmonics the order produces, computed off the very curve above it rather\n *     than from a table of theory.\n *\n * A fourth shaper, **pickup**, takes the plain curve treatment: it is smooth and\n * continuous, so neither the staircase nor the harmonic row applies, and the\n * thing worth seeing is precisely how far the bend has pulled away from the\n * identity diagonal on one side and not the other. Its own parity readout, when\n * a panel wants one, comes from `pickupHarmonics` in `lib/waveshaper-math`.\n *\n * And over any of them, the live layer: feed `getData` (or an `AnalyserNode`)\n * and the stretch of the curve the signal is currently exercising is\n * brightened. This is the question a static curve cannot answer — not \"what\n * would this do at any amplitude\" but \"which amplitudes are arriving\" — and it\n * is why a hard setting can sound clean on a quiet part. Silence brightens\n * nothing rather than everything, which is the honest resting state.\n *\n * All the arithmetic lives in `lib/waveshaper-math`, which is pure, tested in\n * Node, and pinned bit-identical to the shipped audio paths it draws. This file\n * 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 accent for one instance.\n */\n\nimport { useEffect, useId, useMemo, useRef, useState } from \"react\"\nimport type { CSSProperties } from \"react\"\nimport { token } from \"maruaudio/ui/shared/theme\"\nimport {\n  chebyshevHarmonics,\n  createWaveshaperScale,\n  isStaircaseShaper,\n  normalizeHarmonics,\n  operatingRegion,\n  shaperPath,\n  staircasePath,\n  staircaseStepCount,\n  waveshaperGridValues,\n  type HarmonicWeight,\n  type Shaper,\n} from \"maruaudio/ui/shared/waveshaper-math\"\nimport { createPeakHold, useDocumentVisible } from \"maruaudio/ui/shared/use-audio-source\"\n\n/** The stretch of the input axis a signal is exercising, as the display takes it. */\nexport interface TransferRegion {\n  min: number\n  max: number\n}\n\nexport interface TransferCurveProps {\n  /** The shaper to draw. Its `kind` also picks the default treatment. */\n  shaper: Shaper\n\n  /** Left/bottom of both axes, in amplitude. */\n  min?: number\n  /** Right/top of both axes, in amplitude. */\n  max?: number\n  /** Gridline spacing in amplitude. */\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 amplitude numbers along both axes. */\n  showScale?: boolean\n  /** Draw the \"IN\" / \"OUT\" axis titles. */\n  showAxisTitles?: boolean\n  /** Draw the dashed identity diagonal — the signal untouched. */\n  showUnity?: boolean\n\n  /**\n   * Draw the curve as treads and risers. Defaults to true for a crush shaper\n   * and false for everything else, which is the only honest default: a\n   * staircase over a continuous curve would invent levels, and a polyline over\n   * a quantiser would erase them.\n   *\n   * Ignored when the quantiser is finer than the plot can show — see\n   * {@link STAIRCASE_LEGIBLE_STEPS}.\n   */\n  staircase?: boolean\n\n  /** Show the harmonic-weight bar row. Defaults to true for a chebyshev shaper. */\n  showHarmonics?: boolean\n  /**\n   * Amplitude of the sine the harmonic row is measured with, 0..1.\n   *\n   * Not a cosmetic default. At exactly 1 a Chebyshev order produces one\n   * harmonic and nothing else, so the row is a single bar; just below it the\n   * ladder n, n−2, n−4 … appears, which is what real material — never a\n   * full-scale sine — actually gets. 0.85 shows the ladder without burying the\n   * named harmonic.\n   */\n  harmonicAmplitude?: number\n  /** Most bars to draw. The strongest partials win; see the note in the source. */\n  maxHarmonics?: number\n  /** Height of the harmonic row in pixels. */\n  harmonicHeight?: number\n\n  /**\n   * A live analyser to read input samples from. Read-only: the component never\n   * connects, disconnects or reconfigures the node.\n   */\n  analyser?: AnalyserNode | null\n  /**\n   * Fill the supplied frame with input samples and return true — the kit's\n   * `getData` contract, so a worklet, a test harness or a plain array can drive\n   * the overlay with no Web Audio involved. Returning false means \"no data this\n   * frame\" and the region simply decays.\n   */\n  getData?: (target: Float32Array) => boolean | void\n  /** Frame length used when only `getData` is given. Ignored with an analyser. */\n  dataSize?: number\n  /**\n   * The exercised region supplied directly — the driven form, and the analogue\n   * of `DynamicsDisplay`'s `reduction`. Takes precedence over any live source.\n   */\n  region?: TransferRegion | null\n  /** Stop the overlay's loop and hold the last region. */\n  paused?: boolean\n  /**\n   * How fast the region's edges fall back toward silence, in amplitude per\n   * second. The measurement itself has no memory; a region that snapped to\n   * every frame would flicker on anything but a steady tone. 0 disables it.\n   */\n  regionDecay?: number\n\n  /** Override the accent colour for this instance. */\n  color?: string\n  className?: string\n  style?: CSSProperties\n  \"aria-label\"?: string\n}\n\n/**\n * Treads a staircase may have before it stops being a picture of anything.\n *\n * The plot is 172 units wide inside its insets. At 128 treads each one is a\n * unit and a third across — already at the limit — and the crush module's own\n * range reaches 16 bits, where the exact staircase is 131,073 treads. Past this\n * count the component draws the continuous curve instead, which at that density\n * is the same line to the eye and a great deal less SVG.\n */\nexport const STAIRCASE_LEGIBLE_STEPS = 128\n\n/** A label for a partial: 0 is DC, everything else is its harmonic number. */\nfunction harmonicLabel(harmonic: number): string {\n  return harmonic === 0 ? \"DC\" : String(harmonic)\n}\n\n/** What the plot is of, for a screen reader and for the fallback caption. */\nfunction describeShaper(shaper: Shaper): string {\n  switch (shaper.kind) {\n    case \"drive\":\n      return `Soft-clip transfer curve at amount ${shaper.amount.toFixed(2)}`\n    case \"crush\":\n      return `Bitcrusher transfer curve at ${Math.round(shaper.bits)} bits`\n    case \"chebyshev\":\n      return `Chebyshev transfer curve at order ${Math.round(shaper.order)}`\n    case \"pickup\":\n      return `Electromagnetic pickup transfer curve at distance ${shaper.distance.toFixed(\n        2,\n      )} and symmetry ${shaper.symmetry.toFixed(2)}`\n  }\n}\n\nexport function TransferCurve({\n  shaper,\n  min = -1,\n  max = 1,\n  gridStep = 0.5,\n  width = \"100%\",\n  height = 220,\n  showGrid = true,\n  showScale = true,\n  showAxisTitles = true,\n  showUnity = true,\n  staircase,\n  showHarmonics,\n  harmonicAmplitude = 0.85,\n  maxHarmonics = 24,\n  harmonicHeight = 64,\n  analyser,\n  getData,\n  dataSize = 1024,\n  region,\n  paused = false,\n  regionDecay = 1.2,\n  color,\n  className,\n  style,\n  \"aria-label\": ariaLabel,\n}: TransferCurveProps) {\n  const rawId = useId()\n  const clipId = `uikit-tc-${rawId.replace(/[^a-zA-Z0-9]/g, \"\")}`\n  const accent = color ?? token.accent\n\n  const scale = useMemo(() => createWaveshaperScale({ min, max }), [min, max])\n\n  /* ------------------------------------------------------------- the curve */\n\n  const asStaircase = staircase ?? isStaircaseShaper(shaper)\n  // Counted before it is built: the exact staircase at high bit depths is a\n  // six-figure array, and asking first is cheaper than allocating to find out.\n  const stepped =\n    asStaircase &&\n    isStaircaseShaper(shaper) &&\n    staircaseStepCount(shaper, { min: scale.min, max: scale.max }) <= STAIRCASE_LEGIBLE_STEPS\n\n  const curvePath = useMemo(\n    () =>\n      stepped && isStaircaseShaper(shaper)\n        ? staircasePath(shaper, scale)\n        : shaperPath(shaper, scale),\n    [shaper, scale, stepped],\n  )\n\n  const marks = showGrid || showScale ? waveshaperGridValues(scale, gridStep) : []\n  const { padding: pad, innerWidth: innerW, innerHeight: innerH } = scale\n  const { inputToX, outputToY } = scale\n\n  /* -------------------------------------------------- the harmonic bar row */\n\n  const withHarmonics = showHarmonics ?? shaper.kind === \"chebyshev\"\n  const bars = useMemo<HarmonicWeight[]>(() => {\n    if (!withHarmonics || shaper.kind !== \"chebyshev\") return []\n    const all = normalizeHarmonics(\n      chebyshevHarmonics(shaper.order, { amplitude: harmonicAmplitude }),\n    )\n    if (all.length <= maxHarmonics) return all\n    // Strongest first, then back into harmonic order. Truncating by harmonic\n    // number instead would empty the row exactly where it matters most: at full\n    // amplitude an order-50 curve produces harmonic 50 and nothing below it, so\n    // \"the first 24 harmonics\" would be 24 bars of nothing.\n    return [...all]\n      .sort((a, b) => Math.abs(b.weight) - Math.abs(a.weight))\n      .slice(0, maxHarmonics)\n      .sort((a, b) => a.harmonic - b.harmonic)\n  }, [withHarmonics, shaper, harmonicAmplitude, maxHarmonics])\n\n  /* ------------------------------------------------------- the live region */\n\n  const visible = useDocumentVisible()\n  const [live, setLive] = useState<TransferRegion | null>(null)\n  const getDataRef = useRef(getData)\n  getDataRef.current = getData\n\n  const driven = region !== undefined\n  const hasSource = getData !== undefined || (analyser !== undefined && analyser !== null)\n\n  useEffect(() => {\n    if (driven || paused || !visible || !hasSource) return\n    if (typeof window === \"undefined\") return\n\n    // Allocated once for the life of the loop: an animation frame that\n    // allocates a 2048-sample array sixty times a second is a\n    // garbage-collection pause waiting to happen.\n    let frame = new Float32Array(analyser ? analyser.fftSize : dataSize)\n    // Two holds, one per edge, so an asymmetric signal keeps its asymmetry on\n    // the way down instead of both edges decaying to a symmetric guess.\n    const holdLow = createPeakHold(regionDecay)\n    const holdHigh = createPeakHold(regionDecay)\n    let raf = 0\n\n    const tick = () => {\n      raf = requestAnimationFrame(tick)\n\n      let filled = false\n      if (analyser) {\n        if (frame.length !== analyser.fftSize) frame = new Float32Array(analyser.fftSize)\n        // The kit's contract types a frame as a plain Float32Array; Web Audio\n        // insists on one backed by an ArrayBuffer rather than a SharedArrayBuffer.\n        analyser.getFloatTimeDomainData(frame as Float32Array<ArrayBuffer>)\n        filled = true\n      } else {\n        frame.fill(0)\n        filled = getDataRef.current?.(frame) !== false\n      }\n\n      const now = performance.now()\n      const measured = filled ? operatingRegion(frame) : null\n      // Pushing zero on a silent frame is what makes the hold a decay rather\n      // than a latch: the edges keep falling while nothing is playing.\n      const low = holdLow.push(measured ? Math.max(0, -measured.min) : 0, now)\n      const high = holdHigh.push(measured ? Math.max(0, measured.max) : 0, now)\n\n      setLive((previous) => {\n        if (low <= 0 && high <= 0) return previous === null ? previous : null\n        // Rounded before the comparison so a region drifting in the fourth\n        // decimal does not re-render the whole plot every frame.\n        const next = { min: -Math.round(low * 1000) / 1000, max: Math.round(high * 1000) / 1000 }\n        if (previous && previous.min === next.min && previous.max === next.max) return previous\n        return next\n      })\n    }\n\n    raf = requestAnimationFrame(tick)\n    return () => cancelAnimationFrame(raf)\n  }, [driven, paused, visible, hasSource, analyser, dataSize, regionDecay])\n\n  const shown = driven ? (region ?? null) : live\n  // Clipped to the axis: a signal hotter than the window still lights the part\n  // of the curve that is on screen, rather than a rectangle hanging off it.\n  const active =\n    shown && shown.max > shown.min\n      ? {\n          min: Math.max(scale.min, Math.min(scale.max, shown.min)),\n          max: Math.max(scale.min, Math.min(scale.max, shown.max)),\n        }\n      : null\n  const regionX0 = active ? inputToX(active.min) : 0\n  const regionX1 = active ? inputToX(active.max) : 0\n\n  /* ------------------------------------------------------------ the drawing */\n\n  const label =\n    ariaLabel ??\n    `${describeShaper(shaper)}${\n      active\n        ? `; signal exercising ${active.min.toFixed(2)} to ${active.max.toFixed(2)}`\n        : \"\"\n    }`\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        <defs>\n          {active && (\n            <clipPath id={clipId}>\n              <rect x={regionX0} y={0} width={Math.max(0, regionX1 - regionX0)} height={scale.height} />\n            </clipPath>\n          )}\n        </defs>\n\n        {/* plot ground */}\n        <rect x={pad} y={pad} width={innerW} height={innerH} fill={token.text} opacity={0.03} />\n\n        {/* the exercised band: which inputs are arriving, behind everything\n            else so the curve stays the foreground object. */}\n        {active && (\n          <rect\n            x={regionX0}\n            y={pad}\n            width={Math.max(0, regionX1 - regionX0)}\n            height={innerH}\n            fill={accent}\n            opacity={0.12}\n          />\n        )}\n\n        {showGrid && (\n          <g stroke={token.border} strokeWidth={0.75} opacity={0.55}>\n            {marks.map((value) => (\n              <g key={value}>\n                <line x1={inputToX(value)} y1={pad} x2={inputToX(value)} y2={pad + innerH} />\n                <line x1={pad} y1={outputToY(value)} x2={pad + innerW} y2={outputToY(value)} />\n              </g>\n            ))}\n          </g>\n        )}\n\n        {showScale && (\n          <g fill={token.textMuted} fontSize={7} fontFamily={token.fontMono}>\n            {marks.map((value) => (\n              <g key={value}>\n                <text x={inputToX(value)} y={pad + innerH + 11} textAnchor=\"middle\">\n                  {value}\n                </text>\n                <text x={pad - 5} y={outputToY(value) + 2.5} textAnchor=\"end\">\n                  {value}\n                </text>\n              </g>\n            ))}\n          </g>\n        )}\n\n        {/* identity: what the output would be with the stage bypassed */}\n        {showUnity && (\n          <line\n            x1={inputToX(scale.min)}\n            y1={outputToY(scale.min)}\n            x2={inputToX(scale.max)}\n            y2={outputToY(scale.max)}\n            stroke={token.text}\n            strokeWidth={1}\n            strokeDasharray=\"3 3\"\n            opacity={0.3}\n          />\n        )}\n\n        {/* The curve, then the same path again clipped to the exercised band.\n            Two draws of one path rather than two paths: the bright section\n            cannot disagree with the dim one about where the curve is. */}\n        <path\n          d={curvePath}\n          fill=\"none\"\n          stroke={accent}\n          strokeWidth={2}\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          opacity={active ? 0.4 : 1}\n        />\n        {active && (\n          <g clipPath={`url(#${clipId})`}>\n            <path\n              d={curvePath}\n              fill=\"none\"\n              stroke={accent}\n              strokeWidth={2.6}\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n            />\n          </g>\n        )}\n        {active && (\n          <g stroke={accent} strokeWidth={0.75} opacity={0.7} strokeDasharray=\"2 2\">\n            <line x1={regionX0} y1={pad} x2={regionX0} y2={pad + innerH} />\n            <line x1={regionX1} y1={pad} x2={regionX1} y2={pad + innerH} />\n          </g>\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\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\n            </text>\n          </g>\n        )}\n      </svg>\n\n      {withHarmonics && (\n        <svg\n          viewBox={`0 0 ${scale.width} 64`}\n          width=\"100%\"\n          height={harmonicHeight}\n          role=\"img\"\n          aria-label={\n            bars.length === 0\n              ? \"Harmonic weights: none\"\n              : `Harmonic weights: ${bars\n                  .map((bar) => `${harmonicLabel(bar.harmonic)} at ${bar.weight.toFixed(2)}`)\n                  .join(\", \")}`\n          }\n          style={{ display: \"block\", margin: \"6px auto 0\" }}\n        >\n          {(() => {\n            const baseline = 48\n            const top = 8\n            const slot = bars.length > 0 ? innerW / bars.length : innerW\n            const barWidth = Math.min(14, Math.max(2, slot * 0.6))\n            return (\n              <>\n                <line\n                  x1={pad}\n                  y1={baseline}\n                  x2={pad + innerW}\n                  y2={baseline}\n                  stroke={token.border}\n                  strokeWidth={0.75}\n                />\n                {bars.map((bar, index) => {\n                  const cx = pad + slot * (index + 0.5)\n                  const size = Math.abs(bar.weight)\n                  const barHeight = Math.max(0.5, size * (baseline - top))\n                  // A partial in antiphase is drawn hollow rather than\n                  // rectified into a solid bar. Sign is real — it is what\n                  // decides whether a harmonic reinforces the fundamental or\n                  // eats it — and a bar row that hides it invents agreement.\n                  const inverted = bar.weight < 0\n                  return (\n                    <g key={bar.harmonic}>\n                      <rect\n                        x={cx - barWidth / 2}\n                        y={baseline - barHeight}\n                        width={barWidth}\n                        height={barHeight}\n                        fill={inverted ? \"none\" : accent}\n                        stroke={accent}\n                        strokeWidth={inverted ? 1 : 0}\n                        opacity={inverted ? 0.85 : 1}\n                        rx={1}\n                      />\n                      {slot >= 9 && (\n                        <text\n                          x={cx}\n                          y={baseline + 9}\n                          textAnchor=\"middle\"\n                          fill={token.textMuted}\n                          fontSize={7}\n                          fontFamily={token.fontMono}\n                        >\n                          {harmonicLabel(bar.harmonic)}\n                        </text>\n                      )}\n                    </g>\n                  )\n                })}\n                {bars.length === 0 && (\n                  <text\n                    x={scale.width / 2}\n                    y={baseline - 12}\n                    textAnchor=\"middle\"\n                    fill={token.textMuted}\n                    fontSize={8}\n                  >\n                    no harmonics\n                  </text>\n                )}\n                <text\n                  x={pad}\n                  y={62}\n                  fill={token.textMuted}\n                  fontSize={6.5}\n                  letterSpacing=\"0.08em\"\n                >\n                  HARMONICS AT {Math.round(harmonicAmplitude * 100)}% INPUT\n                </text>\n              </>\n            )\n          })()}\n        </svg>\n      )}\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "@ui/transfer-curve.tsx"
    }
  ]
}
