@vsreact/core 0.0.14 → 0.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,98 @@
1
+ # @vsreact/core
2
+
3
+ **Write React. Ship native VST.**
4
+
5
+ VSReacT is a React renderer for JUCE audio plugins — your UI runs in
6
+ QuickJS inside the plugin, lays out with Yoga, and paints with
7
+ `juce::Graphics`. No webview, no browser, no Electron. Real threads,
8
+ real automation gestures, hot reload in your DAW.
9
+
10
+ ```tsx
11
+ import { render, View, Text, ParamKnob } from "@vsreact/core";
12
+
13
+ function App() {
14
+ return (
15
+ <View className="w-full h-full items-center justify-center gap-5">
16
+ <Text className="text-text text-[15] font-bold tracking-widest">MY PLUGIN</Text>
17
+ <View className="flex-row gap-10">
18
+ <ParamKnob paramId="gain" size={88} />
19
+ <ParamKnob paramId="pan" size={88} bipolar />
20
+ </View>
21
+ </View>
22
+ );
23
+ }
24
+
25
+ render(<App />);
26
+ ```
27
+
28
+ That's a working plugin UI: knobs bound to APVTS parameters, drag /
29
+ wheel / double-click-reset, automation-safe begin/set/end gestures.
30
+
31
+ ## Install
32
+
33
+ ```sh
34
+ bun add @vsreact/core # or npm / yarn / pnpm
35
+ ```
36
+
37
+ The native side is a JUCE module fetched with CMake — see the
38
+ [installation guide](https://vsreact.n9records.com/docs/installation).
39
+
40
+ ## What's in the box
41
+
42
+ **Parameter controls** — `Knob`, `Slider`, `Toggle`, `XYPad`,
43
+ `Segmented`, `Select`, `NumberBox`, `Checkbox`, `RadioGroup`,
44
+ `HardwareKnob`, `MacroPad`, `Crossfader` — each with a `Param*` twin
45
+ that binds to a host parameter in one line, and `GenericEditor`, the
46
+ one-line editor for your whole parameter list.
47
+
48
+ **Synth & performance** — `PianoKeyboard` (glissando, held notes),
49
+ `StepSequencer`, `ADSREnvelope` (four-corner editor), `PitchBend`,
50
+ `ModWheel`, `EQCurve` (real RBJ biquad response with draggable band
51
+ nodes).
52
+
53
+ **Meters & visualizers** — `Meter` (peak-hold, hot zone, reverse for
54
+ gain reduction), `RingMeter`, `Bars`, `Waveform`, `PulseOrb`.
55
+
56
+ **Structure & feedback** — `Tabs`, `Disclosure`, `Button`, `Tooltip`,
57
+ `Modal`, `ProgressBar` (determinate + indeterminate), `Spinner`.
58
+
59
+ **Hooks** — `useParameter`, `useParameterList`, `useNativeEvent`,
60
+ `useNativeValue`, `useSpring`, `useTween`, `usePeakHold`,
61
+ `useRollingBuffer`, `useDebounced`, `useThrottled`, `useInterval`,
62
+ `useHover`, `useToggle`, `usePrevious`, `useLayoutRect`, `useOverlay`.
63
+
64
+ **Utilities** — a Tailwind-subset `className` engine with theme
65
+ tokens, `cx`, value formatters (`formatDb`, `formatHz`, `formatMs`,
66
+ `formatPercent`, `formatSemitones`, `midiNoteName`, `midiNoteToHz`),
67
+ and the pure math behind the components (`adsrLevelAt`,
68
+ `biquadMagnitudeDb`, `eqResponseDb`, `snapToStep`, `mapRange`).
69
+
70
+ ## How it works
71
+
72
+ ```
73
+ React (QuickJS) ──mutations──▶ C++ shadow tree ──Yoga──▶ juce::Graphics
74
+ ▲ │
75
+ └────────────── events, parameters, native calls ────────┘
76
+ ```
77
+
78
+ Your bundle runs in QuickJS with a custom react-reconciler that
79
+ streams JSON mutations to a C++ shadow tree. Yoga computes layout,
80
+ JUCE paints, and input events flow back. Parameters ride
81
+ `vsreact::ParameterBridge`; anything else rides `native.call()` /
82
+ `useNativeEvent`.
83
+
84
+ - **Hot reload**: point the native side at your bundle file and it
85
+ reloads on save — inside the DAW.
86
+ - **Automation-safe**: every control opens proper begin/set/end
87
+ gestures.
88
+ - **No webview**: the UI is painted by the same process and toolkit as
89
+ the rest of your plugin.
90
+
91
+ ## Docs
92
+
93
+ - Site & component gallery: <https://vsreact.n9records.com/components>
94
+ - Quick start: <https://vsreact.n9records.com/docs/quick-start>
95
+ - Full docs: <https://vsreact.n9records.com/docs>
96
+ - Analytics add-on: [`@vsreact/posthog`](https://www.npmjs.com/package/@vsreact/posthog)
97
+
98
+ MIT © N9 Records Technologies
@@ -1,7 +1,9 @@
1
1
  export { Knob, Slider, Toggle, XYPad, Segmented, Select, GenericEditor, ParamKnob, ParamSlider, ParamToggle, ParamXYPad, ParamSegmented, ParamSelect, dragToValue, } from "./controls";
2
2
  export type { KnobProps, SliderProps, ToggleProps, XYPadProps, SegmentedProps, SelectProps, GenericEditorProps, ParamKnobProps, ParamSliderProps, ParamToggleProps, ParamXYPadProps, ParamSegmentedProps, ParamSelectProps, } from "./controls";
3
- export { Meter, usePeakHold, peakHoldStep } from "./meter";
4
- export type { MeterProps, PeakHoldOptions, PeakHoldState } from "./meter";
3
+ export { Meter, RingMeter, usePeakHold, peakHoldStep } from "./meter";
4
+ export type { MeterProps, RingMeterProps, PeakHoldOptions, PeakHoldState } from "./meter";
5
+ export { EQCurve } from "./eq";
6
+ export type { EQCurveProps, EQBand, FilterType } from "./eq";
5
7
  export { Bars, Waveform, useRollingBuffer, pushRolling } from "./visualizers";
6
8
  export type { BarsProps, WaveformProps } from "./visualizers";
7
9
  export { Button } from "./button";
@@ -6,7 +6,8 @@
6
6
  // and as the boundary line if the component kit ever becomes its own
7
7
  // package.
8
8
  export { Knob, Slider, Toggle, XYPad, Segmented, Select, GenericEditor, ParamKnob, ParamSlider, ParamToggle, ParamXYPad, ParamSegmented, ParamSelect, dragToValue, } from "./controls";
9
- export { Meter, usePeakHold, peakHoldStep } from "./meter";
9
+ export { Meter, RingMeter, usePeakHold, peakHoldStep } from "./meter";
10
+ export { EQCurve } from "./eq";
10
11
  export { Bars, Waveform, useRollingBuffer, pushRolling } from "./visualizers";
11
12
  export { Button } from "./button";
12
13
  export { Tooltip, Modal } from "./popover";
@@ -85,6 +85,9 @@ export declare function Toggle({ on, label, offLabel, onLabel, size, disabled, t
85
85
  export interface ParamToggleProps {
86
86
  paramId: string;
87
87
  label?: string;
88
+ /** Hardware-style side captions, forwarded to Toggle. */
89
+ offLabel?: string;
90
+ onLabel?: string;
88
91
  size?: number;
89
92
  trackColor?: string;
90
93
  onColor?: string;
package/dist/eq.d.ts ADDED
@@ -0,0 +1,40 @@
1
+ export type FilterType = "peak" | "lowshelf" | "highshelf" | "lowpass" | "highpass" | "bandpass" | "notch";
2
+ export interface EQBand {
3
+ type: FilterType;
4
+ /** Center/corner frequency in Hz (20..20000). */
5
+ freq: number;
6
+ /** Boost/cut in dB (peak & shelves). Default 0. */
7
+ gainDb?: number;
8
+ /** Resonance. Default 0.71. */
9
+ q?: number;
10
+ }
11
+ /** RBJ cookbook biquad magnitude at `atHz`, in dB. */
12
+ export declare function biquadMagnitudeDb(band: EQBand, atHz: number, sampleRate?: number): number;
13
+ /** The summed response of all bands at `atHz`, in dB. */
14
+ export declare function eqResponseDb(bands: EQBand[], atHz: number, sampleRate?: number): number;
15
+ /** 0..1 position ↔ 20..20k Hz on a log scale. */
16
+ export declare const eqXToHz: (x01: number) => number;
17
+ export declare const eqHzToX: (hz: number) => number;
18
+ export interface EQCurveProps {
19
+ bands: EQBand[];
20
+ width?: number;
21
+ height?: number;
22
+ /** Vertical range: ±dbRange dB. Default 18. */
23
+ dbRange?: number;
24
+ /** Fill resolution. Default 48 columns. */
25
+ columns?: number;
26
+ disabled?: boolean;
27
+ trackColor?: string;
28
+ /** The response fill. */
29
+ color?: string;
30
+ handleColor?: string;
31
+ label?: string;
32
+ /** A node was dragged (freq/gain) or wheeled (q). */
33
+ onChange?: (index: number, band: EQBand) => void;
34
+ onBegin?: (index: number) => void;
35
+ onEnd?: (index: number) => void;
36
+ }
37
+ /** The EQ display every plugin wants: the real summed biquad response,
38
+ with one draggable node per band (x = freq on a log scale, y = gain)
39
+ and the wheel adjusting Q. */
40
+ export declare function EQCurve({ bands, width, height, dbRange, columns, disabled, trackColor, color, handleColor, label, onChange, onBegin, onEnd, }: EQCurveProps): import("react").JSX.Element;
package/dist/eq.js ADDED
@@ -0,0 +1,150 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // The EQ curve — a real biquad response display with draggable band
3
+ // nodes. Views only: the response is a center-anchored column fill
4
+ // (boost up, cut down), one node per band; drag moves freq/gain, the
5
+ // wheel adjusts Q. RBJ cookbook math, exported pure for reuse.
6
+ import { useRef } from "react";
7
+ import { View, Text } from "./primitives";
8
+ const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
9
+ /** RBJ cookbook biquad magnitude at `atHz`, in dB. */
10
+ export function biquadMagnitudeDb(band, atHz, sampleRate = 48000) {
11
+ const { type, freq } = band;
12
+ const gainDb = band.gainDb ?? 0;
13
+ const q = Math.max(0.05, band.q ?? 0.71);
14
+ const A = Math.pow(10, gainDb / 40);
15
+ const w0 = (2 * Math.PI * clamp(freq, 1, sampleRate / 2 - 1)) / sampleRate;
16
+ const cos = Math.cos(w0);
17
+ const sin = Math.sin(w0);
18
+ const alpha = sin / (2 * q);
19
+ const rootA2Alpha = 2 * Math.sqrt(A) * alpha;
20
+ let b0, b1, b2, a0, a1, a2;
21
+ switch (type) {
22
+ case "peak":
23
+ b0 = 1 + alpha * A;
24
+ b1 = -2 * cos;
25
+ b2 = 1 - alpha * A;
26
+ a0 = 1 + alpha / A;
27
+ a1 = -2 * cos;
28
+ a2 = 1 - alpha / A;
29
+ break;
30
+ case "lowpass":
31
+ b0 = (1 - cos) / 2;
32
+ b1 = 1 - cos;
33
+ b2 = (1 - cos) / 2;
34
+ a0 = 1 + alpha;
35
+ a1 = -2 * cos;
36
+ a2 = 1 - alpha;
37
+ break;
38
+ case "highpass":
39
+ b0 = (1 + cos) / 2;
40
+ b1 = -(1 + cos);
41
+ b2 = (1 + cos) / 2;
42
+ a0 = 1 + alpha;
43
+ a1 = -2 * cos;
44
+ a2 = 1 - alpha;
45
+ break;
46
+ case "bandpass":
47
+ b0 = alpha;
48
+ b1 = 0;
49
+ b2 = -alpha;
50
+ a0 = 1 + alpha;
51
+ a1 = -2 * cos;
52
+ a2 = 1 - alpha;
53
+ break;
54
+ case "notch":
55
+ b0 = 1;
56
+ b1 = -2 * cos;
57
+ b2 = 1;
58
+ a0 = 1 + alpha;
59
+ a1 = -2 * cos;
60
+ a2 = 1 - alpha;
61
+ break;
62
+ case "lowshelf":
63
+ b0 = A * (A + 1 - (A - 1) * cos + rootA2Alpha);
64
+ b1 = 2 * A * (A - 1 - (A + 1) * cos);
65
+ b2 = A * (A + 1 - (A - 1) * cos - rootA2Alpha);
66
+ a0 = A + 1 + (A - 1) * cos + rootA2Alpha;
67
+ a1 = -2 * (A - 1 + (A + 1) * cos);
68
+ a2 = A + 1 + (A - 1) * cos - rootA2Alpha;
69
+ break;
70
+ case "highshelf":
71
+ b0 = A * (A + 1 + (A - 1) * cos + rootA2Alpha);
72
+ b1 = -2 * A * (A - 1 + (A + 1) * cos);
73
+ b2 = A * (A + 1 + (A - 1) * cos - rootA2Alpha);
74
+ a0 = A + 1 - (A - 1) * cos + rootA2Alpha;
75
+ a1 = 2 * (A - 1 - (A + 1) * cos);
76
+ a2 = A + 1 - (A - 1) * cos - rootA2Alpha;
77
+ break;
78
+ }
79
+ const w = (2 * Math.PI * clamp(atHz, 1, sampleRate / 2 - 1)) / sampleRate;
80
+ const cw = Math.cos(w);
81
+ const c2w = Math.cos(2 * w);
82
+ const num = b0 * b0 + b1 * b1 + b2 * b2 + 2 * (b0 * b1 + b1 * b2) * cw + 2 * b0 * b2 * c2w;
83
+ const den = a0 * a0 + a1 * a1 + a2 * a2 + 2 * (a0 * a1 + a1 * a2) * cw + 2 * a0 * a2 * c2w;
84
+ return 10 * Math.log10(Math.max(1e-12, num / Math.max(1e-12, den)));
85
+ }
86
+ /** The summed response of all bands at `atHz`, in dB. */
87
+ export function eqResponseDb(bands, atHz, sampleRate = 48000) {
88
+ let db = 0;
89
+ for (const band of bands)
90
+ db += biquadMagnitudeDb(band, atHz, sampleRate);
91
+ return db;
92
+ }
93
+ const FREQ_MIN = 20;
94
+ const FREQ_MAX = 20000;
95
+ const logSpan = Math.log(FREQ_MAX / FREQ_MIN);
96
+ /** 0..1 position ↔ 20..20k Hz on a log scale. */
97
+ export const eqXToHz = (x01) => FREQ_MIN * Math.exp(clamp(x01, 0, 1) * logSpan);
98
+ export const eqHzToX = (hz) => Math.log(clamp(hz, FREQ_MIN, FREQ_MAX) / FREQ_MIN) / logSpan;
99
+ /** The EQ display every plugin wants: the real summed biquad response,
100
+ with one draggable node per band (x = freq on a log scale, y = gain)
101
+ and the wheel adjusting Q. */
102
+ export function EQCurve({ bands, width = 220, height = 96, dbRange = 18, columns = 48, disabled, trackColor = "#141714", color = "#C6F13560", handleColor = "#ECF2E8", label, onChange, onBegin, onEnd, }) {
103
+ const start = useRef({ freq: 1000, gainDb: 0 });
104
+ const mid = height / 2;
105
+ const HANDLE = 18;
106
+ const yOfDb = (db) => mid - (clamp(db, -dbRange, dbRange) / dbRange) * (mid - 4);
107
+ const node = (band, index) => {
108
+ const x = eqHzToX(band.freq) * width;
109
+ const y = yOfDb(band.gainDb ?? 0);
110
+ const handlers = disabled
111
+ ? {}
112
+ : {
113
+ onDragStart: () => {
114
+ start.current = { freq: band.freq, gainDb: band.gainDb ?? 0 };
115
+ onBegin?.(index);
116
+ },
117
+ onDrag: (e) => {
118
+ const x01 = clamp(eqHzToX(start.current.freq) + e.dx / width, 0, 1);
119
+ onChange?.(index, {
120
+ ...band,
121
+ freq: Math.round(eqXToHz(x01)),
122
+ gainDb: clamp(start.current.gainDb - (e.dy / (mid - 4)) * dbRange, -dbRange, dbRange),
123
+ });
124
+ },
125
+ onDragEnd: () => onEnd?.(index),
126
+ onWheel: (e) => {
127
+ const q = Math.max(0.05, band.q ?? 0.71);
128
+ onBegin?.(index);
129
+ onChange?.(index, { ...band, q: clamp(q * Math.exp(e.dy * 1.4), 0.1, 18) });
130
+ onEnd?.(index);
131
+ },
132
+ };
133
+ return (_jsx(View, { className: `absolute items-center justify-center ${disabled ? "" : "cursor-pointer"}`, style: { left: x - HANDLE / 2, top: y - HANDLE / 2, width: HANDLE, height: HANDLE }, ...handlers, children: _jsx(View, { className: "rounded-full border", style: { width: 10, height: 10, backgroundColor: handleColor, borderColor: "#00000088" } }) }, index));
134
+ };
135
+ const body = (_jsxs(View, { className: `relative rounded overflow-hidden ${disabled ? "opacity-40" : ""}`, style: { width, height, backgroundColor: trackColor }, children: [_jsx(View, { className: "absolute left-0 right-0 h-[1]", style: { top: mid, backgroundColor: "#FFFFFF24" } }), Array.from({ length: columns }, (_, i) => {
136
+ const hz = eqXToHz((i + 0.5) / columns);
137
+ const db = eqResponseDb(bands, hz);
138
+ const columnHeight = Math.abs(yOfDb(db) - mid);
139
+ return (_jsx(View, { className: "absolute", style: {
140
+ left: (i / columns) * width + 0.5,
141
+ width: width / columns - 1,
142
+ top: db >= 0 ? mid - columnHeight : mid,
143
+ height: Math.max(1, columnHeight),
144
+ backgroundColor: color,
145
+ } }, i));
146
+ }), bands.map(node)] }));
147
+ if (label === undefined)
148
+ return body;
149
+ return (_jsxs(View, { className: "items-center gap-2", children: [body, _jsx(Text, { className: "text-faint text-[10] font-bold tracking-widest", children: label })] }));
150
+ }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import "./runtime";
2
2
  import "./bridge";
3
+ /** The SDK version — stamp it on support dumps and analytics. */
4
+ export declare const VERSION = "0.0.16";
3
5
  export { View, Text, Image, TextInput, NativeView } from "./primitives";
4
6
  export type { CommonProps, TextProps, ImageProps, TextInputProps, NativeViewProps, } from "./primitives";
5
7
  export { render, unmount } from "./render";
@@ -22,8 +24,10 @@ export { useParameter, useParameterList } from "./parameters";
22
24
  export type { ParameterState, ParameterHandle, ParameterInfo } from "./parameters";
23
25
  export { Knob, Slider, Toggle, XYPad, Segmented, Select, GenericEditor, ParamKnob, ParamSlider, ParamToggle, ParamXYPad, ParamSegmented, ParamSelect, dragToValue, } from "./controls";
24
26
  export type { KnobProps, SliderProps, ToggleProps, XYPadProps, SegmentedProps, SelectProps, GenericEditorProps, ParamKnobProps, ParamSliderProps, ParamToggleProps, ParamXYPadProps, ParamSegmentedProps, ParamSelectProps, } from "./controls";
25
- export { Meter, usePeakHold, peakHoldStep } from "./meter";
26
- export type { MeterProps, PeakHoldOptions, PeakHoldState } from "./meter";
27
+ export { Meter, RingMeter, usePeakHold, peakHoldStep } from "./meter";
28
+ export type { MeterProps, RingMeterProps, PeakHoldOptions, PeakHoldState } from "./meter";
29
+ export { EQCurve, biquadMagnitudeDb, eqResponseDb, eqXToHz, eqHzToX } from "./eq";
30
+ export type { EQCurveProps, EQBand, FilterType } from "./eq";
27
31
  export { NumberBox, Checkbox, RadioGroup, ParamNumberBox, ParamCheckbox, ParamRadioGroup, snapToStep, } from "./fields";
28
32
  export type { NumberBoxProps, CheckboxProps, RadioGroupProps, ParamNumberBoxProps, ParamCheckboxProps, ParamRadioGroupProps, } from "./fields";
29
33
  export { ProgressBar, Spinner } from "./feedback";
package/dist/index.js CHANGED
@@ -2,6 +2,8 @@
2
2
  // timer/console/microtask shims react depends on inside QuickJS.
3
3
  import "./runtime";
4
4
  import "./bridge";
5
+ /** The SDK version — stamp it on support dumps and analytics. */
6
+ export const VERSION = "0.0.16";
5
7
  export { View, Text, Image, TextInput, NativeView } from "./primitives";
6
8
  export { render, unmount } from "./render";
7
9
  export { native } from "./native";
@@ -15,7 +17,8 @@ export { cx } from "./cx";
15
17
  export { useTween, useSpring, springStep, lerp, Easing } from "./animation";
16
18
  export { useParameter, useParameterList } from "./parameters";
17
19
  export { Knob, Slider, Toggle, XYPad, Segmented, Select, GenericEditor, ParamKnob, ParamSlider, ParamToggle, ParamXYPad, ParamSegmented, ParamSelect, dragToValue, } from "./controls";
18
- export { Meter, usePeakHold, peakHoldStep } from "./meter";
20
+ export { Meter, RingMeter, usePeakHold, peakHoldStep } from "./meter";
21
+ export { EQCurve, biquadMagnitudeDb, eqResponseDb, eqXToHz, eqHzToX } from "./eq";
19
22
  export { NumberBox, Checkbox, RadioGroup, ParamNumberBox, ParamCheckbox, ParamRadioGroup, snapToStep, } from "./fields";
20
23
  export { ProgressBar, Spinner } from "./feedback";
21
24
  export { PianoKeyboard } from "./keyboard";
package/dist/meter.d.ts CHANGED
@@ -38,3 +38,21 @@ export interface MeterProps {
38
38
  }
39
39
  /** A natively painted level meter with hot zone + peak hold. */
40
40
  export declare function Meter({ value, length, thickness, horizontal, reverse, peak, holdMs, decayPerSecond, hotFrom, trackColor, color, hotColor, label, }: MeterProps): import("react").JSX.Element;
41
+ export interface RingMeterProps {
42
+ /** Level 0..1. */
43
+ value: number;
44
+ /** Diameter. Default 64. */
45
+ size?: number;
46
+ thickness?: number;
47
+ /** Where the arc turns hot, 0..1. Default 0.85; 1 disables. */
48
+ hotFrom?: number;
49
+ trackColor?: string;
50
+ color?: string;
51
+ hotColor?: string;
52
+ /** Center readout. Default: none. */
53
+ format?: (value: number) => string;
54
+ label?: string;
55
+ }
56
+ /** A circular level meter on the native arc keys — channel-strip level
57
+ rings, macro amounts, gain-reduction dials. */
58
+ export declare function RingMeter({ value, size, thickness, hotFrom, trackColor, color, hotColor, format, label, }: RingMeterProps): import("react").JSX.Element;
package/dist/meter.js CHANGED
@@ -54,3 +54,32 @@ export function Meter({ value, length = 120, thickness = 10, horizontal, reverse
54
54
  return bar;
55
55
  return (_jsxs(View, { className: "items-center gap-2", children: [bar, _jsx(Text, { className: "text-faint text-[10] font-bold tracking-widest", children: label })] }));
56
56
  }
57
+ /** A circular level meter on the native arc keys — channel-strip level
58
+ rings, macro amounts, gain-reduction dials. */
59
+ export function RingMeter({ value, size = 64, thickness, hotFrom = 0.85, trackColor = "#FFFFFF14", color = "#C6F135", hotColor = "#FF4545", format, label, }) {
60
+ const level = clamp01(value);
61
+ const hot = clamp01(hotFrom);
62
+ const arcThickness = thickness ?? Math.max(3, size * 0.09);
63
+ const START = -135;
64
+ const SWEEP = 270;
65
+ const ring = (_jsxs(View, { className: "relative items-center justify-center", style: { width: size, height: size }, children: [_jsx(View, { className: "absolute inset-0", style: {
66
+ arcTrackColor: trackColor,
67
+ arcColor: color,
68
+ arcStart: START,
69
+ arcEnd: START + SWEEP,
70
+ arcValueStart: START,
71
+ arcValueEnd: START + SWEEP * Math.min(level, hot),
72
+ arcThickness,
73
+ } }), level > hot ? (_jsx(View, { className: "absolute inset-0", style: {
74
+ arcTrackColor: "#00000000",
75
+ arcColor: hotColor,
76
+ arcStart: START,
77
+ arcEnd: START + SWEEP,
78
+ arcValueStart: START + SWEEP * hot,
79
+ arcValueEnd: START + SWEEP * level,
80
+ arcThickness,
81
+ } })) : null, format !== undefined ? (_jsx(Text, { className: "text-[11] font-bold", style: { color: level > hot ? hotColor : color }, children: format(level) })) : null] }));
82
+ if (label === undefined)
83
+ return ring;
84
+ return (_jsxs(View, { className: "items-center gap-2", children: [ring, _jsx(Text, { className: "text-faint text-[10] font-bold tracking-widest", children: label })] }));
85
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vsreact/core",
3
- "version": "0.0.14",
3
+ "version": "0.0.16",
4
4
  "description": "Write React. Ship native VST. A React renderer for JUCE audio plugins — QuickJS + Yoga + juce::Graphics, no webview.",
5
5
  "keywords": [
6
6
  "react",
package/src/components.ts CHANGED
@@ -37,8 +37,10 @@ export type {
37
37
  ParamSegmentedProps,
38
38
  ParamSelectProps,
39
39
  } from "./controls";
40
- export { Meter, usePeakHold, peakHoldStep } from "./meter";
41
- export type { MeterProps, PeakHoldOptions, PeakHoldState } from "./meter";
40
+ export { Meter, RingMeter, usePeakHold, peakHoldStep } from "./meter";
41
+ export type { MeterProps, RingMeterProps, PeakHoldOptions, PeakHoldState } from "./meter";
42
+ export { EQCurve } from "./eq";
43
+ export type { EQCurveProps, EQBand, FilterType } from "./eq";
42
44
  export { Bars, Waveform, useRollingBuffer, pushRolling } from "./visualizers";
43
45
  export type { BarsProps, WaveformProps } from "./visualizers";
44
46
  export { Button } from "./button";
package/src/controls.tsx CHANGED
@@ -388,6 +388,9 @@ export function Toggle({
388
388
  export interface ParamToggleProps {
389
389
  paramId: string;
390
390
  label?: string;
391
+ /** Hardware-style side captions, forwarded to Toggle. */
392
+ offLabel?: string;
393
+ onLabel?: string;
391
394
  size?: number;
392
395
  trackColor?: string;
393
396
  onColor?: string;
@@ -0,0 +1,127 @@
1
+ import { beforeEach, describe, expect, test } from "bun:test";
2
+
3
+ const batches: unknown[][][] = [];
4
+
5
+ (globalThis as Record<string, any>).__vsreact_flush = (json: string) => {
6
+ batches.push(JSON.parse(json));
7
+ };
8
+ (globalThis as Record<string, any>).__vsreact_nativeCall = () => "null";
9
+
10
+ import {
11
+ render,
12
+ unmount,
13
+ EQCurve,
14
+ RingMeter,
15
+ biquadMagnitudeDb,
16
+ eqResponseDb,
17
+ eqXToHz,
18
+ eqHzToX,
19
+ type EQBand,
20
+ } from "./index";
21
+
22
+ const allOps = () => batches.flat();
23
+ const opsNamed = (name: string) => allOps().filter((op: any) => op[0] === name);
24
+ const dispatch = (msg: unknown) =>
25
+ (globalThis as Record<string, any>).__vsreact_dispatch(JSON.stringify(msg));
26
+
27
+ const nodesWithListener = (type: string): number[] => {
28
+ const seen = new Set<number>();
29
+ for (const op of opsNamed("setProps") as any[]) {
30
+ if (op[2]?.listeners?.includes(type)) seen.add(op[1]);
31
+ }
32
+ return [...seen];
33
+ };
34
+
35
+ beforeEach(() => {
36
+ unmount();
37
+ batches.length = 0;
38
+ });
39
+
40
+ describe("biquad math", () => {
41
+ test("a peak band boosts by its gain at the center frequency", () => {
42
+ const band: EQBand = { type: "peak", freq: 1000, gainDb: 6, q: 1 };
43
+ expect(biquadMagnitudeDb(band, 1000)).toBeCloseTo(6, 1);
44
+ expect(Math.abs(biquadMagnitudeDb(band, 40))).toBeLessThan(0.5); // far away ≈ flat
45
+ });
46
+
47
+ test("zero gain is flat; a lowpass rolls off the highs", () => {
48
+ expect(biquadMagnitudeDb({ type: "peak", freq: 1000, gainDb: 0, q: 1 }, 500)).toBeCloseTo(0, 3);
49
+
50
+ const lp: EQBand = { type: "lowpass", freq: 1000, q: 0.71 };
51
+ expect(biquadMagnitudeDb(lp, 100)).toBeCloseTo(0, 1);
52
+ expect(biquadMagnitudeDb(lp, 10000)).toBeLessThan(-30);
53
+ });
54
+
55
+ test("eqResponseDb sums bands; the log scale maps both ways", () => {
56
+ const bands: EQBand[] = [
57
+ { type: "peak", freq: 1000, gainDb: 6, q: 1 },
58
+ { type: "peak", freq: 1000, gainDb: -2, q: 1 },
59
+ ];
60
+ expect(eqResponseDb(bands, 1000)).toBeCloseTo(4, 1);
61
+
62
+ expect(eqXToHz(0)).toBeCloseTo(20);
63
+ expect(eqXToHz(1)).toBeCloseTo(20000);
64
+ expect(eqHzToX(eqXToHz(0.5))).toBeCloseTo(0.5);
65
+ });
66
+ });
67
+
68
+ describe("EQCurve", () => {
69
+ const bands: EQBand[] = [
70
+ { type: "lowshelf", freq: 120, gainDb: 3, q: 0.71 },
71
+ { type: "peak", freq: 1800, gainDb: -4, q: 1.2 },
72
+ ];
73
+
74
+ test("dragging a node reports new freq and gain", () => {
75
+ const seen: Array<[number, EQBand]> = [];
76
+ render(
77
+ <EQCurve bands={bands} width={200} height={100} dbRange={18} onChange={(i, b) => seen.push([i, b])} />,
78
+ );
79
+
80
+ const nodes = nodesWithListener("drag");
81
+ expect(nodes).toHaveLength(2);
82
+
83
+ dispatch({ kind: "event", nodeId: nodes[1], type: "dragstart", payload: { dx: 0, dy: 0 } });
84
+ dispatch({ kind: "event", nodeId: nodes[1], type: "drag", payload: { dx: 0, dy: -23 } });
85
+
86
+ const [index, band] = seen.at(-1)!;
87
+ expect(index).toBe(1);
88
+ expect(band.freq).toBe(1800); // no horizontal movement
89
+ // dy -23 over (mid-4)=46 → +9 dB from -4 → +5
90
+ expect(band.gainDb).toBeCloseTo(5, 1);
91
+ });
92
+
93
+ test("the wheel adjusts Q inside a begin/end pair", () => {
94
+ const seen: EQBand[] = [];
95
+ const gestures: string[] = [];
96
+ render(
97
+ <EQCurve
98
+ bands={bands}
99
+ onChange={(_i, b) => seen.push(b)}
100
+ onBegin={() => gestures.push("begin")}
101
+ onEnd={() => gestures.push("end")}
102
+ />,
103
+ );
104
+
105
+ const nodes = nodesWithListener("wheel");
106
+ dispatch({ kind: "event", nodeId: nodes[0], type: "wheel", payload: { dy: 0.1 } });
107
+
108
+ expect(seen.at(-1)?.q).toBeCloseTo(0.71 * Math.exp(0.14), 3);
109
+ expect(gestures).toEqual(["begin", "end"]);
110
+ });
111
+ });
112
+
113
+ describe("RingMeter", () => {
114
+ test("the value arc sweeps to the level; hot overlay appears above hotFrom", () => {
115
+ render(<RingMeter value={0.95} hotFrom={0.85} />);
116
+
117
+ const arcs = (opsNamed("setProps") as any[])
118
+ .map((op) => op[2]?.style)
119
+ .filter((s) => s && typeof s.arcValueEnd === "number");
120
+ expect(arcs.length).toBeGreaterThanOrEqual(2);
121
+
122
+ const base = arcs.find((s) => s.arcValueEnd === -135 + 270 * 0.85);
123
+ const hot = arcs.find((s) => Math.abs(s.arcValueEnd - (-135 + 270 * 0.95)) < 0.001);
124
+ expect(base).toBeDefined();
125
+ expect(hot).toBeDefined();
126
+ });
127
+ });
package/src/eq.tsx ADDED
@@ -0,0 +1,231 @@
1
+ // The EQ curve — a real biquad response display with draggable band
2
+ // nodes. Views only: the response is a center-anchored column fill
3
+ // (boost up, cut down), one node per band; drag moves freq/gain, the
4
+ // wheel adjusts Q. RBJ cookbook math, exported pure for reuse.
5
+
6
+ import { useRef } from "react";
7
+ import { View, Text } from "./primitives";
8
+
9
+ const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
10
+
11
+ export type FilterType =
12
+ | "peak"
13
+ | "lowshelf"
14
+ | "highshelf"
15
+ | "lowpass"
16
+ | "highpass"
17
+ | "bandpass"
18
+ | "notch";
19
+
20
+ export interface EQBand {
21
+ type: FilterType;
22
+ /** Center/corner frequency in Hz (20..20000). */
23
+ freq: number;
24
+ /** Boost/cut in dB (peak & shelves). Default 0. */
25
+ gainDb?: number;
26
+ /** Resonance. Default 0.71. */
27
+ q?: number;
28
+ }
29
+
30
+ /** RBJ cookbook biquad magnitude at `atHz`, in dB. */
31
+ export function biquadMagnitudeDb(band: EQBand, atHz: number, sampleRate = 48000): number {
32
+ const { type, freq } = band;
33
+ const gainDb = band.gainDb ?? 0;
34
+ const q = Math.max(0.05, band.q ?? 0.71);
35
+
36
+ const A = Math.pow(10, gainDb / 40);
37
+ const w0 = (2 * Math.PI * clamp(freq, 1, sampleRate / 2 - 1)) / sampleRate;
38
+ const cos = Math.cos(w0);
39
+ const sin = Math.sin(w0);
40
+ const alpha = sin / (2 * q);
41
+ const rootA2Alpha = 2 * Math.sqrt(A) * alpha;
42
+
43
+ let b0: number, b1: number, b2: number, a0: number, a1: number, a2: number;
44
+ switch (type) {
45
+ case "peak":
46
+ b0 = 1 + alpha * A; b1 = -2 * cos; b2 = 1 - alpha * A;
47
+ a0 = 1 + alpha / A; a1 = -2 * cos; a2 = 1 - alpha / A;
48
+ break;
49
+ case "lowpass":
50
+ b0 = (1 - cos) / 2; b1 = 1 - cos; b2 = (1 - cos) / 2;
51
+ a0 = 1 + alpha; a1 = -2 * cos; a2 = 1 - alpha;
52
+ break;
53
+ case "highpass":
54
+ b0 = (1 + cos) / 2; b1 = -(1 + cos); b2 = (1 + cos) / 2;
55
+ a0 = 1 + alpha; a1 = -2 * cos; a2 = 1 - alpha;
56
+ break;
57
+ case "bandpass":
58
+ b0 = alpha; b1 = 0; b2 = -alpha;
59
+ a0 = 1 + alpha; a1 = -2 * cos; a2 = 1 - alpha;
60
+ break;
61
+ case "notch":
62
+ b0 = 1; b1 = -2 * cos; b2 = 1;
63
+ a0 = 1 + alpha; a1 = -2 * cos; a2 = 1 - alpha;
64
+ break;
65
+ case "lowshelf":
66
+ b0 = A * (A + 1 - (A - 1) * cos + rootA2Alpha);
67
+ b1 = 2 * A * (A - 1 - (A + 1) * cos);
68
+ b2 = A * (A + 1 - (A - 1) * cos - rootA2Alpha);
69
+ a0 = A + 1 + (A - 1) * cos + rootA2Alpha;
70
+ a1 = -2 * (A - 1 + (A + 1) * cos);
71
+ a2 = A + 1 + (A - 1) * cos - rootA2Alpha;
72
+ break;
73
+ case "highshelf":
74
+ b0 = A * (A + 1 + (A - 1) * cos + rootA2Alpha);
75
+ b1 = -2 * A * (A - 1 + (A + 1) * cos);
76
+ b2 = A * (A + 1 + (A - 1) * cos - rootA2Alpha);
77
+ a0 = A + 1 - (A - 1) * cos + rootA2Alpha;
78
+ a1 = 2 * (A - 1 - (A + 1) * cos);
79
+ a2 = A + 1 - (A - 1) * cos - rootA2Alpha;
80
+ break;
81
+ }
82
+
83
+ const w = (2 * Math.PI * clamp(atHz, 1, sampleRate / 2 - 1)) / sampleRate;
84
+ const cw = Math.cos(w);
85
+ const c2w = Math.cos(2 * w);
86
+ const num = b0 * b0 + b1 * b1 + b2 * b2 + 2 * (b0 * b1 + b1 * b2) * cw + 2 * b0 * b2 * c2w;
87
+ const den = a0 * a0 + a1 * a1 + a2 * a2 + 2 * (a0 * a1 + a1 * a2) * cw + 2 * a0 * a2 * c2w;
88
+ return 10 * Math.log10(Math.max(1e-12, num / Math.max(1e-12, den)));
89
+ }
90
+
91
+ /** The summed response of all bands at `atHz`, in dB. */
92
+ export function eqResponseDb(bands: EQBand[], atHz: number, sampleRate = 48000): number {
93
+ let db = 0;
94
+ for (const band of bands) db += biquadMagnitudeDb(band, atHz, sampleRate);
95
+ return db;
96
+ }
97
+
98
+ const FREQ_MIN = 20;
99
+ const FREQ_MAX = 20000;
100
+ const logSpan = Math.log(FREQ_MAX / FREQ_MIN);
101
+
102
+ /** 0..1 position ↔ 20..20k Hz on a log scale. */
103
+ export const eqXToHz = (x01: number) => FREQ_MIN * Math.exp(clamp(x01, 0, 1) * logSpan);
104
+ export const eqHzToX = (hz: number) => Math.log(clamp(hz, FREQ_MIN, FREQ_MAX) / FREQ_MIN) / logSpan;
105
+
106
+ export interface EQCurveProps {
107
+ bands: EQBand[];
108
+ width?: number;
109
+ height?: number;
110
+ /** Vertical range: ±dbRange dB. Default 18. */
111
+ dbRange?: number;
112
+ /** Fill resolution. Default 48 columns. */
113
+ columns?: number;
114
+ disabled?: boolean;
115
+ trackColor?: string;
116
+ /** The response fill. */
117
+ color?: string;
118
+ handleColor?: string;
119
+ label?: string;
120
+ /** A node was dragged (freq/gain) or wheeled (q). */
121
+ onChange?: (index: number, band: EQBand) => void;
122
+ onBegin?: (index: number) => void;
123
+ onEnd?: (index: number) => void;
124
+ }
125
+
126
+ /** The EQ display every plugin wants: the real summed biquad response,
127
+ with one draggable node per band (x = freq on a log scale, y = gain)
128
+ and the wheel adjusting Q. */
129
+ export function EQCurve({
130
+ bands,
131
+ width = 220,
132
+ height = 96,
133
+ dbRange = 18,
134
+ columns = 48,
135
+ disabled,
136
+ trackColor = "#141714",
137
+ color = "#C6F13560",
138
+ handleColor = "#ECF2E8",
139
+ label,
140
+ onChange,
141
+ onBegin,
142
+ onEnd,
143
+ }: EQCurveProps) {
144
+ const start = useRef<{ freq: number; gainDb: number }>({ freq: 1000, gainDb: 0 });
145
+ const mid = height / 2;
146
+ const HANDLE = 18;
147
+
148
+ const yOfDb = (db: number) => mid - (clamp(db, -dbRange, dbRange) / dbRange) * (mid - 4);
149
+
150
+ const node = (band: EQBand, index: number) => {
151
+ const x = eqHzToX(band.freq) * width;
152
+ const y = yOfDb(band.gainDb ?? 0);
153
+ const handlers = disabled
154
+ ? {}
155
+ : {
156
+ onDragStart: () => {
157
+ start.current = { freq: band.freq, gainDb: band.gainDb ?? 0 };
158
+ onBegin?.(index);
159
+ },
160
+ onDrag: (e: { dx: number; dy: number }) => {
161
+ const x01 = clamp(eqHzToX(start.current.freq) + e.dx / width, 0, 1);
162
+ onChange?.(index, {
163
+ ...band,
164
+ freq: Math.round(eqXToHz(x01)),
165
+ gainDb: clamp(start.current.gainDb - (e.dy / (mid - 4)) * dbRange, -dbRange, dbRange),
166
+ });
167
+ },
168
+ onDragEnd: () => onEnd?.(index),
169
+ onWheel: (e: { dy: number }) => {
170
+ const q = Math.max(0.05, band.q ?? 0.71);
171
+ onBegin?.(index);
172
+ onChange?.(index, { ...band, q: clamp(q * Math.exp(e.dy * 1.4), 0.1, 18) });
173
+ onEnd?.(index);
174
+ },
175
+ };
176
+
177
+ return (
178
+ <View
179
+ key={index}
180
+ className={`absolute items-center justify-center ${disabled ? "" : "cursor-pointer"}`}
181
+ style={{ left: x - HANDLE / 2, top: y - HANDLE / 2, width: HANDLE, height: HANDLE }}
182
+ {...handlers}
183
+ >
184
+ <View
185
+ className="rounded-full border"
186
+ style={{ width: 10, height: 10, backgroundColor: handleColor, borderColor: "#00000088" }}
187
+ />
188
+ </View>
189
+ );
190
+ };
191
+
192
+ const body = (
193
+ <View
194
+ className={`relative rounded overflow-hidden ${disabled ? "opacity-40" : ""}`}
195
+ style={{ width, height, backgroundColor: trackColor }}
196
+ >
197
+ <View
198
+ className="absolute left-0 right-0 h-[1]"
199
+ style={{ top: mid, backgroundColor: "#FFFFFF24" }}
200
+ />
201
+ {Array.from({ length: columns }, (_, i) => {
202
+ const hz = eqXToHz((i + 0.5) / columns);
203
+ const db = eqResponseDb(bands, hz);
204
+ const columnHeight = Math.abs(yOfDb(db) - mid);
205
+ return (
206
+ <View
207
+ key={i}
208
+ className="absolute"
209
+ style={{
210
+ left: (i / columns) * width + 0.5,
211
+ width: width / columns - 1,
212
+ top: db >= 0 ? mid - columnHeight : mid,
213
+ height: Math.max(1, columnHeight),
214
+ backgroundColor: color,
215
+ }}
216
+ />
217
+ );
218
+ })}
219
+ {bands.map(node)}
220
+ </View>
221
+ );
222
+
223
+ if (label === undefined) return body;
224
+
225
+ return (
226
+ <View className="items-center gap-2">
227
+ {body}
228
+ <Text className="text-faint text-[10] font-bold tracking-widest">{label}</Text>
229
+ </View>
230
+ );
231
+ }
package/src/index.ts CHANGED
@@ -3,6 +3,9 @@
3
3
  import "./runtime";
4
4
  import "./bridge";
5
5
 
6
+ /** The SDK version — stamp it on support dumps and analytics. */
7
+ export const VERSION = "0.0.16";
8
+
6
9
  export { View, Text, Image, TextInput, NativeView } from "./primitives";
7
10
  export type {
8
11
  CommonProps,
@@ -70,8 +73,10 @@ export type {
70
73
  ParamSegmentedProps,
71
74
  ParamSelectProps,
72
75
  } from "./controls";
73
- export { Meter, usePeakHold, peakHoldStep } from "./meter";
74
- export type { MeterProps, PeakHoldOptions, PeakHoldState } from "./meter";
76
+ export { Meter, RingMeter, usePeakHold, peakHoldStep } from "./meter";
77
+ export type { MeterProps, RingMeterProps, PeakHoldOptions, PeakHoldState } from "./meter";
78
+ export { EQCurve, biquadMagnitudeDb, eqResponseDb, eqXToHz, eqHzToX } from "./eq";
79
+ export type { EQCurveProps, EQBand, FilterType } from "./eq";
75
80
  export {
76
81
  NumberBox,
77
82
  Checkbox,
package/src/meter.tsx CHANGED
@@ -177,3 +177,84 @@ export function Meter({
177
177
  </View>
178
178
  );
179
179
  }
180
+
181
+ export interface RingMeterProps {
182
+ /** Level 0..1. */
183
+ value: number;
184
+ /** Diameter. Default 64. */
185
+ size?: number;
186
+ thickness?: number;
187
+ /** Where the arc turns hot, 0..1. Default 0.85; 1 disables. */
188
+ hotFrom?: number;
189
+ trackColor?: string;
190
+ color?: string;
191
+ hotColor?: string;
192
+ /** Center readout. Default: none. */
193
+ format?: (value: number) => string;
194
+ label?: string;
195
+ }
196
+
197
+ /** A circular level meter on the native arc keys — channel-strip level
198
+ rings, macro amounts, gain-reduction dials. */
199
+ export function RingMeter({
200
+ value,
201
+ size = 64,
202
+ thickness,
203
+ hotFrom = 0.85,
204
+ trackColor = "#FFFFFF14",
205
+ color = "#C6F135",
206
+ hotColor = "#FF4545",
207
+ format,
208
+ label,
209
+ }: RingMeterProps) {
210
+ const level = clamp01(value);
211
+ const hot = clamp01(hotFrom);
212
+ const arcThickness = thickness ?? Math.max(3, size * 0.09);
213
+ const START = -135;
214
+ const SWEEP = 270;
215
+
216
+ const ring = (
217
+ <View className="relative items-center justify-center" style={{ width: size, height: size }}>
218
+ <View
219
+ className="absolute inset-0"
220
+ style={{
221
+ arcTrackColor: trackColor,
222
+ arcColor: color,
223
+ arcStart: START,
224
+ arcEnd: START + SWEEP,
225
+ arcValueStart: START,
226
+ arcValueEnd: START + SWEEP * Math.min(level, hot),
227
+ arcThickness,
228
+ }}
229
+ />
230
+ {level > hot ? (
231
+ <View
232
+ className="absolute inset-0"
233
+ style={{
234
+ arcTrackColor: "#00000000",
235
+ arcColor: hotColor,
236
+ arcStart: START,
237
+ arcEnd: START + SWEEP,
238
+ arcValueStart: START + SWEEP * hot,
239
+ arcValueEnd: START + SWEEP * level,
240
+ arcThickness,
241
+ }}
242
+ />
243
+ ) : null}
244
+ {format !== undefined ? (
245
+ <Text className="text-[11] font-bold" style={{ color: level > hot ? hotColor : color }}>
246
+ {format(level)}
247
+ </Text>
248
+ ) : null}
249
+ </View>
250
+ );
251
+
252
+ if (label === undefined) return ring;
253
+
254
+ return (
255
+ <View className="items-center gap-2">
256
+ {ring}
257
+ <Text className="text-faint text-[10] font-bold tracking-widest">{label}</Text>
258
+ </View>
259
+ );
260
+ }
@@ -10,6 +10,7 @@ const batches: unknown[][][] = [];
10
10
  import {
11
11
  render,
12
12
  unmount,
13
+ VERSION,
13
14
  PianoKeyboard,
14
15
  StepSequencer,
15
16
  ProgressBar,
@@ -41,6 +42,12 @@ beforeEach(() => {
41
42
  batches.length = 0;
42
43
  });
43
44
 
45
+ describe("VERSION", () => {
46
+ test("matches the package version", () => {
47
+ expect(VERSION).toBe(require("../package.json").version);
48
+ });
49
+ });
50
+
44
51
  describe("format", () => {
45
52
  test("formatDb signs and floors", () => {
46
53
  expect(formatDb(6)).toBe("+6.0 dB");