@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 +98 -0
- package/dist/components.d.ts +4 -2
- package/dist/components.js +2 -1
- package/dist/controls.d.ts +3 -0
- package/dist/eq.d.ts +40 -0
- package/dist/eq.js +150 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +4 -1
- package/dist/meter.d.ts +18 -0
- package/dist/meter.js +29 -0
- package/package.json +1 -1
- package/src/components.ts +4 -2
- package/src/controls.tsx +3 -0
- package/src/eq.test.tsx +127 -0
- package/src/eq.tsx +231 -0
- package/src/index.ts +7 -2
- package/src/meter.tsx +81 -0
- package/src/perform.test.tsx +7 -0
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
|
package/dist/components.d.ts
CHANGED
|
@@ -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";
|
package/dist/components.js
CHANGED
|
@@ -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";
|
package/dist/controls.d.ts
CHANGED
|
@@ -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
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;
|
package/src/eq.test.tsx
ADDED
|
@@ -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
|
+
}
|
package/src/perform.test.tsx
CHANGED
|
@@ -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");
|