Goniometer

goniometerVisualizers

The signal-side picture of a stereo image, in the orientation mix engineers actually read. A phosphor trace of the channel pair rotated 45° so that mono runs straight up the middle, pure side lies flat across the horizontal, and a hard-left source draws on the left — the three shapes the instrument exists to tell apart, each on its own axis. An unrotated X-Y plot is a perfectly good vectorscope and it is what lissajous-scope draws, but it puts mono on the diagonal, and every hardware goniometer since the RTW meters rotates that away so that 'upright' means 'mono-compatible' for free. The rotation is orthonormal (1/√2, not the matrix convention's ½) because a pure rotation preserves length: a hard-panned source and a centred one of equal energy reach the same distance from the origin, so the blob's extent is a true picture of magnitude rather than a squashed one. Switchable between the mid/side diamond and a raw left/right split, which answers the per-channel question the diamond hides. Display auto-gain is measured on the mono sum, so the scale never depends on which way the image is leaning — otherwise 'the image got wider' could be an artefact of the scaling — and the printed CORR/WIDTH readings are always taken from the raw pair, because correlation happens to be gain-invariant but width and balance are not. Pairs with stereo-field, which draws the same signal's numbers: this one draws its shape.

MSSLR+10-1
Auto-gain
ON
Split L/R
OFF

Kick and bass centred, hats ticking off to the side, a Haas-delayed pad and an uncorrelated reverb tail. The bright vertical spine is the low end; the cloud around it is the pad and the room. Measured width here is 20%.

A 96 BPM mixdown — kick, hats, a Haas-delayed pad and an uncorrelated reverb tail — synthesised a frame at a time and handed over through getStereoData, the same contract an engine tap satisfies, so the component cannot tell this from a live analyser pair. The page stays silent: nothing here builds an AudioContext. The 45° rotation is what makes the reading free — an unrotated X-Y plot puts mono on the diagonal, and lissajous-scope draws exactly that. Here upright is mono, so “is it standing up” and “does it survive a mono sum” are the same question. Turn Auto-gain off and the trace plots at the signal’s own amplitude; the CORR and WIDTH readouts do not move when you do, because they are taken from the raw pair and never from the scaled copy — a meter that moved with the display gain would be lying.Split L/R swaps the rotated diamond for the raw per-channel plot: X is left, Y is right, and mono moves from the vertical to the rising diagonal. It answers a different question — about the channels rather than the image — so a dead channel collapses the figure onto one axis and a level mismatch tilts the diagonal, both of which the diamond hides. For the same signal’s numbers rather than its shape, put a stereo-field beside this.

Install

Copies the source into your project. Anything it depends on comes with it.

$ npx shadcn add @syntho-ui/goniometer

Pulls in

stereo-mathuse-audio-sourcecontrol-theme

First time? Add the registry to your components.json once:

components.json
{
  "registries": {
    "@syntho-ui": "https://engine.syntho.app/r/{name}.json"
  }
}

Usage

Controlled or uncontrolled, whichever suits the surrounding state.

example.tsx
import { Goniometer } from "@/components/ui/goniometer"

// A live pair — read-only, never connected or reconfigured.
<Goniometer analyserLeft={left} analyserRight={right} />

// Engine-agnostic: fill two arrays per frame and return true.
<Goniometer getStereoData={(l, r) => engine.readStereo(l, r)} height={280} />

// Calibration: plot at the signal's own amplitude, no auto-gain.
<Goniometer analyserLeft={left} analyserRight={right} autoGain={false} />

Props

PropTypeDefaultDescription
analyserLeft / analyserRightAnalyserNodeA live pair, read-only — never connected, disconnected or reconfigured. Both are required: half a pair has no stereo image in it, so the loop does not start on one.
getStereoData(left: Float32Array, right: Float32Array) => boolean | voidFill both arrays with the current frame and return true. The engine-agnostic form — a worklet, a compiled engine tap or a test harness drives it with no Web Audio involved. With neither this nor an analyser pair, the screen draws its graticule and says it has no source rather than animating invented samples.
autoGainbooleantrueNormalise the plotted pair toward a readable level, fast down and slow up. A display decision only: the readings are always taken from the raw pair. Turn it off to plot at the signal's own amplitude, which is what a calibration check wants.
gainnumber1Fixed display gain, used when autoGain is off.
orientation"mid-side" | "left-right""mid-side"Which axes to plot on. The default is the rotated diamond every hardware goniometer uses — mono straight up, pure side flat, upright means mono-compatible. "left-right" is the raw per-channel X-Y plot: X is left, Y is right, and mono moves to the rising diagonal. Not merely the diamond un-rotated by accident — it answers a different question, about the CHANNELS rather than the image, so a dead channel collapses the figure onto one axis and a level mismatch between the sides tilts the diagonal, both of which the diamond hides. The graticule and the axis captions change with it: a box with its diagonals instead of a diamond, labelled L and R instead of M and S.
trailLengthnumber600How many points to plot per frame. The frame is walked in strides, so a short trail still spans the whole buffer instead of showing a shrinking window of it.
fadeSpeednumber0.08Alpha of the per-frame wash that fades the previous frame — the CRT phosphor decay. Lower means longer persistence; 0 lets the trace accumulate.
enableGlow / glowIntensityboolean / numbertrue / 2Additive multi-pass bloom around the trace, 1–5 passes with a quadratic falloff so it tapers instead of banding. Costs a stroke pass per level.
showGraticule / showCorrelation / showBalance / showReadoutbooleantrueThe chrome, each independently droppable: the diamond rim and axes, the correlation meter down the right edge, the L…R baseline with the measured balance tick, and the numeric CORR/WIDTH readout.
ballistics{ attack?: number; release?: number }{ attack: 0.35, release: 0.08 }Needle ballistics for the readouts, deliberately the inverse of the kit's meter default. On a correlation meter the move worth catching is the drop toward antiphase, so the fast constant goes on the downward travel — meterFollow names its constants after the direction of travel for exactly this.
colorstringTrace colour. Defaults to the --uikit-accent token. The warning tint is not overridable — it is shared with stereo-field and gain-reduction-meter so one face cannot have two different reds meaning 'this is the bad one'.
heightnumber244Screen height in pixels; width fills the container.
pausedbooleanfalseStop the loop and leave the last trace on screen to decay.

Interaction

  • Drag with pointer capture, so the gesture keeps tracking past the edge of the control.
  • Shift while dragging for a fine pass; release it mid-gesture and coarse tracking resumes.
  • Double-click resets to defaultValue.
  • Arrow keys step normally, Shift+arrow steps finely, PageUp/PageDown jump by ten, Home/End go to the extremes.
  • Proper slider and switch roles with live aria-valuetext, so a screen reader announces the formatted value rather than the raw number.
  • The scroll wheel adjusts the value while the pointer is over the control; stepped ranges move at least one declared step per notch by default.