@blibliki/engine 0.5.2 → 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.
Files changed (47) hide show
  1. package/README.md +22 -2
  2. package/dist/index.d.ts +501 -107
  3. package/dist/index.js +1 -1
  4. package/dist/index.js.map +1 -1
  5. package/package.json +7 -7
  6. package/src/Engine.ts +46 -29
  7. package/src/core/index.ts +11 -2
  8. package/src/core/midi/BaseMidiDevice.ts +47 -0
  9. package/src/core/midi/ComputerKeyboardDevice.ts +2 -1
  10. package/src/core/midi/MidiDeviceManager.ts +125 -31
  11. package/src/core/midi/{MidiDevice.ts → MidiInputDevice.ts} +6 -30
  12. package/src/core/midi/MidiOutputDevice.ts +23 -0
  13. package/src/core/midi/adapters/NodeMidiAdapter.ts +99 -13
  14. package/src/core/midi/adapters/WebMidiAdapter.ts +68 -10
  15. package/src/core/midi/adapters/types.ts +13 -4
  16. package/src/core/midi/controllers/BaseController.ts +14 -0
  17. package/src/core/module/Module.ts +121 -13
  18. package/src/core/module/PolyModule.ts +36 -0
  19. package/src/core/module/VoiceScheduler.ts +150 -10
  20. package/src/core/module/index.ts +9 -4
  21. package/src/index.ts +27 -3
  22. package/src/modules/Chorus.ts +222 -0
  23. package/src/modules/Constant.ts +2 -2
  24. package/src/modules/Delay.ts +347 -0
  25. package/src/modules/Distortion.ts +182 -0
  26. package/src/modules/Envelope.ts +158 -92
  27. package/src/modules/Filter.ts +7 -7
  28. package/src/modules/Gain.ts +2 -2
  29. package/src/modules/LFO.ts +287 -0
  30. package/src/modules/LegacyEnvelope.ts +146 -0
  31. package/src/modules/{MidiSelector.ts → MidiInput.ts} +26 -19
  32. package/src/modules/MidiMapper.ts +59 -4
  33. package/src/modules/MidiOutput.ts +121 -0
  34. package/src/modules/Noise.ts +259 -0
  35. package/src/modules/Oscillator.ts +9 -3
  36. package/src/modules/Reverb.ts +379 -0
  37. package/src/modules/Scale.ts +49 -4
  38. package/src/modules/StepSequencer.ts +410 -22
  39. package/src/modules/StereoPanner.ts +1 -1
  40. package/src/modules/index.ts +142 -29
  41. package/src/processors/custom-envelope-processor.ts +125 -0
  42. package/src/processors/index.ts +10 -0
  43. package/src/processors/lfo-processor.ts +123 -0
  44. package/src/processors/scale-processor.ts +42 -5
  45. package/src/utils/WetDryMixer.ts +123 -0
  46. package/src/utils/expandPatternSequence.ts +18 -0
  47. package/src/utils/index.ts +2 -0
@@ -0,0 +1,347 @@
1
+ import { Division, divisionToMilliseconds } from "@blibliki/transport";
2
+ import { Context } from "@blibliki/utils";
3
+ import { EnumProp, ModulePropSchema } from "@/core";
4
+ import { Module, SetterHooks } from "@/core/module/Module";
5
+ import { WetDryMixer } from "@/utils";
6
+ import { ICreateModule, ModuleType } from ".";
7
+
8
+ export enum DelayTimeMode {
9
+ short = "short",
10
+ long = "long",
11
+ }
12
+
13
+ export type IDelayProps = {
14
+ time: number; // 0-2000 ms (short) or 0-5000 ms (long)
15
+ timeMode: DelayTimeMode; // short (2s) or long (5s)
16
+ sync: boolean; // Enable BPM sync
17
+ division: Division; // Note division when sync is enabled
18
+ feedback: number; // 0-0.95 (feedback amount)
19
+ mix: number; // 0-1 (dry/wet)
20
+ stereo: boolean; // Enable ping-pong stereo mode
21
+ };
22
+
23
+ export type IDelay = Module<ModuleType.Delay>;
24
+
25
+ const NOTE_DIVISIONS: Division[] = [
26
+ "1/64",
27
+ "1/48",
28
+ "1/32",
29
+ "1/24",
30
+ "1/16",
31
+ "1/12",
32
+ "1/8",
33
+ "1/6",
34
+ "3/16",
35
+ "1/4",
36
+ "5/16",
37
+ "1/3",
38
+ "3/8",
39
+ "1/2",
40
+ "3/4",
41
+ "1",
42
+ "1.5",
43
+ "2",
44
+ "3",
45
+ "4",
46
+ "6",
47
+ "8",
48
+ "16",
49
+ "32",
50
+ ];
51
+
52
+ export const delayPropSchema: ModulePropSchema<
53
+ IDelayProps,
54
+ {
55
+ timeMode: EnumProp<DelayTimeMode>;
56
+ division: EnumProp<Division>;
57
+ }
58
+ > = {
59
+ time: {
60
+ kind: "number",
61
+ min: 0,
62
+ max: 5000, // UI max for manual mode (long = 5s)
63
+ step: 1,
64
+ label: "Delay Time",
65
+ },
66
+ timeMode: {
67
+ kind: "enum",
68
+ options: [DelayTimeMode.short, DelayTimeMode.long],
69
+ label: "Time Mode",
70
+ },
71
+ sync: {
72
+ kind: "boolean",
73
+ label: "Sync",
74
+ },
75
+ division: {
76
+ kind: "enum",
77
+ options: NOTE_DIVISIONS,
78
+ label: "Division",
79
+ },
80
+ feedback: {
81
+ kind: "number",
82
+ min: 0,
83
+ max: 0.95,
84
+ step: 0.01,
85
+ label: "Feedback",
86
+ },
87
+ mix: {
88
+ kind: "number",
89
+ min: 0,
90
+ max: 1,
91
+ step: 0.01,
92
+ label: "Mix",
93
+ },
94
+ stereo: {
95
+ kind: "boolean",
96
+ label: "Stereo",
97
+ },
98
+ };
99
+
100
+ const DEFAULT_DELAY_PROPS: IDelayProps = {
101
+ time: 250,
102
+ timeMode: DelayTimeMode.short,
103
+ sync: false,
104
+ division: "1/4",
105
+ feedback: 0.3,
106
+ mix: 0.3,
107
+ stereo: false,
108
+ };
109
+
110
+ // ============================================================================
111
+ // Module Class
112
+ // ============================================================================
113
+
114
+ export default class Delay
115
+ extends Module<ModuleType.Delay>
116
+ implements
117
+ Pick<
118
+ SetterHooks<IDelayProps>,
119
+ | "onAfterSetTime"
120
+ | "onAfterSetTimeMode"
121
+ | "onAfterSetSync"
122
+ | "onAfterSetDivision"
123
+ | "onAfterSetFeedback"
124
+ | "onAfterSetMix"
125
+ | "onAfterSetStereo"
126
+ >
127
+ {
128
+ // Audio graph nodes
129
+ declare audioNode: GainNode; // Input node
130
+ private outputNode!: GainNode; // Final output node
131
+ private wetDryMixer: WetDryMixer;
132
+
133
+ // Mono delay nodes
134
+ private delayNode: DelayNode;
135
+ private feedbackGain: GainNode;
136
+
137
+ // Stereo ping-pong nodes (created when stereo=true)
138
+ private delayLeft: DelayNode | null = null;
139
+ private delayRight: DelayNode | null = null;
140
+ private feedbackLeft: GainNode | null = null;
141
+ private feedbackRight: GainNode | null = null;
142
+ private merger: ChannelMergerNode | null = null;
143
+
144
+ constructor(engineId: string, params: ICreateModule<ModuleType.Delay>) {
145
+ const props = { ...DEFAULT_DELAY_PROPS, ...params.props };
146
+
147
+ // Input node
148
+ const audioNodeConstructor = (context: Context) =>
149
+ context.audioContext.createGain();
150
+
151
+ super(engineId, {
152
+ ...params,
153
+ props,
154
+ audioNodeConstructor,
155
+ });
156
+
157
+ // Set input gain
158
+ this.audioNode.gain.value = 1;
159
+
160
+ // Create WetDryMixer
161
+ this.wetDryMixer = new WetDryMixer(this.context);
162
+
163
+ // Create mono delay graph (default)
164
+ // Max 179s (Web Audio API limit: must be < 180s / 3 minutes)
165
+ this.delayNode = this.context.audioContext.createDelay(179);
166
+ this.feedbackGain = this.context.audioContext.createGain();
167
+
168
+ // Connect mono graph initially
169
+ this.connectMonoGraph();
170
+
171
+ // Apply initial parameters
172
+ this.wetDryMixer.setMix(props.mix);
173
+ this.feedbackGain.gain.value = props.feedback;
174
+ this.updateDelayTime();
175
+
176
+ // Switch to stereo if needed
177
+ if (props.stereo) {
178
+ this.switchToStereo();
179
+ }
180
+
181
+ // Setup BPM listener for sync mode
182
+ this.setupBPMListener();
183
+
184
+ this.registerDefaultIOs("in");
185
+ this.registerCustomOutput();
186
+ }
187
+
188
+ private setupBPMListener() {
189
+ this.engine.transport.addPropertyChangeCallback("bpm", () => {
190
+ if (!this.props.sync) return;
191
+
192
+ this.updateDelayTime();
193
+ });
194
+ }
195
+
196
+ private updateDelayTime() {
197
+ let timeInSeconds: number;
198
+
199
+ if (this.props.sync) {
200
+ // BPM-based timing
201
+ const bpm = this.engine.transport.bpm;
202
+ const timeMs = divisionToMilliseconds(this.props.division, bpm);
203
+ timeInSeconds = timeMs / 1000;
204
+ } else {
205
+ // Manual timing
206
+ timeInSeconds = this.props.time / 1000;
207
+ }
208
+
209
+ this.delayNode.delayTime.value = timeInSeconds;
210
+ if (this.delayLeft) this.delayLeft.delayTime.value = timeInSeconds;
211
+ if (this.delayRight) this.delayRight.delayTime.value = timeInSeconds;
212
+ }
213
+
214
+ private connectMonoGraph() {
215
+ // Disconnect any existing connections
216
+ this.disconnectAll();
217
+
218
+ // Connect: Input -> WetDryMixer (dry)
219
+ this.wetDryMixer.connectInput(this.audioNode);
220
+
221
+ // Connect: Input -> Delay -> Feedback -> Delay (loop)
222
+ this.audioNode.connect(this.delayNode);
223
+ this.delayNode.connect(this.feedbackGain);
224
+ this.feedbackGain.connect(this.delayNode); // Feedback loop
225
+
226
+ // Connect: Delay -> WetDryMixer (wet)
227
+ this.delayNode.connect(this.wetDryMixer.getWetInput());
228
+
229
+ // Output
230
+ this.outputNode = this.wetDryMixer.getOutput();
231
+ }
232
+
233
+ private switchToStereo() {
234
+ if (!this.delayLeft) {
235
+ // Create stereo nodes (max 179s - Web Audio API limit: < 180s)
236
+ this.delayLeft = this.context.audioContext.createDelay(179);
237
+ this.delayRight = this.context.audioContext.createDelay(179);
238
+ this.feedbackLeft = this.context.audioContext.createGain();
239
+ this.feedbackRight = this.context.audioContext.createGain();
240
+ this.merger = this.context.audioContext.createChannelMerger(2);
241
+ }
242
+
243
+ // Disconnect mono graph
244
+ this.disconnectAll();
245
+
246
+ // Connect stereo ping-pong graph
247
+ this.wetDryMixer.connectInput(this.audioNode);
248
+
249
+ // Input -> DelayLeft
250
+ this.audioNode.connect(this.delayLeft);
251
+
252
+ // DelayLeft -> Output to left channel
253
+ this.delayLeft.connect(this.merger!, 0, 0); // Left to channel 0
254
+
255
+ // DelayLeft -> FeedbackRight -> DelayRight (ping-pong to right)
256
+ this.delayLeft.connect(this.feedbackRight!);
257
+ this.feedbackRight!.connect(this.delayRight!);
258
+
259
+ // DelayRight -> Output to right channel
260
+ this.delayRight!.connect(this.merger!, 0, 1); // Right to channel 1
261
+
262
+ // DelayRight -> FeedbackLeft -> DelayLeft (ping-pong back to left)
263
+ this.delayRight!.connect(this.feedbackLeft!);
264
+ this.feedbackLeft!.connect(this.delayLeft);
265
+
266
+ // Merger -> WetDryMixer (wet)
267
+ this.merger!.connect(this.wetDryMixer.getWetInput());
268
+
269
+ this.outputNode = this.wetDryMixer.getOutput();
270
+
271
+ // Sync delay times and feedback gains
272
+ this.delayLeft.delayTime.value = this.delayNode.delayTime.value;
273
+ this.delayRight!.delayTime.value = this.delayNode.delayTime.value;
274
+ this.feedbackLeft!.gain.value = this.feedbackGain.gain.value;
275
+ this.feedbackRight!.gain.value = this.feedbackGain.gain.value;
276
+ }
277
+
278
+ private disconnectAll() {
279
+ try {
280
+ this.audioNode.disconnect();
281
+ this.delayNode.disconnect();
282
+ this.feedbackGain.disconnect();
283
+ if (this.delayLeft) this.delayLeft.disconnect();
284
+ if (this.delayRight) this.delayRight.disconnect();
285
+ if (this.feedbackLeft) this.feedbackLeft.disconnect();
286
+ if (this.feedbackRight) this.feedbackRight.disconnect();
287
+ if (this.merger) this.merger.disconnect();
288
+ } catch {
289
+ // Ignore disconnect errors
290
+ }
291
+ }
292
+
293
+ private registerCustomOutput() {
294
+ this.registerAudioOutput({
295
+ name: "out",
296
+ getAudioNode: () => this.outputNode,
297
+ });
298
+ }
299
+
300
+ // ============================================================================
301
+ // SetterHooks
302
+ // ============================================================================
303
+
304
+ onAfterSetTime = (_value: number) => {
305
+ if (this.props.sync) return;
306
+
307
+ this.updateDelayTime();
308
+ };
309
+
310
+ onAfterSetTimeMode = (value: DelayTimeMode) => {
311
+ // Clamp time if it exceeds the new mode's maximum
312
+ const maxTime = value === DelayTimeMode.short ? 2000 : 5000;
313
+ if (this.props.time > maxTime) {
314
+ this.props = { time: maxTime };
315
+ }
316
+ };
317
+
318
+ onAfterSetSync = (_value: boolean) => {
319
+ this.updateDelayTime();
320
+ };
321
+
322
+ onAfterSetDivision = (_value: Division) => {
323
+ if (this.props.sync) {
324
+ this.updateDelayTime();
325
+ }
326
+ };
327
+
328
+ onAfterSetFeedback = (value: number) => {
329
+ // Cap at 0.95 to prevent runaway feedback
330
+ const cappedValue = Math.min(value, 0.95);
331
+ this.feedbackGain.gain.value = cappedValue;
332
+ if (this.feedbackLeft) this.feedbackLeft.gain.value = cappedValue;
333
+ if (this.feedbackRight) this.feedbackRight.gain.value = cappedValue;
334
+ };
335
+
336
+ onAfterSetMix = (value: number) => {
337
+ this.wetDryMixer.setMix(value);
338
+ };
339
+
340
+ onAfterSetStereo = (value: boolean) => {
341
+ if (value) {
342
+ this.switchToStereo();
343
+ } else {
344
+ this.connectMonoGraph();
345
+ }
346
+ };
347
+ }
@@ -0,0 +1,182 @@
1
+ import { Context } from "@blibliki/utils";
2
+ import { GainNode } from "@blibliki/utils/web-audio-api";
3
+ import { ModulePropSchema } from "@/core";
4
+ import { IModuleConstructor, Module, SetterHooks } from "@/core/module/Module";
5
+ import { IPolyModuleConstructor, PolyModule } from "@/core/module/PolyModule";
6
+ import { WetDryMixer } from "@/utils";
7
+ import { ICreateModule, ModuleType } from ".";
8
+
9
+ export type IDistortionProps = {
10
+ drive: number; // 0-10 (controls distortion amount)
11
+ tone: number; // 200-20000 Hz (lowpass filter cutoff)
12
+ mix: number; // 0-1 (dry/wet blend)
13
+ };
14
+
15
+ export type IDistortion = Module<ModuleType.Distortion>;
16
+
17
+ export const distortionPropSchema: ModulePropSchema<IDistortionProps> = {
18
+ drive: {
19
+ kind: "number",
20
+ min: 0,
21
+ max: 10,
22
+ step: 0.1,
23
+ label: "Drive",
24
+ },
25
+ tone: {
26
+ kind: "number",
27
+ min: 200,
28
+ max: 20000,
29
+ step: 1,
30
+ exp: 3,
31
+ label: "Tone",
32
+ },
33
+ mix: {
34
+ kind: "number",
35
+ min: 0,
36
+ max: 1,
37
+ step: 0.01,
38
+ label: "Mix",
39
+ },
40
+ };
41
+
42
+ const DEFAULT_PROPS: IDistortionProps = {
43
+ drive: 2.0, // Moderate distortion
44
+ tone: 8000, // Mid-bright tone
45
+ mix: 1.0, // 100% wet (full effect)
46
+ };
47
+
48
+ export class MonoDistortion
49
+ extends Module<ModuleType.Distortion>
50
+ implements
51
+ Pick<
52
+ SetterHooks<IDistortionProps>,
53
+ "onAfterSetDrive" | "onAfterSetTone" | "onAfterSetMix"
54
+ >
55
+ {
56
+ declare audioNode: GainNode; // Input node (inherited from Module)
57
+ private outputNode: GainNode; // Final output from mixer
58
+ private inputGain: GainNode; // Drive stage (pre-distortion gain)
59
+ private waveshaper: WaveShaperNode; // Tanh distortion
60
+ private filter: BiquadFilterNode; // Post-distortion lowpass filter
61
+ private wetDryMixer: WetDryMixer; // Wet/dry blending
62
+
63
+ constructor(engineId: string, params: ICreateModule<ModuleType.Distortion>) {
64
+ const props = { ...DEFAULT_PROPS, ...params.props };
65
+
66
+ const audioNodeConstructor = (context: Context) =>
67
+ new GainNode(context.audioContext, { gain: 1 });
68
+
69
+ super(engineId, {
70
+ ...params,
71
+ audioNodeConstructor,
72
+ props,
73
+ });
74
+
75
+ // Create audio nodes
76
+ const audioContext = this.context.audioContext;
77
+
78
+ this.inputGain = audioContext.createGain();
79
+ this.waveshaper = audioContext.createWaveShaper();
80
+ this.filter = audioContext.createBiquadFilter();
81
+ this.wetDryMixer = new WetDryMixer(this.context);
82
+
83
+ // Configure filter
84
+ this.filter.type = "lowpass";
85
+ this.filter.Q.value = 0.707; // Butterworth response (no resonance peak)
86
+
87
+ // Connect audio graph
88
+ // Dry path: Input -> WetDryMixer
89
+ this.wetDryMixer.connectInput(this.audioNode);
90
+
91
+ // Wet path: Input -> InputGain -> WaveShaper -> Filter -> WetDryMixer
92
+ this.audioNode.connect(this.inputGain);
93
+ this.inputGain.connect(this.waveshaper);
94
+ this.waveshaper.connect(this.filter);
95
+ this.filter.connect(this.wetDryMixer.getWetInput());
96
+
97
+ // Output from mixer
98
+ this.outputNode = this.wetDryMixer.getOutput();
99
+
100
+ // Apply initial parameters
101
+ this.updateInputGain(props.drive);
102
+ this.updateDistortionCurve(props.drive);
103
+ this.filter.frequency.value = props.tone;
104
+ this.wetDryMixer.setMix(props.mix);
105
+
106
+ // Register IOs
107
+ this.registerDefaultIOs("in");
108
+ this.registerCustomOutput();
109
+ }
110
+
111
+ private registerCustomOutput() {
112
+ this.registerAudioOutput({
113
+ name: "out",
114
+ getAudioNode: () => this.outputNode,
115
+ });
116
+ }
117
+
118
+ private generateDistortionCurve(drive: number): Float32Array | null {
119
+ const samples = 65536; // High resolution for smooth distortion
120
+ const buffer = new ArrayBuffer(samples * Float32Array.BYTES_PER_ELEMENT);
121
+ const curve: Float32Array = new Float32Array(buffer) as Float32Array;
122
+
123
+ for (let i = 0; i < samples; i++) {
124
+ // Map sample index to input range [-1, 1]
125
+ const x = (i * 2) / samples - 1;
126
+
127
+ // Apply tanh waveshaping with drive scaling
128
+ // Higher drive values push more of the signal into saturation
129
+ const driven = x * drive;
130
+ curve[i] = Math.tanh(driven);
131
+ }
132
+
133
+ return curve;
134
+ }
135
+
136
+ private updateDistortionCurve(drive: number): void {
137
+ // @ts-expect-error - TypeScript strict mode issue with Float32Array<ArrayBufferLike> vs Float32Array<ArrayBuffer>
138
+ this.waveshaper.curve = this.generateDistortionCurve(drive);
139
+ }
140
+
141
+ private updateInputGain(drive: number): void {
142
+ // Exponential scaling: each increment doubles the gain
143
+ // drive=0 -> 1x, drive=1 -> 2x, drive=2 -> 4x, ..., drive=10 -> 1024x
144
+ this.inputGain.gain.value = Math.pow(2, drive);
145
+ }
146
+
147
+ onAfterSetDrive: SetterHooks<IDistortionProps>["onAfterSetDrive"] = (
148
+ value,
149
+ ) => {
150
+ this.updateInputGain(value);
151
+ this.updateDistortionCurve(value);
152
+ };
153
+
154
+ onAfterSetTone: SetterHooks<IDistortionProps>["onAfterSetTone"] = (value) => {
155
+ this.filter.frequency.value = value;
156
+ };
157
+
158
+ onAfterSetMix: SetterHooks<IDistortionProps>["onAfterSetMix"] = (value) => {
159
+ this.wetDryMixer.setMix(value);
160
+ };
161
+ }
162
+
163
+ export default class PolyDistortion extends PolyModule<ModuleType.Distortion> {
164
+ constructor(
165
+ engineId: string,
166
+ params: IPolyModuleConstructor<ModuleType.Distortion>,
167
+ ) {
168
+ const props = { ...DEFAULT_PROPS, ...params.props };
169
+ const monoModuleConstructor = (
170
+ engineId: string,
171
+ params: IModuleConstructor<ModuleType.Distortion>,
172
+ ) => Module.create(MonoDistortion, engineId, params);
173
+
174
+ super(engineId, {
175
+ ...params,
176
+ props,
177
+ monoModuleConstructor,
178
+ });
179
+
180
+ this.registerDefaultIOs();
181
+ }
182
+ }