@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 CHANGED
@@ -1,24 +1,24 @@
1
- // Bundles a VSReacT app entry into a single QuickJS-ready IIFE.
2
- // bun run build.ts [entry] [outfile]
3
- export {};
4
-
5
- const entry = process.argv[2] ?? "demo/main.tsx";
6
- const outfile = process.argv[3] ?? "demo/build/main.js";
7
-
8
- const result = await Bun.build({
9
- entrypoints: [entry],
10
- target: "browser",
11
- format: "iife",
12
- minify: false,
13
- define: {
14
- "process.env.NODE_ENV": '"production"',
15
- },
16
- });
17
-
18
- if (!result.success) {
19
- for (const log of result.logs) console.error(log);
20
- process.exit(1);
21
- }
22
-
23
- await Bun.write(outfile, await result.outputs[0].text());
24
- console.log(`built ${entry} -> ${outfile}`);
1
+ // Bundles a VSReacT app entry into a single QuickJS-ready IIFE.
2
+ // bun run build.ts [entry] [outfile]
3
+ export {};
4
+
5
+ const entry = process.argv[2] ?? "demo/main.tsx";
6
+ const outfile = process.argv[3] ?? "demo/build/main.js";
7
+
8
+ const result = await Bun.build({
9
+ entrypoints: [entry],
10
+ target: "browser",
11
+ format: "iife",
12
+ minify: false,
13
+ define: {
14
+ "process.env.NODE_ENV": '"production"',
15
+ },
16
+ });
17
+
18
+ if (!result.success) {
19
+ for (const log of result.logs) console.error(log);
20
+ process.exit(1);
21
+ }
22
+
23
+ await Bun.write(outfile, await result.outputs[0].text());
24
+ console.log(`built ${entry} -> ${outfile}`);
@@ -12,5 +12,11 @@ export { NumberBox, Checkbox, RadioGroup, ParamNumberBox, ParamCheckbox, ParamRa
12
12
  export type { NumberBoxProps, CheckboxProps, RadioGroupProps, ParamNumberBoxProps, ParamCheckboxProps, ParamRadioGroupProps, } from "./fields";
13
13
  export { ProgressBar, Spinner } from "./feedback";
14
14
  export type { ProgressBarProps, SpinnerProps } from "./feedback";
15
+ export { PianoKeyboard } from "./keyboard";
16
+ export type { PianoKeyboardProps } from "./keyboard";
17
+ export { StepSequencer } from "./sequencer";
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";
15
21
  export { MacroPad, ParamMacroPad, HardwareKnob, ParamHardwareKnob, Crossfader, ParamCrossfader, PulseOrb, } from "./specialty";
16
22
  export type { MacroPadProps, ParamMacroPadProps, HardwareKnobProps, ParamHardwareKnobProps, CrossfaderProps, ParamCrossfaderProps, PulseOrbProps, } from "./specialty";
@@ -12,4 +12,7 @@ export { Button } from "./button";
12
12
  export { Tooltip, Modal } from "./popover";
13
13
  export { NumberBox, Checkbox, RadioGroup, ParamNumberBox, ParamCheckbox, ParamRadioGroup, snapToStep, } from "./fields";
14
14
  export { ProgressBar, Spinner } from "./feedback";
15
+ export { PianoKeyboard } from "./keyboard";
16
+ export { StepSequencer } from "./sequencer";
17
+ export { ADSREnvelope, ParamADSREnvelope, PitchBend, ModWheel, ParamModWheel, ParamPitchBend, } from "./synth";
15
18
  export { MacroPad, ParamMacroPad, HardwareKnob, ParamHardwareKnob, Crossfader, ParamCrossfader, PulseOrb, } from "./specialty";
@@ -5,12 +5,15 @@ export interface ProgressBarProps {
5
5
  height?: number;
6
6
  /** Renders "42%" alongside the bar. Default false. */
7
7
  showPercent?: boolean;
8
+ /** Unknown duration — a segment sweeps the track and `value` is
9
+ ignored. Default false. */
10
+ indeterminate?: boolean;
8
11
  trackColor?: string;
9
12
  color?: string;
10
13
  label?: string;
11
14
  }
12
15
  /** A determinate progress bar — downloads, renders, analysis passes. */
13
- export declare function ProgressBar({ value, width, height, showPercent, trackColor, color, label, }: ProgressBarProps): import("react").JSX.Element;
16
+ export declare function ProgressBar({ value, width, height, showPercent, indeterminate, trackColor, color, label, }: ProgressBarProps): import("react").JSX.Element;
14
17
  export interface SpinnerProps {
15
18
  /** Diameter. Default 28. */
16
19
  size?: number;
package/dist/feedback.js CHANGED
@@ -6,9 +6,19 @@ import { View, Text } from "./primitives";
6
6
  import { useInterval } from "./hooks";
7
7
  const clamp01 = (v) => Math.min(1, Math.max(0, v));
8
8
  /** A determinate progress bar — downloads, renders, analysis passes. */
9
- export function ProgressBar({ value, width = 200, height = 8, showPercent = false, trackColor = "#2A2F27", color = "#C6F135", label, }) {
9
+ export function ProgressBar({ value, width = 200, height = 8, showPercent = false, indeterminate = false, trackColor = "#2A2F27", color = "#C6F135", label, }) {
10
10
  const level = clamp01(value);
11
- const bar = (_jsxs(View, { className: "flex-row items-center gap-3", children: [_jsx(View, { className: "rounded-full overflow-hidden", style: { width, height, backgroundColor: trackColor }, children: _jsx(View, { className: "rounded-full", style: { width: level * width, height, backgroundColor: color } }) }), showPercent ? (_jsx(Text, { className: "text-faint text-[11] font-bold", style: { width: 34 }, children: `${Math.round(level * 100)}%` })) : null] }));
11
+ // The indeterminate sweep: a 30%-width segment marching left right.
12
+ const [sweep, setSweep] = useState(0);
13
+ useInterval(() => setSweep((t) => (t + 0.018) % 1), indeterminate ? 16 : null);
14
+ const segment = width * 0.3;
15
+ const bar = (_jsxs(View, { className: "flex-row items-center gap-3", children: [_jsx(View, { className: "rounded-full overflow-hidden relative", style: { width, height, backgroundColor: trackColor }, children: indeterminate ? (_jsx(View, { className: "rounded-full absolute", style: {
16
+ left: sweep * (width + segment) - segment,
17
+ top: 0,
18
+ width: segment,
19
+ height,
20
+ backgroundColor: color,
21
+ } })) : (_jsx(View, { className: "rounded-full", style: { width: level * width, height, backgroundColor: color } })) }), showPercent && !indeterminate ? (_jsx(Text, { className: "text-faint text-[11] font-bold", style: { width: 34 }, children: `${Math.round(level * 100)}%` })) : null] }));
12
22
  if (label === undefined)
13
23
  return bar;
14
24
  return (_jsxs(View, { className: "items-center gap-2", children: [bar, _jsx(Text, { className: "text-faint text-[10] font-bold tracking-widest", children: label })] }));
@@ -0,0 +1,21 @@
1
+ /** Linear interpolation of `value` from [inMin, inMax] to [outMin, outMax].
2
+ `clampOut` pins the result inside the output range. */
3
+ export declare function mapRange(value: number, inMin: number, inMax: number, outMin: number, outMax: number, clampOut?: boolean): number;
4
+ /** Decibels with an explicit sign: "+6.0 dB", "0.0 dB", "-12.5 dB".
5
+ -Infinity (a gain fader on the floor) reads "-inf dB". */
6
+ export declare function formatDb(db: number, decimals?: number): string;
7
+ /** Frequency with automatic kHz: "440 Hz", "1.2 kHz". */
8
+ export declare function formatHz(hz: number, decimals?: number): string;
9
+ /** Time with automatic seconds: "350 ms", "1.25 s". */
10
+ export declare function formatMs(ms: number, decimals?: number): string;
11
+ /** A 0..1 value as "42%". */
12
+ export declare function formatPercent(value01: number, decimals?: number): string;
13
+ /** Semitones with an explicit sign: "+7 st", "0 st", "-12 st". */
14
+ export declare function formatSemitones(semitones: number, decimals?: number): string;
15
+ /** A MIDI note number as scientific pitch: 60 → "C4", 69 → "A4". */
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 ADDED
@@ -0,0 +1,59 @@
1
+ // Value formatting for parameter readouts — the strings DAW users
2
+ // expect next to a knob. Pure functions; pair them with NumberBox's
3
+ // `format` prop or any Text.
4
+ const trim = (value, decimals) => String(parseFloat(value.toFixed(decimals)));
5
+ /** Linear interpolation of `value` from [inMin, inMax] to [outMin, outMax].
6
+ `clampOut` pins the result inside the output range. */
7
+ export function mapRange(value, inMin, inMax, outMin, outMax, clampOut = false) {
8
+ const t = inMax === inMin ? 0 : (value - inMin) / (inMax - inMin);
9
+ const out = outMin + t * (outMax - outMin);
10
+ if (!clampOut)
11
+ return out;
12
+ const lo = Math.min(outMin, outMax);
13
+ const hi = Math.max(outMin, outMax);
14
+ return Math.min(hi, Math.max(lo, out));
15
+ }
16
+ /** Decibels with an explicit sign: "+6.0 dB", "0.0 dB", "-12.5 dB".
17
+ -Infinity (a gain fader on the floor) reads "-inf dB". */
18
+ export function formatDb(db, decimals = 1) {
19
+ if (db === Number.NEGATIVE_INFINITY)
20
+ return "-inf dB";
21
+ const text = db.toFixed(decimals);
22
+ return `${db > 0 && !text.startsWith("+") ? "+" : ""}${text} dB`;
23
+ }
24
+ /** Frequency with automatic kHz: "440 Hz", "1.2 kHz". */
25
+ export function formatHz(hz, decimals = 1) {
26
+ if (Math.abs(hz) >= 1000)
27
+ return `${trim(hz / 1000, Math.max(decimals, 1))} kHz`;
28
+ return `${trim(hz, decimals)} Hz`;
29
+ }
30
+ /** Time with automatic seconds: "350 ms", "1.25 s". */
31
+ export function formatMs(ms, decimals = 0) {
32
+ if (Math.abs(ms) >= 1000)
33
+ return `${trim(ms / 1000, Math.max(decimals, 2))} s`;
34
+ return `${trim(ms, decimals)} ms`;
35
+ }
36
+ /** A 0..1 value as "42%". */
37
+ export function formatPercent(value01, decimals = 0) {
38
+ return `${trim(value01 * 100, decimals)}%`;
39
+ }
40
+ /** Semitones with an explicit sign: "+7 st", "0 st", "-12 st". */
41
+ export function formatSemitones(semitones, decimals = 0) {
42
+ const text = trim(semitones, decimals);
43
+ return `${semitones > 0 && !text.startsWith("+") ? "+" : ""}${text} st`;
44
+ }
45
+ const NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
46
+ /** A MIDI note number as scientific pitch: 60 → "C4", 69 → "A4". */
47
+ export function midiNoteName(note) {
48
+ const n = Math.round(note);
49
+ return `${NOTE_NAMES[((n % 12) + 12) % 12]}${Math.floor(n / 12) - 1}`;
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
@@ -28,6 +28,13 @@ export { NumberBox, Checkbox, RadioGroup, ParamNumberBox, ParamCheckbox, ParamRa
28
28
  export type { NumberBoxProps, CheckboxProps, RadioGroupProps, ParamNumberBoxProps, ParamCheckboxProps, ParamRadioGroupProps, } from "./fields";
29
29
  export { ProgressBar, Spinner } from "./feedback";
30
30
  export type { ProgressBarProps, SpinnerProps } from "./feedback";
31
+ export { PianoKeyboard } from "./keyboard";
32
+ export type { PianoKeyboardProps } from "./keyboard";
33
+ export { StepSequencer } from "./sequencer";
34
+ export type { StepSequencerProps } from "./sequencer";
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";
31
38
  export { MacroPad, ParamMacroPad, HardwareKnob, ParamHardwareKnob, Crossfader, ParamCrossfader, PulseOrb, } from "./specialty";
32
39
  export type { MacroPadProps, ParamMacroPadProps, HardwareKnobProps, ParamHardwareKnobProps, CrossfaderProps, ParamCrossfaderProps, PulseOrbProps, } from "./specialty";
33
40
  export type { DragEventPayload, LayoutRect, WheelEventPayload } from "./primitives";
package/dist/index.js CHANGED
@@ -18,4 +18,8 @@ export { Knob, Slider, Toggle, XYPad, Segmented, Select, GenericEditor, ParamKno
18
18
  export { Meter, usePeakHold, peakHoldStep } from "./meter";
19
19
  export { NumberBox, Checkbox, RadioGroup, ParamNumberBox, ParamCheckbox, ParamRadioGroup, snapToStep, } from "./fields";
20
20
  export { ProgressBar, Spinner } from "./feedback";
21
+ export { PianoKeyboard } from "./keyboard";
22
+ export { StepSequencer } from "./sequencer";
23
+ export { ADSREnvelope, ParamADSREnvelope, PitchBend, ModWheel, ParamModWheel, ParamPitchBend, adsrLevelAt, } from "./synth";
24
+ export { mapRange, formatDb, formatHz, formatMs, formatPercent, formatSemitones, midiNoteName, midiNoteToHz, hzToMidiNote, } from "./format";
21
25
  export { MacroPad, ParamMacroPad, HardwareKnob, ParamHardwareKnob, Crossfader, ParamCrossfader, PulseOrb, } from "./specialty";
@@ -0,0 +1,23 @@
1
+ export interface PianoKeyboardProps {
2
+ /** MIDI note of the leftmost key (snapped down to a white key).
3
+ Default 48 (C3). */
4
+ startNote?: number;
5
+ /** Whole octaves; the keyboard ends on the top C. Default 2. */
6
+ octaves?: number;
7
+ /** Width of one white key. Default 24. */
8
+ whiteKeyWidth?: number;
9
+ /** White-key height; blacks are 60% of it. Default 96. */
10
+ height?: number;
11
+ /** Externally-held notes (host MIDI in) — painted active. */
12
+ heldNotes?: number[];
13
+ /** Prints "C3" on each C. Default true. */
14
+ showOctaveLabels?: boolean;
15
+ disabled?: boolean;
16
+ whiteColor?: string;
17
+ blackColor?: string;
18
+ activeColor?: string;
19
+ borderColor?: string;
20
+ onNoteOn?: (note: number) => void;
21
+ onNoteOff?: (note: number) => void;
22
+ }
23
+ export declare function PianoKeyboard({ startNote, octaves, whiteKeyWidth, height, heldNotes, showOctaveLabels, disabled, whiteColor, blackColor, activeColor, borderColor, onNoteOn, onNoteOff, }: PianoKeyboardProps): import("react").JSX.Element;
@@ -0,0 +1,79 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // The playable piano keyboard — preview a patch without touching the
3
+ // host. Views only (white row + absolutely-positioned blacks); press
4
+ // for note-on, release for note-off, drag across keys for glissando.
5
+ import { useRef, useState } from "react";
6
+ import { View, Text } from "./primitives";
7
+ import { midiNoteName } from "./format";
8
+ const BLACK_SEMIS = new Set([1, 3, 6, 8, 10]);
9
+ const isBlack = (note) => BLACK_SEMIS.has(((note % 12) + 12) % 12);
10
+ export function PianoKeyboard({ startNote = 48, octaves = 2, whiteKeyWidth = 24, height = 96, heldNotes, showOctaveLabels = true, disabled, whiteColor = "#ECF2E8", blackColor = "#17191C", activeColor = "#C6F135", borderColor = "#00000066", onNoteOn, onNoteOff, }) {
11
+ const [pressed, setPressed] = useState(null);
12
+ const active = useRef(null);
13
+ // Snap the start onto a white key so the layout math stays honest.
14
+ let base = Math.round(startNote);
15
+ while (isBlack(base))
16
+ base += 1;
17
+ const whiteCount = Math.max(1, Math.round(octaves)) * 7 + 1;
18
+ const blackWidth = whiteKeyWidth * 0.62;
19
+ const blackHeight = height * 0.6;
20
+ const whites = [];
21
+ const blacks = [];
22
+ for (let note = base; whites.length < whiteCount; note++) {
23
+ if (isBlack(note)) {
24
+ blacks.push({ note, left: whites.length * whiteKeyWidth - blackWidth / 2 });
25
+ }
26
+ else {
27
+ whites.push(note);
28
+ }
29
+ }
30
+ const press = (note) => {
31
+ if (active.current === note)
32
+ return;
33
+ if (active.current !== null)
34
+ onNoteOff?.(active.current);
35
+ active.current = note;
36
+ setPressed(note);
37
+ onNoteOn?.(note);
38
+ };
39
+ const release = () => {
40
+ if (active.current === null)
41
+ return;
42
+ onNoteOff?.(active.current);
43
+ active.current = null;
44
+ setPressed(null);
45
+ };
46
+ /** The key under an approximate pointer position (glissando). */
47
+ const noteAt = (x, y) => {
48
+ if (y <= blackHeight) {
49
+ for (const black of blacks) {
50
+ if (x >= black.left && x <= black.left + blackWidth)
51
+ return black.note;
52
+ }
53
+ }
54
+ const index = Math.min(whiteCount - 1, Math.max(0, Math.floor(x / whiteKeyWidth)));
55
+ return whites[index];
56
+ };
57
+ const isActive = (note) => pressed === note || (heldNotes !== undefined && heldNotes.includes(note));
58
+ const keyHandlers = (note, centerX, centerY) => disabled
59
+ ? {}
60
+ : {
61
+ onMouseDown: () => press(note),
62
+ onMouseUp: release,
63
+ onDrag: (e) => press(noteAt(centerX + e.dx, centerY + e.dy)),
64
+ onDragEnd: release,
65
+ };
66
+ return (_jsxs(View, { className: `flex-row relative ${disabled ? "opacity-40" : "cursor-pointer"}`, style: { width: whiteCount * whiteKeyWidth, height }, children: [whites.map((note, i) => (_jsx(View, { className: "border rounded-b-[3] justify-end items-center pb-1", style: {
67
+ width: whiteKeyWidth,
68
+ height,
69
+ backgroundColor: isActive(note) ? activeColor : whiteColor,
70
+ borderColor,
71
+ }, ...keyHandlers(note, (i + 0.5) * whiteKeyWidth, height * 0.8), children: showOctaveLabels && ((note % 12) + 12) % 12 === 0 ? (_jsx(Text, { className: "text-[8] font-bold", style: { color: "#00000088" }, children: midiNoteName(note) })) : null }, note))), blacks.map((black) => (_jsx(View, { className: "absolute border rounded-b-[3]", style: {
72
+ left: black.left,
73
+ top: 0,
74
+ width: blackWidth,
75
+ height: blackHeight,
76
+ backgroundColor: isActive(black.note) ? activeColor : blackColor,
77
+ borderColor,
78
+ }, ...keyHandlers(black.note, black.left + blackWidth / 2, blackHeight / 2) }, black.note)))] }));
79
+ }
@@ -0,0 +1,23 @@
1
+ export interface StepSequencerProps {
2
+ /** rows × steps. `pattern[row][step]` true = lit. */
3
+ pattern: boolean[][];
4
+ /** The column currently playing — painted with a bright ring. */
5
+ playhead?: number;
6
+ /** Printed left of each row ("KICK", "SNARE"…). */
7
+ rowLabels?: string[];
8
+ /** Cell edge length. Default 20. */
9
+ cellSize?: number;
10
+ /** Space between cells. Default 5. */
11
+ gap?: number;
12
+ /** Downbeat tint every N steps. Default 4; 0 disables. */
13
+ groupEvery?: number;
14
+ disabled?: boolean;
15
+ cellColor?: string;
16
+ /** Downbeat cells (step 0, 4, 8…) when unlit. */
17
+ downbeatColor?: string;
18
+ activeColor?: string;
19
+ playheadColor?: string;
20
+ labelColor?: string;
21
+ onToggle: (row: number, step: number, next: boolean) => void;
22
+ }
23
+ export declare function StepSequencer({ pattern, playhead, rowLabels, cellSize, gap, groupEvery, disabled, cellColor, downbeatColor, activeColor, playheadColor, labelColor, onToggle, }: StepSequencerProps): import("react").JSX.Element;
@@ -0,0 +1,20 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // The step sequencer grid — rows of cells you click on and off, with a
3
+ // playhead column the host drives. Patterns live in the app's state;
4
+ // the component is fully controlled.
5
+ import { View, Text } from "./primitives";
6
+ export function StepSequencer({ pattern, playhead, rowLabels, cellSize = 20, gap = 5, groupEvery = 4, disabled, cellColor = "#242922", downbeatColor = "#2E342B", activeColor = "#C6F135", playheadColor = "#ECF2E8", labelColor = "#a1a1aa", onToggle, }) {
7
+ const labelWidth = rowLabels !== undefined && rowLabels.length > 0
8
+ ? Math.max(...rowLabels.map((label) => label.length)) * 7 + 10
9
+ : 0;
10
+ return (_jsx(View, { className: disabled ? "opacity-40" : "", style: { rowGap: gap }, children: pattern.map((row, r) => (_jsxs(View, { className: "flex-row items-center", style: { columnGap: gap }, children: [labelWidth > 0 ? (_jsx(Text, { className: "text-[10] font-bold tracking-widest", style: { width: labelWidth, color: labelColor }, children: rowLabels?.[r] ?? "" })) : null, row.map((lit, s) => (_jsx(View, { className: `rounded-[4] border ${disabled ? "" : "cursor-pointer"}`, style: {
11
+ width: cellSize,
12
+ height: cellSize,
13
+ backgroundColor: lit
14
+ ? activeColor
15
+ : groupEvery > 0 && s % groupEvery === 0
16
+ ? downbeatColor
17
+ : cellColor,
18
+ borderColor: s === playhead ? playheadColor : "#00000055",
19
+ }, onClick: disabled ? undefined : () => onToggle(r, s, !lit) }, s)))] }, r))) }));
20
+ }
@@ -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/dist/tw.js CHANGED
@@ -217,6 +217,11 @@ function resolveClass(cls) {
217
217
  case "text": {
218
218
  if (rest in textSizes)
219
219
  return { fontSize: textSizes[rest] };
220
+ if (rest.startsWith("[") && !rest.startsWith("[#")) {
221
+ const size = parseLength(rest);
222
+ if (typeof size === "number")
223
+ return { fontSize: size };
224
+ }
220
225
  const color = resolveColor(rest);
221
226
  return color !== undefined ? { color } : undefined;
222
227
  }