@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.
@@ -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";
@@ -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 { mapRange, formatDb, formatHz, formatMs, formatPercent, formatSemitones, midiNoteName, } from "./format";
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 { mapRange, formatDb, formatHz, formatMs, formatPercent, formatSemitones, midiNoteName, } from "./format";
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";
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vsreact/core",
3
- "version": "0.0.12",
3
+ "version": "0.0.13",
4
4
  "description": "Write React. Ship native VST. A React renderer for JUCE audio plugins — QuickJS + Yoga + juce::Graphics, no webview.",
5
5
  "keywords": [
6
6
  "react",
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
+ }