@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.
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
@@ -12,10 +12,18 @@ export const voiceSchedulerPropSchema: ModulePropSchema<IVoiceSchedulerProps> =
12
12
  {};
13
13
  const DEFAULT_PROPS = {};
14
14
 
15
+ interface OccupationRange {
16
+ noteName: string;
17
+ startTime: ContextTime;
18
+ endTime: ContextTime; // Can be Infinity if noteOff hasn't been triggered yet
19
+ }
20
+
15
21
  class Voice extends Module<ModuleType.VoiceScheduler> {
16
22
  declare audioNode: undefined;
17
23
  activeNote: string | null = null;
18
24
  triggeredAt: ContextTime = 0;
25
+ // Track all current and future occupation ranges
26
+ private occupationRanges: OccupationRange[] = [];
19
27
 
20
28
  constructor(
21
29
  engineId: string,
@@ -29,21 +37,88 @@ class Voice extends Module<ModuleType.VoiceScheduler> {
29
37
  });
30
38
  }
31
39
 
40
+ /**
41
+ * Check if this voice is occupied at a given time
42
+ */
43
+ isOccupiedAt(time: ContextTime): boolean {
44
+ return this.occupationRanges.some(
45
+ (range) => time >= range.startTime && time < range.endTime,
46
+ );
47
+ }
48
+
49
+ /**
50
+ * Get the earliest end time of all occupation ranges at or after the given time
51
+ * Returns Infinity if the voice has infinite occupation
52
+ */
53
+ getEarliestEndTimeAfter(time: ContextTime): ContextTime {
54
+ const relevantRanges = this.occupationRanges.filter(
55
+ (range) => range.endTime > time,
56
+ );
57
+
58
+ if (relevantRanges.length === 0) return -Infinity;
59
+
60
+ return Math.min(...relevantRanges.map((range) => range.endTime));
61
+ }
62
+
63
+ /**
64
+ * Clean up past occupation ranges that are no longer needed
65
+ */
66
+ private cleanupPastRanges(currentTime: ContextTime) {
67
+ this.occupationRanges = this.occupationRanges.filter(
68
+ (range) => range.endTime > currentTime || range.endTime === Infinity,
69
+ );
70
+ }
71
+
72
+ /**
73
+ * Clear all occupation ranges
74
+ * Useful when resetting or stopping playback
75
+ */
76
+ clearOccupationRanges() {
77
+ this.occupationRanges = [];
78
+ }
79
+
32
80
  midiTriggered = (midiEvent: MidiEvent) => {
33
81
  const { triggeredAt, note, type } = midiEvent;
34
82
 
35
83
  if (!note) return;
36
84
  const noteName = note.fullName;
37
85
 
86
+ this.cleanupPastRanges(triggeredAt);
87
+
88
+ // Determine if this is a future event (more than 10ms ahead)
89
+ const currentTime = this.context.audioContext.currentTime;
90
+ const isFutureEvent = triggeredAt - currentTime > 0.01;
91
+
38
92
  switch (type) {
39
- case MidiEventType.noteOn:
93
+ case MidiEventType.noteOn: {
40
94
  this.activeNote = noteName;
41
95
  this.triggeredAt = triggeredAt;
42
96
 
97
+ // Only add occupation ranges for future events
98
+ // Real-time events use activeNote field only
99
+ if (isFutureEvent) {
100
+ this.occupationRanges.push({
101
+ noteName,
102
+ startTime: triggeredAt,
103
+ endTime: Infinity,
104
+ });
105
+ }
106
+
43
107
  break;
44
- case MidiEventType.noteOff:
108
+ }
109
+ case MidiEventType.noteOff: {
45
110
  this.activeNote = null;
111
+
112
+ // Always try to close any open occupation range for this note
113
+ // This handles both scheduled future events and cleanup when stopping
114
+ const range = this.occupationRanges.find(
115
+ (r) => r.noteName === noteName && r.endTime === Infinity,
116
+ );
117
+ if (range) {
118
+ range.endTime = triggeredAt;
119
+ }
46
120
  break;
121
+ }
47
122
  default:
48
123
  throw Error("This type is not a note");
49
124
  }
@@ -79,7 +154,7 @@ export default class VoiceScheduler extends PolyModule<ModuleType.VoiceScheduler
79
154
 
80
155
  switch (midiEvent.type) {
81
156
  case MidiEventType.noteOn:
82
- voice = this.findFreeVoice();
157
+ voice = this.findFreeVoice(midiEvent.triggeredAt);
83
158
 
84
159
  break;
85
160
  case MidiEventType.noteOff:
@@ -87,6 +162,9 @@ export default class VoiceScheduler extends PolyModule<ModuleType.VoiceScheduler
87
162
  (v) => v.activeNote === midiEvent.note!.fullName,
88
163
  );
89
164
  break;
165
+ case MidiEventType.cc:
166
+ this.midiOutput.onMidiEvent(midiEvent);
167
+ return;
90
168
  default:
91
169
  throw Error("This type is not a note");
92
170
  }
@@ -98,23 +176,85 @@ export default class VoiceScheduler extends PolyModule<ModuleType.VoiceScheduler
98
176
  this.midiOutput.onMidiEvent(midiEvent);
99
177
  };
100
178
 
101
- private findFreeVoice(): Voice {
102
- let voice = this.audioModules.find((v) => !v.activeNote);
179
+ private findFreeVoice(targetTime: ContextTime): Voice {
180
+ const currentTime = this.context.audioContext.currentTime;
181
+ // Consider it real-time if within 10ms of current time
182
+ const isRealTime = Math.abs(targetTime - currentTime) < 0.01;
183
+
184
+ let voice: Voice | undefined;
103
185
 
104
- // If no available voice, get the one with the lowest triggeredAt
186
+ if (isRealTime) {
187
+ // For real-time events, use simple activeNote check (original behavior)
188
+ // This avoids issues with residual occupation ranges
189
+ voice = this.audioModules.find((v) => !v.activeNote);
190
+ } else {
191
+ // For future events, use occupation range system
192
+ voice = this.audioModules.find((v) => !v.isOccupiedAt(targetTime));
193
+ }
194
+
195
+ // If no available voice, steal one based on the strategy:
196
+ // Primary: voice with earliest end time at or after target time
197
+ // Secondary: among voices with similar end times, choose oldest (earliest triggeredAt)
105
198
  if (!voice) {
106
- const sorted = this.audioModules.sort((a, b) => {
107
- return a.triggeredAt - b.triggeredAt;
108
- });
109
- voice = sorted[0];
199
+ if (isRealTime) {
200
+ // For real-time, use original voice stealing strategy
201
+ const sorted = this.audioModules.sort((a, b) => {
202
+ return a.triggeredAt - b.triggeredAt;
203
+ });
204
+ voice = sorted[0];
205
+ } else {
206
+ // For future events, use occupation-aware strategy
207
+ const sorted = this.audioModules.sort((a, b) => {
208
+ const aEndTime = a.getEarliestEndTimeAfter(targetTime);
209
+ const bEndTime = b.getEarliestEndTimeAfter(targetTime);
210
+
211
+ // Primary sort by end time
212
+ if (aEndTime !== bEndTime) {
213
+ return aEndTime - bEndTime;
214
+ }
215
+
216
+ // Secondary sort by triggered time (oldest first)
217
+ return a.triggeredAt - b.triggeredAt;
218
+ });
219
+ voice = sorted[0];
220
+ }
221
+
110
222
  if (!voice) {
111
223
  throw new Error("No voices available in voice scheduler");
112
224
  }
225
+
226
+ // Important: Send a noteOff event for the stolen voice's current note
227
+ // This ensures the envelope releases properly before the new note starts
228
+ if (voice.activeNote) {
229
+ const stolenNoteName = voice.activeNote;
230
+ // Always release the stolen note at the current time for immediate audio release
231
+ const releaseTime = this.context.audioContext.currentTime;
232
+ const noteOffEvent = MidiEvent.fromNote(
233
+ stolenNoteName,
234
+ false, // noteOn = false means noteOff
235
+ releaseTime,
236
+ );
237
+ noteOffEvent.voiceNo = voice.voiceNo;
238
+
239
+ // Trigger the note off on this voice before reusing it
240
+ voice.midiTriggered(noteOffEvent);
241
+ this.midiOutput.onMidiEvent(noteOffEvent);
242
+ }
113
243
  }
114
244
 
115
245
  return voice;
116
246
  }
117
247
 
248
+ /**
249
+ * Clear all occupation ranges for all voices
250
+ * Call this when stopping playback or resetting the scheduler
251
+ */
252
+ clearAllOccupationRanges() {
253
+ this.audioModules.forEach((voice) => {
254
+ voice.clearOccupationRanges();
255
+ });
256
+ }
257
+
118
258
  private registerInputs() {
119
259
  this.registerMidiInput({
120
260
  name: "midi in",
@@ -3,9 +3,14 @@ import { IModuleSerialize } from "./Module";
3
3
  import { IPolyModuleSerialize } from "./PolyModule";
4
4
 
5
5
  export { Module } from "./Module";
6
- export type { IModule, IModuleSerialize, SetterHooks } from "./Module";
6
+ export type {
7
+ IModule,
8
+ IModuleSerialize,
9
+ SetterHooks,
10
+ StateSetterHooks,
11
+ } from "./Module";
7
12
  export type { IPolyModule, IPolyModuleSerialize } from "./PolyModule";
8
13
 
9
- export type IAnyModuleSerialize =
10
- | IModuleSerialize<ModuleType>
11
- | IPolyModuleSerialize<ModuleType>;
14
+ export type IAnyModuleSerialize<MT extends ModuleType = ModuleType> =
15
+ | IModuleSerialize<MT>
16
+ | IPolyModuleSerialize<MT>;
package/src/index.ts CHANGED
@@ -17,11 +17,19 @@ export type {
17
17
  BooleanProp,
18
18
  ArrayProp,
19
19
  INote,
20
+ SetterHooks,
21
+ StateSetterHooks,
22
+ } from "./core";
23
+ export {
24
+ MidiDevice,
25
+ MidiInputDevice,
26
+ MidiOutputDevice,
27
+ MidiPortState,
28
+ Note,
20
29
  } from "./core";
21
- export { MidiDevice, MidiPortState, Note } from "./core";
22
30
 
23
31
  export { TransportState } from "@blibliki/transport";
24
- export type { TimeSignature, Position } from "@blibliki/transport";
32
+ export type { BPM, TimeSignature, Position } from "@blibliki/transport";
25
33
 
26
34
  export { Context } from "@blibliki/utils";
27
35
 
@@ -30,18 +38,34 @@ export {
30
38
  moduleSchemas,
31
39
  OscillatorWave,
32
40
  MidiMappingMode,
41
+ LFOWaveform,
42
+ Resolution,
43
+ PlaybackMode,
44
+ stepPropSchema,
45
+ NoiseType,
46
+ DelayTimeMode,
33
47
  } from "./modules";
48
+ export { default as StepSequencer } from "./modules/StepSequencer";
34
49
  export type {
35
50
  IOscillator,
36
51
  IGain,
37
52
  IMaster,
38
- ISequence,
39
53
  IStepSequencerProps,
54
+ IStepSequencerState,
40
55
  IStepSequencer,
56
+ IStep,
57
+ IPage,
58
+ IPattern,
59
+ IStepNote,
60
+ IStepCC,
41
61
  ModuleTypeToPropsMapping,
62
+ ModuleTypeToStateMapping,
42
63
  ICreateModule,
43
64
  ModuleParams,
44
65
  IMidiMapper,
45
66
  IMidiMapperProps,
46
67
  MidiMapping,
68
+ ILFO,
69
+ ILFOProps,
70
+ INoise,
47
71
  } from "./modules";
@@ -0,0 +1,222 @@
1
+ import { Context } from "@blibliki/utils";
2
+ import { GainNode } from "@blibliki/utils/web-audio-api";
3
+ import { ModulePropSchema } from "@/core";
4
+ import { IModule, Module, SetterHooks } from "@/core/module/Module";
5
+ import { WetDryMixer } from "@/utils";
6
+ import { ICreateModule, ModuleType } from ".";
7
+
8
+ export type IChorusProps = {
9
+ rate: number; // 0.1-10 Hz (LFO frequency)
10
+ depth: number; // 0-1 (modulation depth)
11
+ mix: number; // 0-1 (dry/wet blend)
12
+ feedback: number; // 0-0.95 (feedback amount)
13
+ };
14
+
15
+ export type IChorus = IModule<ModuleType.Chorus>;
16
+
17
+ export const chorusPropSchema: ModulePropSchema<IChorusProps> = {
18
+ rate: {
19
+ kind: "number",
20
+ min: 0.1,
21
+ max: 10,
22
+ step: 0.1,
23
+ exp: 2, // Exponential scaling for better low-frequency control
24
+ label: "Rate",
25
+ },
26
+ depth: {
27
+ kind: "number",
28
+ min: 0,
29
+ max: 1,
30
+ step: 0.01,
31
+ label: "Depth",
32
+ },
33
+ mix: {
34
+ kind: "number",
35
+ min: 0,
36
+ max: 1,
37
+ step: 0.01,
38
+ label: "Mix",
39
+ },
40
+ feedback: {
41
+ kind: "number",
42
+ min: 0,
43
+ max: 0.95,
44
+ step: 0.01,
45
+ label: "Feedback",
46
+ },
47
+ };
48
+
49
+ const DEFAULT_PROPS: IChorusProps = {
50
+ rate: 0.5, // 0.5 Hz (slow modulation)
51
+ depth: 0.5, // Medium depth
52
+ mix: 0.5, // 50/50 blend
53
+ feedback: 0.2, // Subtle feedback
54
+ };
55
+
56
+ export default class Chorus
57
+ extends Module<ModuleType.Chorus>
58
+ implements
59
+ Pick<
60
+ SetterHooks<IChorusProps>,
61
+ | "onAfterSetRate"
62
+ | "onAfterSetDepth"
63
+ | "onAfterSetMix"
64
+ | "onAfterSetFeedback"
65
+ >
66
+ {
67
+ declare audioNode: GainNode; // Input node (inherited from Module)
68
+ private outputNode: GainNode; // Final output from mixer
69
+ private feedbackGain: GainNode; // Feedback amount
70
+ private delayLeft: DelayNode; // First chorus voice
71
+ private delayRight: DelayNode; // Second chorus voice
72
+ private lfoLeft: OscillatorNode; // LFO for left voice
73
+ private lfoRight: OscillatorNode; // LFO for right voice
74
+ private depthLeft: GainNode; // Modulation depth for left
75
+ private depthRight: GainNode; // Modulation depth for right
76
+ private merger: ChannelMergerNode; // Combine to stereo
77
+ private wetDryMixer: WetDryMixer; // Wet/dry blending
78
+ private rateModGain: GainNode; // External rate modulation input
79
+
80
+ constructor(engineId: string, params: ICreateModule<ModuleType.Chorus>) {
81
+ const props = { ...DEFAULT_PROPS, ...params.props };
82
+
83
+ const audioNodeConstructor = (context: Context) =>
84
+ new GainNode(context.audioContext, { gain: 1 });
85
+
86
+ super(engineId, {
87
+ ...params,
88
+ audioNodeConstructor,
89
+ props,
90
+ });
91
+
92
+ // Create audio nodes
93
+ const audioContext = this.context.audioContext;
94
+
95
+ this.feedbackGain = audioContext.createGain();
96
+ this.delayLeft = audioContext.createDelay(0.1); // Max 0.1s delay
97
+ this.delayRight = audioContext.createDelay(0.1);
98
+ this.lfoLeft = audioContext.createOscillator();
99
+ this.lfoRight = audioContext.createOscillator();
100
+ this.depthLeft = audioContext.createGain();
101
+ this.depthRight = audioContext.createGain();
102
+ this.merger = audioContext.createChannelMerger(2);
103
+ this.wetDryMixer = new WetDryMixer(this.context);
104
+ this.rateModGain = audioContext.createGain();
105
+
106
+ // Configure LFOs
107
+ this.lfoLeft.type = "sine";
108
+ this.lfoRight.type = "sine";
109
+
110
+ // Connect audio graph
111
+ // Dry path
112
+ this.wetDryMixer.connectInput(this.audioNode);
113
+
114
+ // Rate modulation input connects to both LFO frequencies
115
+ this.rateModGain.connect(this.lfoLeft.frequency);
116
+ this.rateModGain.connect(this.lfoRight.frequency);
117
+
118
+ // LFO modulation to delay times
119
+ this.lfoLeft.connect(this.depthLeft);
120
+ this.depthLeft.connect(this.delayLeft.delayTime);
121
+ this.lfoRight.connect(this.depthRight);
122
+ this.depthRight.connect(this.delayRight.delayTime);
123
+
124
+ // Wet path with feedback
125
+ this.audioNode.connect(this.feedbackGain);
126
+ this.feedbackGain.connect(this.delayLeft);
127
+ this.feedbackGain.connect(this.delayRight);
128
+
129
+ // Delays to merger (stereo)
130
+ this.delayLeft.connect(this.merger, 0, 0); // Left channel
131
+ this.delayRight.connect(this.merger, 0, 1); // Right channel
132
+
133
+ // Feedback loop
134
+ this.merger.connect(this.feedbackGain);
135
+
136
+ // Merger to wet path
137
+ this.merger.connect(this.wetDryMixer.getWetInput());
138
+
139
+ // Output
140
+ this.outputNode = this.wetDryMixer.getOutput();
141
+
142
+ // Apply initial parameters
143
+ // Base delay time: 20ms typical for chorus
144
+ this.delayLeft.delayTime.value = 0.02;
145
+ this.delayRight.delayTime.value = 0.02;
146
+
147
+ // Set LFO frequency (rate)
148
+ this.lfoLeft.frequency.value = props.rate;
149
+ this.lfoRight.frequency.value = props.rate;
150
+
151
+ // Depth: convert (0-1) to seconds (0-0.01s = 0-10ms modulation)
152
+ const depthInSeconds = props.depth * 0.01;
153
+ this.depthLeft.gain.value = depthInSeconds;
154
+ this.depthRight.gain.value = depthInSeconds;
155
+
156
+ // Feedback
157
+ this.feedbackGain.gain.value = props.feedback;
158
+
159
+ // Mix
160
+ this.wetDryMixer.setMix(props.mix);
161
+
162
+ // Start oscillators with phase offset for stereo width
163
+ const now = this.context.audioContext.currentTime;
164
+ this.lfoLeft.start(now);
165
+ // Start right LFO with 180° phase offset (half period)
166
+ const phaseOffset = 1 / (2 * props.rate);
167
+ this.lfoRight.start(now + phaseOffset);
168
+
169
+ // Register IOs
170
+ this.registerDefaultIOs("in");
171
+ this.registerAdditionalInputs();
172
+ this.registerCustomOutput();
173
+ }
174
+
175
+ private registerAdditionalInputs() {
176
+ // External rate modulation input
177
+ this.registerAudioInput({
178
+ name: "rate",
179
+ getAudioNode: () => this.rateModGain,
180
+ });
181
+ }
182
+
183
+ private registerCustomOutput() {
184
+ this.registerAudioOutput({
185
+ name: "out",
186
+ getAudioNode: () => this.outputNode,
187
+ });
188
+ }
189
+
190
+ onAfterSetRate: SetterHooks<IChorusProps>["onAfterSetRate"] = (value) => {
191
+ this.lfoLeft.frequency.value = value;
192
+ this.lfoRight.frequency.value = value;
193
+ // Note: Phase offset maintained by start time, not adjustable at runtime
194
+ };
195
+
196
+ onAfterSetDepth: SetterHooks<IChorusProps>["onAfterSetDepth"] = (value) => {
197
+ const depthInSeconds = value * 0.01; // Convert to 0-10ms range
198
+ this.depthLeft.gain.value = depthInSeconds;
199
+ this.depthRight.gain.value = depthInSeconds;
200
+ };
201
+
202
+ onAfterSetMix: SetterHooks<IChorusProps>["onAfterSetMix"] = (value) => {
203
+ this.wetDryMixer.setMix(value);
204
+ };
205
+
206
+ onAfterSetFeedback: SetterHooks<IChorusProps>["onAfterSetFeedback"] = (
207
+ value,
208
+ ) => {
209
+ const cappedValue = Math.min(value, 0.95); // Prevent runaway feedback
210
+ this.feedbackGain.gain.value = cappedValue;
211
+ };
212
+
213
+ dispose() {
214
+ try {
215
+ this.lfoLeft.stop();
216
+ this.lfoRight.stop();
217
+ } catch {
218
+ // Ignore errors if already stopped
219
+ }
220
+ super.dispose();
221
+ }
222
+ }
@@ -72,7 +72,7 @@ export default class Constant
72
72
  this.start(triggeredAt);
73
73
  };
74
74
 
75
- triggerRelease = () => {
76
- // Do nothing
75
+ triggerRelease = (_: Note, triggeredAt: ContextTime) => {
76
+ this.stop(triggeredAt);
77
77
  };
78
78
  }