@vsreact/core 0.0.14 → 0.0.15
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/dist/components.d.ts +4 -2
- package/dist/components.js +2 -1
- package/dist/eq.d.ts +40 -0
- package/dist/eq.js +150 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -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/eq.test.tsx +127 -0
- package/src/eq.tsx +231 -0
- package/src/index.ts +4 -2
- package/src/meter.tsx +81 -0
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/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
|
@@ -22,8 +22,10 @@ export { useParameter, useParameterList } from "./parameters";
|
|
|
22
22
|
export type { ParameterState, ParameterHandle, ParameterInfo } from "./parameters";
|
|
23
23
|
export { Knob, Slider, Toggle, XYPad, Segmented, Select, GenericEditor, ParamKnob, ParamSlider, ParamToggle, ParamXYPad, ParamSegmented, ParamSelect, dragToValue, } from "./controls";
|
|
24
24
|
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";
|
|
25
|
+
export { Meter, RingMeter, usePeakHold, peakHoldStep } from "./meter";
|
|
26
|
+
export type { MeterProps, RingMeterProps, PeakHoldOptions, PeakHoldState } from "./meter";
|
|
27
|
+
export { EQCurve, biquadMagnitudeDb, eqResponseDb, eqXToHz, eqHzToX } from "./eq";
|
|
28
|
+
export type { EQCurveProps, EQBand, FilterType } from "./eq";
|
|
27
29
|
export { NumberBox, Checkbox, RadioGroup, ParamNumberBox, ParamCheckbox, ParamRadioGroup, snapToStep, } from "./fields";
|
|
28
30
|
export type { NumberBoxProps, CheckboxProps, RadioGroupProps, ParamNumberBoxProps, ParamCheckboxProps, ParamRadioGroupProps, } from "./fields";
|
|
29
31
|
export { ProgressBar, Spinner } from "./feedback";
|
package/dist/index.js
CHANGED
|
@@ -15,7 +15,8 @@ export { cx } from "./cx";
|
|
|
15
15
|
export { useTween, useSpring, springStep, lerp, Easing } from "./animation";
|
|
16
16
|
export { useParameter, useParameterList } from "./parameters";
|
|
17
17
|
export { Knob, Slider, Toggle, XYPad, Segmented, Select, GenericEditor, ParamKnob, ParamSlider, ParamToggle, ParamXYPad, ParamSegmented, ParamSelect, dragToValue, } from "./controls";
|
|
18
|
-
export { Meter, usePeakHold, peakHoldStep } from "./meter";
|
|
18
|
+
export { Meter, RingMeter, usePeakHold, peakHoldStep } from "./meter";
|
|
19
|
+
export { EQCurve, biquadMagnitudeDb, eqResponseDb, eqXToHz, eqHzToX } from "./eq";
|
|
19
20
|
export { NumberBox, Checkbox, RadioGroup, ParamNumberBox, ParamCheckbox, ParamRadioGroup, snapToStep, } from "./fields";
|
|
20
21
|
export { ProgressBar, Spinner } from "./feedback";
|
|
21
22
|
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/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
|
@@ -70,8 +70,10 @@ export type {
|
|
|
70
70
|
ParamSegmentedProps,
|
|
71
71
|
ParamSelectProps,
|
|
72
72
|
} from "./controls";
|
|
73
|
-
export { Meter, usePeakHold, peakHoldStep } from "./meter";
|
|
74
|
-
export type { MeterProps, PeakHoldOptions, PeakHoldState } from "./meter";
|
|
73
|
+
export { Meter, RingMeter, usePeakHold, peakHoldStep } from "./meter";
|
|
74
|
+
export type { MeterProps, RingMeterProps, PeakHoldOptions, PeakHoldState } from "./meter";
|
|
75
|
+
export { EQCurve, biquadMagnitudeDb, eqResponseDb, eqXToHz, eqHzToX } from "./eq";
|
|
76
|
+
export type { EQCurveProps, EQBand, FilterType } from "./eq";
|
|
75
77
|
export {
|
|
76
78
|
NumberBox,
|
|
77
79
|
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
|
+
}
|