@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,146 @@
1
+ import { ContextTime } from "@blibliki/transport";
2
+ import { Context } from "@blibliki/utils";
3
+ import { GainNode } from "@blibliki/utils/web-audio-api";
4
+ import { Module } from "@/core";
5
+ import Note from "@/core/Note";
6
+ import { IModuleConstructor } from "@/core/module/Module";
7
+ import { IPolyModuleConstructor, PolyModule } from "@/core/module/PolyModule";
8
+ import { ModulePropSchema } from "@/core/schema";
9
+ import { ICreateModule, ModuleType } from ".";
10
+
11
+ export type IEnvelopeProps = {
12
+ attack: number;
13
+ decay: number;
14
+ sustain: number;
15
+ release: number;
16
+ };
17
+
18
+ const DEFAULT_PROPS: IEnvelopeProps = {
19
+ attack: 0.01,
20
+ decay: 0.1,
21
+ sustain: 0.7,
22
+ release: 0.3,
23
+ };
24
+
25
+ export const envelopePropSchema: ModulePropSchema<IEnvelopeProps> = {
26
+ attack: {
27
+ kind: "number",
28
+ min: 0.001,
29
+ max: 10,
30
+ step: 0.001,
31
+ exp: 3,
32
+ label: "Attack",
33
+ },
34
+ decay: {
35
+ kind: "number",
36
+ min: 0.001,
37
+ max: 10,
38
+ step: 0.001,
39
+ exp: 3,
40
+ label: "Decay",
41
+ },
42
+ sustain: {
43
+ kind: "number",
44
+ min: 0,
45
+ max: 1,
46
+ step: 0.01,
47
+ label: "Sustain",
48
+ },
49
+ release: {
50
+ kind: "number",
51
+ min: 0.001,
52
+ max: 10,
53
+ step: 0.001,
54
+ exp: 3,
55
+ label: "Release",
56
+ },
57
+ };
58
+
59
+ // Constants for safe audio parameter automation
60
+ const MIN_GAIN = 0.00001;
61
+
62
+ class MonoEnvelope extends Module<ModuleType.LegacyEnvelope> {
63
+ declare audioNode: GainNode;
64
+
65
+ constructor(
66
+ engineId: string,
67
+ params: ICreateModule<ModuleType.LegacyEnvelope>,
68
+ ) {
69
+ const props = { ...DEFAULT_PROPS, ...params.props };
70
+ const audioNodeConstructor = (context: Context) => {
71
+ const audioNode = new GainNode(context.audioContext);
72
+ audioNode.gain.value = 0;
73
+ return audioNode;
74
+ };
75
+
76
+ super(engineId, {
77
+ ...params,
78
+ props,
79
+ audioNodeConstructor,
80
+ });
81
+
82
+ this.registerDefaultIOs();
83
+ }
84
+
85
+ triggerAttack(note: Note, triggeredAt: ContextTime) {
86
+ super.triggerAttack(note, triggeredAt);
87
+
88
+ const { attack, decay, sustain } = this.props;
89
+ const gain = this.audioNode.gain;
90
+
91
+ // Exponential reset to avoid clicks when retriggering (production-style)
92
+ gain.cancelAndHoldAtTime(triggeredAt);
93
+ const resetTimeConstant = 0.002; // 2ms time constant for exponential decay
94
+ const resetDuration = resetTimeConstant * 5; // ~10ms total (5 time constants ≈ 99% complete)
95
+ gain.setTargetAtTime(MIN_GAIN, triggeredAt, resetTimeConstant);
96
+
97
+ // Attack phase: linear ramp to peak
98
+ const attackStartTime = triggeredAt + resetDuration;
99
+ const attackEndTime = attackStartTime + attack;
100
+ gain.setValueAtTime(MIN_GAIN, attackStartTime); // Ensure we start from MIN_GAIN
101
+ gain.linearRampToValueAtTime(1.0, attackEndTime);
102
+
103
+ // Decay phase: exponential ramp to sustain level
104
+ if (sustain < 1) {
105
+ const decayEndTime = attackEndTime + decay;
106
+ // exponentialRampToValueAtTime cannot reach 0, use MIN_GAIN instead
107
+ const sustainValue = sustain > 0 ? sustain : MIN_GAIN;
108
+ gain.exponentialRampToValueAtTime(sustainValue, decayEndTime);
109
+ }
110
+ }
111
+
112
+ triggerRelease(note: Note, triggeredAt: ContextTime) {
113
+ super.triggerRelease(note, triggeredAt);
114
+
115
+ // Only release if this is the last active note
116
+ if (this.activeNotes.length > 0) return;
117
+
118
+ const { release } = this.props;
119
+ const gain = this.audioNode.gain;
120
+
121
+ // Release phase: exponential fade to silence (analog-style)
122
+ gain.cancelAndHoldAtTime(triggeredAt);
123
+ gain.setTargetAtTime(MIN_GAIN, triggeredAt, release);
124
+ }
125
+ }
126
+
127
+ export default class Envelope extends PolyModule<ModuleType.LegacyEnvelope> {
128
+ constructor(
129
+ engineId: string,
130
+ params: IPolyModuleConstructor<ModuleType.LegacyEnvelope>,
131
+ ) {
132
+ const props = { ...DEFAULT_PROPS, ...params.props };
133
+ const monoModuleConstructor = (
134
+ engineId: string,
135
+ params: IModuleConstructor<ModuleType.LegacyEnvelope>,
136
+ ) => Module.create(MonoEnvelope, engineId, params);
137
+
138
+ super(engineId, {
139
+ ...params,
140
+ props,
141
+ monoModuleConstructor,
142
+ });
143
+
144
+ this.registerDefaultIOs();
145
+ }
146
+ }
@@ -1,16 +1,22 @@
1
- import { IModule, Module, MidiOutput, SetterHooks, MidiDevice } from "@/core";
1
+ import {
2
+ IModule,
3
+ Module,
4
+ MidiOutput,
5
+ SetterHooks,
6
+ MidiInputDevice,
7
+ } from "@/core";
2
8
  import ComputerKeyboardInput from "@/core/midi/ComputerKeyboardDevice";
3
9
  import MidiEvent from "@/core/midi/MidiEvent";
4
10
  import { ModulePropSchema } from "@/core/schema";
5
11
  import { ICreateModule, ModuleType } from ".";
6
12
 
7
- export type IMidiSelector = IModule<ModuleType.MidiSelector>;
8
- export type IMidiSelectorProps = {
13
+ export type IMidiInput = IModule<ModuleType.MidiInput>;
14
+ export type IMidiInputProps = {
9
15
  selectedId: string | undefined | null;
10
16
  selectedName: string | undefined | null;
11
17
  };
12
18
 
13
- export const midiSelectorPropSchema: ModulePropSchema<IMidiSelectorProps> = {
19
+ export const midiInputPropSchema: ModulePropSchema<IMidiInputProps> = {
14
20
  selectedId: {
15
21
  kind: "string",
16
22
  label: "Midi device ID",
@@ -21,23 +27,20 @@ export const midiSelectorPropSchema: ModulePropSchema<IMidiSelectorProps> = {
21
27
  },
22
28
  };
23
29
 
24
- const DEFAULT_PROPS: IMidiSelectorProps = {
30
+ const DEFAULT_PROPS: IMidiInputProps = {
25
31
  selectedId: undefined,
26
32
  selectedName: undefined,
27
33
  };
28
34
 
29
- export default class MidiSelector
30
- extends Module<ModuleType.MidiSelector>
31
- implements Pick<SetterHooks<IMidiSelectorProps>, "onSetSelectedId">
35
+ export default class MidiInput
36
+ extends Module<ModuleType.MidiInput>
37
+ implements Pick<SetterHooks<IMidiInputProps>, "onSetSelectedId">
32
38
  {
33
39
  declare audioNode: undefined;
34
40
  midiOutput!: MidiOutput;
35
41
  _forwardMidiEvent?: (midiEvent: MidiEvent) => void;
36
42
 
37
- constructor(
38
- engineId: string,
39
- params: ICreateModule<ModuleType.MidiSelector>,
40
- ) {
43
+ constructor(engineId: string, params: ICreateModule<ModuleType.MidiInput>) {
41
44
  const props = { ...DEFAULT_PROPS, ...params.props };
42
45
 
43
46
  super(engineId, {
@@ -51,14 +54,16 @@ export default class MidiSelector
51
54
  // 3. By fuzzy name match (for cross-platform compatibility)
52
55
  let midiDevice =
53
56
  this.props.selectedId &&
54
- this.engine.findMidiDevice(this.props.selectedId);
57
+ this.engine.findMidiInputDevice(this.props.selectedId);
55
58
 
56
59
  if (!midiDevice && this.props.selectedName) {
57
- midiDevice = this.engine.findMidiDeviceByName(this.props.selectedName);
60
+ midiDevice = this.engine.findMidiInputDeviceByName(
61
+ this.props.selectedName,
62
+ );
58
63
 
59
64
  // If exact name match fails, try fuzzy matching
60
65
  if (!midiDevice) {
61
- const fuzzyMatch = this.engine.findMidiDeviceByFuzzyName(
66
+ const fuzzyMatch = this.engine.findMidiInputDeviceByFuzzyName(
62
67
  this.props.selectedName,
63
68
  0.6, // 60% similarity threshold
64
69
  );
@@ -79,13 +84,13 @@ export default class MidiSelector
79
84
  this.registerOutputs();
80
85
  }
81
86
 
82
- onSetSelectedId: SetterHooks<IMidiSelectorProps>["onSetSelectedId"] = (
87
+ onSetSelectedId: SetterHooks<IMidiInputProps>["onSetSelectedId"] = (
83
88
  value,
84
89
  ) => {
85
90
  this.removeEventListener();
86
91
  if (!value) return value;
87
92
 
88
- const midiDevice = this.engine.findMidiDevice(value);
93
+ const midiDevice = this.engine.findMidiInputDevice(value);
89
94
  if (!midiDevice) return value;
90
95
 
91
96
  if (this.props.selectedName !== midiDevice.name) {
@@ -107,14 +112,16 @@ export default class MidiSelector
107
112
  return this._forwardMidiEvent;
108
113
  }
109
114
 
110
- private addEventListener(midiDevice: MidiDevice | ComputerKeyboardInput) {
115
+ private addEventListener(
116
+ midiDevice: MidiInputDevice | ComputerKeyboardInput,
117
+ ) {
111
118
  midiDevice.addEventListener(this.forwardMidiEvent);
112
119
  }
113
120
 
114
121
  private removeEventListener() {
115
122
  if (!this.props.selectedId) return;
116
123
 
117
- const midiDevice = this.engine.findMidiDevice(this.props.selectedId);
124
+ const midiDevice = this.engine.findMidiInputDevice(this.props.selectedId);
118
125
  midiDevice?.removeEventListener(this.forwardMidiEvent);
119
126
  }
120
127
 
@@ -1,5 +1,5 @@
1
1
  import { ContextTime } from "@blibliki/transport";
2
- import { IModule, MidiEvent, Module, SetterHooks } from "@/core";
2
+ import { IModule, MidiEvent, MidiOutput, Module, SetterHooks } from "@/core";
3
3
  import { ModulePropSchema, NumberProp, PropSchema } from "@/core/schema";
4
4
  import { ICreateModule, moduleSchemas, ModuleType } from ".";
5
5
 
@@ -26,6 +26,7 @@ export enum MidiMappingMode {
26
26
 
27
27
  export type MidiMapping<T extends ModuleType> = {
28
28
  cc?: number;
29
+ value?: number;
29
30
  moduleId?: string;
30
31
  moduleType?: T;
31
32
  propName?: string;
@@ -102,6 +103,7 @@ export default class MidiMapper
102
103
  implements MidiMapperSetterHooks
103
104
  {
104
105
  declare audioNode: undefined;
106
+ private _midiOut: MidiOutput; // Will be used to send CC values on page change
105
107
 
106
108
  constructor(engineId: string, params: ICreateModule<ModuleType.MidiMapper>) {
107
109
  const props = { ...DEFAULT_PROPS, ...params.props };
@@ -115,10 +117,31 @@ export default class MidiMapper
115
117
  name: "midi in",
116
118
  onMidiEvent: this.onMidiEvent,
117
119
  });
120
+
121
+ this._midiOut = this.registerMidiOutput({
122
+ name: "midi out",
123
+ });
118
124
  }
119
125
 
120
126
  onSetActivePage: MidiMapperSetterHooks["onSetActivePage"] = (value) => {
121
- return Math.max(Math.min(value, this.props.pages.length - 1), 0);
127
+ const activePage = Math.max(
128
+ Math.min(value, this.props.pages.length - 1),
129
+ 0,
130
+ );
131
+
132
+ const newPage = this.props.pages[activePage];
133
+
134
+ // Send stored CC values to MIDI output when changing pages
135
+ const now = this.context.currentTime;
136
+ newPage?.mappings.forEach((mapping) => {
137
+ if (mapping.cc !== undefined && mapping.value !== undefined) {
138
+ // Create CC MIDI event and send it
139
+ const midiEvent = MidiEvent.fromCC(mapping.cc, mapping.value, now);
140
+ this._midiOut.onMidiEvent(midiEvent);
141
+ }
142
+ });
143
+
144
+ return activePage;
122
145
  };
123
146
 
124
147
  handleCC = (event: MidiEvent, triggeredAt: ContextTime) => {
@@ -127,12 +150,44 @@ export default class MidiMapper
127
150
  const activePage = this.props.pages[this.props.activePage];
128
151
  if (!activePage) return;
129
152
 
130
- [
153
+ const matchingMappings = [
131
154
  ...this.props.globalMappings.filter((m) => m.cc === event.cc),
132
155
  ...activePage.mappings.filter((m) => m.cc === event.cc),
133
- ].forEach((mapping) => {
156
+ ];
157
+
158
+ // Forward all matching mappings
159
+ matchingMappings.forEach((mapping) => {
134
160
  this.forwardMapping(event, mapping, triggeredAt);
135
161
  });
162
+
163
+ // Update mapping values if we have matching CCs
164
+ if (matchingMappings.length > 0 && event.ccValue !== undefined) {
165
+ const updatedGlobalMappings = this.props.globalMappings.map((mapping) => {
166
+ if (mapping.cc === event.cc) {
167
+ return { ...mapping, value: event.ccValue };
168
+ }
169
+ return mapping;
170
+ });
171
+
172
+ const updatedPageMappings = activePage.mappings.map((mapping) => {
173
+ if (mapping.cc === event.cc) {
174
+ return { ...mapping, value: event.ccValue };
175
+ }
176
+ return mapping;
177
+ });
178
+
179
+ const updatedPages = this.props.pages.map((page, index) =>
180
+ index === this.props.activePage
181
+ ? { ...page, mappings: updatedPageMappings }
182
+ : page,
183
+ );
184
+
185
+ this.props = {
186
+ pages: updatedPages,
187
+ globalMappings: updatedGlobalMappings,
188
+ };
189
+ this.triggerPropsUpdate();
190
+ }
136
191
  };
137
192
 
138
193
  forwardMapping = (
@@ -0,0 +1,121 @@
1
+ import {
2
+ IModule,
3
+ Module,
4
+ MidiInput,
5
+ SetterHooks,
6
+ MidiOutputDevice,
7
+ } from "@/core";
8
+ import MidiEvent from "@/core/midi/MidiEvent";
9
+ import { ModulePropSchema } from "@/core/schema";
10
+ import { ICreateModule, ModuleType } from ".";
11
+
12
+ export type IMidiOutput = IModule<ModuleType.MidiOutput>;
13
+ export type IMidiOutputProps = {
14
+ selectedId: string | undefined | null;
15
+ selectedName: string | undefined | null;
16
+ };
17
+
18
+ export const midiOutputPropSchema: ModulePropSchema<IMidiOutputProps> = {
19
+ selectedId: {
20
+ kind: "string",
21
+ label: "Midi device ID",
22
+ },
23
+ selectedName: {
24
+ kind: "string",
25
+ label: "Midi device name",
26
+ },
27
+ };
28
+
29
+ const DEFAULT_PROPS: IMidiOutputProps = {
30
+ selectedId: undefined,
31
+ selectedName: undefined,
32
+ };
33
+
34
+ export default class MidiOutput
35
+ extends Module<ModuleType.MidiOutput>
36
+ implements Pick<SetterHooks<IMidiOutputProps>, "onSetSelectedId">
37
+ {
38
+ declare audioNode: undefined;
39
+ midiInput!: MidiInput;
40
+ private currentDevice?: MidiOutputDevice;
41
+
42
+ constructor(engineId: string, params: ICreateModule<ModuleType.MidiOutput>) {
43
+ const props = { ...DEFAULT_PROPS, ...params.props };
44
+
45
+ super(engineId, {
46
+ ...params,
47
+ props,
48
+ });
49
+
50
+ // Try to find device in order of preference:
51
+ // 1. By exact ID match
52
+ // 2. By exact name match
53
+ // 3. By fuzzy name match (for cross-platform compatibility)
54
+ let midiDevice =
55
+ this.props.selectedId &&
56
+ this.engine.findMidiOutputDevice(this.props.selectedId);
57
+
58
+ if (!midiDevice && this.props.selectedName) {
59
+ midiDevice = this.engine.findMidiOutputDeviceByName(
60
+ this.props.selectedName,
61
+ );
62
+
63
+ // If exact name match fails, try fuzzy matching
64
+ if (!midiDevice) {
65
+ const fuzzyMatch = this.engine.findMidiOutputDeviceByFuzzyName(
66
+ this.props.selectedName,
67
+ 0.6, // 60% similarity threshold
68
+ );
69
+
70
+ if (fuzzyMatch) {
71
+ midiDevice = fuzzyMatch.device;
72
+ console.log(
73
+ `MIDI device fuzzy matched: "${this.props.selectedName}" -> "${midiDevice.name}" (confidence: ${Math.round(fuzzyMatch.score * 100)}%)`,
74
+ );
75
+ }
76
+ }
77
+ }
78
+
79
+ if (midiDevice) {
80
+ this.currentDevice = midiDevice;
81
+ }
82
+
83
+ this.registerInputs();
84
+ }
85
+
86
+ onSetSelectedId: SetterHooks<IMidiOutputProps>["onSetSelectedId"] = (
87
+ value,
88
+ ) => {
89
+ if (!value) {
90
+ this.currentDevice = undefined;
91
+ return value;
92
+ }
93
+
94
+ const midiDevice = this.engine.findMidiOutputDevice(value);
95
+ if (!midiDevice) return value;
96
+
97
+ if (this.props.selectedName !== midiDevice.name) {
98
+ this.props = { selectedName: midiDevice.name };
99
+ this.triggerPropsUpdate();
100
+ }
101
+
102
+ this.currentDevice = midiDevice;
103
+
104
+ return value;
105
+ };
106
+
107
+ onMidiEvent = (midiEvent: MidiEvent) => {
108
+ if (!this.currentDevice) return;
109
+
110
+ // Send raw MIDI data to hardware
111
+ const rawData = midiEvent.rawMessage.data;
112
+ this.currentDevice.send(rawData);
113
+ };
114
+
115
+ private registerInputs() {
116
+ this.midiInput = this.registerMidiInput({
117
+ name: "midi in",
118
+ onMidiEvent: this.onMidiEvent,
119
+ });
120
+ }
121
+ }