@vsreact/core 0.0.12 → 0.0.13
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 +2 -0
- package/dist/components.js +1 -0
- package/dist/format.d.ts +5 -0
- package/dist/format.js +9 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -1
- package/dist/synth.d.ts +79 -0
- package/dist/synth.js +144 -0
- package/package.json +1 -1
- package/src/components.ts +17 -0
- package/src/format.ts +11 -0
- package/src/index.ts +20 -0
- package/src/synth.test.tsx +168 -0
- package/src/synth.tsx +370 -0
package/dist/components.d.ts
CHANGED
|
@@ -16,5 +16,7 @@ export { PianoKeyboard } from "./keyboard";
|
|
|
16
16
|
export type { PianoKeyboardProps } from "./keyboard";
|
|
17
17
|
export { StepSequencer } from "./sequencer";
|
|
18
18
|
export type { StepSequencerProps } from "./sequencer";
|
|
19
|
+
export { ADSREnvelope, ParamADSREnvelope, PitchBend, ModWheel, ParamModWheel, ParamPitchBend, } from "./synth";
|
|
20
|
+
export type { ADSRKey, ADSREnvelopeProps, ParamADSREnvelopeProps, PitchBendProps, ModWheelProps, ParamModWheelProps, ParamPitchBendProps, } from "./synth";
|
|
19
21
|
export { MacroPad, ParamMacroPad, HardwareKnob, ParamHardwareKnob, Crossfader, ParamCrossfader, PulseOrb, } from "./specialty";
|
|
20
22
|
export type { MacroPadProps, ParamMacroPadProps, HardwareKnobProps, ParamHardwareKnobProps, CrossfaderProps, ParamCrossfaderProps, PulseOrbProps, } from "./specialty";
|
package/dist/components.js
CHANGED
|
@@ -14,4 +14,5 @@ export { NumberBox, Checkbox, RadioGroup, ParamNumberBox, ParamCheckbox, ParamRa
|
|
|
14
14
|
export { ProgressBar, Spinner } from "./feedback";
|
|
15
15
|
export { PianoKeyboard } from "./keyboard";
|
|
16
16
|
export { StepSequencer } from "./sequencer";
|
|
17
|
+
export { ADSREnvelope, ParamADSREnvelope, PitchBend, ModWheel, ParamModWheel, ParamPitchBend, } from "./synth";
|
|
17
18
|
export { MacroPad, ParamMacroPad, HardwareKnob, ParamHardwareKnob, Crossfader, ParamCrossfader, PulseOrb, } from "./specialty";
|
package/dist/format.d.ts
CHANGED
|
@@ -14,3 +14,8 @@ export declare function formatPercent(value01: number, decimals?: number): strin
|
|
|
14
14
|
export declare function formatSemitones(semitones: number, decimals?: number): string;
|
|
15
15
|
/** A MIDI note number as scientific pitch: 60 → "C4", 69 → "A4". */
|
|
16
16
|
export declare function midiNoteName(note: number): string;
|
|
17
|
+
/** A MIDI note as a frequency (equal temperament): 69 → 440. Feed your
|
|
18
|
+
oscillator straight from PianoKeyboard's onNoteOn. */
|
|
19
|
+
export declare function midiNoteToHz(note: number, a4?: number): number;
|
|
20
|
+
/** The nearest MIDI note for a frequency: 440 → 69. */
|
|
21
|
+
export declare function hzToMidiNote(hz: number, a4?: number): number;
|
package/dist/format.js
CHANGED
|
@@ -48,3 +48,12 @@ export function midiNoteName(note) {
|
|
|
48
48
|
const n = Math.round(note);
|
|
49
49
|
return `${NOTE_NAMES[((n % 12) + 12) % 12]}${Math.floor(n / 12) - 1}`;
|
|
50
50
|
}
|
|
51
|
+
/** A MIDI note as a frequency (equal temperament): 69 → 440. Feed your
|
|
52
|
+
oscillator straight from PianoKeyboard's onNoteOn. */
|
|
53
|
+
export function midiNoteToHz(note, a4 = 440) {
|
|
54
|
+
return a4 * Math.pow(2, (note - 69) / 12);
|
|
55
|
+
}
|
|
56
|
+
/** The nearest MIDI note for a frequency: 440 → 69. */
|
|
57
|
+
export function hzToMidiNote(hz, a4 = 440) {
|
|
58
|
+
return Math.round(69 + 12 * Math.log2(hz / a4));
|
|
59
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -32,7 +32,9 @@ export { PianoKeyboard } from "./keyboard";
|
|
|
32
32
|
export type { PianoKeyboardProps } from "./keyboard";
|
|
33
33
|
export { StepSequencer } from "./sequencer";
|
|
34
34
|
export type { StepSequencerProps } from "./sequencer";
|
|
35
|
-
export {
|
|
35
|
+
export { ADSREnvelope, ParamADSREnvelope, PitchBend, ModWheel, ParamModWheel, ParamPitchBend, adsrLevelAt, } from "./synth";
|
|
36
|
+
export type { ADSRKey, ADSREnvelopeProps, ParamADSREnvelopeProps, PitchBendProps, ModWheelProps, ParamModWheelProps, ParamPitchBendProps, } from "./synth";
|
|
37
|
+
export { mapRange, formatDb, formatHz, formatMs, formatPercent, formatSemitones, midiNoteName, midiNoteToHz, hzToMidiNote, } from "./format";
|
|
36
38
|
export { MacroPad, ParamMacroPad, HardwareKnob, ParamHardwareKnob, Crossfader, ParamCrossfader, PulseOrb, } from "./specialty";
|
|
37
39
|
export type { MacroPadProps, ParamMacroPadProps, HardwareKnobProps, ParamHardwareKnobProps, CrossfaderProps, ParamCrossfaderProps, PulseOrbProps, } from "./specialty";
|
|
38
40
|
export type { DragEventPayload, LayoutRect, WheelEventPayload } from "./primitives";
|
package/dist/index.js
CHANGED
|
@@ -20,5 +20,6 @@ export { NumberBox, Checkbox, RadioGroup, ParamNumberBox, ParamCheckbox, ParamRa
|
|
|
20
20
|
export { ProgressBar, Spinner } from "./feedback";
|
|
21
21
|
export { PianoKeyboard } from "./keyboard";
|
|
22
22
|
export { StepSequencer } from "./sequencer";
|
|
23
|
-
export {
|
|
23
|
+
export { ADSREnvelope, ParamADSREnvelope, PitchBend, ModWheel, ParamModWheel, ParamPitchBend, adsrLevelAt, } from "./synth";
|
|
24
|
+
export { mapRange, formatDb, formatHz, formatMs, formatPercent, formatSemitones, midiNoteName, midiNoteToHz, hzToMidiNote, } from "./format";
|
|
24
25
|
export { MacroPad, ParamMacroPad, HardwareKnob, ParamHardwareKnob, Crossfader, ParamCrossfader, PulseOrb, } from "./specialty";
|
package/dist/synth.d.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
export type ADSRKey = "attack" | "decay" | "sustain" | "release";
|
|
2
|
+
export interface ADSREnvelopeProps {
|
|
3
|
+
/** All 0..1: times are a fraction of each stage's max width. */
|
|
4
|
+
attack: number;
|
|
5
|
+
decay: number;
|
|
6
|
+
sustain: number;
|
|
7
|
+
release: number;
|
|
8
|
+
width?: number;
|
|
9
|
+
height?: number;
|
|
10
|
+
/** Fill resolution. Default 44 columns. */
|
|
11
|
+
columns?: number;
|
|
12
|
+
disabled?: boolean;
|
|
13
|
+
trackColor?: string;
|
|
14
|
+
/** The filled envelope body. */
|
|
15
|
+
color?: string;
|
|
16
|
+
handleColor?: string;
|
|
17
|
+
label?: string;
|
|
18
|
+
onChange: (key: ADSRKey, value: number) => void;
|
|
19
|
+
onBegin?: (key: ADSRKey) => void;
|
|
20
|
+
onEnd?: (key: ADSRKey) => void;
|
|
21
|
+
}
|
|
22
|
+
/** The envelope level at x, piecewise over A → D → S plateau → R. */
|
|
23
|
+
export declare function adsrLevelAt(x: number, width: number, attack: number, decay: number, sustain: number, release: number): number;
|
|
24
|
+
/** The classic four-corner envelope editor: drag the attack peak, the
|
|
25
|
+
decay/sustain corner (both axes), and the release corner. */
|
|
26
|
+
export declare function ADSREnvelope({ attack, decay, sustain, release, width, height, columns, disabled, trackColor, color, handleColor, label, onChange, onBegin, onEnd, }: ADSREnvelopeProps): import("react").JSX.Element;
|
|
27
|
+
export interface ParamADSREnvelopeProps {
|
|
28
|
+
attackId: string;
|
|
29
|
+
decayId: string;
|
|
30
|
+
sustainId: string;
|
|
31
|
+
releaseId: string;
|
|
32
|
+
width?: number;
|
|
33
|
+
height?: number;
|
|
34
|
+
color?: string;
|
|
35
|
+
label?: string;
|
|
36
|
+
}
|
|
37
|
+
/** An ADSREnvelope over four host parameters, gestures opened per
|
|
38
|
+
handle (the decay/sustain corner drives both together). */
|
|
39
|
+
export declare function ParamADSREnvelope({ attackId, decayId, sustainId, releaseId, ...visual }: ParamADSREnvelopeProps): import("react").JSX.Element;
|
|
40
|
+
interface WheelSkin {
|
|
41
|
+
width?: number;
|
|
42
|
+
height?: number;
|
|
43
|
+
disabled?: boolean;
|
|
44
|
+
trackColor?: string;
|
|
45
|
+
thumbColor?: string;
|
|
46
|
+
accentColor?: string;
|
|
47
|
+
label?: string;
|
|
48
|
+
}
|
|
49
|
+
export interface PitchBendProps extends WheelSkin {
|
|
50
|
+
/** Bend, −1..+1; fires continuously and again as the spring returns. */
|
|
51
|
+
onChange?: (value: number) => void;
|
|
52
|
+
/** Snap back to center on release. Default true. */
|
|
53
|
+
springBack?: boolean;
|
|
54
|
+
}
|
|
55
|
+
/** The pitch wheel: drag up/down from center, springs back to 0 on
|
|
56
|
+
release (momentary — it owns its own state). */
|
|
57
|
+
export declare function PitchBend({ onChange, springBack, ...skin }: PitchBendProps): import("react").JSX.Element;
|
|
58
|
+
export interface ModWheelProps extends WheelSkin {
|
|
59
|
+
/** 0..1, controlled — mod wheels stay where you leave them. */
|
|
60
|
+
value: number;
|
|
61
|
+
onChange: (value: number) => void;
|
|
62
|
+
onBegin?: () => void;
|
|
63
|
+
onEnd?: () => void;
|
|
64
|
+
}
|
|
65
|
+
/** The mod wheel: a vertical strip that holds its position. */
|
|
66
|
+
export declare function ModWheel({ value, onChange, onBegin, onEnd, ...skin }: ModWheelProps): import("react").JSX.Element;
|
|
67
|
+
export interface ParamModWheelProps extends WheelSkin {
|
|
68
|
+
paramId: string;
|
|
69
|
+
}
|
|
70
|
+
/** A ModWheel bound to a host parameter. */
|
|
71
|
+
export declare function ParamModWheel({ paramId, ...skin }: ParamModWheelProps): import("react").JSX.Element;
|
|
72
|
+
export interface ParamPitchBendProps extends WheelSkin {
|
|
73
|
+
/** A 0..1 host parameter with 0.5 center. */
|
|
74
|
+
paramId: string;
|
|
75
|
+
}
|
|
76
|
+
/** A PitchBend writing 0.5 ± bend/2 to a host parameter; release closes
|
|
77
|
+
the gesture at dead center (the visual spring is cosmetic). */
|
|
78
|
+
export declare function ParamPitchBend({ paramId, ...skin }: ParamPitchBendProps): import("react").JSX.Element;
|
|
79
|
+
export {};
|
package/dist/synth.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Synth-surface controls — the ADSR envelope editor and the pitch/mod
|
|
3
|
+
// wheels. Views only: the envelope is a filled column fill (like
|
|
4
|
+
// <Waveform>) with draggable corner handles riding on top.
|
|
5
|
+
import { useEffect, useRef, useState } from "react";
|
|
6
|
+
import { View, Text } from "./primitives";
|
|
7
|
+
import { useSpring } from "./animation";
|
|
8
|
+
import { useParameter } from "./parameters";
|
|
9
|
+
const clamp01 = (v) => Math.min(1, Math.max(0, v));
|
|
10
|
+
/** The envelope level at x, piecewise over A → D → S plateau → R. */
|
|
11
|
+
export function adsrLevelAt(x, width, attack, decay, sustain, release) {
|
|
12
|
+
const segW = width * 0.27;
|
|
13
|
+
const ax = attack * segW;
|
|
14
|
+
const dw = decay * segW;
|
|
15
|
+
const rw = release * segW;
|
|
16
|
+
const rx = width - rw;
|
|
17
|
+
if (x <= ax)
|
|
18
|
+
return ax === 0 ? 1 : x / ax;
|
|
19
|
+
if (x <= ax + dw)
|
|
20
|
+
return 1 - (1 - sustain) * ((x - ax) / (dw || 1));
|
|
21
|
+
if (x <= rx)
|
|
22
|
+
return sustain;
|
|
23
|
+
return rw === 0 ? 0 : Math.max(0, sustain * (1 - (x - rx) / rw));
|
|
24
|
+
}
|
|
25
|
+
/** The classic four-corner envelope editor: drag the attack peak, the
|
|
26
|
+
decay/sustain corner (both axes), and the release corner. */
|
|
27
|
+
export function ADSREnvelope({ attack, decay, sustain, release, width = 220, height = 96, columns = 44, disabled, trackColor = "#141714", color = "#C6F13566", handleColor = "#ECF2E8", label, onChange, onBegin, onEnd, }) {
|
|
28
|
+
const start = useRef({ attack: 0, decay: 0, sustain: 0, release: 0 });
|
|
29
|
+
const a = clamp01(attack);
|
|
30
|
+
const d = clamp01(decay);
|
|
31
|
+
const s = clamp01(sustain);
|
|
32
|
+
const r = clamp01(release);
|
|
33
|
+
const segW = width * 0.27;
|
|
34
|
+
const HANDLE = 18;
|
|
35
|
+
const handleProps = (keys, move) => disabled
|
|
36
|
+
? {}
|
|
37
|
+
: {
|
|
38
|
+
onDragStart: () => {
|
|
39
|
+
start.current = { attack: a, decay: d, sustain: s, release: r };
|
|
40
|
+
for (const key of keys)
|
|
41
|
+
onBegin?.(key);
|
|
42
|
+
},
|
|
43
|
+
onDrag: (e) => move(e.dx, e.dy),
|
|
44
|
+
onDragEnd: () => {
|
|
45
|
+
for (const key of keys)
|
|
46
|
+
onEnd?.(key);
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
const dot = (left, top, keys, move) => (_jsx(View, { className: `absolute items-center justify-center ${disabled ? "" : "cursor-pointer"}`, style: { left: left - HANDLE / 2, top: top - HANDLE / 2, width: HANDLE, height: HANDLE }, ...handleProps(keys, move), children: _jsx(View, { className: "rounded-full border", style: { width: 9, height: 9, backgroundColor: handleColor, borderColor: "#00000088" } }) }));
|
|
50
|
+
const body = (_jsxs(View, { className: `relative rounded overflow-hidden ${disabled ? "opacity-40" : ""}`, style: { width, height, backgroundColor: trackColor }, children: [_jsx(View, { className: "absolute inset-0 flex-row items-end px-[1]", style: { columnGap: 1 }, children: Array.from({ length: columns }, (_, i) => {
|
|
51
|
+
const level = adsrLevelAt(((i + 0.5) / columns) * width, width, a, d, s, r);
|
|
52
|
+
return (_jsx(View, { className: "flex-1 rounded-[1]", style: { height: Math.max(1, level * (height - 4)), backgroundColor: color } }, i));
|
|
53
|
+
}) }), dot(a * segW, 4, ["attack"], (dx) => onChange("attack", clamp01(start.current.attack + dx / segW))), dot(a * segW + d * segW, 4 + (1 - s) * (height - 8), ["decay", "sustain"], (dx, dy) => {
|
|
54
|
+
onChange("decay", clamp01(start.current.decay + dx / segW));
|
|
55
|
+
onChange("sustain", clamp01(start.current.sustain - dy / (height - 8)));
|
|
56
|
+
}), dot(width - r * segW, 4 + (1 - s) * (height - 8), ["release"], (dx) => onChange("release", clamp01(start.current.release - dx / segW)))] }));
|
|
57
|
+
if (label === undefined)
|
|
58
|
+
return body;
|
|
59
|
+
return (_jsxs(View, { className: "items-center gap-2", children: [body, _jsx(Text, { className: "text-faint text-[10] font-bold tracking-widest", children: label })] }));
|
|
60
|
+
}
|
|
61
|
+
/** An ADSREnvelope over four host parameters, gestures opened per
|
|
62
|
+
handle (the decay/sustain corner drives both together). */
|
|
63
|
+
export function ParamADSREnvelope({ attackId, decayId, sustainId, releaseId, ...visual }) {
|
|
64
|
+
const params = {
|
|
65
|
+
attack: useParameter(attackId),
|
|
66
|
+
decay: useParameter(decayId),
|
|
67
|
+
sustain: useParameter(sustainId),
|
|
68
|
+
release: useParameter(releaseId),
|
|
69
|
+
};
|
|
70
|
+
return (_jsx(ADSREnvelope, { attack: params.attack.value, decay: params.decay.value, sustain: params.sustain.value, release: params.release.value, onChange: (key, value) => params[key].set(value), onBegin: (key) => params[key].begin(), onEnd: (key) => params[key].end(), ...visual }));
|
|
71
|
+
}
|
|
72
|
+
function WheelChrome({ value01, width = 34, height = 110, disabled, trackColor = "#141714", thumbColor = "#3A4038", accentColor = "#C6F135", label, centerMark, handlers, }) {
|
|
73
|
+
const THUMB = 16;
|
|
74
|
+
const travel = height - 4 - THUMB;
|
|
75
|
+
const wheel = (_jsxs(View, { className: `relative rounded-lg border overflow-hidden ${disabled ? "opacity-40" : "cursor-pointer"}`, style: { width, height, backgroundColor: trackColor, borderColor: "#00000066" }, ...handlers, children: [centerMark ? (_jsx(View, { className: "absolute left-0 right-0 h-[1]", style: { top: height / 2, backgroundColor: "#FFFFFF2E" } })) : null, _jsx(View, { className: "absolute left-[2] right-[2] rounded", style: { top: 2 + (1 - clamp01(value01)) * travel, height: THUMB, backgroundColor: thumbColor }, children: _jsx(View, { className: "absolute left-[2] right-[2] rounded-full", style: { top: THUMB / 2 - 1.25, height: 2.5, backgroundColor: accentColor } }) })] }));
|
|
76
|
+
if (label === undefined)
|
|
77
|
+
return wheel;
|
|
78
|
+
return (_jsxs(View, { className: "items-center gap-2", children: [wheel, _jsx(Text, { className: "text-faint text-[10] font-bold tracking-widest", children: label })] }));
|
|
79
|
+
}
|
|
80
|
+
/** The pitch wheel: drag up/down from center, springs back to 0 on
|
|
81
|
+
release (momentary — it owns its own state). */
|
|
82
|
+
export function PitchBend({ onChange, springBack = true, ...skin }) {
|
|
83
|
+
const [drag, setDrag] = useState(null);
|
|
84
|
+
const sprung = useSpring(drag ?? 0, { stiffness: 320, damping: 24 });
|
|
85
|
+
const value = drag ?? (springBack ? sprung : 0);
|
|
86
|
+
const onChangeRef = useRef(onChange);
|
|
87
|
+
onChangeRef.current = onChange;
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
onChangeRef.current?.(value);
|
|
90
|
+
}, [value]);
|
|
91
|
+
const startValue = useRef(0);
|
|
92
|
+
const height = skin.height ?? 110;
|
|
93
|
+
const handlers = skin.disabled
|
|
94
|
+
? {}
|
|
95
|
+
: {
|
|
96
|
+
onDragStart: () => {
|
|
97
|
+
startValue.current = drag ?? 0;
|
|
98
|
+
setDrag(startValue.current);
|
|
99
|
+
},
|
|
100
|
+
onDrag: (e) => setDrag(Math.min(1, Math.max(-1, startValue.current - e.dy / (height / 2)))),
|
|
101
|
+
onDragEnd: () => setDrag(null),
|
|
102
|
+
};
|
|
103
|
+
return _jsx(WheelChrome, { value01: (value + 1) / 2, centerMark: true, handlers: handlers, ...skin });
|
|
104
|
+
}
|
|
105
|
+
/** The mod wheel: a vertical strip that holds its position. */
|
|
106
|
+
export function ModWheel({ value, onChange, onBegin, onEnd, ...skin }) {
|
|
107
|
+
const startValue = useRef(0);
|
|
108
|
+
const height = skin.height ?? 110;
|
|
109
|
+
const handlers = skin.disabled
|
|
110
|
+
? {}
|
|
111
|
+
: {
|
|
112
|
+
onDragStart: () => {
|
|
113
|
+
startValue.current = clamp01(value);
|
|
114
|
+
onBegin?.();
|
|
115
|
+
},
|
|
116
|
+
onDrag: (e) => onChange(clamp01(startValue.current - e.dy / (height - 20))),
|
|
117
|
+
onDragEnd: () => onEnd?.(),
|
|
118
|
+
};
|
|
119
|
+
return _jsx(WheelChrome, { value01: clamp01(value), handlers: handlers, ...skin });
|
|
120
|
+
}
|
|
121
|
+
/** A ModWheel bound to a host parameter. */
|
|
122
|
+
export function ParamModWheel({ paramId, ...skin }) {
|
|
123
|
+
const param = useParameter(paramId);
|
|
124
|
+
return (_jsx(ModWheel, { value: param.value, onChange: param.set, onBegin: param.begin, onEnd: param.end, label: skin.label ?? param.name.toUpperCase(), ...skin }));
|
|
125
|
+
}
|
|
126
|
+
/** A PitchBend writing 0.5 ± bend/2 to a host parameter; release closes
|
|
127
|
+
the gesture at dead center (the visual spring is cosmetic). */
|
|
128
|
+
export function ParamPitchBend({ paramId, ...skin }) {
|
|
129
|
+
const param = useParameter(paramId);
|
|
130
|
+
const active = useRef(false);
|
|
131
|
+
return (_jsx(PitchBend, { onChange: (v) => {
|
|
132
|
+
if (v === 0 && !active.current)
|
|
133
|
+
return; // at rest (incl. mount) — nothing to write
|
|
134
|
+
if (!active.current) {
|
|
135
|
+
active.current = true;
|
|
136
|
+
param.begin();
|
|
137
|
+
}
|
|
138
|
+
param.set(0.5 + v / 2);
|
|
139
|
+
if (v === 0) {
|
|
140
|
+
active.current = false;
|
|
141
|
+
param.end();
|
|
142
|
+
}
|
|
143
|
+
}, ...skin }));
|
|
144
|
+
}
|
package/package.json
CHANGED
package/src/components.ts
CHANGED
|
@@ -68,6 +68,23 @@ export { PianoKeyboard } from "./keyboard";
|
|
|
68
68
|
export type { PianoKeyboardProps } from "./keyboard";
|
|
69
69
|
export { StepSequencer } from "./sequencer";
|
|
70
70
|
export type { StepSequencerProps } from "./sequencer";
|
|
71
|
+
export {
|
|
72
|
+
ADSREnvelope,
|
|
73
|
+
ParamADSREnvelope,
|
|
74
|
+
PitchBend,
|
|
75
|
+
ModWheel,
|
|
76
|
+
ParamModWheel,
|
|
77
|
+
ParamPitchBend,
|
|
78
|
+
} from "./synth";
|
|
79
|
+
export type {
|
|
80
|
+
ADSRKey,
|
|
81
|
+
ADSREnvelopeProps,
|
|
82
|
+
ParamADSREnvelopeProps,
|
|
83
|
+
PitchBendProps,
|
|
84
|
+
ModWheelProps,
|
|
85
|
+
ParamModWheelProps,
|
|
86
|
+
ParamPitchBendProps,
|
|
87
|
+
} from "./synth";
|
|
71
88
|
export {
|
|
72
89
|
MacroPad,
|
|
73
90
|
ParamMacroPad,
|
package/src/format.ts
CHANGED
|
@@ -60,3 +60,14 @@ export function midiNoteName(note: number): string {
|
|
|
60
60
|
const n = Math.round(note);
|
|
61
61
|
return `${NOTE_NAMES[((n % 12) + 12) % 12]}${Math.floor(n / 12) - 1}`;
|
|
62
62
|
}
|
|
63
|
+
|
|
64
|
+
/** A MIDI note as a frequency (equal temperament): 69 → 440. Feed your
|
|
65
|
+
oscillator straight from PianoKeyboard's onNoteOn. */
|
|
66
|
+
export function midiNoteToHz(note: number, a4 = 440): number {
|
|
67
|
+
return a4 * Math.pow(2, (note - 69) / 12);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The nearest MIDI note for a frequency: 440 → 69. */
|
|
71
|
+
export function hzToMidiNote(hz: number, a4 = 440): number {
|
|
72
|
+
return Math.round(69 + 12 * Math.log2(hz / a4));
|
|
73
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -94,6 +94,24 @@ export { PianoKeyboard } from "./keyboard";
|
|
|
94
94
|
export type { PianoKeyboardProps } from "./keyboard";
|
|
95
95
|
export { StepSequencer } from "./sequencer";
|
|
96
96
|
export type { StepSequencerProps } from "./sequencer";
|
|
97
|
+
export {
|
|
98
|
+
ADSREnvelope,
|
|
99
|
+
ParamADSREnvelope,
|
|
100
|
+
PitchBend,
|
|
101
|
+
ModWheel,
|
|
102
|
+
ParamModWheel,
|
|
103
|
+
ParamPitchBend,
|
|
104
|
+
adsrLevelAt,
|
|
105
|
+
} from "./synth";
|
|
106
|
+
export type {
|
|
107
|
+
ADSRKey,
|
|
108
|
+
ADSREnvelopeProps,
|
|
109
|
+
ParamADSREnvelopeProps,
|
|
110
|
+
PitchBendProps,
|
|
111
|
+
ModWheelProps,
|
|
112
|
+
ParamModWheelProps,
|
|
113
|
+
ParamPitchBendProps,
|
|
114
|
+
} from "./synth";
|
|
97
115
|
export {
|
|
98
116
|
mapRange,
|
|
99
117
|
formatDb,
|
|
@@ -102,6 +120,8 @@ export {
|
|
|
102
120
|
formatPercent,
|
|
103
121
|
formatSemitones,
|
|
104
122
|
midiNoteName,
|
|
123
|
+
midiNoteToHz,
|
|
124
|
+
hzToMidiNote,
|
|
105
125
|
} from "./format";
|
|
106
126
|
export {
|
|
107
127
|
MacroPad,
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
const batches: unknown[][][] = [];
|
|
4
|
+
const nativeCalls: Array<{ name: string; args: any }> = [];
|
|
5
|
+
let paramGetResult: any = { value: 0.5, text: "0.5", name: "Mod", label: "" };
|
|
6
|
+
|
|
7
|
+
(globalThis as Record<string, any>).__vsreact_flush = (json: string) => {
|
|
8
|
+
batches.push(JSON.parse(json));
|
|
9
|
+
};
|
|
10
|
+
(globalThis as Record<string, any>).__vsreact_nativeCall = (name: string, argsJson: string) => {
|
|
11
|
+
const args = JSON.parse(argsJson);
|
|
12
|
+
nativeCalls.push({ name, args });
|
|
13
|
+
if (name === "param:get") return JSON.stringify(paramGetResult);
|
|
14
|
+
return "null";
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
render,
|
|
19
|
+
unmount,
|
|
20
|
+
ADSREnvelope,
|
|
21
|
+
ModWheel,
|
|
22
|
+
ParamModWheel,
|
|
23
|
+
PitchBend,
|
|
24
|
+
adsrLevelAt,
|
|
25
|
+
midiNoteToHz,
|
|
26
|
+
hzToMidiNote,
|
|
27
|
+
} from "./index";
|
|
28
|
+
|
|
29
|
+
const allOps = () => batches.flat();
|
|
30
|
+
const opsNamed = (name: string) => allOps().filter((op: any) => op[0] === name);
|
|
31
|
+
const dispatch = (msg: unknown) =>
|
|
32
|
+
(globalThis as Record<string, any>).__vsreact_dispatch(JSON.stringify(msg));
|
|
33
|
+
|
|
34
|
+
const nodesWithListener = (type: string): number[] => {
|
|
35
|
+
const seen = new Set<number>();
|
|
36
|
+
for (const op of opsNamed("setProps") as any[]) {
|
|
37
|
+
if (op[2]?.listeners?.includes(type)) seen.add(op[1]);
|
|
38
|
+
}
|
|
39
|
+
return [...seen];
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
unmount();
|
|
44
|
+
batches.length = 0;
|
|
45
|
+
nativeCalls.length = 0;
|
|
46
|
+
paramGetResult = { value: 0.5, text: "0.5", name: "Mod", label: "" };
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("pitch math", () => {
|
|
50
|
+
test("midiNoteToHz and hzToMidiNote agree at the anchors", () => {
|
|
51
|
+
expect(midiNoteToHz(69)).toBe(440);
|
|
52
|
+
expect(midiNoteToHz(57)).toBeCloseTo(220);
|
|
53
|
+
expect(midiNoteToHz(60)).toBeCloseTo(261.626, 2);
|
|
54
|
+
expect(hzToMidiNote(440)).toBe(69);
|
|
55
|
+
expect(hzToMidiNote(262)).toBe(60);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("adsrLevelAt", () => {
|
|
60
|
+
// width 100, segW 27
|
|
61
|
+
test("traces the four stages", () => {
|
|
62
|
+
const at = (x: number) => adsrLevelAt(x, 100, 0.5, 0.5, 0.5, 0.5);
|
|
63
|
+
expect(at(0)).toBe(0);
|
|
64
|
+
expect(at(13.5)).toBeCloseTo(1); // attack peak at 0.5*27
|
|
65
|
+
expect(at(27)).toBeCloseTo(0.5); // decay bottom
|
|
66
|
+
expect(at(50)).toBe(0.5); // sustain plateau
|
|
67
|
+
expect(at(100)).toBeCloseTo(0); // release floor
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("zero attack jumps straight to the peak", () => {
|
|
71
|
+
expect(adsrLevelAt(0, 100, 0, 0.5, 0.5, 0.5)).toBe(1);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("ADSREnvelope", () => {
|
|
76
|
+
test("dragging the attack handle reports a new attack", () => {
|
|
77
|
+
const seen: Array<[string, number]> = [];
|
|
78
|
+
render(
|
|
79
|
+
<ADSREnvelope
|
|
80
|
+
attack={0.5}
|
|
81
|
+
decay={0.5}
|
|
82
|
+
sustain={0.5}
|
|
83
|
+
release={0.5}
|
|
84
|
+
width={200}
|
|
85
|
+
onChange={(k, v) => seen.push([k, v])}
|
|
86
|
+
/>,
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
// Handles render in order: attack, decay/sustain, release.
|
|
90
|
+
const handles = nodesWithListener("drag");
|
|
91
|
+
expect(handles).toHaveLength(3);
|
|
92
|
+
|
|
93
|
+
dispatch({ kind: "event", nodeId: handles[0], type: "dragstart", payload: { dx: 0, dy: 0 } });
|
|
94
|
+
dispatch({ kind: "event", nodeId: handles[0], type: "drag", payload: { dx: 27, dy: 0 } });
|
|
95
|
+
// segW = 200*0.27 = 54; +27px = +0.5 → clamped at 1
|
|
96
|
+
expect(seen.at(-1)).toEqual(["attack", 1]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("the corner handle drives decay and sustain together", () => {
|
|
100
|
+
const seen: Array<[string, number]> = [];
|
|
101
|
+
const begins: string[] = [];
|
|
102
|
+
render(
|
|
103
|
+
<ADSREnvelope
|
|
104
|
+
attack={0.5}
|
|
105
|
+
decay={0.5}
|
|
106
|
+
sustain={0.5}
|
|
107
|
+
release={0.5}
|
|
108
|
+
width={200}
|
|
109
|
+
height={104}
|
|
110
|
+
onChange={(k, v) => seen.push([k, v])}
|
|
111
|
+
onBegin={(k) => begins.push(k)}
|
|
112
|
+
/>,
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const handles = nodesWithListener("drag");
|
|
116
|
+
dispatch({ kind: "event", nodeId: handles[1], type: "dragstart", payload: { dx: 0, dy: 0 } });
|
|
117
|
+
expect(begins).toEqual(["decay", "sustain"]);
|
|
118
|
+
|
|
119
|
+
dispatch({ kind: "event", nodeId: handles[1], type: "drag", payload: { dx: -27, dy: -48 } });
|
|
120
|
+
const decay = seen.find(([k]) => k === "decay");
|
|
121
|
+
const sustain = seen.find(([k]) => k === "sustain");
|
|
122
|
+
expect(decay?.[1]).toBeCloseTo(0); // -27/54 from 0.5
|
|
123
|
+
expect(sustain?.[1]).toBeCloseTo(1); // -48/(104-8) up from 0.5
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("ModWheel", () => {
|
|
128
|
+
test("dragging up raises the value from its start", () => {
|
|
129
|
+
const seen: number[] = [];
|
|
130
|
+
render(<ModWheel value={0.5} height={120} onChange={(v) => seen.push(v)} />);
|
|
131
|
+
|
|
132
|
+
const id = nodesWithListener("drag")[0];
|
|
133
|
+
dispatch({ kind: "event", nodeId: id, type: "dragstart", payload: { dx: 0, dy: 0 } });
|
|
134
|
+
dispatch({ kind: "event", nodeId: id, type: "drag", payload: { dx: 0, dy: -50 } });
|
|
135
|
+
expect(seen.at(-1)).toBeCloseTo(1); // 0.5 + 50/100
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("ParamModWheel opens a gesture and writes the parameter", () => {
|
|
139
|
+
render(<ParamModWheel paramId="mod" />);
|
|
140
|
+
|
|
141
|
+
const id = nodesWithListener("drag")[0];
|
|
142
|
+
dispatch({ kind: "event", nodeId: id, type: "dragstart", payload: { dx: 0, dy: 0 } });
|
|
143
|
+
dispatch({ kind: "event", nodeId: id, type: "drag", payload: { dx: 0, dy: 30 } });
|
|
144
|
+
dispatch({ kind: "event", nodeId: id, type: "dragend", payload: { dx: 0, dy: 30 } });
|
|
145
|
+
|
|
146
|
+
const names = nativeCalls.map((c) => c.name);
|
|
147
|
+
expect(names).toContain("param:begin");
|
|
148
|
+
expect(names).toContain("param:set");
|
|
149
|
+
expect(names).toContain("param:end");
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe("PitchBend", () => {
|
|
154
|
+
test("drag bends, release springs back toward center", async () => {
|
|
155
|
+
const seen: number[] = [];
|
|
156
|
+
render(<PitchBend height={120} onChange={(v) => seen.push(v)} />);
|
|
157
|
+
|
|
158
|
+
const id = nodesWithListener("drag")[0];
|
|
159
|
+
dispatch({ kind: "event", nodeId: id, type: "dragstart", payload: { dx: 0, dy: 0 } });
|
|
160
|
+
dispatch({ kind: "event", nodeId: id, type: "drag", payload: { dx: 0, dy: -30 } });
|
|
161
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
162
|
+
expect(seen.at(-1)).toBeCloseTo(0.5); // 30/(120/2)
|
|
163
|
+
|
|
164
|
+
dispatch({ kind: "event", nodeId: id, type: "dragend", payload: { dx: 0, dy: -30 } });
|
|
165
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
166
|
+
expect(seen.at(-1)).toBe(0); // spring settled at dead center
|
|
167
|
+
});
|
|
168
|
+
});
|
package/src/synth.tsx
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
// Synth-surface controls — the ADSR envelope editor and the pitch/mod
|
|
2
|
+
// wheels. Views only: the envelope is a filled column fill (like
|
|
3
|
+
// <Waveform>) with draggable corner handles riding on top.
|
|
4
|
+
|
|
5
|
+
import { useEffect, useRef, useState } from "react";
|
|
6
|
+
import { View, Text } from "./primitives";
|
|
7
|
+
import { useSpring } from "./animation";
|
|
8
|
+
import { useParameter } from "./parameters";
|
|
9
|
+
|
|
10
|
+
const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
|
|
11
|
+
|
|
12
|
+
export type ADSRKey = "attack" | "decay" | "sustain" | "release";
|
|
13
|
+
|
|
14
|
+
export interface ADSREnvelopeProps {
|
|
15
|
+
/** All 0..1: times are a fraction of each stage's max width. */
|
|
16
|
+
attack: number;
|
|
17
|
+
decay: number;
|
|
18
|
+
sustain: number;
|
|
19
|
+
release: number;
|
|
20
|
+
width?: number;
|
|
21
|
+
height?: number;
|
|
22
|
+
/** Fill resolution. Default 44 columns. */
|
|
23
|
+
columns?: number;
|
|
24
|
+
disabled?: boolean;
|
|
25
|
+
trackColor?: string;
|
|
26
|
+
/** The filled envelope body. */
|
|
27
|
+
color?: string;
|
|
28
|
+
handleColor?: string;
|
|
29
|
+
label?: string;
|
|
30
|
+
onChange: (key: ADSRKey, value: number) => void;
|
|
31
|
+
onBegin?: (key: ADSRKey) => void;
|
|
32
|
+
onEnd?: (key: ADSRKey) => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The envelope level at x, piecewise over A → D → S plateau → R. */
|
|
36
|
+
export function adsrLevelAt(
|
|
37
|
+
x: number,
|
|
38
|
+
width: number,
|
|
39
|
+
attack: number,
|
|
40
|
+
decay: number,
|
|
41
|
+
sustain: number,
|
|
42
|
+
release: number,
|
|
43
|
+
): number {
|
|
44
|
+
const segW = width * 0.27;
|
|
45
|
+
const ax = attack * segW;
|
|
46
|
+
const dw = decay * segW;
|
|
47
|
+
const rw = release * segW;
|
|
48
|
+
const rx = width - rw;
|
|
49
|
+
|
|
50
|
+
if (x <= ax) return ax === 0 ? 1 : x / ax;
|
|
51
|
+
if (x <= ax + dw) return 1 - (1 - sustain) * ((x - ax) / (dw || 1));
|
|
52
|
+
if (x <= rx) return sustain;
|
|
53
|
+
return rw === 0 ? 0 : Math.max(0, sustain * (1 - (x - rx) / rw));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The classic four-corner envelope editor: drag the attack peak, the
|
|
57
|
+
decay/sustain corner (both axes), and the release corner. */
|
|
58
|
+
export function ADSREnvelope({
|
|
59
|
+
attack,
|
|
60
|
+
decay,
|
|
61
|
+
sustain,
|
|
62
|
+
release,
|
|
63
|
+
width = 220,
|
|
64
|
+
height = 96,
|
|
65
|
+
columns = 44,
|
|
66
|
+
disabled,
|
|
67
|
+
trackColor = "#141714",
|
|
68
|
+
color = "#C6F13566",
|
|
69
|
+
handleColor = "#ECF2E8",
|
|
70
|
+
label,
|
|
71
|
+
onChange,
|
|
72
|
+
onBegin,
|
|
73
|
+
onEnd,
|
|
74
|
+
}: ADSREnvelopeProps) {
|
|
75
|
+
const start = useRef({ attack: 0, decay: 0, sustain: 0, release: 0 });
|
|
76
|
+
|
|
77
|
+
const a = clamp01(attack);
|
|
78
|
+
const d = clamp01(decay);
|
|
79
|
+
const s = clamp01(sustain);
|
|
80
|
+
const r = clamp01(release);
|
|
81
|
+
|
|
82
|
+
const segW = width * 0.27;
|
|
83
|
+
const HANDLE = 18;
|
|
84
|
+
|
|
85
|
+
const handleProps = (keys: ADSRKey[], move: (dx: number, dy: number) => void) =>
|
|
86
|
+
disabled
|
|
87
|
+
? {}
|
|
88
|
+
: {
|
|
89
|
+
onDragStart: () => {
|
|
90
|
+
start.current = { attack: a, decay: d, sustain: s, release: r };
|
|
91
|
+
for (const key of keys) onBegin?.(key);
|
|
92
|
+
},
|
|
93
|
+
onDrag: (e: { dx: number; dy: number }) => move(e.dx, e.dy),
|
|
94
|
+
onDragEnd: () => {
|
|
95
|
+
for (const key of keys) onEnd?.(key);
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const dot = (left: number, top: number, keys: ADSRKey[], move: (dx: number, dy: number) => void) => (
|
|
100
|
+
<View
|
|
101
|
+
className={`absolute items-center justify-center ${disabled ? "" : "cursor-pointer"}`}
|
|
102
|
+
style={{ left: left - HANDLE / 2, top: top - HANDLE / 2, width: HANDLE, height: HANDLE }}
|
|
103
|
+
{...handleProps(keys, move)}
|
|
104
|
+
>
|
|
105
|
+
<View
|
|
106
|
+
className="rounded-full border"
|
|
107
|
+
style={{ width: 9, height: 9, backgroundColor: handleColor, borderColor: "#00000088" }}
|
|
108
|
+
/>
|
|
109
|
+
</View>
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const body = (
|
|
113
|
+
<View
|
|
114
|
+
className={`relative rounded overflow-hidden ${disabled ? "opacity-40" : ""}`}
|
|
115
|
+
style={{ width, height, backgroundColor: trackColor }}
|
|
116
|
+
>
|
|
117
|
+
<View className="absolute inset-0 flex-row items-end px-[1]" style={{ columnGap: 1 }}>
|
|
118
|
+
{Array.from({ length: columns }, (_, i) => {
|
|
119
|
+
const level = adsrLevelAt(((i + 0.5) / columns) * width, width, a, d, s, r);
|
|
120
|
+
return (
|
|
121
|
+
<View
|
|
122
|
+
key={i}
|
|
123
|
+
className="flex-1 rounded-[1]"
|
|
124
|
+
style={{ height: Math.max(1, level * (height - 4)), backgroundColor: color }}
|
|
125
|
+
/>
|
|
126
|
+
);
|
|
127
|
+
})}
|
|
128
|
+
</View>
|
|
129
|
+
{dot(a * segW, 4, ["attack"], (dx) => onChange("attack", clamp01(start.current.attack + dx / segW)))}
|
|
130
|
+
{dot(a * segW + d * segW, 4 + (1 - s) * (height - 8), ["decay", "sustain"], (dx, dy) => {
|
|
131
|
+
onChange("decay", clamp01(start.current.decay + dx / segW));
|
|
132
|
+
onChange("sustain", clamp01(start.current.sustain - dy / (height - 8)));
|
|
133
|
+
})}
|
|
134
|
+
{dot(width - r * segW, 4 + (1 - s) * (height - 8), ["release"], (dx) =>
|
|
135
|
+
onChange("release", clamp01(start.current.release - dx / segW)),
|
|
136
|
+
)}
|
|
137
|
+
</View>
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
if (label === undefined) return body;
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<View className="items-center gap-2">
|
|
144
|
+
{body}
|
|
145
|
+
<Text className="text-faint text-[10] font-bold tracking-widest">{label}</Text>
|
|
146
|
+
</View>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface ParamADSREnvelopeProps {
|
|
151
|
+
attackId: string;
|
|
152
|
+
decayId: string;
|
|
153
|
+
sustainId: string;
|
|
154
|
+
releaseId: string;
|
|
155
|
+
width?: number;
|
|
156
|
+
height?: number;
|
|
157
|
+
color?: string;
|
|
158
|
+
label?: string;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** An ADSREnvelope over four host parameters, gestures opened per
|
|
162
|
+
handle (the decay/sustain corner drives both together). */
|
|
163
|
+
export function ParamADSREnvelope({
|
|
164
|
+
attackId,
|
|
165
|
+
decayId,
|
|
166
|
+
sustainId,
|
|
167
|
+
releaseId,
|
|
168
|
+
...visual
|
|
169
|
+
}: ParamADSREnvelopeProps) {
|
|
170
|
+
const params = {
|
|
171
|
+
attack: useParameter(attackId),
|
|
172
|
+
decay: useParameter(decayId),
|
|
173
|
+
sustain: useParameter(sustainId),
|
|
174
|
+
release: useParameter(releaseId),
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<ADSREnvelope
|
|
179
|
+
attack={params.attack.value}
|
|
180
|
+
decay={params.decay.value}
|
|
181
|
+
sustain={params.sustain.value}
|
|
182
|
+
release={params.release.value}
|
|
183
|
+
onChange={(key, value) => params[key].set(value)}
|
|
184
|
+
onBegin={(key) => params[key].begin()}
|
|
185
|
+
onEnd={(key) => params[key].end()}
|
|
186
|
+
{...visual}
|
|
187
|
+
/>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/* ── wheels ─────────────────────────────────────────────────────────── */
|
|
192
|
+
|
|
193
|
+
interface WheelSkin {
|
|
194
|
+
width?: number;
|
|
195
|
+
height?: number;
|
|
196
|
+
disabled?: boolean;
|
|
197
|
+
trackColor?: string;
|
|
198
|
+
thumbColor?: string;
|
|
199
|
+
accentColor?: string;
|
|
200
|
+
label?: string;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function WheelChrome({
|
|
204
|
+
value01,
|
|
205
|
+
width = 34,
|
|
206
|
+
height = 110,
|
|
207
|
+
disabled,
|
|
208
|
+
trackColor = "#141714",
|
|
209
|
+
thumbColor = "#3A4038",
|
|
210
|
+
accentColor = "#C6F135",
|
|
211
|
+
label,
|
|
212
|
+
centerMark,
|
|
213
|
+
handlers,
|
|
214
|
+
}: WheelSkin & {
|
|
215
|
+
/** Thumb position, 0 (bottom) .. 1 (top). */
|
|
216
|
+
value01: number;
|
|
217
|
+
centerMark?: boolean;
|
|
218
|
+
handlers: Record<string, unknown>;
|
|
219
|
+
}) {
|
|
220
|
+
const THUMB = 16;
|
|
221
|
+
const travel = height - 4 - THUMB;
|
|
222
|
+
|
|
223
|
+
const wheel = (
|
|
224
|
+
<View
|
|
225
|
+
className={`relative rounded-lg border overflow-hidden ${disabled ? "opacity-40" : "cursor-pointer"}`}
|
|
226
|
+
style={{ width, height, backgroundColor: trackColor, borderColor: "#00000066" }}
|
|
227
|
+
{...handlers}
|
|
228
|
+
>
|
|
229
|
+
{centerMark ? (
|
|
230
|
+
<View
|
|
231
|
+
className="absolute left-0 right-0 h-[1]"
|
|
232
|
+
style={{ top: height / 2, backgroundColor: "#FFFFFF2E" }}
|
|
233
|
+
/>
|
|
234
|
+
) : null}
|
|
235
|
+
<View
|
|
236
|
+
className="absolute left-[2] right-[2] rounded"
|
|
237
|
+
style={{ top: 2 + (1 - clamp01(value01)) * travel, height: THUMB, backgroundColor: thumbColor }}
|
|
238
|
+
>
|
|
239
|
+
<View
|
|
240
|
+
className="absolute left-[2] right-[2] rounded-full"
|
|
241
|
+
style={{ top: THUMB / 2 - 1.25, height: 2.5, backgroundColor: accentColor }}
|
|
242
|
+
/>
|
|
243
|
+
</View>
|
|
244
|
+
</View>
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
if (label === undefined) return wheel;
|
|
248
|
+
|
|
249
|
+
return (
|
|
250
|
+
<View className="items-center gap-2">
|
|
251
|
+
{wheel}
|
|
252
|
+
<Text className="text-faint text-[10] font-bold tracking-widest">{label}</Text>
|
|
253
|
+
</View>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export interface PitchBendProps extends WheelSkin {
|
|
258
|
+
/** Bend, −1..+1; fires continuously and again as the spring returns. */
|
|
259
|
+
onChange?: (value: number) => void;
|
|
260
|
+
/** Snap back to center on release. Default true. */
|
|
261
|
+
springBack?: boolean;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** The pitch wheel: drag up/down from center, springs back to 0 on
|
|
265
|
+
release (momentary — it owns its own state). */
|
|
266
|
+
export function PitchBend({ onChange, springBack = true, ...skin }: PitchBendProps) {
|
|
267
|
+
const [drag, setDrag] = useState<number | null>(null);
|
|
268
|
+
const sprung = useSpring(drag ?? 0, { stiffness: 320, damping: 24 });
|
|
269
|
+
const value = drag ?? (springBack ? sprung : 0);
|
|
270
|
+
|
|
271
|
+
const onChangeRef = useRef(onChange);
|
|
272
|
+
onChangeRef.current = onChange;
|
|
273
|
+
useEffect(() => {
|
|
274
|
+
onChangeRef.current?.(value);
|
|
275
|
+
}, [value]);
|
|
276
|
+
|
|
277
|
+
const startValue = useRef(0);
|
|
278
|
+
const height = skin.height ?? 110;
|
|
279
|
+
|
|
280
|
+
const handlers = skin.disabled
|
|
281
|
+
? {}
|
|
282
|
+
: {
|
|
283
|
+
onDragStart: () => {
|
|
284
|
+
startValue.current = drag ?? 0;
|
|
285
|
+
setDrag(startValue.current);
|
|
286
|
+
},
|
|
287
|
+
onDrag: (e: { dy: number }) =>
|
|
288
|
+
setDrag(Math.min(1, Math.max(-1, startValue.current - e.dy / (height / 2)))),
|
|
289
|
+
onDragEnd: () => setDrag(null),
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
return <WheelChrome value01={(value + 1) / 2} centerMark handlers={handlers} {...skin} />;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export interface ModWheelProps extends WheelSkin {
|
|
296
|
+
/** 0..1, controlled — mod wheels stay where you leave them. */
|
|
297
|
+
value: number;
|
|
298
|
+
onChange: (value: number) => void;
|
|
299
|
+
onBegin?: () => void;
|
|
300
|
+
onEnd?: () => void;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** The mod wheel: a vertical strip that holds its position. */
|
|
304
|
+
export function ModWheel({ value, onChange, onBegin, onEnd, ...skin }: ModWheelProps) {
|
|
305
|
+
const startValue = useRef(0);
|
|
306
|
+
const height = skin.height ?? 110;
|
|
307
|
+
|
|
308
|
+
const handlers = skin.disabled
|
|
309
|
+
? {}
|
|
310
|
+
: {
|
|
311
|
+
onDragStart: () => {
|
|
312
|
+
startValue.current = clamp01(value);
|
|
313
|
+
onBegin?.();
|
|
314
|
+
},
|
|
315
|
+
onDrag: (e: { dy: number }) => onChange(clamp01(startValue.current - e.dy / (height - 20))),
|
|
316
|
+
onDragEnd: () => onEnd?.(),
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
return <WheelChrome value01={clamp01(value)} handlers={handlers} {...skin} />;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export interface ParamModWheelProps extends WheelSkin {
|
|
323
|
+
paramId: string;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** A ModWheel bound to a host parameter. */
|
|
327
|
+
export function ParamModWheel({ paramId, ...skin }: ParamModWheelProps) {
|
|
328
|
+
const param = useParameter(paramId);
|
|
329
|
+
|
|
330
|
+
return (
|
|
331
|
+
<ModWheel
|
|
332
|
+
value={param.value}
|
|
333
|
+
onChange={param.set}
|
|
334
|
+
onBegin={param.begin}
|
|
335
|
+
onEnd={param.end}
|
|
336
|
+
label={skin.label ?? param.name.toUpperCase()}
|
|
337
|
+
{...skin}
|
|
338
|
+
/>
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export interface ParamPitchBendProps extends WheelSkin {
|
|
343
|
+
/** A 0..1 host parameter with 0.5 center. */
|
|
344
|
+
paramId: string;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** A PitchBend writing 0.5 ± bend/2 to a host parameter; release closes
|
|
348
|
+
the gesture at dead center (the visual spring is cosmetic). */
|
|
349
|
+
export function ParamPitchBend({ paramId, ...skin }: ParamPitchBendProps) {
|
|
350
|
+
const param = useParameter(paramId);
|
|
351
|
+
const active = useRef(false);
|
|
352
|
+
|
|
353
|
+
return (
|
|
354
|
+
<PitchBend
|
|
355
|
+
onChange={(v) => {
|
|
356
|
+
if (v === 0 && !active.current) return; // at rest (incl. mount) — nothing to write
|
|
357
|
+
if (!active.current) {
|
|
358
|
+
active.current = true;
|
|
359
|
+
param.begin();
|
|
360
|
+
}
|
|
361
|
+
param.set(0.5 + v / 2);
|
|
362
|
+
if (v === 0) {
|
|
363
|
+
active.current = false;
|
|
364
|
+
param.end();
|
|
365
|
+
}
|
|
366
|
+
}}
|
|
367
|
+
{...skin}
|
|
368
|
+
/>
|
|
369
|
+
);
|
|
370
|
+
}
|