{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "core-stereo-field",
  "type": "registry:ui",
  "title": "Stereo Field",
  "description": "The parameter-side picture of a stereo image: an L-R field with a pan cursor the host can animate from an LFO phase, the width parameter drawn as a spread around it, mid/side balance bars, and a correlation meter whose lower half is tinted because correlation below zero is the mono-compatibility danger zone. Takes precomputed values, a live analyser pair, or a getStereoData callback. Pairs with lissajous-scope, which is the signal-side view of the same signal — this one is not a goniometer and does not try to be.",
  "dependencies": [
    "maruaudio@>=0.0.3 <0.1.0"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "components/ui/stereo-field.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/stereo-field\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 * StereoField — the parameter-side picture of a stereo image.\n *\n * One panel, four things, all of them about where the sound sits between the\n * speakers rather than what it looks like:\n *\n *   - **the L–R field** with a pan cursor at `pan` and, around it, the spread\n *     `width` draws;\n *   - **mid/side balance bars**, the split of the signal's energy between what\n *     a mono sum keeps and what it destroys;\n *   - **a correlation meter**, -1..+1, whose lower half is tinted as a warning\n *     because that is the mono-compatibility danger zone;\n *   - **an energy balance mark**, where the signal actually sits, drawn as a\n *     different mark from the cursor on purpose.\n *\n * ## The cursor is a reading, not a control\n *\n * `pan` and `width` are props. Nothing here takes pointer input, has focus, or\n * reports anything back — an auto-panner animates the cursor by passing its\n * LFO's phase through its own depth and rate every frame, exactly as\n * `lfo-visualizer` takes `phase` and as `eq-display` takes `cursor`. That\n * decision is recorded in `todo/ui-kit/surface-primitives.md` item 9: a cursor\n * takes no input and reports nothing, so it is one more thing computed from\n * props, and the rule keeping interaction out of a renderer does not apply to\n * it. The clock stays with the host, which is the only place that knows what\n * the LFO is doing.\n *\n * ## Parameter side, signal side\n *\n * This is the *parameter-side* view; `ui/lissajous-scope` is the signal-side\n * one, and the catalog pairs them (`todo/modules/07-ui-surface-catalog.md`,\n * stereo/spatial family). The scope draws the shape of the image — the blob, the\n * tilt, the collapse to a line — from the samples themselves. This draws the\n * numbers: where the pan parameter has put the image, how wide the width\n * parameter is asking for it to be, and the two measurements (correlation,\n * balance) that say whether the result is what was asked for. **It is not a\n * goniometer and does not try to be one.** Put both on a widener face and they\n * answer different questions about the same signal.\n *\n * ## Why the cursor and the balance mark are two marks\n *\n * `balance` is an energy reading and NOT a pan-knob readback. A mono source\n * panned to 0.5 by the constant-power law balances at 0.414 (`tan(p·π/4)` —\n * pinned in `lib/stereo-math`'s tests), and both numbers are correct about\n * different things. Drawing them as one mark would force a choice between\n * lying about the parameter and lying about the signal, so the cursor is solid\n * and the balance is a hollow tick under the field, and when they disagree you\n * can see by how much.\n *\n * ## Correlation below zero\n *\n * Above 0 the two channels reinforce on a mono sum; at 0 they are unrelated; at\n * -1 they cancel completely and the material *disappears* the moment anything\n * downstream sums to mono — a phone speaker, a club PA's sub feed, a vinyl\n * cutter. That is why the 0..-1 half of the meter is tinted with the same\n * warning colour `gain-reduction-meter` uses for its hot zone and the +1..0\n * half is neutral: the scale is not symmetric in meaning, so it is not drawn\n * symmetric in colour.\n *\n * ## Inputs — the kit's three-way contract\n *\n * The same contract `level-meter` uses, one shape wider because this reads a\n * *pair*:\n *\n *   1. **precomputed values** — `correlation`, `balance`, `mid`, `side`. The\n *      driven form and the primary one, since the parameter side of a module\n *      usually already knows these. Per field: anything supplied here wins over\n *      any live source, the way `gain-reduction-meter` resolves its levels.\n *   2. **`analyserLeft` / `analyserRight`** — a live pair. Read-only: the\n *      component never connects, disconnects or reconfigures a node, and it\n *      never builds an AudioContext (browsers only allow that from a gesture,\n *      so it stays the app's decision).\n *   3. **`getStereoData(left, right)`** — fill two arrays per frame and return\n *      true. The engine-agnostic form: a worklet, a test harness or a plain\n *      pair of arrays can drive the meter with no Web Audio involved.\n *\n * Live readings pass through {@link meterFollow} so the needle moves like a\n * meter rather than like a scope; driven values do not, because a driven value\n * is already whatever the host meant it to be and smoothing it would put lag\n * between a knob and its own display.\n *\n * All the arithmetic is in `lib/stereo-math`, which is pure and tested in Node.\n * This file is the drawing. DOM and percentages rather than SVG: every mark\n * here is a rectangle or a line on one horizontal axis, so the layout engine\n * can do the responsive work and the panel reflows to any width without a\n * viewBox to keep in step.\n *\n * SSR-safe: the whole driven picture renders on the server, and every DOM and\n * Web Audio touch is inside an effect.\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, useMemo, useRef, useState } from \"react\"\nimport type { CSSProperties } from \"react\"\nimport { token } from \"maruaudio/ui/shared/theme\"\nimport { useDocumentVisible } from \"maruaudio/ui/shared/use-audio-source\"\nimport {\n  correlationToUnit,\n  formatCorrelation,\n  meterFollow,\n  panToUnit,\n  stereoReading,\n  stereoWidthRatio,\n  widthSpread,\n  type MeterBallistics,\n  type StereoReading,\n} from \"maruaudio/ui/shared/stereo-math\"\n\n/**\n * Ballistics for the live readings.\n *\n * Deliberately the inverse of the kit's meter default (fast attack, slow\n * release). On a correlation meter the move worth catching is the **drop**\n * toward antiphase — a phase problem that flashes past unseen is a phase\n * problem that ships — so the fast constant goes on the downward travel, which\n * in `meterFollow`'s vocabulary is `release`. The names there are directions,\n * not speeds, precisely so this can be said by passing numbers rather than by\n * meaning something different.\n */\nexport const STEREO_FIELD_BALLISTICS: Required<MeterBallistics> = {\n  attack: 0.35,\n  release: 0.08,\n}\n\n/**\n * The warning tint for correlation below zero.\n *\n * Shared with `gain-reduction-meter`'s hot zone on purpose: a face carrying\n * both should not have two different reds meaning \"this is the bad one\".\n */\nconst DANGER = \"var(--uikit-meter-high, #ef4444)\"\n\nexport interface StereoFieldProps {\n  /**\n   * Pan cursor position, -1 (hard left) .. +1 (hard right). A parameter, not a\n   * measurement — animate it from an LFO phase for an auto-panner.\n   */\n  pan?: number\n  /**\n   * Width parameter, 0 (a point at the cursor) .. 1 (the whole field when\n   * centred). Drawn as a symmetric spread around the cursor, clamped at the\n   * field's edges. Also a parameter: what the audio's width control did to the\n   * signal is what `side` and the correlation meter report.\n   */\n  width?: number\n\n  /** Measured correlation, -1..+1. Overrides any live source. */\n  correlation?: number\n  /** Measured L/R energy balance, -1..+1. Overrides any live source. */\n  balance?: number\n  /** Measured mid (L+R)/2 RMS. Overrides any live source. */\n  mid?: number\n  /** Measured side (L-R)/2 RMS. Overrides any live source. */\n  side?: number\n\n  /**\n   * Live left channel. Read-only — never connected or reconfigured. **Both\n   * analysers are required**: one analyser is a mono signal, and half a pair\n   * has no stereo reading in it, so the loop does not start on its own.\n   */\n  analyserLeft?: AnalyserNode | null\n  /** Live right channel. Read-only — never connected or reconfigured. */\n  analyserRight?: AnalyserNode | null\n  /**\n   * Fill both arrays with the current frame and return true. Returning false\n   * (or omitting the prop) is \"no data this frame\", the same convention\n   * `use-audio-source`'s `getData` uses.\n   */\n  getStereoData?: (left: Float32Array, right: Float32Array) => boolean | void\n  /** Buffer length used with `getStereoData`. Ignored when analysers are given. */\n  dataSize?: number\n  /** Stop the loop and hold the last reading. */\n  paused?: boolean\n  /** Needle ballistics for live readings. See {@link STEREO_FIELD_BALLISTICS}. */\n  ballistics?: MeterBallistics\n\n  /** Draw the correlation meter. */\n  showCorrelation?: boolean\n  /** Draw the mid/side balance bars. */\n  showMidSide?: boolean\n  /** Draw the field's tick marks and end labels. */\n  showScale?: boolean\n  /** End captions. Defaults to L and R. */\n  labels?: readonly [string, string]\n\n  /**\n   * Height of the L-R field in pixels. There is no layout `width` prop: `width`\n   * here means the stereo width parameter, and one name cannot be both. The\n   * panel fills its container — size it with `className` or `style`.\n   */\n  height?: number\n  /** Override the accent colour for this instance. */\n  color?: string\n  className?: string\n  style?: CSSProperties\n  \"aria-label\"?: string\n}\n\n/** Percent string for a 0..1 fraction, so the marks and the CSS agree. */\nfunction percent(unit: number): string {\n  return `${unit * 100}%`\n}\n\n/** The field's tick positions, in field coordinates. */\nconst FIELD_TICKS = [-1, -0.5, 0, 0.5, 1] as const\n\nexport function StereoField({\n  pan = 0,\n  width = 0,\n  correlation,\n  balance,\n  mid,\n  side,\n  analyserLeft,\n  analyserRight,\n  getStereoData,\n  dataSize = 1024,\n  paused = false,\n  ballistics = STEREO_FIELD_BALLISTICS,\n  showCorrelation = true,\n  showMidSide = true,\n  showScale = true,\n  labels = [\"L\", \"R\"],\n  height = 64,\n  color,\n  className,\n  style,\n  \"aria-label\": ariaLabel,\n}: StereoFieldProps) {\n  const accent = color ?? token.accent\n  const visible = useDocumentVisible()\n\n  // Which fields the host has already answered. A driven field is never\n  // measured and never smoothed, so a panel that knows its own correlation can\n  // still borrow the live mid/side.\n  const driven = {\n    correlation: correlation !== undefined,\n    balance: balance !== undefined,\n    mid: mid !== undefined,\n    side: side !== undefined,\n  }\n  const fullyDriven =\n    driven.correlation && driven.balance && driven.mid && driven.side\n\n  // Live readings are animation state: they change every frame and no consumer\n  // needs to hear about it.\n  const [live, setLive] = useState<StereoReading | null>(null)\n\n  const getStereoDataRef = useRef(getStereoData)\n  getStereoDataRef.current = getStereoData\n  const ballisticsRef = useRef(ballistics)\n  ballisticsRef.current = ballistics\n\n  useEffect(() => {\n    if (fullyDriven || paused || !visible) return\n    if (typeof window === \"undefined\") return\n    const hasAnalysers = Boolean(analyserLeft && analyserRight)\n    if (!hasAnalysers && !getStereoDataRef.current) return\n\n    // One buffer per channel, allocated once — a rAF loop that allocates two\n    // kilo-sample arrays per frame hands the collector 120 of them a second.\n    const size = analyserLeft ? analyserLeft.fftSize : dataSize\n    const leftBuffer = new Float32Array(size)\n    const rightBuffer = new Float32Array(\n      analyserRight ? analyserRight.fftSize : size,\n    )\n\n    let raf = 0\n    let previous = 0\n    // The needle starts from the resting reading rather than from zero, so a\n    // meter attached to silence does not sweep up from the danger zone on its\n    // first frames.\n    let held: StereoReading = { correlation: 1, balance: 0, mid: 0, side: 0, width: 0 }\n\n    const frame = (now: number) => {\n      raf = requestAnimationFrame(frame)\n\n      let hasData = false\n      if (analyserLeft && analyserRight) {\n        analyserLeft.getFloatTimeDomainData(leftBuffer)\n        analyserRight.getFloatTimeDomainData(rightBuffer)\n        hasData = true\n      } else if (getStereoDataRef.current) {\n        hasData = getStereoDataRef.current(leftBuffer, rightBuffer) !== false\n      }\n      if (!hasData) return\n\n      // The first frame has no elapsed time to smooth over, and a tab that has\n      // just been revealed would otherwise present one enormous dt.\n      const dt = previous === 0 ? 0 : Math.min((now - previous) / 1000, 0.25)\n      previous = now\n\n      const target = stereoReading(leftBuffer, rightBuffer)\n      const follow = (from: number, to: number) =>\n        dt > 0 ? meterFollow(from, to, dt, ballisticsRef.current) : to\n\n      const next: StereoReading = {\n        correlation: follow(held.correlation, target.correlation),\n        balance: follow(held.balance, target.balance),\n        mid: follow(held.mid, target.mid),\n        side: follow(held.side, target.side),\n        width: 0,\n      }\n      // Width is recomputed from the SMOOTHED energies rather than smoothed\n      // itself: a ratio of two independently-smoothed numbers and a smoothed\n      // ratio disagree during a transient, and the bars would then not add up\n      // to the width the panel prints beside them.\n      next.width = stereoWidthRatio({ mid: next.mid, side: next.side })\n\n      held = next\n      setLive(next)\n    }\n\n    raf = requestAnimationFrame(frame)\n    return () => cancelAnimationFrame(raf)\n  }, [fullyDriven, paused, visible, analyserLeft, analyserRight, dataSize])\n\n  // Resolve per field: a supplied value wins, then the live reading, then\n  // nothing at all — which is a state, not a zero.\n  const shownCorrelation = correlation ?? live?.correlation ?? null\n  const shownBalance = balance ?? live?.balance ?? null\n  const shownMid = mid ?? live?.mid ?? null\n  const shownSide = side ?? live?.side ?? null\n  const hasEnergy = shownMid !== null || shownSide !== null\n  const energyWidth = hasEnergy\n    ? stereoWidthRatio({ mid: shownMid ?? 0, side: shownSide ?? 0 })\n    : null\n\n  const spread = useMemo(() => widthSpread(pan, width), [pan, width])\n  const cursorUnit = panToUnit(pan)\n  const spreadStart = panToUnit(spread.min)\n  const spreadEnd = panToUnit(spread.max)\n\n  const label =\n    ariaLabel ??\n    [\n      `Stereo field: pan ${pan.toFixed(2)}, width ${Math.round(\n        Math.min(1, Math.max(0, Number.isFinite(width) ? width : 0)) * 100,\n      )}%`,\n      shownCorrelation !== null\n        ? `correlation ${formatCorrelation(shownCorrelation)}${\n            shownCorrelation < 0 ? \" — out of phase, mono-incompatible\" : \"\"\n          }`\n        : null,\n      shownBalance !== null ? `energy balance ${shownBalance.toFixed(2)}` : null,\n      energyWidth !== null ? `measured width ${Math.round(energyWidth * 100)}%` : null,\n    ]\n      .filter(Boolean)\n      .join(\", \")\n\n  const captionStyle: CSSProperties = {\n    fontSize: 9,\n    fontWeight: 600,\n    letterSpacing: \"0.08em\",\n    color: token.textMuted,\n  }\n  const readoutStyle: CSSProperties = {\n    fontFamily: token.fontMono,\n    fontSize: 10,\n    color: token.text,\n  }\n\n  return (\n    <div\n      className={className}\n      role=\"img\"\n      aria-label={label}\n      style={{\n        display: \"flex\",\n        flexDirection: \"column\",\n        gap: 8,\n        width: \"100%\",\n        padding: 10,\n        boxSizing: \"border-box\",\n        background: token.surface,\n        border: `1px solid ${token.border}`,\n        borderRadius: token.radius,\n        fontFamily: token.font,\n        ...style,\n      }}\n    >\n      {/* ---------------------------------------------------------- the field */}\n      <div style={{ display: \"flex\", alignItems: \"center\", gap: 6 }}>\n        <span style={captionStyle}>{labels[0]}</span>\n\n        <div\n          style={{\n            position: \"relative\",\n            flex: 1,\n            height,\n            background: token.track,\n            borderRadius: 4,\n            overflow: \"hidden\",\n            boxShadow: \"inset 0 1px 3px rgba(0,0,0,0.45)\",\n          }}\n        >\n          {/* ticks, with the centre drawn harder: it is the one position on\n              this axis that means something on its own. */}\n          {showScale &&\n            FIELD_TICKS.map((tick) => (\n              <div\n                key={`tick-${tick}`}\n                style={{\n                  position: \"absolute\",\n                  top: 0,\n                  bottom: 0,\n                  left: percent(panToUnit(tick)),\n                  width: 1,\n                  marginLeft: -0.5,\n                  background: token.border,\n                  opacity: tick === 0 ? 0.9 : 0.4,\n                }}\n              />\n            ))}\n\n          {/* the width spread: the image the parameters are asking for. Drawn\n              as a band rather than as two edges because it is an extent, and a\n              pair of edges reads as two sources. */}\n          <div\n            style={{\n              position: \"absolute\",\n              top: \"18%\",\n              bottom: \"18%\",\n              left: percent(spreadStart),\n              width: percent(Math.max(0, spreadEnd - spreadStart)),\n              background: accent,\n              opacity: 0.22,\n              borderRadius: 3,\n              // No transition: the host may be animating this from an LFO at\n              // frame rate, and a CSS ease on top of that is two animations\n              // fighting over one property.\n              transition: \"none\",\n            }}\n          />\n\n          {/* the pan cursor */}\n          <div\n            style={{\n              position: \"absolute\",\n              top: \"10%\",\n              bottom: \"10%\",\n              left: percent(cursorUnit),\n              width: 2,\n              marginLeft: -1,\n              background: accent,\n              boxShadow: `0 0 6px ${accent}`,\n            }}\n          />\n          <div\n            style={{\n              position: \"absolute\",\n              top: \"50%\",\n              left: percent(cursorUnit),\n              width: 7,\n              height: 7,\n              marginLeft: -3.5,\n              marginTop: -3.5,\n              borderRadius: \"50%\",\n              background: accent,\n            }}\n          />\n\n          {/* the measured balance: a hollow tick, deliberately unlike the\n              cursor. See the header — these two disagree by design, and the\n              gap between them is worth being able to see. */}\n          {shownBalance !== null && (\n            <div\n              style={{\n                position: \"absolute\",\n                bottom: 2,\n                left: percent(panToUnit(shownBalance)),\n                width: 9,\n                height: 9,\n                marginLeft: -4.5,\n                border: `1.5px solid ${token.text}`,\n                borderRadius: 2,\n                opacity: 0.75,\n                transform: \"rotate(45deg)\",\n                background: \"transparent\",\n              }}\n            />\n          )}\n        </div>\n\n        <span style={captionStyle}>{labels[1]}</span>\n      </div>\n\n      {/* ------------------------------------------------- mid / side balance */}\n      {showMidSide && (\n        <div style={{ display: \"flex\", flexDirection: \"column\", gap: 3 }}>\n          {(\n            [\n              [\"M\", shownMid, accent],\n              [\"S\", shownSide, token.accent2],\n            ] as const\n          ).map(([name, energy, barColor]) => {\n            // The bars are a BALANCE, not levels: each is its share of\n            // mid + side, so the S bar's length is exactly the measured width\n            // ratio and the two always fill the row between them. An absolute\n            // level bar is `level-meter`'s job and would need a reference this\n            // panel does not have.\n            const total = (shownMid ?? 0) + (shownSide ?? 0)\n            const share = hasEnergy && total > 0 ? (energy ?? 0) / total : 0\n            return (\n              <div key={name} style={{ display: \"flex\", alignItems: \"center\", gap: 6 }}>\n                <span style={{ ...captionStyle, width: 10 }}>{name}</span>\n                <div\n                  style={{\n                    position: \"relative\",\n                    flex: 1,\n                    height: 6,\n                    background: token.track,\n                    borderRadius: 3,\n                    overflow: \"hidden\",\n                  }}\n                >\n                  <div\n                    style={{\n                      position: \"absolute\",\n                      inset: 0,\n                      right: \"auto\",\n                      width: percent(share),\n                      background: barColor,\n                      opacity: hasEnergy ? 0.85 : 0.25,\n                    }}\n                  />\n                </div>\n              </div>\n            )\n          })}\n          <div style={{ display: \"flex\", justifyContent: \"space-between\" }}>\n            <span style={captionStyle}>MID / SIDE</span>\n            <span style={readoutStyle}>\n              {energyWidth === null ? \"-\" : `${Math.round(energyWidth * 100)}% side`}\n            </span>\n          </div>\n        </div>\n      )}\n\n      {/* ---------------------------------------------------- the correlation */}\n      {showCorrelation && (\n        <div style={{ display: \"flex\", flexDirection: \"column\", gap: 3 }}>\n          <div\n            style={{\n              position: \"relative\",\n              height: 10,\n              background: token.track,\n              borderRadius: 3,\n              overflow: \"hidden\",\n            }}\n          >\n            {/* the danger half, always drawn: the scale is asymmetric in\n                meaning, so it is asymmetric in colour whether or not the\n                needle is in it. */}\n            <div\n              style={{\n                position: \"absolute\",\n                top: 0,\n                bottom: 0,\n                left: 0,\n                width: \"50%\",\n                background: DANGER,\n                opacity: 0.16,\n              }}\n            />\n\n            {shownCorrelation !== null && (\n              <div\n                style={{\n                  position: \"absolute\",\n                  top: 0,\n                  bottom: 0,\n                  ...(shownCorrelation >= 0\n                    ? {\n                        left: \"50%\",\n                        width: percent(correlationToUnit(shownCorrelation) - 0.5),\n                        background: accent,\n                      }\n                    : {\n                        left: percent(correlationToUnit(shownCorrelation)),\n                        width: percent(0.5 - correlationToUnit(shownCorrelation)),\n                        background: DANGER,\n                      }),\n                  opacity: 0.9,\n                }}\n              />\n            )}\n\n            {/* the zero mark: the only number on this scale that changes what\n                the reading means. */}\n            <div\n              style={{\n                position: \"absolute\",\n                top: 0,\n                bottom: 0,\n                left: \"50%\",\n                width: 1,\n                marginLeft: -0.5,\n                background: token.text,\n                opacity: 0.55,\n              }}\n            />\n\n            {shownCorrelation !== null && (\n              <div\n                style={{\n                  position: \"absolute\",\n                  top: -1,\n                  bottom: -1,\n                  left: percent(correlationToUnit(shownCorrelation)),\n                  width: 2,\n                  marginLeft: -1,\n                  background: token.text,\n                }}\n              />\n            )}\n          </div>\n\n          <div style={{ display: \"flex\", justifyContent: \"space-between\", alignItems: \"baseline\" }}>\n            <span style={captionStyle}>CORRELATION</span>\n            {showScale && (\n              <span style={{ ...captionStyle, fontFamily: token.fontMono }}>-1 · 0 · +1</span>\n            )}\n            <span\n              style={{\n                ...readoutStyle,\n                color:\n                  shownCorrelation !== null && shownCorrelation < 0 ? DANGER : token.text,\n              }}\n            >\n              {shownCorrelation === null ? \"-\" : formatCorrelation(shownCorrelation)}\n            </span>\n          </div>\n        </div>\n      )}\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "@ui/stereo-field.tsx"
    }
  ]
}
