@vsreact/core 0.0.11 → 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/build.ts +24 -24
- package/dist/components.d.ts +6 -0
- package/dist/components.js +3 -0
- package/dist/feedback.d.ts +4 -1
- package/dist/feedback.js +12 -2
- package/dist/format.d.ts +21 -0
- package/dist/format.js +59 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +4 -0
- package/dist/keyboard.d.ts +23 -0
- package/dist/keyboard.js +79 -0
- package/dist/sequencer.d.ts +23 -0
- package/dist/sequencer.js +20 -0
- package/dist/synth.d.ts +79 -0
- package/dist/synth.js +144 -0
- package/dist/tw.js +5 -0
- package/package.json +64 -64
- package/src/bridge.ts +66 -66
- package/src/components.ts +21 -0
- package/src/feedback.tsx +28 -6
- package/src/format.ts +73 -0
- package/src/hostConfig.test.tsx +120 -120
- package/src/hostConfig.ts +164 -164
- package/src/index.ts +33 -0
- package/src/keyboard.tsx +151 -0
- package/src/native.ts +18 -18
- package/src/perform.test.tsx +188 -0
- package/src/primitives.tsx +95 -95
- package/src/render.tsx +47 -47
- package/src/runtime.ts +92 -92
- package/src/sequencer.tsx +83 -0
- package/src/synth.test.tsx +168 -0
- package/src/synth.tsx +370 -0
- package/src/theme.ts +159 -159
- package/src/tw.test.ts +119 -113
- package/src/tw.ts +288 -284
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// The step sequencer grid — rows of cells you click on and off, with a
|
|
2
|
+
// playhead column the host drives. Patterns live in the app's state;
|
|
3
|
+
// the component is fully controlled.
|
|
4
|
+
|
|
5
|
+
import { View, Text } from "./primitives";
|
|
6
|
+
|
|
7
|
+
export interface StepSequencerProps {
|
|
8
|
+
/** rows × steps. `pattern[row][step]` true = lit. */
|
|
9
|
+
pattern: boolean[][];
|
|
10
|
+
/** The column currently playing — painted with a bright ring. */
|
|
11
|
+
playhead?: number;
|
|
12
|
+
/** Printed left of each row ("KICK", "SNARE"…). */
|
|
13
|
+
rowLabels?: string[];
|
|
14
|
+
/** Cell edge length. Default 20. */
|
|
15
|
+
cellSize?: number;
|
|
16
|
+
/** Space between cells. Default 5. */
|
|
17
|
+
gap?: number;
|
|
18
|
+
/** Downbeat tint every N steps. Default 4; 0 disables. */
|
|
19
|
+
groupEvery?: number;
|
|
20
|
+
disabled?: boolean;
|
|
21
|
+
cellColor?: string;
|
|
22
|
+
/** Downbeat cells (step 0, 4, 8…) when unlit. */
|
|
23
|
+
downbeatColor?: string;
|
|
24
|
+
activeColor?: string;
|
|
25
|
+
playheadColor?: string;
|
|
26
|
+
labelColor?: string;
|
|
27
|
+
onToggle: (row: number, step: number, next: boolean) => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function StepSequencer({
|
|
31
|
+
pattern,
|
|
32
|
+
playhead,
|
|
33
|
+
rowLabels,
|
|
34
|
+
cellSize = 20,
|
|
35
|
+
gap = 5,
|
|
36
|
+
groupEvery = 4,
|
|
37
|
+
disabled,
|
|
38
|
+
cellColor = "#242922",
|
|
39
|
+
downbeatColor = "#2E342B",
|
|
40
|
+
activeColor = "#C6F135",
|
|
41
|
+
playheadColor = "#ECF2E8",
|
|
42
|
+
labelColor = "#a1a1aa",
|
|
43
|
+
onToggle,
|
|
44
|
+
}: StepSequencerProps) {
|
|
45
|
+
const labelWidth =
|
|
46
|
+
rowLabels !== undefined && rowLabels.length > 0
|
|
47
|
+
? Math.max(...rowLabels.map((label) => label.length)) * 7 + 10
|
|
48
|
+
: 0;
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<View className={disabled ? "opacity-40" : ""} style={{ rowGap: gap }}>
|
|
52
|
+
{pattern.map((row, r) => (
|
|
53
|
+
<View key={r} className="flex-row items-center" style={{ columnGap: gap }}>
|
|
54
|
+
{labelWidth > 0 ? (
|
|
55
|
+
<Text
|
|
56
|
+
className="text-[10] font-bold tracking-widest"
|
|
57
|
+
style={{ width: labelWidth, color: labelColor }}
|
|
58
|
+
>
|
|
59
|
+
{rowLabels?.[r] ?? ""}
|
|
60
|
+
</Text>
|
|
61
|
+
) : null}
|
|
62
|
+
{row.map((lit, s) => (
|
|
63
|
+
<View
|
|
64
|
+
key={s}
|
|
65
|
+
className={`rounded-[4] border ${disabled ? "" : "cursor-pointer"}`}
|
|
66
|
+
style={{
|
|
67
|
+
width: cellSize,
|
|
68
|
+
height: cellSize,
|
|
69
|
+
backgroundColor: lit
|
|
70
|
+
? activeColor
|
|
71
|
+
: groupEvery > 0 && s % groupEvery === 0
|
|
72
|
+
? downbeatColor
|
|
73
|
+
: cellColor,
|
|
74
|
+
borderColor: s === playhead ? playheadColor : "#00000055",
|
|
75
|
+
}}
|
|
76
|
+
onClick={disabled ? undefined : () => onToggle(r, s, !lit)}
|
|
77
|
+
/>
|
|
78
|
+
))}
|
|
79
|
+
</View>
|
|
80
|
+
))}
|
|
81
|
+
</View>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
@@ -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
|
+
}
|