@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
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
const batches: unknown[][][] = [];
|
|
4
|
+
|
|
5
|
+
(globalThis as Record<string, any>).__vsreact_flush = (json: string) => {
|
|
6
|
+
batches.push(JSON.parse(json));
|
|
7
|
+
};
|
|
8
|
+
(globalThis as Record<string, any>).__vsreact_nativeCall = () => "null";
|
|
9
|
+
|
|
10
|
+
import { render, unmount, View, Text, Tabs, Disclosure, Meter, useNativeValue } from "./index";
|
|
11
|
+
|
|
12
|
+
const allOps = () => batches.flat();
|
|
13
|
+
const opsNamed = (name: string) => allOps().filter((op: any) => op[0] === name);
|
|
14
|
+
const dispatch = (msg: unknown) =>
|
|
15
|
+
(globalThis as Record<string, any>).__vsreact_dispatch(JSON.stringify(msg));
|
|
16
|
+
|
|
17
|
+
const nodesWithListener = (type: string): number[] => {
|
|
18
|
+
const seen = new Set<number>();
|
|
19
|
+
for (const op of opsNamed("setProps") as any[]) {
|
|
20
|
+
if (op[2]?.listeners?.includes(type)) seen.add(op[1]);
|
|
21
|
+
}
|
|
22
|
+
return [...seen];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const textsSet = (): string[] =>
|
|
26
|
+
(opsNamed("setText") as any[]).map((op) => op[2]).filter((t) => typeof t === "string");
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
unmount();
|
|
30
|
+
batches.length = 0;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("Tabs", () => {
|
|
34
|
+
test("uncontrolled: clicking a tab switches the panel", async () => {
|
|
35
|
+
render(
|
|
36
|
+
<Tabs labels={["MAIN", "FX"]}>
|
|
37
|
+
<Text>main-panel</Text>
|
|
38
|
+
<Text>fx-panel</Text>
|
|
39
|
+
</Tabs>,
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
expect(textsSet()).toContain("main-panel");
|
|
43
|
+
expect(textsSet()).not.toContain("fx-panel");
|
|
44
|
+
|
|
45
|
+
const tabs = nodesWithListener("click");
|
|
46
|
+
expect(tabs).toHaveLength(2);
|
|
47
|
+
dispatch({ kind: "event", nodeId: tabs[1], type: "click" });
|
|
48
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
49
|
+
|
|
50
|
+
expect(textsSet()).toContain("fx-panel");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("controlled: reports the click, renders the given index", () => {
|
|
54
|
+
const seen: number[] = [];
|
|
55
|
+
render(
|
|
56
|
+
<Tabs labels={["A", "B", "C"]} index={2} onChange={(i) => seen.push(i)}>
|
|
57
|
+
<Text>a</Text>
|
|
58
|
+
<Text>b</Text>
|
|
59
|
+
<Text>c</Text>
|
|
60
|
+
</Tabs>,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
expect(textsSet()).toContain("c");
|
|
64
|
+
const tabs = nodesWithListener("click");
|
|
65
|
+
dispatch({ kind: "event", nodeId: tabs[0], type: "click" });
|
|
66
|
+
expect(seen).toEqual([0]);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("Disclosure", () => {
|
|
71
|
+
test("closed by default; clicking the header reveals the content", async () => {
|
|
72
|
+
render(
|
|
73
|
+
<Disclosure title="ADVANCED">
|
|
74
|
+
<Text>secret-settings</Text>
|
|
75
|
+
</Disclosure>,
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
expect(textsSet()).not.toContain("secret-settings");
|
|
79
|
+
|
|
80
|
+
const header = nodesWithListener("click")[0];
|
|
81
|
+
dispatch({ kind: "event", nodeId: header, type: "click" });
|
|
82
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
83
|
+
|
|
84
|
+
expect(textsSet()).toContain("secret-settings");
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("Meter reverse", () => {
|
|
89
|
+
test("reverse anchors the vertical fill to the top", () => {
|
|
90
|
+
render(<Meter value={0.5} length={100} peak={false} reverse />);
|
|
91
|
+
|
|
92
|
+
const fill: any = (opsNamed("setProps") as any[]).find(
|
|
93
|
+
(op) => op[2]?.style?.height === 42.5 || op[2]?.style?.height === 50,
|
|
94
|
+
);
|
|
95
|
+
expect(fill).toBeDefined();
|
|
96
|
+
expect(fill[2].style.top).toBe(0);
|
|
97
|
+
expect(fill[2].style.bottom).toBeUndefined();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("default still fills from the bottom", () => {
|
|
101
|
+
render(<Meter value={0.5} length={100} peak={false} />);
|
|
102
|
+
|
|
103
|
+
const fill: any = (opsNamed("setProps") as any[]).find(
|
|
104
|
+
(op) => (op[2]?.style?.height === 42.5 || op[2]?.style?.height === 50) && op[2]?.style?.bottom === 0,
|
|
105
|
+
);
|
|
106
|
+
expect(fill).toBeDefined();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("useNativeValue", () => {
|
|
111
|
+
test("holds the latest payload of a native event", async () => {
|
|
112
|
+
const seen: number[] = [];
|
|
113
|
+
function App() {
|
|
114
|
+
const meter = useNativeValue("meter", { level: 0 });
|
|
115
|
+
seen.push(meter.level);
|
|
116
|
+
return <View />;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
render(<App />);
|
|
120
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
121
|
+
|
|
122
|
+
dispatch({ kind: "native", name: "meter", payload: { level: 0.8 } });
|
|
123
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
124
|
+
|
|
125
|
+
expect(seen.at(-1)).toBe(0.8);
|
|
126
|
+
});
|
|
127
|
+
});
|
package/src/layout.tsx
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Workspace structure — tabs for multi-page plugin UIs and the
|
|
2
|
+
// disclosure row for collapsible settings sections.
|
|
3
|
+
|
|
4
|
+
import { useState, type ReactNode } from "react";
|
|
5
|
+
import { View, Text } from "./primitives";
|
|
6
|
+
|
|
7
|
+
const clampIndex = (i: number, count: number) => Math.min(Math.max(0, count - 1), Math.max(0, i));
|
|
8
|
+
|
|
9
|
+
export interface TabsProps {
|
|
10
|
+
labels: string[];
|
|
11
|
+
/** Controlled active tab; omit to let Tabs manage it. */
|
|
12
|
+
index?: number;
|
|
13
|
+
/** Starting tab when uncontrolled. Default 0. */
|
|
14
|
+
defaultIndex?: number;
|
|
15
|
+
onChange?: (index: number) => void;
|
|
16
|
+
/** Fixed width; defaults to content width. */
|
|
17
|
+
width?: number;
|
|
18
|
+
/** Space between the bar and the panel. Default 12. */
|
|
19
|
+
gap?: number;
|
|
20
|
+
accentColor?: string;
|
|
21
|
+
textColor?: string;
|
|
22
|
+
activeTextColor?: string;
|
|
23
|
+
trackColor?: string;
|
|
24
|
+
/** One panel per label (a lone child works for label-driven UIs). */
|
|
25
|
+
children?: ReactNode | ReactNode[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The page switcher — MAIN / FX / SETTINGS. A themed tab bar with an
|
|
29
|
+
underline indicator; renders the active panel below it. */
|
|
30
|
+
export function Tabs({
|
|
31
|
+
labels,
|
|
32
|
+
index,
|
|
33
|
+
defaultIndex = 0,
|
|
34
|
+
onChange,
|
|
35
|
+
width,
|
|
36
|
+
gap = 12,
|
|
37
|
+
accentColor = "#C6F135",
|
|
38
|
+
textColor = "#7c8087",
|
|
39
|
+
activeTextColor = "#ECF2E8",
|
|
40
|
+
trackColor = "#FFFFFF14",
|
|
41
|
+
children,
|
|
42
|
+
}: TabsProps) {
|
|
43
|
+
const [internal, setInternal] = useState(defaultIndex);
|
|
44
|
+
const current = clampIndex(index ?? internal, labels.length);
|
|
45
|
+
|
|
46
|
+
const select = (i: number) => {
|
|
47
|
+
setInternal(i);
|
|
48
|
+
onChange?.(i);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const panels: ReactNode[] = Array.isArray(children) ? children : children !== undefined ? [children] : [];
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<View style={width !== undefined ? { width } : undefined}>
|
|
55
|
+
<View className="flex-row relative" style={{ columnGap: 4 }}>
|
|
56
|
+
<View
|
|
57
|
+
className="absolute left-0 right-0 h-[1]"
|
|
58
|
+
style={{ bottom: 0, backgroundColor: trackColor }}
|
|
59
|
+
/>
|
|
60
|
+
{labels.map((label, i) => (
|
|
61
|
+
<View
|
|
62
|
+
key={`${label}-${i}`}
|
|
63
|
+
className="items-center cursor-pointer px-3 pt-1"
|
|
64
|
+
onClick={() => select(i)}
|
|
65
|
+
>
|
|
66
|
+
<Text
|
|
67
|
+
className="text-[11] font-bold tracking-widest"
|
|
68
|
+
style={{ color: i === current ? activeTextColor : textColor }}
|
|
69
|
+
>
|
|
70
|
+
{label}
|
|
71
|
+
</Text>
|
|
72
|
+
<View
|
|
73
|
+
className="rounded-full self-stretch"
|
|
74
|
+
style={{
|
|
75
|
+
height: 2,
|
|
76
|
+
marginTop: 6,
|
|
77
|
+
backgroundColor: i === current ? accentColor : "#00000000",
|
|
78
|
+
}}
|
|
79
|
+
/>
|
|
80
|
+
</View>
|
|
81
|
+
))}
|
|
82
|
+
</View>
|
|
83
|
+
{panels.length > 0 ? (
|
|
84
|
+
<View style={{ marginTop: gap }}>{panels[Math.min(current, panels.length - 1)]}</View>
|
|
85
|
+
) : null}
|
|
86
|
+
</View>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface DisclosureProps {
|
|
91
|
+
title: string;
|
|
92
|
+
/** Controlled open state; omit to let Disclosure manage it. */
|
|
93
|
+
open?: boolean;
|
|
94
|
+
/** Starting state when uncontrolled. Default false. */
|
|
95
|
+
defaultOpen?: boolean;
|
|
96
|
+
onChange?: (open: boolean) => void;
|
|
97
|
+
width?: number;
|
|
98
|
+
textColor?: string;
|
|
99
|
+
accentColor?: string;
|
|
100
|
+
children?: ReactNode;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A collapsible section row — "ADVANCED", "MODULATION". Click the
|
|
104
|
+
header to fold the content in and out. */
|
|
105
|
+
export function Disclosure({
|
|
106
|
+
title,
|
|
107
|
+
open,
|
|
108
|
+
defaultOpen = false,
|
|
109
|
+
onChange,
|
|
110
|
+
width,
|
|
111
|
+
textColor = "#a1a1aa",
|
|
112
|
+
accentColor = "#C6F135",
|
|
113
|
+
children,
|
|
114
|
+
}: DisclosureProps) {
|
|
115
|
+
const [internal, setInternal] = useState(defaultOpen);
|
|
116
|
+
const isOpen = open ?? internal;
|
|
117
|
+
|
|
118
|
+
const toggle = () => {
|
|
119
|
+
setInternal(!isOpen);
|
|
120
|
+
onChange?.(!isOpen);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<View style={width !== undefined ? { width } : undefined}>
|
|
125
|
+
<View className="flex-row items-center gap-2 cursor-pointer py-1" onClick={toggle}>
|
|
126
|
+
<Text className="text-[10] font-bold" style={{ color: accentColor, width: 10 }}>
|
|
127
|
+
{isOpen ? "▾" : "▸"}
|
|
128
|
+
</Text>
|
|
129
|
+
<Text className="text-[11] font-bold tracking-widest" style={{ color: textColor }}>
|
|
130
|
+
{title}
|
|
131
|
+
</Text>
|
|
132
|
+
</View>
|
|
133
|
+
{isOpen ? <View className="pt-2 pl-5">{children}</View> : null}
|
|
134
|
+
</View>
|
|
135
|
+
);
|
|
136
|
+
}
|
package/src/meter.tsx
CHANGED
|
@@ -67,6 +67,9 @@ export interface MeterProps {
|
|
|
67
67
|
thickness?: number;
|
|
68
68
|
/** Horizontal bar instead of the vertical default. */
|
|
69
69
|
horizontal?: boolean;
|
|
70
|
+
/** Fill from the top (vertical) / right (horizontal) — gain-reduction
|
|
71
|
+
meters. Default false. */
|
|
72
|
+
reverse?: boolean;
|
|
70
73
|
/** Show the peak-hold line. Default true. */
|
|
71
74
|
peak?: boolean;
|
|
72
75
|
holdMs?: number;
|
|
@@ -85,6 +88,7 @@ export function Meter({
|
|
|
85
88
|
length = 120,
|
|
86
89
|
thickness = 10,
|
|
87
90
|
horizontal,
|
|
91
|
+
reverse = false,
|
|
88
92
|
peak = true,
|
|
89
93
|
holdMs,
|
|
90
94
|
decayPerSecond,
|
|
@@ -108,19 +112,27 @@ export function Meter({
|
|
|
108
112
|
style={{ width: length, height: thickness, backgroundColor: trackColor }}
|
|
109
113
|
>
|
|
110
114
|
<View
|
|
111
|
-
className="absolute
|
|
112
|
-
style={{ width: fill, backgroundColor: color }}
|
|
115
|
+
className="absolute top-0 bottom-0"
|
|
116
|
+
style={reverse ? { right: 0, width: fill, backgroundColor: color } : { left: 0, width: fill, backgroundColor: color }}
|
|
113
117
|
/>
|
|
114
118
|
{hotFill > 0 ? (
|
|
115
119
|
<View
|
|
116
120
|
className="absolute top-0 bottom-0"
|
|
117
|
-
style={
|
|
121
|
+
style={
|
|
122
|
+
reverse
|
|
123
|
+
? { right: hot * length, width: hotFill, backgroundColor: hotColor }
|
|
124
|
+
: { left: hot * length, width: hotFill, backgroundColor: hotColor }
|
|
125
|
+
}
|
|
118
126
|
/>
|
|
119
127
|
) : null}
|
|
120
128
|
{peak && peakAt > 2 ? (
|
|
121
129
|
<View
|
|
122
130
|
className="absolute top-0 bottom-0 w-[2]"
|
|
123
|
-
style={
|
|
131
|
+
style={
|
|
132
|
+
reverse
|
|
133
|
+
? { right: peakAt - 2, backgroundColor: held >= hot ? hotColor : color }
|
|
134
|
+
: { left: peakAt - 2, backgroundColor: held >= hot ? hotColor : color }
|
|
135
|
+
}
|
|
124
136
|
/>
|
|
125
137
|
) : null}
|
|
126
138
|
</View>
|
|
@@ -130,19 +142,27 @@ export function Meter({
|
|
|
130
142
|
style={{ width: thickness, height: length, backgroundColor: trackColor }}
|
|
131
143
|
>
|
|
132
144
|
<View
|
|
133
|
-
className="absolute
|
|
134
|
-
style={{ height: fill, backgroundColor: color }}
|
|
145
|
+
className="absolute left-0 right-0"
|
|
146
|
+
style={reverse ? { top: 0, height: fill, backgroundColor: color } : { bottom: 0, height: fill, backgroundColor: color }}
|
|
135
147
|
/>
|
|
136
148
|
{hotFill > 0 ? (
|
|
137
149
|
<View
|
|
138
150
|
className="absolute left-0 right-0"
|
|
139
|
-
style={
|
|
151
|
+
style={
|
|
152
|
+
reverse
|
|
153
|
+
? { top: hot * length, height: hotFill, backgroundColor: hotColor }
|
|
154
|
+
: { bottom: hot * length, height: hotFill, backgroundColor: hotColor }
|
|
155
|
+
}
|
|
140
156
|
/>
|
|
141
157
|
) : null}
|
|
142
158
|
{peak && peakAt > 2 ? (
|
|
143
159
|
<View
|
|
144
160
|
className="absolute left-0 right-0 h-[2]"
|
|
145
|
-
style={
|
|
161
|
+
style={
|
|
162
|
+
reverse
|
|
163
|
+
? { top: peakAt - 2, backgroundColor: held >= hot ? hotColor : color }
|
|
164
|
+
: { bottom: peakAt - 2, backgroundColor: held >= hot ? hotColor : color }
|
|
165
|
+
}
|
|
146
166
|
/>
|
|
147
167
|
) : null}
|
|
148
168
|
</View>
|
|
@@ -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
|
+
});
|