{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "core-goniometer",
  "type": "registry:ui",
  "title": "Goniometer",
  "description": "The signal-side SHAPE of a stereo image: a phosphor trace of the channel pair rotated 45 degrees so mono runs straight up the middle, pure side lies flat across the horizontal, and a hard-left source draws on the left. Diamond graticule, an L-R baseline carrying the measured energy balance, a correlation meter whose lower half is tinted, and a CORR/WIDTH readout. Switchable between the mid/side diamond and a raw left/right split, which answers the per-channel question the diamond hides. Display auto-gain measured on the mono sum, so the scale never depends on which way the image leans, and the printed readings are always taken from the raw pair. Takes a live analyser pair or a getStereoData callback; with neither it draws its graticule and says it has no source rather than animating invented samples.",
  "dependencies": [
    "@webgpu/types",
    "maruaudio@>=0.0.3 <0.1.0"
  ],
  "registryDependencies": [
    "https://pluginui.com/r/core-spectrogram.json"
  ],
  "files": [
    {
      "path": "components/ui/goniometer.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/goniometer\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 * Goniometer — the stereo image as a shape, in the orientation mix engineers\n * actually read.\n *\n * A phosphor trace of a channel pair rotated 45° so that **mono runs straight\n * up the middle**, pure side lies **flat across the horizontal**, and a\n * hard-left source draws on the **left**. Around it: a diamond graticule, an\n * L…R baseline carrying the measured energy balance, a correlation meter whose\n * lower half is tinted, and a numeric readout.\n *\n * ## Why this exists next to `lissajous-scope` and `stereo-field`\n *\n * Three surfaces, three questions, and they are genuinely different:\n *\n *   - **`lissajous-scope`** plots one mono signal against a phase-shifted copy\n *     of *itself*. It needs no stereo pair and draws circles and rosettes; it\n *     is a *waveform* instrument.\n *   - **`stereo-field`** draws the *numbers* — where the pan parameter put the\n *     image, how wide the width parameter asked for it, and the two\n *     measurements that say whether it worked. It is the *parameter-side* view\n *     and it says in its own header that it is not a goniometer.\n *   - **this** is the *signal-side shape*: the blob, its tilt, its collapse to\n *     a line. It is the one that shows a producer, in one glance, that the\n *     chorus is leaning right or that the pad has gone mono-incompatible.\n *\n * The three share their arithmetic. Every number here comes from\n * `lib/stereo-math` — `goniometerPoints` for the rotation, `stereoReading` for\n * the readouts, `meterFollow` for the ballistics — so a face carrying all three\n * cannot have them disagree about what correlation means.\n *\n * ## The rotation, and the constant that differs from the readings\n *\n * The trace is `goniometerPoints`, which uses the **orthonormal** `1/√2` M/S\n * scaling because that transform is a pure rotation and a rotation preserves\n * the blob's extent. The **readings** printed beside it use `stereo-math`'s\n * matrix convention (`(l+r)/2`), because a mid bar should read a mono file's own\n * RMS. Geometry and measurement are allowed to disagree about a constant; what\n * they are not allowed to do is each roll their own correlation, and they do\n * not — both call the same function.\n *\n * ## Auto-gain is a display decision, and the readings never see it\n *\n * Quiet passages are exactly when someone is trying to see the tail, so the\n * plotted pair is normalised toward a target RMS (`goniometerAutoGain`, fast\n * down / slow up). The gain is measured on the **mono sum**, so the scale does\n * not depend on which way the image is leaning — otherwise \"the image got\n * wider\" could be an artefact of the scaling.\n *\n * The **readings are taken from the raw pair, always**. Correlation happens to\n * be gain-invariant, but width and balance are not, and a meter that moved when\n * the display auto-gain moved would be lying about the signal.\n *\n * ## Persistence\n *\n * Each frame washes over the last with a translucent surface-coloured\n * rectangle rather than clearing it, so old trails decay like a CRT's phosphor\n * — the same technique `lissajous-scope` uses, and the reason a transient stays\n * legible after the sample that drew it has gone. `fadeSpeed` is that wash's\n * alpha: lower means longer persistence.\n *\n * ## Inputs — the kit's three-way contract\n *\n * The same shape `stereo-field` takes, because they read the same kind of\n * source:\n *\n *   1. **`analyserLeft` / `analyserRight`** — a live pair. Read-only: nothing\n *      here connects, disconnects or reconfigures a node, and nothing builds an\n *      AudioContext. **Both are required** — half a pair has no stereo image in\n *      it, so the loop does not start on one.\n *   2. **`getStereoData(left, right)`** — fill two arrays per frame and return\n *      true. The engine-agnostic form: a worklet, a compiled engine tap or a\n *      test harness drives it with no Web Audio involved.\n *   3. **neither** — the screen renders its graticule and says it has no tap.\n *      It never animates invented samples: a scope that draws a picture of\n *      nothing is worse than a scope that draws nothing.\n *\n * SSR-safe: the empty screen and the whole graticule render on the server, and\n * every canvas and 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 trace for one instance.\n */\n\nimport { useEffect, useRef, useState } from \"react\"\nimport type { CSSProperties } from \"react\"\nimport {\n  canvasColor,\n  token,\n} from \"maruaudio/ui/shared/theme\"\nimport { useDocumentVisible } from \"maruaudio/ui/shared/use-audio-source\"\nimport {\n  correlationToUnit,\n  formatCorrelation,\n  goniometerAutoGain,\n  goniometerPoints,\n  meterFollow,\n  stereoReading,\n  type GoniometerPoint,\n  type GoniometerOrientation,\n  type MeterBallistics,\n  type StereoReading,\n} from \"maruaudio/ui/shared/stereo-math\"\n\n/**\n * Ballistics for the readouts, matching `stereo-field`'s.\n *\n * Deliberately the inverse of the kit's meter default: on a correlation meter\n * the move worth catching is the **drop** toward antiphase, so the fast\n * constant goes on the downward travel — which in `meterFollow`'s vocabulary is\n * `release`, because its names are directions rather than speeds.\n */\nexport const GONIOMETER_BALLISTICS: Required<MeterBallistics> = {\n  attack: 0.35,\n  release: 0.08,\n}\n\n/** Time constants for the display auto-gain, in seconds. */\nconst GAIN_UP_TAU = 1.6\nconst GAIN_DOWN_TAU = 0.12\n\n/**\n * The warning tint for correlation below zero.\n *\n * Shared with `gain-reduction-meter`'s hot zone and `stereo-field`'s, so a face\n * carrying several of them has exactly one colour meaning \"this is the bad one\".\n */\nconst DANGER = \"var(--uikit-meter-high, #ef4444)\"\n\n/** How often the numeric chrome re-renders, in seconds. */\nconst READOUT_INTERVAL = 0.08\n\nexport interface GoniometerProps {\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 has\n   * no stereo image 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 leave the last trace on screen to decay. */\n  paused?: boolean\n\n  /**\n   * Normalise the plotted pair toward a readable level. A display decision —\n   * the printed readings are always taken from the raw pair. Off plots the\n   * signal at its own amplitude, which is what a calibration check wants.\n   */\n  autoGain?: boolean\n  /** Fixed display gain, used when `autoGain` is off. */\n  gain?: number\n  /**\n   * Which axes to plot on.\n   *\n   * `\"mid-side\"` (default) is the rotated diamond every hardware goniometer\n   * uses: mono straight up, pure side flat, upright means mono-compatible.\n   * `\"left-right\"` is the raw per-channel X-Y plot — X is left, Y is right,\n   * mono on the rising diagonal. Use it when the question is about the\n   * CHANNELS rather than the image: a dead channel collapses onto one axis and\n   * a level mismatch tilts the diagonal, both of which the diamond hides.\n   */\n  orientation?: GoniometerOrientation\n  /** How many points to plot per frame. */\n  trailLength?: number\n  /**\n   * Alpha of the per-frame wash that fades the previous frame. Lower means\n   * longer phosphor persistence. 0 disables fading (the trace accumulates).\n   */\n  fadeSpeed?: number\n  /** Trace thickness in CSS pixels at the head of the trail. */\n  lineWidth?: number\n  /** Additive multi-pass bloom around the trace. Costs a pass per level. */\n  enableGlow?: boolean\n  /** Number of bloom passes, 1–5. Only read when `enableGlow` is on. */\n  glowIntensity?: number\n\n  /** Draw the diamond rim, axes and half-scale reference. */\n  showGraticule?: boolean\n  /** Draw the correlation meter down the right edge. */\n  showCorrelation?: boolean\n  /** Draw the L…R baseline with the measured balance tick. */\n  showBalance?: boolean\n  /** Draw the numeric CORR / WIDTH readout. */\n  showReadout?: boolean\n  /** Needle ballistics for the readouts. See {@link GONIOMETER_BALLISTICS}. */\n  ballistics?: MeterBallistics\n\n  /** Screen height in CSS pixels. The width fills the container. */\n  height?: number\n  /** Override the trace colour for this instance. Defaults to the accent token. */\n  color?: string\n  className?: string\n  style?: CSSProperties\n  \"aria-label\"?: string\n}\n\n/** Parse `#rgb`/`#rrggbb` to components, so the wash and glow can vary alpha. */\nfunction parseColor(color: string): { r: number; g: number; b: number } | null {\n  const hex = color.trim()\n  if (!hex.startsWith(\"#\")) return null\n  const body = hex.slice(1)\n  if (body.length === 3) {\n    return {\n      r: Number.parseInt(body[0] + body[0], 16),\n      g: Number.parseInt(body[1] + body[1], 16),\n      b: Number.parseInt(body[2] + body[2], 16),\n    }\n  }\n  if (body.length === 6) {\n    return {\n      r: Number.parseInt(body.slice(0, 2), 16),\n      g: Number.parseInt(body.slice(2, 4), 16),\n      b: Number.parseInt(body.slice(4, 6), 16),\n    }\n  }\n  return null\n}\n\nexport function Goniometer({\n  analyserLeft,\n  analyserRight,\n  getStereoData,\n  dataSize = 2048,\n  paused = false,\n  autoGain = true,\n  gain = 1,\n  orientation = \"mid-side\",\n  trailLength = 600,\n  fadeSpeed = 0.08,\n  lineWidth = 1,\n  enableGlow = true,\n  glowIntensity = 2,\n  showGraticule = true,\n  showCorrelation = true,\n  showBalance = true,\n  showReadout = true,\n  ballistics = GONIOMETER_BALLISTICS,\n  height = 244,\n  color,\n  className,\n  style,\n  \"aria-label\": ariaLabel,\n}: GoniometerProps) {\n  const canvasRef = useRef<HTMLCanvasElement | null>(null)\n  const visible = useDocumentVisible()\n\n  const getStereoDataRef = useRef(getStereoData)\n  getStereoDataRef.current = getStereoData\n  const ballisticsRef = useRef(ballistics)\n  ballisticsRef.current = ballistics\n\n  const hasAnalysers = Boolean(analyserLeft && analyserRight)\n  const tapped = hasAnalysers || Boolean(getStereoData)\n\n  // The readouts are animation state: they change many times a second and no\n  // consumer needs to hear about it. They tick at READOUT_INTERVAL rather than\n  // at the frame rate, which is as fast as a number is readable and keeps React\n  // out of the drawing path entirely.\n  const [readout, setReadout] = useState<StereoReading | null>(null)\n\n  /**\n   * Draw one frame.\n   *\n   * Held in a ref rather than passed down, so the render loop below is created\n   * once per source change instead of being torn down every time a style prop\n   * moves — a scope that restarts its rAF loop on every parent render loses its\n   * phosphor trail on every parent render.\n   */\n  const drawRef = useRef<(points: GoniometerPoint[] | null) => void>(() => {})\n  drawRef.current = (points) => {\n    const canvas = canvasRef.current\n    if (!canvas) return\n    const ctx = canvas.getContext(\"2d\")\n    if (!ctx) return\n\n    // Size the backing store to the element's own CSS box at device pixel\n    // ratio, so the trace is crisp on a retina screen and circles stay round.\n    const dpr = window.devicePixelRatio || 1\n    const cssWidth = canvas.clientWidth\n    const cssHeight = canvas.clientHeight\n    if (cssWidth === 0 || cssHeight === 0) return\n    if (canvas.width !== Math.round(cssWidth * dpr) || canvas.height !== Math.round(cssHeight * dpr)) {\n      canvas.width = Math.round(cssWidth * dpr)\n      canvas.height = Math.round(cssHeight * dpr)\n    }\n    ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n\n    const styles = getComputedStyle(canvas)\n    const stroke = canvasColor(ctx.canvas, color ?? token.accent)\n    const surface = styles.getPropertyValue(\"--uikit-surface\").trim() || \"#14161d\"\n\n    // Persistence: wash the previous frame toward the surface colour instead of\n    // clearing it. The canvas is transparent (the CSS background shows\n    // through), so the wash has to paint the surface colour itself — clearRect\n    // alpha compositing would leave the old trail untouched.\n    const surfaceRgb = parseColor(surface)\n    if (fadeSpeed <= 0) {\n      if (!points) ctx.clearRect(0, 0, cssWidth, cssHeight)\n    } else {\n      ctx.fillStyle = surfaceRgb\n        ? `rgba(${surfaceRgb.r}, ${surfaceRgb.g}, ${surfaceRgb.b}, ${fadeSpeed})`\n        : surface\n      ctx.globalAlpha = surfaceRgb ? 1 : fadeSpeed\n      ctx.fillRect(0, 0, cssWidth, cssHeight)\n      ctx.globalAlpha = 1\n    }\n\n    if (!points || points.length < 2) return\n\n    // The plot is square and centred: a goniometer read on non-square axes\n    // reports a width that is the aspect ratio's, not the signal's. The 1/√2\n    // brings full-scale mono (which the orthonormal rotation puts at √2) to the\n    // rim of the diamond rather than past it.\n    const size = Math.min(cssWidth, cssHeight) * 0.84\n    const centreX = cssWidth / 2\n    const centreY = cssHeight / 2\n    // The M/S rotation can reach √2 for full-scale mono, so its plot is scaled\n    // down to bring that to the rim. The L/R axes each carry one channel at its\n    // own amplitude, so full scale is exactly 1 and no such correction applies.\n    const scale = orientation === \"left-right\" ? size / 2 : (size / 2) / Math.SQRT2\n    const toX = (x: number) => centreX + x * scale\n    const toY = (y: number) => centreY - y * scale\n\n    ctx.lineCap = \"round\"\n    ctx.lineJoin = \"round\"\n    ctx.save()\n\n    /**\n     * The trail's alpha ramps along its length — the head bright, the tail\n     * faded — which is what gives the figure a direction of travel. Rather than\n     * one stroke per segment (hundreds of draw calls a frame, and with glow\n     * that count times the pass count), the ramp is approximated with a handful\n     * of contiguous alpha BANDS: over a 600-point trail a few bands are visually\n     * identical to a per-point ramp at a fraction of the cost. Bands overlap by\n     * one point so there is no seam.\n     */\n    const BANDS = 6\n    const strokeTrail = (segmentWidth: number, alphaScale: number) => {\n      ctx.lineWidth = segmentWidth\n      const n = points.length\n      const bandCount = Math.min(BANDS, n - 1)\n      for (let band = 0; band < bandCount; band += 1) {\n        const start = Math.floor((band * (n - 1)) / bandCount)\n        const end = Math.floor(((band + 1) * (n - 1)) / bandCount)\n        if (end <= start) continue\n        ctx.globalAlpha = (end / n) * alphaScale\n        ctx.beginPath()\n        ctx.moveTo(toX(points[start].x), toY(points[start].y))\n        for (let i = start + 1; i <= end; i += 1) {\n          ctx.lineTo(toX(points[i].x), toY(points[i].y))\n        }\n        ctx.stroke()\n      }\n    }\n\n    if (enableGlow && glowIntensity > 0) {\n      // Additive blending: overlapping wide, faint passes sum toward white in\n      // the middle, which reads as bloom rather than as a thick line.\n      ctx.globalCompositeOperation = \"lighter\"\n      const passes = Math.max(1, Math.min(5, Math.floor(glowIntensity)))\n      const glow = parseColor(stroke)\n      for (let pass = passes; pass >= 1; pass -= 1) {\n        // Quadratic falloff: the widest pass is the faintest, so the bloom\n        // tapers smoothly instead of banding.\n        const passAlpha = 0.4 / (pass * pass)\n        ctx.strokeStyle = glow\n          ? `rgba(${glow.r}, ${glow.g}, ${glow.b}, ${passAlpha})`\n          : stroke\n        strokeTrail(lineWidth + pass * 8, passAlpha)\n      }\n      ctx.globalCompositeOperation = \"source-over\"\n    }\n\n    ctx.strokeStyle = stroke\n    strokeTrail(lineWidth, 0.9)\n    ctx.globalAlpha = 1\n    ctx.restore()\n  }\n\n  // --------------------------------------------------------- the sample pump\n  useEffect(() => {\n    if (!tapped || paused || !visible) return\n    if (typeof window === \"undefined\") return\n\n    // Buffers for the lifetime of the pump. A rAF loop that allocates two\n    // kilo-sample arrays per frame hands the collector hundreds a second.\n    const size = analyserLeft ? analyserLeft.fftSize : dataSize\n    const left = new Float32Array(size)\n    const right = new Float32Array(analyserRight ? analyserRight.fftSize : size)\n\n    let raf = 0\n    let previous = 0\n    let displayGain = 1\n    // The needles start from the resting reading rather than from zero, so a\n    // meter attached to silence does not sweep up out of the danger zone on its\n    // first frames — correlation rests at +1 because silence cannot cancel.\n    let held: StereoReading = { correlation: 1, balance: 0, mid: 0, side: 0, width: 0 }\n    let sinceReadout = 0\n\n    const frame = (now: number) => {\n      raf = window.requestAnimationFrame(frame)\n\n      let hasData = false\n      if (analyserLeft && analyserRight) {\n        analyserLeft.getFloatTimeDomainData(left)\n        analyserRight.getFloatTimeDomainData(right)\n        hasData = true\n      } else if (getStereoDataRef.current) {\n        hasData = getStereoDataRef.current(left, right) !== 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      if (autoGain) {\n        const want = goniometerAutoGain(left, right)\n        // Silence returns null: hold the gain rather than snapping to the\n        // ceiling and amplifying the noise floor into a full screen.\n        if (want !== null) {\n          const tau = want < displayGain ? GAIN_DOWN_TAU : GAIN_UP_TAU\n          displayGain =\n            dt > 0 ? displayGain + (want - displayGain) * (1 - Math.exp(-dt / tau)) : want\n        }\n      } else {\n        displayGain = gain\n      }\n\n      drawRef.current(\n        goniometerPoints(left, right, { trailLength, gain: displayGain, orientation }),\n      )\n\n      // Readings from the RAW pair, never the gained copy — see the header.\n      const target = stereoReading(left, right)\n      if (dt > 0) {\n        const follow = (from: number, to: number) =>\n          meterFollow(from, to, dt, ballisticsRef.current)\n        held = {\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 follows directly rather than being recomputed from the\n          // smoothed energies: this surface prints width as its own number and\n          // draws no mid/side bars, so there is nothing for it to disagree with.\n          width: follow(held.width, target.width),\n        }\n      } else {\n        held = target\n      }\n\n      sinceReadout += dt\n      if (sinceReadout >= READOUT_INTERVAL) {\n        sinceReadout = 0\n        setReadout(held)\n      }\n    }\n\n    raf = window.requestAnimationFrame(frame)\n    return () => window.cancelAnimationFrame(raf)\n  }, [\n    tapped,\n    paused,\n    visible,\n    analyserLeft,\n    analyserRight,\n    dataSize,\n    autoGain,\n    gain,\n    orientation,\n    trailLength,\n  ])\n\n  const correlation = readout?.correlation ?? null\n  const balance = readout?.balance ?? null\n  const width = readout?.width ?? null\n\n  const label =\n    ariaLabel ??\n    (tapped\n      ? [\n          \"Goniometer: stereo image as a phosphor trace, mono vertical\",\n          correlation !== null\n            ? `correlation ${formatCorrelation(correlation)}${\n                correlation < 0 ? \" — out of phase, mono-incompatible\" : \"\"\n              }`\n            : null,\n          width !== null ? `width ${Math.round(width * 100)}%` : null,\n          balance !== null ? `energy balance ${balance.toFixed(2)}` : null,\n        ]\n          .filter(Boolean)\n          .join(\", \")\n      : \"Goniometer: no stereo source connected\")\n\n  const monoStyle: CSSProperties = {\n    fontFamily: token.fontMono,\n    fontSize: 9,\n    letterSpacing: \"0.14em\",\n    color: token.textMuted,\n    pointerEvents: \"none\",\n  }\n\n  return (\n    <div\n      className={className}\n      role=\"img\"\n      aria-label={label}\n      style={{\n        position: \"relative\",\n        width: \"100%\",\n        height,\n        boxSizing: \"border-box\",\n        background: token.surface,\n        border: `1px solid ${token.border}`,\n        borderRadius: token.radius,\n        overflow: \"hidden\",\n        fontFamily: token.font,\n        ...style,\n      }}\n    >\n      {tapped ? (\n        <canvas\n          ref={canvasRef}\n          aria-hidden=\"true\"\n          style={{ display: \"block\", width: \"100%\", height: \"100%\" }}\n        />\n      ) : null}\n\n      <Graticule\n        correlation={correlation}\n        balance={balance}\n        accent={color ?? token.accent}\n        orientation={orientation}\n        showGraticule={showGraticule}\n        showCorrelation={showCorrelation}\n        showBalance={showBalance}\n      />\n\n      <Captions\n        orientation={orientation}\n        showCorrelation={showCorrelation}\n        showBalance={showBalance}\n        style={monoStyle}\n      />\n\n      {showReadout && readout ? (\n        <div\n          style={{\n            ...monoStyle,\n            position: \"absolute\",\n            left: 10,\n            top: 8,\n            display: \"flex\",\n            gap: 10,\n            color: token.text,\n          }}\n        >\n          <span style={{ color: readout.correlation < 0 ? DANGER : token.text }}>\n            CORR {formatCorrelation(readout.correlation)}\n          </span>\n          <span>WIDTH {Math.round(readout.width * 100)}%</span>\n        </div>\n      ) : null}\n\n      {tapped ? null : (\n        <span\n          style={{\n            ...monoStyle,\n            position: \"absolute\",\n            left: 0,\n            right: 0,\n            bottom: 12,\n            textAlign: \"center\",\n            textTransform: \"uppercase\",\n            letterSpacing: \"0.2em\",\n            fontSize: 8.5,\n          }}\n        >\n          No stereo source\n        </span>\n      )}\n    </div>\n  )\n}\n\n/**\n * The graticule over the trace.\n *\n * One SVG in a percentage viewBox, so it stretches with the screen and needs no\n * size measurement of its own — which is what keeps this free of a\n * ResizeObserver while the canvas handles its own DPR sizing. `pointerEvents:\n * none` throughout, so anything the host puts behind it stays clickable.\n *\n * It renders whether or not there is a tap: an instrument with its scale drawn\n * and nothing on it reads as \"connected to nothing\", which is the truth, where\n * a blank rectangle reads as broken.\n */\nfunction Graticule({\n  correlation,\n  balance,\n  accent,\n  orientation,\n  showGraticule,\n  showCorrelation,\n  showBalance,\n}: {\n  correlation: number | null\n  balance: number | null\n  accent: string\n  orientation: GoniometerOrientation\n  showGraticule: boolean\n  showCorrelation: boolean\n  showBalance: boolean\n}) {\n  const meterX = 93\n  const needleY = 10 + (1 - correlationToUnit(correlation ?? 1)) * 66\n  const tickX = 12 + (((balance ?? 0) + 1) / 2) * 68\n\n  const line = {\n    stroke: token.border,\n    strokeWidth: 0.4,\n    vectorEffect: \"non-scaling-stroke\" as const,\n  }\n\n  return (\n    <svg\n      viewBox=\"0 0 100 100\"\n      preserveAspectRatio=\"none\"\n      aria-hidden=\"true\"\n      style={{\n        position: \"absolute\",\n        inset: 0,\n        width: \"100%\",\n        height: \"100%\",\n        pointerEvents: \"none\",\n      }}\n    >\n      {showGraticule ? (\n        orientation === \"left-right\" ? (\n          <>\n            {/* The L/R box. The rim IS full scale on each axis independently,\n                so a channel clipping shows as the trace flattening against\n                that channel's own edge. */}\n            <rect x=\"8\" y=\"8\" width=\"84\" height=\"84\" fill=\"none\" {...line} opacity={0.5} />\n            <rect x=\"29\" y=\"29\" width=\"42\" height=\"42\" fill=\"none\" {...line} opacity={0.25} />\n            {/* The diagonals ARE the readings on these axes: the rising one is\n                mono, the falling one is an inverted pair. Drawn harder than the\n                axes because on this orientation they carry the meaning the\n                diamond's vertical carries. */}\n            <line x1=\"8\" y1=\"92\" x2=\"92\" y2=\"8\" {...line} opacity={0.45} />\n            <line x1=\"8\" y1=\"8\" x2=\"92\" y2=\"92\" {...line} opacity={0.2} strokeDasharray=\"1 2\" />\n            <line x1=\"50\" y1=\"8\" x2=\"50\" y2=\"92\" {...line} opacity={0.18} />\n            <line x1=\"8\" y1=\"50\" x2=\"92\" y2=\"50\" {...line} opacity={0.18} />\n          </>\n        ) : (\n          <>\n            {/* The rim IS full scale: the canvas maps the rotated pair so that a\n                full-scale mono signal reaches this diamond's top vertex. */}\n            <polygon points=\"50,8 92,50 50,92 8,50\" fill=\"none\" {...line} opacity={0.5} />\n            <polygon points=\"50,29 71,50 50,71 29,50\" fill=\"none\" {...line} opacity={0.25} />\n            {/* Vertical is mono/mid, horizontal is pure side. */}\n            <line x1=\"50\" y1=\"8\" x2=\"50\" y2=\"92\" {...line} opacity={0.25} />\n            <line x1=\"8\" y1=\"50\" x2=\"92\" y2=\"50\" {...line} opacity={0.25} />\n          </>\n        )\n      ) : null}\n\n      {showBalance ? (\n        <>\n          {/* Dotted, so the baseline reads as a scale rather than a divider. */}\n          <line x1=\"12\" y1=\"96\" x2=\"80\" y2=\"96\" {...line} strokeDasharray=\"0.6 2\" opacity={0.5} />\n          <line x1=\"46\" y1=\"94.5\" x2=\"46\" y2=\"97.5\" {...line} opacity={0.7} />\n          {balance !== null ? (\n            <polygon points={`${tickX},94 ${tickX + 1.4},97 ${tickX - 1.4},97`} fill={accent} />\n          ) : null}\n        </>\n      ) : null}\n\n      {showCorrelation ? (\n        <>\n          <rect x={meterX} y=\"10\" width=\"4\" height=\"66\" fill={token.track} opacity={0.4} />\n          {/* The lower half is tinted because the scale is not symmetric in\n              meaning: below zero the pair partly cancels on a mono sum, and at\n              -1 the material simply is not there once anything folds to mono. */}\n          <rect x={meterX} y=\"43\" width=\"4\" height=\"33\" fill={DANGER} opacity={0.16} />\n          <rect x={meterX} y=\"10\" width=\"4\" height=\"66\" fill=\"none\" {...line} opacity={0.4} />\n          <line x1={meterX} y1=\"43\" x2={meterX + 4} y2=\"43\" {...line} opacity={0.7} />\n          {correlation !== null ? (\n            <rect\n              x={meterX - 1}\n              y={needleY - 0.5}\n              width=\"6\"\n              height=\"1\"\n              fill={correlation < 0 ? DANGER : accent}\n            />\n          ) : null}\n        </>\n      ) : null}\n    </svg>\n  )\n}\n\n/**\n * The captions, in HTML rather than SVG text.\n *\n * SVG text inside a `preserveAspectRatio=\"none\"` viewBox is stretched by the\n * same non-uniform scale as the geometry, which smears every glyph on a wide\n * screen. HTML absolute positioning keeps the type upright and at a real font\n * size.\n */\nfunction Captions({\n  orientation,\n  showCorrelation,\n  showBalance,\n  style,\n}: {\n  orientation: GoniometerOrientation\n  showCorrelation: boolean\n  showBalance: boolean\n  style: CSSProperties\n}) {\n  const base: CSSProperties = { ...style, position: \"absolute\", fontSize: 8, letterSpacing: \"0.12em\" }\n  const lr = orientation === \"left-right\"\n  return (\n    <>\n      {/* The axis captions ARE the orientation: on the diamond the vertical is\n          mid and both horizontals are side; on L/R each axis is one channel. */}\n      <span style={{ ...base, left: \"50%\", top: 4, transform: \"translateX(-50%)\" }}>\n        {lr ? \"R\" : \"M\"}\n      </span>\n      <span style={{ ...base, left: 4, top: \"50%\", transform: \"translateY(-50%)\" }}>\n        {lr ? \"L\" : \"S\"}\n      </span>\n      {lr ? null : (\n        <span style={{ ...base, right: \"9%\", top: \"50%\", transform: \"translateY(-50%)\" }}>S</span>\n      )}\n      {showBalance ? (\n        <>\n          <span style={{ ...base, left: 6, bottom: 4 }}>L</span>\n          <span style={{ ...base, left: \"78%\", bottom: 4 }}>R</span>\n        </>\n      ) : null}\n      {showCorrelation ? (\n        <>\n          <span style={{ ...base, right: 2, top: \"6%\" }}>+1</span>\n          <span style={{ ...base, right: 3, top: \"43%\" }}>0</span>\n          <span style={{ ...base, right: 2, top: \"77%\" }}>-1</span>\n        </>\n      ) : null}\n    </>\n  )\n}\n\n/** The existing goniometer correlation rail, reusable over host-owned scopes. */\nexport function GoniometerCorrelation({\n  correlation,\n  accent = token.accent,\n}: {\n  correlation: number | null\n  accent?: string\n}) {\n  return (\n    <div\n      role={correlation === null ? \"img\" : \"meter\"}\n      aria-label={\n        correlation === null\n          ? \"Stereo correlation: no signal\"\n          : \"Stereo correlation\"\n      }\n      aria-valuemin={-1}\n      aria-valuemax={1}\n      aria-valuenow={correlation ?? undefined}\n      aria-valuetext={\n        correlation === null ? \"No signal\" : formatCorrelation(correlation)\n      }\n      style={{ position: \"absolute\", inset: 0, pointerEvents: \"none\" }}\n    >\n      <Graticule\n        correlation={correlation}\n        balance={null}\n        accent={accent}\n        orientation=\"mid-side\"\n        showGraticule={false}\n        showBalance={false}\n        showCorrelation\n      />\n      <span\n        style={{\n          position: \"absolute\",\n          right: 2,\n          top: \"1%\",\n          fontSize: 8,\n          color: token.text,\n        }}\n      >\n        +1\n      </span>\n      <span\n        style={{\n          position: \"absolute\",\n          right: 3,\n          top: \"43%\",\n          fontSize: 8,\n          color: token.text,\n        }}\n      >\n        0\n      </span>\n      <span\n        style={{\n          position: \"absolute\",\n          right: 2,\n          top: \"77%\",\n          fontSize: 8,\n          color: token.text,\n        }}\n      >\n        −1\n      </span>\n      <span\n        style={{\n          position: \"absolute\",\n          right: 3,\n          bottom: 4,\n          fontSize: 9,\n          color: token.textMuted,\n          fontFamily: token.fontMono,\n        }}\n      >\n        CORR {correlation === null ? \"—\" : formatCorrelation(correlation)}\n      </span>\n    </div>\n  )\n}\n\n/** Shared orientation captions for a scope whose host owns the drawing surface. */\nexport function GoniometerAxes({\n  orientation = \"mid-side\",\n}: {\n  orientation?: GoniometerOrientation\n}) {\n  return (\n    <Captions\n      orientation={orientation}\n      showCorrelation={false}\n      showBalance={false}\n      style={{\n        color: token.textMuted,\n        fontFamily: token.fontMono,\n        pointerEvents: \"none\",\n      }}\n    />\n  )\n}\n",
      "type": "registry:component",
      "target": "@ui/goniometer.tsx"
    },
    {
      "path": "components/ui/stereo-monitor.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/goniometer\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 */\nimport { useEffect, useMemo, useRef, useState, type CSSProperties } from \"react\"\nimport {\n  createWebGpuSpectrogramSession,\n  type WebGpuSpectrogramSession,\n} from \"maruaudio/ui/shared/webgpu-spectrogram\"\nimport type { WebGpuMeterPlot } from \"maruaudio/ui/shared/webgpu-meter-plots\"\nimport {\n  paintStereoField,\n  readStereoPlot,\n  resetStereoPlot,\n  type StereoPlotReading,\n  type StereoFieldAppearance,\n  stereoFieldColors,\n} from \"maruaudio/ui/shared/stereo-field-renderer\"\nimport { GoniometerAxes, GoniometerCorrelation } from \"@/components/ui/goniometer\"\n\nexport interface StereoMonitorProps {\n  /** Host-owned measurement only. This component opens no audio devices. */\n  getStereoData?: (left: Float32Array, right: Float32Array) => boolean\n  running?: boolean\n  style?: number\n  autoGain?: boolean\n  renderer?: \"webgpu\" | \"canvas2d\"\n  showReadout?: boolean\n  showAxes?: boolean\n  showStatus?: boolean\n  appearance?: StereoFieldAppearance\n  /** Share a face-owned device with sibling GPU layers; null selects fallback. */\n  gpuSession?: WebGpuSpectrogramSession | null\n  onReading?: (reading: StereoPlotReading) => void\n  className?: string\n  height?: CSSProperties[\"height\"]\n}\n\nconst EMPTY = new Float32Array(0)\n// Stereo density uses a uniform tint; waveform/spectrum palettes stay caller-owned.\nconst COLORS = new Float32Array(4096 * 4)\nfor (let i = 0; i < 4096; i++) COLORS.set([0.4, 0.95, 0.8, 1], i * 4)\n\n/** Share one device across a face's trace and authored sprite layers. */\nexport function useWebGpuVisualizationSession(enabled: boolean) {\n  const [session, setSession] = useState<WebGpuSpectrogramSession | null>(null)\n  useEffect(() => {\n    if (!enabled) {\n      setSession(null)\n      return\n    }\n    let cancelled = false\n    let active: WebGpuSpectrogramSession | null = null\n    void createWebGpuSpectrogramSession(() => {\n      if (!cancelled) setSession(null)\n    })\n      .then((value) => {\n        if (cancelled) {\n          value?.dispose()\n          return\n        }\n        active = value\n        setSession(value)\n      })\n      .catch(() => {\n        if (!cancelled) setSession(null)\n      })\n    return () => {\n      cancelled = true\n      active?.dispose()\n    }\n  }, [enabled])\n  return enabled ? session : null\n}\n\n/** Shared acquisition/renderer lifecycle for compact stereo monitors. */\nexport function StereoMonitor({\n  getStereoData,\n  running = true,\n  style = 3,\n  autoGain = true,\n  renderer = \"webgpu\",\n  showReadout = true,\n  showAxes = true,\n  showStatus = true,\n  appearance,\n  gpuSession,\n  onReading,\n  className,\n  height = \"100%\",\n}: StereoMonitorProps) {\n  const canvasRef = useRef<HTMLCanvasElement>(null)\n  const colors = useMemo(\n    () => (appearance ? stereoFieldColors(appearance) : COLORS),\n    [appearance]\n  )\n  const input = useRef({\n    getStereoData,\n    style,\n    autoGain,\n    onReading,\n    appearance,\n    colors,\n  })\n  input.current = {\n    getStereoData,\n    style,\n    autoGain,\n    onReading,\n    appearance,\n    colors,\n  }\n  const [backend, setBackend] = useState(\"Canvas2D\")\n  const [reading, setReading] = useState<StereoPlotReading>({\n    correlation: null,\n    gain: 1,\n    width: 0,\n    balance: 0,\n  })\n\n  useEffect(() => {\n    const canvas = canvasRef.current\n    if (!canvas) return\n    let cancelled = false\n    let plot: WebGpuMeterPlot | undefined\n    let disposeSession = () => {}\n    const fallback = () => {\n      plot?.dispose()\n      plot = undefined\n      disposeSession()\n      if (!cancelled) setBackend(\"Canvas2D\")\n    }\n    if (renderer === \"webgpu\" && gpuSession !== undefined) {\n      if (gpuSession) {\n        try {\n          plot = gpuSession.createPlot(canvas, \"stereo\")\n          setBackend(\"WebGPU\")\n        } catch {\n          fallback()\n        }\n      } else setBackend(\"Canvas2D\")\n    } else if (renderer === \"webgpu\") {\n      void createWebGpuSpectrogramSession(fallback)\n        .then((session) => {\n          if (!session) return\n          if (cancelled) {\n            session.dispose()\n            return\n          }\n          disposeSession = () => session.dispose()\n          try {\n            plot = session.createPlot(canvas, \"stereo\")\n            setBackend(\"WebGPU\")\n          } catch {\n            fallback()\n          }\n        })\n        .catch(fallback)\n    } else setBackend(\"Canvas2D\")\n\n    let raf = 0\n    let previous = 0\n    let lastReadout = 0\n    let lastStyle = -1\n    const left = new Float32Array(2048)\n    const right = new Float32Array(2048)\n    const gpu = (paint: (renderer: WebGpuMeterPlot) => void) => {\n      if (!plot) return false\n      try {\n        paint(plot)\n        return true\n      } catch {\n        fallback()\n        return false\n      }\n    }\n    const tick = (now: number) => {\n      raf = requestAnimationFrame(tick)\n      if (!running || document.hidden || now - previous < 1000 / 30) return\n      previous = now\n      const current = input.current\n      const scale = Math.min(1.5, window.devicePixelRatio || 1)\n      const width = Math.max(1, Math.round(canvas.clientWidth * scale))\n      const height = Math.max(1, Math.round(canvas.clientHeight * scale))\n      if (canvas.width !== width || canvas.height !== height) {\n        canvas.width = width\n        canvas.height = height\n      }\n      if (current.style !== lastStyle) {\n        gpu((renderer) => renderer.clear())\n        canvas.getContext(\"2d\")?.clearRect(0, 0, width, height)\n        resetStereoPlot(canvas)\n        lastStyle = current.style\n      }\n      if (current.getStereoData?.(left, right) === true) {\n        paintStereoField(canvas, left, right, {\n          colors: current.colors,\n          appearance: current.appearance,\n          color: current.appearance\n            ? `rgb(${current.appearance.center.map((value) => Math.round(value * 255)).join(\",\")})`\n            : \"#a3f5d6\",\n          guideline: {\n            color: \"#a3c9bb\",\n            stereoGuidelineOpacity: 0.15,\n            stereoBorderOpacity: 0.2,\n          },\n          style: current.style,\n          autoGain: current.autoGain,\n          gpu,\n        })\n        if (now - lastReadout >= 100) {\n          const value = readStereoPlot(canvas)\n          setReading(value)\n          current.onReading?.(value)\n          lastReadout = now\n        }\n      } else if (current.style !== 4) {\n        gpu((renderer) =>\n          renderer.draw(\n            EMPTY,\n            current.colors,\n            current.style,\n            current.appearance ? 1 : 0\n          )\n        )\n      }\n    }\n    if (running) raf = requestAnimationFrame(tick)\n    return () => {\n      cancelled = true\n      cancelAnimationFrame(raf)\n      plot?.dispose()\n      disposeSession()\n      resetStereoPlot(canvas)\n    }\n  }, [running, renderer, gpuSession])\n\n  return (\n    <div className={className} style={{ position: \"relative\", height }}>\n      <canvas\n        ref={canvasRef}\n        data-renderer={backend.toLowerCase()}\n        aria-label=\"Stereo signal monitor\"\n        style={{ display: \"block\", width: \"100%\", height: \"100%\" }}\n      />\n      {showAxes && style === 3 && <GoniometerAxes />}\n      {showReadout && (\n        <>\n          <GoniometerCorrelation correlation={reading.correlation} />\n        </>\n      )}\n      {showStatus && (\n        <span\n          style={{\n            position: \"absolute\",\n            left: 6,\n            bottom: 4,\n            fontSize: 9,\n            opacity: 0.6,\n            pointerEvents: \"none\",\n          }}\n        >\n          {backend} · ×{reading.gain.toFixed(1)}\n        </span>\n      )}\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "@ui/stereo-monitor.tsx"
    }
  ]
}
