@vsreact/core 0.0.12 → 0.0.14

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/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
+ }