@blibliki/engine 0.5.1 → 0.9.0
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/README.md +22 -2
- package/dist/index.d.ts +501 -107
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/Engine.ts +46 -29
- package/src/core/index.ts +11 -2
- package/src/core/midi/BaseMidiDevice.ts +47 -0
- package/src/core/midi/ComputerKeyboardDevice.ts +2 -1
- package/src/core/midi/MidiDeviceManager.ts +125 -31
- package/src/core/midi/{MidiDevice.ts → MidiInputDevice.ts} +6 -30
- package/src/core/midi/MidiOutputDevice.ts +23 -0
- package/src/core/midi/adapters/NodeMidiAdapter.ts +99 -13
- package/src/core/midi/adapters/WebMidiAdapter.ts +68 -10
- package/src/core/midi/adapters/types.ts +13 -4
- package/src/core/midi/controllers/BaseController.ts +14 -0
- package/src/core/module/Module.ts +121 -13
- package/src/core/module/PolyModule.ts +36 -0
- package/src/core/module/VoiceScheduler.ts +150 -10
- package/src/core/module/index.ts +9 -4
- package/src/index.ts +27 -3
- package/src/modules/Chorus.ts +222 -0
- package/src/modules/Constant.ts +2 -2
- package/src/modules/Delay.ts +347 -0
- package/src/modules/Distortion.ts +182 -0
- package/src/modules/Envelope.ts +158 -92
- package/src/modules/Filter.ts +7 -7
- package/src/modules/Gain.ts +2 -2
- package/src/modules/LFO.ts +287 -0
- package/src/modules/LegacyEnvelope.ts +146 -0
- package/src/modules/{MidiSelector.ts → MidiInput.ts} +26 -19
- package/src/modules/MidiMapper.ts +59 -4
- package/src/modules/MidiOutput.ts +121 -0
- package/src/modules/Noise.ts +259 -0
- package/src/modules/Oscillator.ts +9 -3
- package/src/modules/Reverb.ts +379 -0
- package/src/modules/Scale.ts +49 -4
- package/src/modules/StepSequencer.ts +410 -22
- package/src/modules/StereoPanner.ts +1 -1
- package/src/modules/index.ts +142 -29
- package/src/processors/custom-envelope-processor.ts +125 -0
- package/src/processors/index.ts +10 -0
- package/src/processors/lfo-processor.ts +123 -0
- package/src/processors/scale-processor.ts +42 -5
- package/src/utils/WetDryMixer.ts +123 -0
- package/src/utils/expandPatternSequence.ts +18 -0
- package/src/utils/index.ts +2 -0
|
@@ -6,6 +6,8 @@ import type {
|
|
|
6
6
|
IMidiAccess,
|
|
7
7
|
IMidiAdapter,
|
|
8
8
|
IMidiInputPort,
|
|
9
|
+
IMidiOutputPort,
|
|
10
|
+
IMidiPort,
|
|
9
11
|
MidiMessageCallback,
|
|
10
12
|
} from "./types";
|
|
11
13
|
|
|
@@ -26,8 +28,18 @@ type NodeMidiInput = {
|
|
|
26
28
|
isPortOpen(): boolean;
|
|
27
29
|
};
|
|
28
30
|
|
|
31
|
+
type NodeMidiOutput = {
|
|
32
|
+
getPortCount(): number;
|
|
33
|
+
getPortName(port: number): string;
|
|
34
|
+
openPort(port: number): void;
|
|
35
|
+
closePort(): void;
|
|
36
|
+
sendMessage(message: number[]): void;
|
|
37
|
+
isPortOpen(): boolean;
|
|
38
|
+
};
|
|
39
|
+
|
|
29
40
|
type NodeMidiModule = {
|
|
30
41
|
Input: new () => NodeMidiInput;
|
|
42
|
+
Output: new () => NodeMidiOutput;
|
|
31
43
|
};
|
|
32
44
|
|
|
33
45
|
class NodeMidiInputPort implements IMidiInputPort {
|
|
@@ -51,8 +63,8 @@ class NodeMidiInputPort implements IMidiInputPort {
|
|
|
51
63
|
return this._state;
|
|
52
64
|
}
|
|
53
65
|
|
|
54
|
-
|
|
55
|
-
|
|
66
|
+
get type() {
|
|
67
|
+
return "input" as const;
|
|
56
68
|
}
|
|
57
69
|
|
|
58
70
|
addEventListener(callback: MidiMessageCallback): void {
|
|
@@ -99,8 +111,55 @@ class NodeMidiInputPort implements IMidiInputPort {
|
|
|
99
111
|
}
|
|
100
112
|
}
|
|
101
113
|
|
|
114
|
+
class NodeMidiOutputPort implements IMidiOutputPort {
|
|
115
|
+
readonly id: string;
|
|
116
|
+
readonly name: string;
|
|
117
|
+
private portIndex: number;
|
|
118
|
+
private output: NodeMidiOutput;
|
|
119
|
+
private _state: "connected" | "disconnected" = "disconnected";
|
|
120
|
+
private isOpen = false;
|
|
121
|
+
|
|
122
|
+
constructor(portIndex: number, name: string, output: NodeMidiOutput) {
|
|
123
|
+
this.portIndex = portIndex;
|
|
124
|
+
this.id = `node-midi-out-${portIndex}`;
|
|
125
|
+
this.name = name;
|
|
126
|
+
this.output = output;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
get state(): "connected" | "disconnected" {
|
|
130
|
+
return this._state;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
get type() {
|
|
134
|
+
return "output" as const;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private ensureOpen(): void {
|
|
138
|
+
if (!this.isOpen) {
|
|
139
|
+
try {
|
|
140
|
+
this.output.openPort(this.portIndex);
|
|
141
|
+
this.isOpen = true;
|
|
142
|
+
this._state = "connected";
|
|
143
|
+
} catch (err) {
|
|
144
|
+
console.error(`Error opening MIDI output port ${this.portIndex}:`, err);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
send(data: number[] | Uint8Array, _timestamp?: number): void {
|
|
150
|
+
this.ensureOpen();
|
|
151
|
+
try {
|
|
152
|
+
const message = Array.isArray(data) ? data : Array.from(data);
|
|
153
|
+
this.output.sendMessage(message);
|
|
154
|
+
} catch (err) {
|
|
155
|
+
console.error(`Error sending MIDI message:`, err);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
102
160
|
class NodeMidiAccess implements IMidiAccess {
|
|
103
|
-
private
|
|
161
|
+
private inputPorts = new Map<string, NodeMidiInputPort>();
|
|
162
|
+
private outputPorts = new Map<string, NodeMidiOutputPort>();
|
|
104
163
|
private MidiModule: NodeMidiModule;
|
|
105
164
|
|
|
106
165
|
constructor(MidiModule: NodeMidiModule) {
|
|
@@ -109,43 +168,70 @@ class NodeMidiAccess implements IMidiAccess {
|
|
|
109
168
|
}
|
|
110
169
|
|
|
111
170
|
private scanPorts(): void {
|
|
171
|
+
// Scan input ports
|
|
112
172
|
try {
|
|
113
173
|
const input = new this.MidiModule.Input();
|
|
114
|
-
const
|
|
174
|
+
const inputCount = input.getPortCount();
|
|
115
175
|
|
|
116
|
-
for (let i = 0; i <
|
|
176
|
+
for (let i = 0; i < inputCount; i++) {
|
|
117
177
|
const portName = input.getPortName(i);
|
|
118
178
|
const id = `node-midi-${i}`;
|
|
119
179
|
|
|
120
|
-
if (!this.
|
|
121
|
-
// Create a new input instance for each port
|
|
180
|
+
if (!this.inputPorts.has(id)) {
|
|
122
181
|
const portInput = new this.MidiModule.Input();
|
|
123
182
|
const port = new NodeMidiInputPort(i, portName, portInput);
|
|
124
|
-
this.
|
|
183
|
+
this.inputPorts.set(id, port);
|
|
125
184
|
}
|
|
126
185
|
}
|
|
127
186
|
|
|
128
|
-
// Clean up the scanning input
|
|
129
187
|
if (input.isPortOpen()) {
|
|
130
188
|
input.closePort();
|
|
131
189
|
}
|
|
132
190
|
} catch (err) {
|
|
133
|
-
console.error("Error scanning MIDI ports:", err);
|
|
191
|
+
console.error("Error scanning MIDI input ports:", err);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Scan output ports
|
|
195
|
+
try {
|
|
196
|
+
const output = new this.MidiModule.Output();
|
|
197
|
+
const outputCount = output.getPortCount();
|
|
198
|
+
|
|
199
|
+
for (let i = 0; i < outputCount; i++) {
|
|
200
|
+
const portName = output.getPortName(i);
|
|
201
|
+
const id = `node-midi-out-${i}`;
|
|
202
|
+
|
|
203
|
+
if (!this.outputPorts.has(id)) {
|
|
204
|
+
const portOutput = new this.MidiModule.Output();
|
|
205
|
+
const port = new NodeMidiOutputPort(i, portName, portOutput);
|
|
206
|
+
this.outputPorts.set(id, port);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (output.isPortOpen()) {
|
|
211
|
+
output.closePort();
|
|
212
|
+
}
|
|
213
|
+
} catch (err) {
|
|
214
|
+
console.error("Error scanning MIDI output ports:", err);
|
|
134
215
|
}
|
|
135
216
|
}
|
|
136
217
|
|
|
137
218
|
*inputs(): IterableIterator<IMidiInputPort> {
|
|
138
|
-
for (const [, port] of this.
|
|
219
|
+
for (const [, port] of this.inputPorts) {
|
|
220
|
+
yield port;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
*outputs(): IterableIterator<IMidiOutputPort> {
|
|
225
|
+
for (const [, port] of this.outputPorts) {
|
|
139
226
|
yield port;
|
|
140
227
|
}
|
|
141
228
|
}
|
|
142
229
|
|
|
143
230
|
addEventListener(
|
|
144
231
|
_event: "statechange",
|
|
145
|
-
_callback: (port:
|
|
232
|
+
_callback: (port: IMidiPort) => void,
|
|
146
233
|
): void {
|
|
147
234
|
// node-midi doesn't support hot-plugging detection
|
|
148
|
-
// This could be implemented with polling if needed
|
|
149
235
|
console.warn(
|
|
150
236
|
"Hot-plug detection not supported with node-midi adapter. Restart required for new devices.",
|
|
151
237
|
);
|
|
@@ -5,6 +5,8 @@ import type {
|
|
|
5
5
|
IMidiAccess,
|
|
6
6
|
IMidiAdapter,
|
|
7
7
|
IMidiInputPort,
|
|
8
|
+
IMidiOutputPort,
|
|
9
|
+
IMidiPort,
|
|
8
10
|
MidiMessageCallback,
|
|
9
11
|
} from "./types";
|
|
10
12
|
|
|
@@ -25,6 +27,10 @@ class WebMidiInputPort implements IMidiInputPort {
|
|
|
25
27
|
return this.input.name ?? `Device ${this.input.id}`;
|
|
26
28
|
}
|
|
27
29
|
|
|
30
|
+
get type() {
|
|
31
|
+
return this.input.type;
|
|
32
|
+
}
|
|
33
|
+
|
|
28
34
|
get state(): "connected" | "disconnected" {
|
|
29
35
|
return this.input.state as "connected" | "disconnected";
|
|
30
36
|
}
|
|
@@ -58,9 +64,38 @@ class WebMidiInputPort implements IMidiInputPort {
|
|
|
58
64
|
}
|
|
59
65
|
}
|
|
60
66
|
|
|
67
|
+
class WebMidiOutputPort implements IMidiOutputPort {
|
|
68
|
+
private output: MIDIOutput;
|
|
69
|
+
|
|
70
|
+
constructor(output: MIDIOutput) {
|
|
71
|
+
this.output = output;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
get id(): string {
|
|
75
|
+
return this.output.id;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
get name(): string {
|
|
79
|
+
return this.output.name ?? `Device ${this.output.id}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
get type() {
|
|
83
|
+
return this.output.type;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
get state(): "connected" | "disconnected" {
|
|
87
|
+
return this.output.state as "connected" | "disconnected";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
send(data: number[] | Uint8Array, timestamp?: number): void {
|
|
91
|
+
this.output.send(data, timestamp);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
61
95
|
class WebMidiAccess implements IMidiAccess {
|
|
62
96
|
private midiAccess: MIDIAccess;
|
|
63
|
-
private
|
|
97
|
+
private inputCache = new Map<string, WebMidiInputPort>();
|
|
98
|
+
private outputCache = new Map<string, WebMidiOutputPort>();
|
|
64
99
|
|
|
65
100
|
constructor(midiAccess: MIDIAccess) {
|
|
66
101
|
this.midiAccess = midiAccess;
|
|
@@ -68,27 +103,50 @@ class WebMidiAccess implements IMidiAccess {
|
|
|
68
103
|
|
|
69
104
|
*inputs(): IterableIterator<IMidiInputPort> {
|
|
70
105
|
for (const [, input] of this.midiAccess.inputs) {
|
|
71
|
-
if (!this.
|
|
72
|
-
this.
|
|
106
|
+
if (!this.inputCache.has(input.id)) {
|
|
107
|
+
this.inputCache.set(input.id, new WebMidiInputPort(input));
|
|
73
108
|
}
|
|
74
|
-
yield this.
|
|
109
|
+
yield this.inputCache.get(input.id)!;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
*outputs(): IterableIterator<IMidiOutputPort> {
|
|
114
|
+
for (const [, output] of this.midiAccess.outputs) {
|
|
115
|
+
if (!this.outputCache.has(output.id)) {
|
|
116
|
+
this.outputCache.set(output.id, new WebMidiOutputPort(output));
|
|
117
|
+
}
|
|
118
|
+
yield this.outputCache.get(output.id)!;
|
|
75
119
|
}
|
|
76
120
|
}
|
|
77
121
|
|
|
78
122
|
addEventListener(
|
|
79
123
|
event: "statechange",
|
|
80
|
-
callback: (port:
|
|
124
|
+
callback: (port: IMidiPort) => void,
|
|
81
125
|
): void {
|
|
82
126
|
this.midiAccess.addEventListener(event, (e) => {
|
|
83
127
|
const port = e.port;
|
|
84
|
-
if (port
|
|
128
|
+
if (!port) return;
|
|
129
|
+
|
|
130
|
+
const midiPort: IMidiPort = {
|
|
131
|
+
id: port.id,
|
|
132
|
+
name: port.name ?? `Device ${port.id}`,
|
|
133
|
+
state: port.state as "connected" | "disconnected",
|
|
134
|
+
type: port.type as "input" | "output",
|
|
135
|
+
};
|
|
85
136
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
this.
|
|
137
|
+
if (port.type === "input") {
|
|
138
|
+
const input = port as MIDIInput;
|
|
139
|
+
if (!this.inputCache.has(input.id)) {
|
|
140
|
+
this.inputCache.set(input.id, new WebMidiInputPort(input));
|
|
141
|
+
}
|
|
142
|
+
} else {
|
|
143
|
+
const output = port as MIDIOutput;
|
|
144
|
+
if (!this.outputCache.has(output.id)) {
|
|
145
|
+
this.outputCache.set(output.id, new WebMidiOutputPort(output));
|
|
146
|
+
}
|
|
89
147
|
}
|
|
90
148
|
|
|
91
|
-
callback(
|
|
149
|
+
callback(midiPort);
|
|
92
150
|
});
|
|
93
151
|
}
|
|
94
152
|
}
|
|
@@ -10,19 +10,28 @@ export interface IMidiMessageEvent {
|
|
|
10
10
|
|
|
11
11
|
export type MidiMessageCallback = (event: IMidiMessageEvent) => void;
|
|
12
12
|
|
|
13
|
-
export interface IMidiInputPort {
|
|
13
|
+
export interface IMidiInputPort extends IMidiPort {
|
|
14
|
+
addEventListener(callback: MidiMessageCallback): void;
|
|
15
|
+
removeEventListener(callback: MidiMessageCallback): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface IMidiOutputPort extends IMidiPort {
|
|
19
|
+
send(data: number[] | Uint8Array, timestamp?: number): void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface IMidiPort {
|
|
14
23
|
readonly id: string;
|
|
15
24
|
readonly name: string;
|
|
16
25
|
readonly state: "connected" | "disconnected";
|
|
17
|
-
|
|
18
|
-
removeEventListener(callback: MidiMessageCallback): void;
|
|
26
|
+
readonly type: "input" | "output";
|
|
19
27
|
}
|
|
20
28
|
|
|
21
29
|
export interface IMidiAccess {
|
|
22
30
|
inputs(): IterableIterator<IMidiInputPort>;
|
|
31
|
+
outputs(): IterableIterator<IMidiOutputPort>;
|
|
23
32
|
addEventListener(
|
|
24
33
|
event: "statechange",
|
|
25
|
-
callback: (port:
|
|
34
|
+
callback: (port: IMidiPort) => void,
|
|
26
35
|
): void;
|
|
27
36
|
}
|
|
28
37
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { IMidiOutputPort } from "../adapters";
|
|
2
|
+
|
|
3
|
+
export abstract class BaseController {
|
|
4
|
+
protected outputPort: IMidiOutputPort;
|
|
5
|
+
protected isInDawMode = false;
|
|
6
|
+
|
|
7
|
+
constructor(outputPort: IMidiOutputPort) {
|
|
8
|
+
this.outputPort = outputPort;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
abstract enterDawMode(): Promise<void>;
|
|
12
|
+
|
|
13
|
+
abstract exitDawMode(): Promise<void>;
|
|
14
|
+
}
|
|
@@ -7,7 +7,13 @@ import {
|
|
|
7
7
|
requestAnimationFrame,
|
|
8
8
|
} from "@blibliki/utils";
|
|
9
9
|
import { Engine } from "@/Engine";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
AnyModule,
|
|
12
|
+
ICreateModule,
|
|
13
|
+
ModuleType,
|
|
14
|
+
ModuleTypeToPropsMapping,
|
|
15
|
+
ModuleTypeToStateMapping,
|
|
16
|
+
} from "@/modules";
|
|
11
17
|
import {
|
|
12
18
|
AudioInputProps,
|
|
13
19
|
AudioOutputProps,
|
|
@@ -49,6 +55,16 @@ export type SetterHooks<P> = {
|
|
|
49
55
|
) => void;
|
|
50
56
|
};
|
|
51
57
|
|
|
58
|
+
export type StateSetterHooks<S> = {
|
|
59
|
+
[K in keyof S as `onSetState${Capitalize<string & K>}`]: (
|
|
60
|
+
value: S[K],
|
|
61
|
+
) => S[K];
|
|
62
|
+
} & {
|
|
63
|
+
[K in keyof S as `onAfterSetState${Capitalize<string & K>}`]: (
|
|
64
|
+
value: S[K],
|
|
65
|
+
) => void;
|
|
66
|
+
};
|
|
67
|
+
|
|
52
68
|
export abstract class Module<T extends ModuleType> implements IModule<T> {
|
|
53
69
|
id: string;
|
|
54
70
|
engineId: string;
|
|
@@ -59,9 +75,46 @@ export abstract class Module<T extends ModuleType> implements IModule<T> {
|
|
|
59
75
|
inputs: InputCollection;
|
|
60
76
|
outputs: OutputCollection;
|
|
61
77
|
protected _props!: ModuleTypeToPropsMapping[T];
|
|
78
|
+
protected _state!: ModuleTypeToStateMapping[T];
|
|
62
79
|
protected activeNotes: Note[];
|
|
80
|
+
protected _propsInitialized = false;
|
|
63
81
|
private pendingUIUpdates = false;
|
|
64
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Factory method for creating modules with proper initialization timing.
|
|
85
|
+
*
|
|
86
|
+
* This method ensures hooks are called AFTER the child class constructor completes,
|
|
87
|
+
* solving the ES6 class field initialization problem where function properties like hooks
|
|
88
|
+
* aren't available during super() call.
|
|
89
|
+
*
|
|
90
|
+
* @example
|
|
91
|
+
* const gain = Module.create(MonoGain, engineId, {
|
|
92
|
+
* name: "gain",
|
|
93
|
+
* moduleType: ModuleType.Gain,
|
|
94
|
+
* props: { gain: 0.5 }
|
|
95
|
+
* });
|
|
96
|
+
*/
|
|
97
|
+
static create<T extends ModuleType, M extends Module<T>>(
|
|
98
|
+
ModuleClass: new (engineId: string, params: ICreateModule<T>) => M,
|
|
99
|
+
engineId: string,
|
|
100
|
+
params: Omit<IModuleConstructor<T>, "props"> & {
|
|
101
|
+
props: Partial<IModule<T>["props"]>;
|
|
102
|
+
},
|
|
103
|
+
): M {
|
|
104
|
+
// Create instance with deferred prop initialization
|
|
105
|
+
const instance = new ModuleClass(engineId, {
|
|
106
|
+
...params,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Now trigger prop setters after child constructor has completed
|
|
110
|
+
// At this point, all child class properties (including arrow functions) exist
|
|
111
|
+
// TODO: We have to refactor all modules the remove the props assignment from constructor
|
|
112
|
+
instance.props = { ...instance.props };
|
|
113
|
+
instance._propsInitialized = true;
|
|
114
|
+
|
|
115
|
+
return instance;
|
|
116
|
+
}
|
|
117
|
+
|
|
65
118
|
constructor(engineId: string, params: IModuleConstructor<T>) {
|
|
66
119
|
const { id, name, moduleType, voiceNo, audioNodeConstructor, props } =
|
|
67
120
|
params;
|
|
@@ -77,11 +130,6 @@ export abstract class Module<T extends ModuleType> implements IModule<T> {
|
|
|
77
130
|
|
|
78
131
|
this.inputs = new InputCollection(this);
|
|
79
132
|
this.outputs = new OutputCollection(this);
|
|
80
|
-
|
|
81
|
-
// Defer hook calls until after subclass is fully initialized
|
|
82
|
-
queueMicrotask(() => {
|
|
83
|
-
this.props = props;
|
|
84
|
-
});
|
|
85
133
|
}
|
|
86
134
|
|
|
87
135
|
get props(): ModuleTypeToPropsMapping[T] {
|
|
@@ -89,20 +137,25 @@ export abstract class Module<T extends ModuleType> implements IModule<T> {
|
|
|
89
137
|
}
|
|
90
138
|
|
|
91
139
|
set props(value: Partial<ModuleTypeToPropsMapping[T]>) {
|
|
92
|
-
const updatedValue = {
|
|
140
|
+
const updatedValue: Partial<ModuleTypeToPropsMapping[T]> = {};
|
|
141
|
+
const isFirstSet = !this._propsInitialized;
|
|
93
142
|
|
|
94
143
|
(Object.keys(value) as (keyof ModuleTypeToPropsMapping[T])[]).forEach(
|
|
95
144
|
(key) => {
|
|
96
145
|
const propValue = value[key];
|
|
97
|
-
|
|
146
|
+
// On first set, always include the value. On subsequent sets, only if it changed.
|
|
147
|
+
if (
|
|
148
|
+
propValue !== undefined &&
|
|
149
|
+
(isFirstSet || this._props[key] !== propValue)
|
|
150
|
+
) {
|
|
98
151
|
const result = this.callPropHook("onSet", key, propValue);
|
|
99
|
-
|
|
100
|
-
updatedValue[key] = result;
|
|
101
|
-
}
|
|
152
|
+
updatedValue[key] = result ?? propValue;
|
|
102
153
|
}
|
|
103
154
|
},
|
|
104
155
|
);
|
|
105
156
|
|
|
157
|
+
if (Object.keys(updatedValue).length === 0) return;
|
|
158
|
+
|
|
106
159
|
this._props = { ...this._props, ...updatedValue };
|
|
107
160
|
|
|
108
161
|
(
|
|
@@ -134,6 +187,56 @@ export abstract class Module<T extends ModuleType> implements IModule<T> {
|
|
|
134
187
|
return undefined;
|
|
135
188
|
}
|
|
136
189
|
|
|
190
|
+
get state(): ModuleTypeToStateMapping[T] {
|
|
191
|
+
return this._state;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
set state(value: Partial<ModuleTypeToStateMapping[T]>) {
|
|
195
|
+
const updatedValue: Partial<ModuleTypeToStateMapping[T]> = {};
|
|
196
|
+
|
|
197
|
+
(Object.keys(value) as (keyof ModuleTypeToStateMapping[T])[]).forEach(
|
|
198
|
+
(key) => {
|
|
199
|
+
const stateValue = value[key];
|
|
200
|
+
if (stateValue !== undefined && this._state[key] !== stateValue) {
|
|
201
|
+
const result = this.callStateHook("onSetState", key, stateValue);
|
|
202
|
+
updatedValue[key] = result ?? stateValue;
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
if (Object.keys(updatedValue).length === 0) return;
|
|
208
|
+
|
|
209
|
+
this._state = { ...this._state, ...updatedValue };
|
|
210
|
+
|
|
211
|
+
(
|
|
212
|
+
Object.keys(updatedValue) as (keyof ModuleTypeToStateMapping[T])[]
|
|
213
|
+
).forEach((key) => {
|
|
214
|
+
const stateValue = updatedValue[key];
|
|
215
|
+
if (stateValue !== undefined) {
|
|
216
|
+
this.callStateHook("onAfterSetState", key, stateValue);
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private callStateHook<K extends keyof ModuleTypeToStateMapping[T]>(
|
|
222
|
+
hookType: "onSetState" | "onAfterSetState",
|
|
223
|
+
key: K,
|
|
224
|
+
value: ModuleTypeToStateMapping[T][K],
|
|
225
|
+
): ModuleTypeToStateMapping[T][K] | undefined {
|
|
226
|
+
const hookName = `${hookType}${upperFirst(key as string)}`;
|
|
227
|
+
const hook = this[hookName as keyof this];
|
|
228
|
+
|
|
229
|
+
if (typeof hook === "function") {
|
|
230
|
+
const result = (
|
|
231
|
+
hook as (
|
|
232
|
+
value: ModuleTypeToStateMapping[T][K],
|
|
233
|
+
) => ModuleTypeToStateMapping[T][K] | undefined
|
|
234
|
+
).call(this, value);
|
|
235
|
+
return result;
|
|
236
|
+
}
|
|
237
|
+
return undefined;
|
|
238
|
+
}
|
|
239
|
+
|
|
137
240
|
serialize(): IModuleSerialize<T> {
|
|
138
241
|
return {
|
|
139
242
|
id: this.id,
|
|
@@ -223,13 +326,18 @@ export abstract class Module<T extends ModuleType> implements IModule<T> {
|
|
|
223
326
|
|
|
224
327
|
private sheduleTriggerUpdate() {
|
|
225
328
|
requestAnimationFrame(() => {
|
|
226
|
-
|
|
329
|
+
const updateParams: IModule<T> & {
|
|
330
|
+
state?: ModuleTypeToStateMapping[T];
|
|
331
|
+
} = {
|
|
227
332
|
id: this.id,
|
|
228
333
|
moduleType: this.moduleType,
|
|
229
334
|
voiceNo: this.voiceNo,
|
|
230
335
|
name: this.name,
|
|
231
336
|
props: this.props,
|
|
232
|
-
|
|
337
|
+
state: this._state,
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
this.engine._triggerPropsUpdate(updateParams);
|
|
233
341
|
this.pendingUIUpdates = false;
|
|
234
342
|
});
|
|
235
343
|
}
|
|
@@ -53,6 +53,35 @@ export abstract class PolyModule<
|
|
|
53
53
|
private _name!: string;
|
|
54
54
|
private pendingUIUpdates = false;
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Factory method for creating modules with proper initialization timing.
|
|
58
|
+
*
|
|
59
|
+
* This method ensures hooks are called AFTER the child class constructor completes,
|
|
60
|
+
* solving the ES6 class field initialization problem where function properties like hooks
|
|
61
|
+
* aren't available during super() call.
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* const gain = Module.create(Gain, engineId, {
|
|
65
|
+
* name: "gain",
|
|
66
|
+
* moduleType: ModuleType.Gain,
|
|
67
|
+
* props: { gain: 0.5 }
|
|
68
|
+
* });
|
|
69
|
+
*/
|
|
70
|
+
static create<T extends ModuleType, M extends PolyModule<T>>(
|
|
71
|
+
ModuleClass: new (engineId: string, params: IPolyModuleConstructor<T>) => M,
|
|
72
|
+
engineId: string,
|
|
73
|
+
params: IPolyModuleConstructor<T>,
|
|
74
|
+
): M {
|
|
75
|
+
// Create instance with deferred prop initialization
|
|
76
|
+
const instance = new ModuleClass(engineId, {
|
|
77
|
+
...params,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
instance.props = { ...instance.props };
|
|
81
|
+
|
|
82
|
+
return instance;
|
|
83
|
+
}
|
|
84
|
+
|
|
56
85
|
constructor(engineId: string, params: IPolyModuleConstructor<T>) {
|
|
57
86
|
const { id, name, moduleType, voices, monoModuleConstructor, props } =
|
|
58
87
|
params;
|
|
@@ -167,6 +196,13 @@ export abstract class PolyModule<
|
|
|
167
196
|
}
|
|
168
197
|
|
|
169
198
|
onMidiEvent = (midiEvent: MidiEvent) => {
|
|
199
|
+
if (midiEvent.cc) {
|
|
200
|
+
this.audioModules.forEach((m) => {
|
|
201
|
+
m.onMidiEvent(midiEvent);
|
|
202
|
+
});
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
170
206
|
const voiceNo = midiEvent.voiceNo ?? 0;
|
|
171
207
|
const audioModule = this.findVoice(voiceNo);
|
|
172
208
|
audioModule.onMidiEvent(midiEvent);
|