@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.
- 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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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",
|
package/src/core/module/index.ts
CHANGED
|
@@ -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 {
|
|
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<
|
|
11
|
-
| IPolyModuleSerialize<
|
|
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
|
+
}
|