@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/dist/components.d.ts +4 -0
- package/dist/components.js +2 -0
- package/dist/format.d.ts +5 -0
- package/dist/format.js +9 -0
- package/dist/hooks.d.ts +8 -0
- package/dist/hooks.js +12 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +4 -2
- package/dist/layout.d.ts +37 -0
- package/dist/layout.js +33 -0
- package/dist/meter.d.ts +4 -1
- package/dist/meter.js +10 -2
- package/dist/synth.d.ts +79 -0
- package/dist/synth.js +144 -0
- package/package.json +1 -1
- package/src/components.ts +19 -0
- package/src/format.ts +11 -0
- package/src/hooks.ts +13 -0
- package/src/index.ts +23 -0
- package/src/layout.test.tsx +127 -0
- package/src/layout.tsx +136 -0
- package/src/meter.tsx +28 -8
- package/src/synth.test.tsx +168 -0
- package/src/synth.tsx +370 -0
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
|
+
}
|