action-engine-js 1.0.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/LICENSE +45 -0
- package/README.md +348 -0
- package/actionengine/3rdparty/goblin/goblin.js +9609 -0
- package/actionengine/3rdparty/goblin/goblin.min.js +5 -0
- package/actionengine/camera/actioncamera.js +90 -0
- package/actionengine/camera/cameracollisionhandler.js +69 -0
- package/actionengine/character/actioncharacter.js +360 -0
- package/actionengine/character/actioncharacter3D.js +61 -0
- package/actionengine/core/app.js +430 -0
- package/actionengine/debug/basedebugpanel.js +858 -0
- package/actionengine/display/canvasmanager.js +75 -0
- package/actionengine/display/gl/programmanager.js +570 -0
- package/actionengine/display/gl/shaders/lineshader.js +118 -0
- package/actionengine/display/gl/shaders/objectshader.js +1756 -0
- package/actionengine/display/gl/shaders/particleshader.js +43 -0
- package/actionengine/display/gl/shaders/shadowshader.js +319 -0
- package/actionengine/display/gl/shaders/spriteshader.js +100 -0
- package/actionengine/display/gl/shaders/watershader.js +67 -0
- package/actionengine/display/graphics/actionmodel3D.js +191 -0
- package/actionengine/display/graphics/actionsprite3D.js +230 -0
- package/actionengine/display/graphics/lighting/actiondirectionalshadowlight.js +864 -0
- package/actionengine/display/graphics/lighting/actionlight.js +211 -0
- package/actionengine/display/graphics/lighting/actionomnidirectionalshadowlight.js +862 -0
- package/actionengine/display/graphics/lighting/lightingconstants.js +263 -0
- package/actionengine/display/graphics/lighting/lightmanager.js +789 -0
- package/actionengine/display/graphics/renderableobject.js +44 -0
- package/actionengine/display/graphics/renderers/actionrenderer2D.js +341 -0
- package/actionengine/display/graphics/renderers/actionrenderer3D/actionrenderer3D.js +655 -0
- package/actionengine/display/graphics/renderers/actionrenderer3D/canvasmanager3D.js +82 -0
- package/actionengine/display/graphics/renderers/actionrenderer3D/debugrenderer3D.js +493 -0
- package/actionengine/display/graphics/renderers/actionrenderer3D/objectrenderer3D.js +790 -0
- package/actionengine/display/graphics/renderers/actionrenderer3D/spriteRenderer3D.js +266 -0
- package/actionengine/display/graphics/renderers/actionrenderer3D/sunrenderer3D.js +140 -0
- package/actionengine/display/graphics/renderers/actionrenderer3D/waterrenderer3D.js +173 -0
- package/actionengine/display/graphics/renderers/actionrenderer3D/weatherrenderer3D.js +87 -0
- package/actionengine/display/graphics/texture/proceduraltexture.js +192 -0
- package/actionengine/display/graphics/texture/texturemanager.js +242 -0
- package/actionengine/display/graphics/texture/textureregistry.js +177 -0
- package/actionengine/input/actionscrollablearea.js +1405 -0
- package/actionengine/input/inputhandler.js +1647 -0
- package/actionengine/math/geometry/geometrybuilder.js +161 -0
- package/actionengine/math/geometry/glbexporter.js +364 -0
- package/actionengine/math/geometry/glbloader.js +722 -0
- package/actionengine/math/geometry/modelcodegenerator.js +97 -0
- package/actionengine/math/geometry/triangle.js +33 -0
- package/actionengine/math/geometry/triangleutils.js +34 -0
- package/actionengine/math/mathutils.js +25 -0
- package/actionengine/math/matrix4.js +785 -0
- package/actionengine/math/physics/actionphysics.js +108 -0
- package/actionengine/math/physics/actionphysicsobject3D.js +164 -0
- package/actionengine/math/physics/actionphysicsworld3D.js +238 -0
- package/actionengine/math/physics/actionraycast.js +129 -0
- package/actionengine/math/physics/shapes/actionphysicsbox3D.js +158 -0
- package/actionengine/math/physics/shapes/actionphysicscapsule3D.js +200 -0
- package/actionengine/math/physics/shapes/actionphysicscompoundshape3D.js +147 -0
- package/actionengine/math/physics/shapes/actionphysicscone3D.js +126 -0
- package/actionengine/math/physics/shapes/actionphysicsconvexshape3D.js +72 -0
- package/actionengine/math/physics/shapes/actionphysicscylinder3D.js +117 -0
- package/actionengine/math/physics/shapes/actionphysicsmesh3D.js +74 -0
- package/actionengine/math/physics/shapes/actionphysicsplane3D.js +100 -0
- package/actionengine/math/physics/shapes/actionphysicssphere3D.js +95 -0
- package/actionengine/math/quaternion.js +61 -0
- package/actionengine/math/vector2.js +277 -0
- package/actionengine/math/vector3.js +318 -0
- package/actionengine/math/viewfrustum.js +136 -0
- package/actionengine/network/ACTIONNETREADME.md +810 -0
- package/actionengine/network/client/ActionNetManager.js +802 -0
- package/actionengine/network/client/ActionNetManagerGUI.js +1709 -0
- package/actionengine/network/client/ActionNetManagerP2P.js +1537 -0
- package/actionengine/network/client/SyncSystem.js +422 -0
- package/actionengine/network/p2p/ActionNetPeer.js +142 -0
- package/actionengine/network/p2p/ActionNetTrackerClient.js +623 -0
- package/actionengine/network/p2p/DataConnection.js +282 -0
- package/actionengine/network/p2p/README.md +510 -0
- package/actionengine/network/p2p/example.html +502 -0
- package/actionengine/network/server/ActionNetServer.js +577 -0
- package/actionengine/network/server/ActionNetServerSSL.js +579 -0
- package/actionengine/network/server/ActionNetServerUtils.js +458 -0
- package/actionengine/network/server/SERVERREADME.md +314 -0
- package/actionengine/network/server/package-lock.json +35 -0
- package/actionengine/network/server/package.json +13 -0
- package/actionengine/network/server/start.bat +27 -0
- package/actionengine/network/server/start.sh +25 -0
- package/actionengine/network/server/startwss.bat +27 -0
- package/actionengine/sound/audiomanager.js +1589 -0
- package/actionengine/sound/soundfont/ACTIONSOUNDFONT_README.md +205 -0
- package/actionengine/sound/soundfont/actionparser.js +718 -0
- package/actionengine/sound/soundfont/actionreverb.js +252 -0
- package/actionengine/sound/soundfont/actionsoundfont.js +543 -0
- package/actionengine/sound/soundfont/sf2playerlicence.txt +29 -0
- package/actionengine/sound/soundfont/soundfont.js +2 -0
- package/dist/action-engine.min.js +328 -0
- package/package.json +35 -0
|
@@ -0,0 +1,1589 @@
|
|
|
1
|
+
// actionengine/sound/audiomanager.js
|
|
2
|
+
class ActionAudioManager {
|
|
3
|
+
constructor() {
|
|
4
|
+
this.context = null;
|
|
5
|
+
this.enabled = true;
|
|
6
|
+
this.sounds = new Map();
|
|
7
|
+
this.samples = new Map();
|
|
8
|
+
this.sampleDefinitions = new Map();
|
|
9
|
+
this.masterGain = null;
|
|
10
|
+
this.baseVolume = 0.2;
|
|
11
|
+
this.initializeAudioContext();
|
|
12
|
+
// Add tracking for scheduled MIDI events
|
|
13
|
+
this.scheduledMIDIEvents = new Set();
|
|
14
|
+
// MIDI state tracking
|
|
15
|
+
this.midiReady = false;
|
|
16
|
+
this.sf2Player = null;
|
|
17
|
+
this.activeSounds = new Map();
|
|
18
|
+
// Enhanced sound tracking for new features
|
|
19
|
+
this.soundInstances = new Map(); // Track individual sound instances
|
|
20
|
+
this.soundVolumes = new Map(); // Individual sound volumes
|
|
21
|
+
this.repeatTimeouts = new Map(); // Track repeat timeouts for cleanup
|
|
22
|
+
this.midiChannels = new Array(16).fill(null).map(() => ({
|
|
23
|
+
volume: 127,
|
|
24
|
+
pan: 64,
|
|
25
|
+
expression: 127,
|
|
26
|
+
program: 0,
|
|
27
|
+
bank: 0,
|
|
28
|
+
pitchBend: 0,
|
|
29
|
+
pitchBendSensitivity: 2,
|
|
30
|
+
duration: 0.5 // Add default duration
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
this.midiProgramMap = {
|
|
34
|
+
// Piano (0-7)
|
|
35
|
+
acoustic_grand_piano: 0,
|
|
36
|
+
bright_acoustic_piano: 1,
|
|
37
|
+
electric_grand_piano: 2,
|
|
38
|
+
honkytonk_piano: 3,
|
|
39
|
+
electric_piano_1: 4,
|
|
40
|
+
electric_piano_2: 5,
|
|
41
|
+
harpsichord: 6,
|
|
42
|
+
clavinet: 7,
|
|
43
|
+
|
|
44
|
+
// Chromatic Percussion (8-15)
|
|
45
|
+
celesta: 8,
|
|
46
|
+
glockenspiel: 9,
|
|
47
|
+
music_box: 10,
|
|
48
|
+
vibraphone: 11,
|
|
49
|
+
marimba: 12,
|
|
50
|
+
xylophone: 13,
|
|
51
|
+
tubular_bells: 14,
|
|
52
|
+
dulcimer: 15,
|
|
53
|
+
|
|
54
|
+
// Organ (16-23)
|
|
55
|
+
drawbar_organ: 16,
|
|
56
|
+
percussive_organ: 17,
|
|
57
|
+
rock_organ: 18,
|
|
58
|
+
church_organ: 19,
|
|
59
|
+
reed_organ: 20,
|
|
60
|
+
accordion: 21,
|
|
61
|
+
harmonica: 22,
|
|
62
|
+
tango_accordion: 23,
|
|
63
|
+
|
|
64
|
+
// Guitar (24-31)
|
|
65
|
+
acoustic_guitar_nylon: 24,
|
|
66
|
+
acoustic_guitar_steel: 25,
|
|
67
|
+
electric_guitar_jazz: 26,
|
|
68
|
+
electric_guitar_clean: 27,
|
|
69
|
+
electric_guitar_muted: 28,
|
|
70
|
+
overdriven_guitar: 29,
|
|
71
|
+
distortion_guitar: 30,
|
|
72
|
+
guitar_harmonics: 31,
|
|
73
|
+
|
|
74
|
+
// Bass (32-39)
|
|
75
|
+
acoustic_bass: 32,
|
|
76
|
+
electric_bass_finger: 33,
|
|
77
|
+
electric_bass_pick: 34,
|
|
78
|
+
fretless_bass: 35,
|
|
79
|
+
slap_bass_1: 36,
|
|
80
|
+
slap_bass_2: 37,
|
|
81
|
+
synth_bass_1: 38,
|
|
82
|
+
synth_bass_2: 39,
|
|
83
|
+
|
|
84
|
+
// Strings (40-47)
|
|
85
|
+
violin: 40,
|
|
86
|
+
viola: 41,
|
|
87
|
+
cello: 42,
|
|
88
|
+
contrabass: 43,
|
|
89
|
+
tremolo_strings: 44,
|
|
90
|
+
pizzicato_strings: 45,
|
|
91
|
+
orchestral_harp: 46,
|
|
92
|
+
timpani: 47,
|
|
93
|
+
|
|
94
|
+
// Ensemble (48-55)
|
|
95
|
+
string_ensemble_1: 48,
|
|
96
|
+
string_ensemble_2: 49,
|
|
97
|
+
synth_strings_1: 50,
|
|
98
|
+
synth_strings_2: 51,
|
|
99
|
+
choir_aahs: 52,
|
|
100
|
+
voice_oohs: 53,
|
|
101
|
+
synth_choir: 54,
|
|
102
|
+
orchestra_hit: 55,
|
|
103
|
+
|
|
104
|
+
// Brass (56-63)
|
|
105
|
+
trumpet: 56,
|
|
106
|
+
trombone: 57,
|
|
107
|
+
tuba: 58,
|
|
108
|
+
muted_trumpet: 59,
|
|
109
|
+
french_horn: 60,
|
|
110
|
+
brass_section: 61,
|
|
111
|
+
synth_brass_1: 62,
|
|
112
|
+
synth_brass_2: 63,
|
|
113
|
+
|
|
114
|
+
// Reed (64-71)
|
|
115
|
+
soprano_sax: 64,
|
|
116
|
+
alto_sax: 65,
|
|
117
|
+
tenor_sax: 66,
|
|
118
|
+
baritone_sax: 67,
|
|
119
|
+
oboe: 68,
|
|
120
|
+
english_horn: 69,
|
|
121
|
+
bassoon: 70,
|
|
122
|
+
clarinet: 71,
|
|
123
|
+
|
|
124
|
+
// Pipe (72-79)
|
|
125
|
+
piccolo: 72,
|
|
126
|
+
flute: 73,
|
|
127
|
+
recorder: 74,
|
|
128
|
+
pan_flute: 75,
|
|
129
|
+
blown_bottle: 76,
|
|
130
|
+
shakuhachi: 77,
|
|
131
|
+
whistle: 78,
|
|
132
|
+
ocarina: 79,
|
|
133
|
+
|
|
134
|
+
// Synth Lead (80-87)
|
|
135
|
+
lead_1_square: 80,
|
|
136
|
+
lead_2_sawtooth: 81,
|
|
137
|
+
lead_3_calliope: 82,
|
|
138
|
+
lead_4_chiff: 83,
|
|
139
|
+
lead_5_charang: 84,
|
|
140
|
+
lead_6_voice: 85,
|
|
141
|
+
lead_7_fifths: 86,
|
|
142
|
+
lead_8_bass_lead: 87,
|
|
143
|
+
|
|
144
|
+
// Synth Pad (88-95)
|
|
145
|
+
pad_1_new_age: 88,
|
|
146
|
+
pad_2_warm: 89,
|
|
147
|
+
pad_3_polysynth: 90,
|
|
148
|
+
pad_4_choir: 91,
|
|
149
|
+
pad_5_bowed: 92,
|
|
150
|
+
pad_6_metallic: 93,
|
|
151
|
+
pad_7_halo: 94,
|
|
152
|
+
pad_8_sweep: 95,
|
|
153
|
+
|
|
154
|
+
// Synth Effects (96-103)
|
|
155
|
+
fx_1_rain: 96,
|
|
156
|
+
fx_2_soundtrack: 97,
|
|
157
|
+
fx_3_crystal: 98,
|
|
158
|
+
fx_4_atmosphere: 99,
|
|
159
|
+
fx_5_brightness: 100,
|
|
160
|
+
fx_6_goblins: 101,
|
|
161
|
+
fx_7_echoes: 102,
|
|
162
|
+
fx_8_scifi: 103,
|
|
163
|
+
|
|
164
|
+
// Ethnic (104-111)
|
|
165
|
+
sitar: 104,
|
|
166
|
+
banjo: 105,
|
|
167
|
+
shamisen: 106,
|
|
168
|
+
koto: 107,
|
|
169
|
+
kalimba: 108,
|
|
170
|
+
bagpipe: 109,
|
|
171
|
+
fiddle: 110,
|
|
172
|
+
shanai: 111,
|
|
173
|
+
|
|
174
|
+
// Percussive (112-119)
|
|
175
|
+
tinkle_bell: 112,
|
|
176
|
+
agogo: 113,
|
|
177
|
+
steel_drums: 114,
|
|
178
|
+
woodblock: 115,
|
|
179
|
+
taiko_drum: 116,
|
|
180
|
+
melodic_tom: 117,
|
|
181
|
+
synth_drum: 118,
|
|
182
|
+
reverse_cymbal: 119,
|
|
183
|
+
|
|
184
|
+
// Sound Effects (120-127)
|
|
185
|
+
guitar_fret_noise: 120,
|
|
186
|
+
breath_noise: 121,
|
|
187
|
+
seashore: 122,
|
|
188
|
+
bird_tweet: 123,
|
|
189
|
+
telephone_ring: 124,
|
|
190
|
+
helicopter: 125,
|
|
191
|
+
applause: 126,
|
|
192
|
+
gunshot: 127
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async initializeMIDI() {
|
|
197
|
+
if (this.sf2Player) return;
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
this.sf2Player = new SoundFont(this.context); // Pass the context
|
|
201
|
+
await this.sf2Player.loadSoundFontFromBase64(window.TimGM6mb_BASE64);
|
|
202
|
+
this.midiReady = true;
|
|
203
|
+
} catch (error) {
|
|
204
|
+
console.error("[AudioManager] Failed to initialize SF2 player:", error);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// MIDI Control Methods
|
|
209
|
+
programChange(channel, program) {
|
|
210
|
+
if (!this.midiReady || !this.sf2Player) return;
|
|
211
|
+
this.midiChannels[channel].program = program;
|
|
212
|
+
this.sf2Player.channel = channel;
|
|
213
|
+
this.sf2Player.program = program;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
bankSelect(channel, bank) {
|
|
217
|
+
if (!this.midiReady || !this.sf2Player) return;
|
|
218
|
+
this.midiChannels[channel].bank = bank;
|
|
219
|
+
this.sf2Player.channel = channel;
|
|
220
|
+
this.sf2Player.bank = bank;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
noteOn(channel, note, velocity) {
|
|
224
|
+
if (!this.midiReady || !this.sf2Player) return;
|
|
225
|
+
this.sf2Player.noteOn(note, velocity, channel);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
noteOff(channel, note) {
|
|
229
|
+
if (!this.midiReady || !this.sf2Player) return;
|
|
230
|
+
this.sf2Player.noteOff(note, 127, channel);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
pitchBend(channel, value) {
|
|
234
|
+
if (!this.midiReady) return;
|
|
235
|
+
this.midiChannels[channel].pitchBend = value;
|
|
236
|
+
// TODO: Implement pitch bend with SF2 player if supported
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
setChannelVolume(channel, value) {
|
|
240
|
+
if (!this.midiReady) return;
|
|
241
|
+
this.midiChannels[channel].volume = value;
|
|
242
|
+
// TODO: Implement volume control with SF2 player if supported
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
setChannelPan(channel, value) {
|
|
246
|
+
if (!this.midiReady) return;
|
|
247
|
+
this.midiChannels[channel].pan = value;
|
|
248
|
+
// TODO: Implement pan with SF2 player if supported
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
allNotesOff(channel) {
|
|
252
|
+
if (!this.midiReady) return;
|
|
253
|
+
// Send noteOff for all possible notes on this channel
|
|
254
|
+
for (let note = 0; note < 128; note++) {
|
|
255
|
+
this.noteOff(channel, note);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
resetAllControllers(channel) {
|
|
260
|
+
if (!this.midiReady) return;
|
|
261
|
+
const ch = this.midiChannels[channel];
|
|
262
|
+
ch.volume = 127;
|
|
263
|
+
ch.pan = 64;
|
|
264
|
+
ch.expression = 127;
|
|
265
|
+
ch.pitchBend = 0;
|
|
266
|
+
this.allNotesOff(channel);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Original createSound maintained for compatibility
|
|
270
|
+
createSound(name, options, type = "simple") {
|
|
271
|
+
// Handle legacy format where options is just a frequency number
|
|
272
|
+
if (typeof options === "number") {
|
|
273
|
+
options = {
|
|
274
|
+
frequency: this.midiToFrequency(options),
|
|
275
|
+
oscillatorType: type !== "simple" ? type : "sine" // type parameter was oscillatorType in old format
|
|
276
|
+
};
|
|
277
|
+
type = "simple";
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
switch (type) {
|
|
281
|
+
case "sonicpi":
|
|
282
|
+
const parsed = this.parseSonicPi(options.script, options.samples);
|
|
283
|
+
|
|
284
|
+
if (options.samples) {
|
|
285
|
+
// Check once if we need MIDI
|
|
286
|
+
if (Object.values(options.samples).some((def) => def.soundType === "midi")) {
|
|
287
|
+
this.initializeMIDI();
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Then just store all the definitions
|
|
291
|
+
Object.entries(options.samples).forEach(([sampleName, definition]) => {
|
|
292
|
+
this.sampleDefinitions.set(sampleName, definition);
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
this.sounds.set(name, {
|
|
297
|
+
type: "sonicpi",
|
|
298
|
+
script: options.script,
|
|
299
|
+
parsedSequence: parsed.sequence,
|
|
300
|
+
samples: options.samples || {},
|
|
301
|
+
bpm: parsed.bpm
|
|
302
|
+
});
|
|
303
|
+
break;
|
|
304
|
+
|
|
305
|
+
case "simple":
|
|
306
|
+
default:
|
|
307
|
+
// Extract the envelope values with defaults
|
|
308
|
+
const envelope = options.envelope || {
|
|
309
|
+
attack: 0.1,
|
|
310
|
+
decay: 0.2,
|
|
311
|
+
sustain: 0.7,
|
|
312
|
+
release: 0.3
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
// Create the sound definition with all parameters
|
|
316
|
+
this.sounds.set(name, {
|
|
317
|
+
type: "simple",
|
|
318
|
+
oscillatorType: options.type || "sine", // This handles the waveform
|
|
319
|
+
frequency: options.frequency || 440,
|
|
320
|
+
amp: options.amp || 0.5,
|
|
321
|
+
duration: options.duration || 1,
|
|
322
|
+
envelope: envelope
|
|
323
|
+
});
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
initializeAudioContext() {
|
|
329
|
+
const enableAudio = () => {
|
|
330
|
+
if (!this.context) {
|
|
331
|
+
this.context = new (window.AudioContext || window.webkitAudioContext)();
|
|
332
|
+
this.masterGain = this.context.createGain();
|
|
333
|
+
this.masterGain.connect(this.context.destination);
|
|
334
|
+
this.setVolume(0.5);
|
|
335
|
+
|
|
336
|
+
// Create sample buffers once context is available
|
|
337
|
+
this.createSampleBuffers();
|
|
338
|
+
}
|
|
339
|
+
document.removeEventListener("click", enableAudio);
|
|
340
|
+
document.removeEventListener("touchstart", enableAudio);
|
|
341
|
+
document.removeEventListener("keydown", enableAudio);
|
|
342
|
+
};
|
|
343
|
+
document.addEventListener("click", enableAudio);
|
|
344
|
+
document.addEventListener("touchstart", enableAudio);
|
|
345
|
+
document.addEventListener("keydown", enableAudio);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
createSampleBuffers() {
|
|
349
|
+
this.sampleDefinitions.forEach((definition, sampleName) => {
|
|
350
|
+
// Skip buffer creation for MIDI samples
|
|
351
|
+
if (definition.soundType === "midi") {
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
const bufferSize = this.context.sampleRate * 2;
|
|
355
|
+
const buffer = this.context.createBuffer(2, bufferSize, this.context.sampleRate);
|
|
356
|
+
const left = buffer.getChannelData(0);
|
|
357
|
+
const right = buffer.getChannelData(1);
|
|
358
|
+
|
|
359
|
+
for (let i = 0; i < bufferSize; i++) {
|
|
360
|
+
const t = i / this.context.sampleRate;
|
|
361
|
+
let value;
|
|
362
|
+
|
|
363
|
+
switch (definition.type) {
|
|
364
|
+
case "sin":
|
|
365
|
+
// Adjust decay to be less aggressive
|
|
366
|
+
const decayFactor = definition.decay * 5; // Slow down the decay
|
|
367
|
+
value = Math.sin(t * definition.frequency * 2 * Math.PI) * Math.exp(-t / decayFactor);
|
|
368
|
+
break;
|
|
369
|
+
case "square":
|
|
370
|
+
value = Math.sign(Math.sin(t * definition.frequency)) * Math.exp(-t * definition.decay);
|
|
371
|
+
break;
|
|
372
|
+
case "saw":
|
|
373
|
+
value = ((t * definition.frequency) % 1) * 2 - 1;
|
|
374
|
+
value *= Math.exp(-t * definition.decay);
|
|
375
|
+
break;
|
|
376
|
+
default:
|
|
377
|
+
value = Math.sin(t * definition.frequency) * Math.exp(-t * definition.decay);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
left[i] = right[i] = value;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
this.samples.set(sampleName, buffer);
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
preloadSample(name, definition) {
|
|
388
|
+
if (!this.context) return;
|
|
389
|
+
|
|
390
|
+
const bufferSize = this.context.sampleRate * 2;
|
|
391
|
+
const buffer = this.context.createBuffer(2, bufferSize, this.context.sampleRate);
|
|
392
|
+
const left = buffer.getChannelData(0);
|
|
393
|
+
const right = buffer.getChannelData(1);
|
|
394
|
+
|
|
395
|
+
for (let i = 0; i < bufferSize; i++) {
|
|
396
|
+
const t = i / this.context.sampleRate;
|
|
397
|
+
let value;
|
|
398
|
+
|
|
399
|
+
switch (definition.type) {
|
|
400
|
+
case "sin":
|
|
401
|
+
value = Math.sin(t * definition.frequency) * Math.exp(-t * definition.decay);
|
|
402
|
+
break;
|
|
403
|
+
case "square":
|
|
404
|
+
value = Math.sign(Math.sin(t * definition.frequency)) * Math.exp(-t * definition.decay);
|
|
405
|
+
break;
|
|
406
|
+
case "saw":
|
|
407
|
+
value = ((t * definition.frequency) % 1) * 2 - 1;
|
|
408
|
+
value *= Math.exp(-t * definition.decay);
|
|
409
|
+
break;
|
|
410
|
+
default:
|
|
411
|
+
value = Math.sin(t * definition.frequency) * Math.exp(-t * definition.decay);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
left[i] = right[i] = value;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
this.samples.set(name, buffer);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// New method for creating FM synthesis sounds
|
|
421
|
+
createFMSound(
|
|
422
|
+
name,
|
|
423
|
+
{
|
|
424
|
+
carrierFreq,
|
|
425
|
+
modulatorFreq,
|
|
426
|
+
modulationIndex = 100,
|
|
427
|
+
type = "sine",
|
|
428
|
+
duration,
|
|
429
|
+
envelope = {
|
|
430
|
+
attack: 0.01,
|
|
431
|
+
decay: 0.1,
|
|
432
|
+
sustain: 0.5,
|
|
433
|
+
release: 0.1
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
) {
|
|
437
|
+
this.sounds.set(name, {
|
|
438
|
+
type: "fm",
|
|
439
|
+
carrierFreq,
|
|
440
|
+
modulatorFreq,
|
|
441
|
+
modulationIndex,
|
|
442
|
+
oscillatorType: type,
|
|
443
|
+
duration,
|
|
444
|
+
envelope
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Create multi-oscillator sound
|
|
449
|
+
createComplexSound(
|
|
450
|
+
name,
|
|
451
|
+
{
|
|
452
|
+
frequencies = [], // Array of frequency values
|
|
453
|
+
types = [], // Array of oscillator types
|
|
454
|
+
mix = [], // Array of volume levels
|
|
455
|
+
duration,
|
|
456
|
+
envelope = {
|
|
457
|
+
attack: 0.01,
|
|
458
|
+
decay: 0.1,
|
|
459
|
+
sustain: 0.5,
|
|
460
|
+
release: 0.1
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
) {
|
|
464
|
+
this.sounds.set(name, {
|
|
465
|
+
type: "complex",
|
|
466
|
+
frequencies,
|
|
467
|
+
oscillatorTypes: types,
|
|
468
|
+
mix,
|
|
469
|
+
duration,
|
|
470
|
+
envelope
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Create noise-based sound
|
|
475
|
+
createNoiseSound(
|
|
476
|
+
name,
|
|
477
|
+
{
|
|
478
|
+
duration,
|
|
479
|
+
noiseType = "white", // 'white', 'pink', or 'brown'
|
|
480
|
+
envelope = {
|
|
481
|
+
attack: 0.01,
|
|
482
|
+
decay: 0.1,
|
|
483
|
+
sustain: 0.5,
|
|
484
|
+
release: 0.1
|
|
485
|
+
},
|
|
486
|
+
filterOptions = {
|
|
487
|
+
frequency: 1000,
|
|
488
|
+
Q: 1,
|
|
489
|
+
type: "lowpass"
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
) {
|
|
493
|
+
this.sounds.set(name, {
|
|
494
|
+
type: "noise",
|
|
495
|
+
noiseType,
|
|
496
|
+
duration,
|
|
497
|
+
envelope,
|
|
498
|
+
filterOptions
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Create sweep effect sound
|
|
503
|
+
createSweepSound(
|
|
504
|
+
name,
|
|
505
|
+
{
|
|
506
|
+
startFreq,
|
|
507
|
+
endFreq,
|
|
508
|
+
type = "sine",
|
|
509
|
+
duration,
|
|
510
|
+
envelope = {
|
|
511
|
+
attack: 0.01,
|
|
512
|
+
decay: 0.1,
|
|
513
|
+
sustain: 0.5,
|
|
514
|
+
release: 0.1
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
) {
|
|
518
|
+
this.sounds.set(name, {
|
|
519
|
+
type: "sweep",
|
|
520
|
+
startFreq,
|
|
521
|
+
endFreq,
|
|
522
|
+
oscillatorType: type,
|
|
523
|
+
duration,
|
|
524
|
+
envelope
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Helper to parse parameters like "attack: 0.1, release: 0.3"
|
|
529
|
+
parseParameters(paramsString) {
|
|
530
|
+
const params = {};
|
|
531
|
+
const pairs = paramsString.split(",");
|
|
532
|
+
|
|
533
|
+
for (const pair of pairs) {
|
|
534
|
+
const [key, value] = pair.split(":").map((s) => s.trim());
|
|
535
|
+
params[key] = parseFloat(value);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
return params;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Helper to convert MIDI note numbers to frequency
|
|
542
|
+
midiToFrequency(note) {
|
|
543
|
+
if (typeof note !== "number" || isNaN(note)) {
|
|
544
|
+
console.warn("[AudioManager] Invalid MIDI note:", note);
|
|
545
|
+
return 440; // Return A4 as default
|
|
546
|
+
}
|
|
547
|
+
return 440 * Math.pow(2, (note - 69) / 12);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Set individual sound volume
|
|
552
|
+
* @param {string} name - Sound name
|
|
553
|
+
* @param {number} volume - Volume level (0.0 to 1.0)
|
|
554
|
+
*/
|
|
555
|
+
setSoundVolume(name, volume) {
|
|
556
|
+
volume = Math.max(0, Math.min(1, volume)); // Clamp between 0 and 1
|
|
557
|
+
this.soundVolumes.set(name, volume);
|
|
558
|
+
|
|
559
|
+
// Update any currently playing instances
|
|
560
|
+
const instances = this.soundInstances.get(name) || [];
|
|
561
|
+
instances.forEach(instance => {
|
|
562
|
+
if (instance.gainNode && instance.gainNode.gain) {
|
|
563
|
+
const currentTime = this.context.currentTime;
|
|
564
|
+
const masterVol = this.masterGain ? this.masterGain.gain.value / this.baseVolume : 1.0;
|
|
565
|
+
const finalVolume = volume * masterVol;
|
|
566
|
+
instance.gainNode.gain.setValueAtTime(finalVolume, currentTime);
|
|
567
|
+
}
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Get individual sound volume
|
|
573
|
+
* @param {string} name - Sound name
|
|
574
|
+
* @returns {number} Volume level (0.0 to 1.0)
|
|
575
|
+
*/
|
|
576
|
+
getSoundVolume(name) {
|
|
577
|
+
return this.soundVolumes.get(name) || 1.0;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Enhanced play method to handle all sound types with new options
|
|
581
|
+
play(name, options = {}) {
|
|
582
|
+
if (!this.enabled || !this.context) return;
|
|
583
|
+
|
|
584
|
+
const sound = this.sounds.get(name);
|
|
585
|
+
if (!sound) return;
|
|
586
|
+
|
|
587
|
+
// Always prevent sound stacking - stop any existing instances of this sound
|
|
588
|
+
const instances = this.soundInstances.get(name) || [];
|
|
589
|
+
if (instances.length > 0) {
|
|
590
|
+
// Stop existing instances
|
|
591
|
+
instances.forEach(instance => this.stopSoundInstance(instance));
|
|
592
|
+
this.soundInstances.set(name, []);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Set up repeat tracking
|
|
596
|
+
const repeatInfo = {
|
|
597
|
+
count: options.repeat || 1,
|
|
598
|
+
current: 0,
|
|
599
|
+
onEnd: options.onEnd || null
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
// Play the first instance
|
|
603
|
+
const controlObject = this.playInstance(name, sound, options, repeatInfo);
|
|
604
|
+
|
|
605
|
+
return controlObject;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Play a single instance of a sound
|
|
610
|
+
*/
|
|
611
|
+
playInstance(name, sound, options, repeatInfo) {
|
|
612
|
+
let controlObject;
|
|
613
|
+
|
|
614
|
+
// Apply individual sound volume if set
|
|
615
|
+
const soundVolume = options.volume !== undefined ? options.volume : this.getSoundVolume(name);
|
|
616
|
+
const enhancedOptions = { ...options, volume: soundVolume };
|
|
617
|
+
|
|
618
|
+
switch (sound.type) {
|
|
619
|
+
case "simple":
|
|
620
|
+
controlObject = this.playSimple(sound, enhancedOptions);
|
|
621
|
+
break;
|
|
622
|
+
case "fm":
|
|
623
|
+
controlObject = this.playFM(sound, enhancedOptions);
|
|
624
|
+
break;
|
|
625
|
+
case "complex":
|
|
626
|
+
controlObject = this.playComplex(sound, enhancedOptions);
|
|
627
|
+
break;
|
|
628
|
+
case "noise":
|
|
629
|
+
controlObject = this.playNoise(sound, enhancedOptions);
|
|
630
|
+
break;
|
|
631
|
+
case "sweep":
|
|
632
|
+
controlObject = this.playSweep(sound, enhancedOptions);
|
|
633
|
+
break;
|
|
634
|
+
case "sonicpi":
|
|
635
|
+
controlObject = this.playSonicPi(sound, enhancedOptions);
|
|
636
|
+
break;
|
|
637
|
+
default:
|
|
638
|
+
console.warn(`[AudioManager] Unknown sound type for ${name}`);
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
if (controlObject) {
|
|
643
|
+
// Track this instance
|
|
644
|
+
const instances = this.soundInstances.get(name) || [];
|
|
645
|
+
const instanceData = {
|
|
646
|
+
...controlObject,
|
|
647
|
+
name: name,
|
|
648
|
+
startTime: this.context.currentTime,
|
|
649
|
+
repeatInfo: repeatInfo
|
|
650
|
+
};
|
|
651
|
+
instances.push(instanceData);
|
|
652
|
+
this.soundInstances.set(name, instances);
|
|
653
|
+
|
|
654
|
+
// Set up sound end detection for callbacks and repeats
|
|
655
|
+
this.setupSoundEndDetection(name, sound, instanceData);
|
|
656
|
+
|
|
657
|
+
this.activeSounds.set(name, controlObject);
|
|
658
|
+
}
|
|
659
|
+
return controlObject;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Set up sound end detection for callbacks and repeat functionality
|
|
664
|
+
*/
|
|
665
|
+
setupSoundEndDetection(name, sound, instanceData) {
|
|
666
|
+
// Calculate sound duration
|
|
667
|
+
let duration = sound.duration || 1;
|
|
668
|
+
if (sound.envelope) {
|
|
669
|
+
const env = sound.envelope;
|
|
670
|
+
duration = (env.attack || 0.1) + (env.decay || 0.2) + (env.release || 0.3);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Set timeout to handle sound end
|
|
674
|
+
const endTimeout = setTimeout(() => {
|
|
675
|
+
this.handleSoundEnd(name, instanceData);
|
|
676
|
+
}, duration * 1000);
|
|
677
|
+
|
|
678
|
+
instanceData.endTimeout = endTimeout;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Handle sound end - cleanup, callbacks, and repeat logic
|
|
683
|
+
*/
|
|
684
|
+
handleSoundEnd(name, instanceData) {
|
|
685
|
+
// Remove this instance from tracking
|
|
686
|
+
const instances = this.soundInstances.get(name) || [];
|
|
687
|
+
const index = instances.indexOf(instanceData);
|
|
688
|
+
if (index > -1) {
|
|
689
|
+
instances.splice(index, 1);
|
|
690
|
+
this.soundInstances.set(name, instances);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const repeatInfo = instanceData.repeatInfo;
|
|
694
|
+
repeatInfo.current++;
|
|
695
|
+
|
|
696
|
+
// Check if we should repeat
|
|
697
|
+
const shouldRepeat = (repeatInfo.count === -1) || (repeatInfo.current < repeatInfo.count);
|
|
698
|
+
|
|
699
|
+
if (shouldRepeat) {
|
|
700
|
+
// Schedule next repeat
|
|
701
|
+
const repeatTimeout = setTimeout(() => {
|
|
702
|
+
const sound = this.sounds.get(name);
|
|
703
|
+
if (sound) {
|
|
704
|
+
this.playInstance(name, sound, instanceData.options || {}, repeatInfo);
|
|
705
|
+
}
|
|
706
|
+
}, 50); // Small delay between repeats
|
|
707
|
+
|
|
708
|
+
// Track repeat timeout for cleanup
|
|
709
|
+
const repeatTimeouts = this.repeatTimeouts.get(name) || [];
|
|
710
|
+
repeatTimeouts.push(repeatTimeout);
|
|
711
|
+
this.repeatTimeouts.set(name, repeatTimeouts);
|
|
712
|
+
} else {
|
|
713
|
+
// Sound is completely finished, call onEnd callback
|
|
714
|
+
if (repeatInfo.onEnd) {
|
|
715
|
+
try {
|
|
716
|
+
repeatInfo.onEnd({
|
|
717
|
+
soundName: name,
|
|
718
|
+
totalRepeats: repeatInfo.current
|
|
719
|
+
});
|
|
720
|
+
} catch (error) {
|
|
721
|
+
console.error(`[AudioManager] Error in onEnd callback for ${name}:`, error);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* Stop a specific sound instance
|
|
729
|
+
*/
|
|
730
|
+
stopSoundInstance(instanceData) {
|
|
731
|
+
const now = this.context.currentTime;
|
|
732
|
+
|
|
733
|
+
// Clear end timeout
|
|
734
|
+
if (instanceData.endTimeout) {
|
|
735
|
+
clearTimeout(instanceData.endTimeout);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// Stop audio nodes
|
|
739
|
+
if (instanceData.nodes) {
|
|
740
|
+
instanceData.nodes.forEach((node) => {
|
|
741
|
+
if (node.gain) {
|
|
742
|
+
node.gain.setValueAtTime(node.gain.value, now);
|
|
743
|
+
node.gain.linearRampToValueAtTime(0, now + 0.05);
|
|
744
|
+
}
|
|
745
|
+
setTimeout(() => {
|
|
746
|
+
try {
|
|
747
|
+
if (node.stop) node.stop();
|
|
748
|
+
} catch (e) {
|
|
749
|
+
// Node might have already stopped
|
|
750
|
+
}
|
|
751
|
+
}, 50);
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// Handle MIDI notes
|
|
756
|
+
if (instanceData.midiNotes) {
|
|
757
|
+
instanceData.midiNotes.forEach((note) => {
|
|
758
|
+
if (this.sf2Player) {
|
|
759
|
+
this.sf2Player.noteOff(note.note, note.velocity || 127, note.channel || 0);
|
|
760
|
+
}
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
stopSound(name) {
|
|
766
|
+
// Stop all instances of this sound
|
|
767
|
+
const instances = this.soundInstances.get(name) || [];
|
|
768
|
+
instances.forEach(instance => {
|
|
769
|
+
this.stopSoundInstance(instance);
|
|
770
|
+
});
|
|
771
|
+
this.soundInstances.set(name, []);
|
|
772
|
+
|
|
773
|
+
// Clear any pending repeat timeouts
|
|
774
|
+
const repeatTimeouts = this.repeatTimeouts.get(name) || [];
|
|
775
|
+
repeatTimeouts.forEach(timeout => clearTimeout(timeout));
|
|
776
|
+
this.repeatTimeouts.set(name, []);
|
|
777
|
+
|
|
778
|
+
// Original stopSound logic for backward compatibility
|
|
779
|
+
const sound = this.activeSounds.get(name);
|
|
780
|
+
if (sound) {
|
|
781
|
+
const now = this.context.currentTime;
|
|
782
|
+
|
|
783
|
+
if (sound.nodes) {
|
|
784
|
+
// Handle WebAudio nodes
|
|
785
|
+
sound.nodes.forEach((node) => {
|
|
786
|
+
if (node.gain) {
|
|
787
|
+
node.gain.setValueAtTime(node.gain.value, now);
|
|
788
|
+
node.gain.linearRampToValueAtTime(0, now + 0.05);
|
|
789
|
+
}
|
|
790
|
+
setTimeout(() => {
|
|
791
|
+
try {
|
|
792
|
+
if (node.stop) {
|
|
793
|
+
node.stop();
|
|
794
|
+
}
|
|
795
|
+
} catch (e) {
|
|
796
|
+
// Node might have already stopped
|
|
797
|
+
}
|
|
798
|
+
}, 50);
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// If this is a MIDI sound, handle note offs
|
|
803
|
+
if (sound.midiNotes) {
|
|
804
|
+
sound.midiNotes.forEach((note) => {
|
|
805
|
+
if (this.sf2Player) {
|
|
806
|
+
this.sf2Player.noteOff(note.note, note.velocity || 127, note.channel || 0);
|
|
807
|
+
}
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
this.activeSounds.delete(name);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
stopAllSounds() {
|
|
816
|
+
// Stop all sound instances
|
|
817
|
+
this.soundInstances.forEach((instances, name) => {
|
|
818
|
+
instances.forEach(instance => {
|
|
819
|
+
this.stopSoundInstance(instance);
|
|
820
|
+
});
|
|
821
|
+
});
|
|
822
|
+
this.soundInstances.clear();
|
|
823
|
+
|
|
824
|
+
// Clear all repeat timeouts
|
|
825
|
+
this.repeatTimeouts.forEach((timeouts) => {
|
|
826
|
+
timeouts.forEach(timeout => clearTimeout(timeout));
|
|
827
|
+
});
|
|
828
|
+
this.repeatTimeouts.clear();
|
|
829
|
+
|
|
830
|
+
// Stop all active sounds (original logic)
|
|
831
|
+
this.activeSounds.forEach((sound, name) => {
|
|
832
|
+
this.stopSound(name);
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
// Clear all scheduled MIDI timeouts
|
|
836
|
+
this.scheduledMIDIEvents.forEach((timeoutId) => {
|
|
837
|
+
clearTimeout(timeoutId);
|
|
838
|
+
});
|
|
839
|
+
this.scheduledMIDIEvents.clear();
|
|
840
|
+
|
|
841
|
+
// Additionally ensure all MIDI notes are off on all channels
|
|
842
|
+
if (this.sf2Player) {
|
|
843
|
+
for (let channel = 0; channel < 16; channel++) {
|
|
844
|
+
this.allNotesOff(channel);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
this.activeSounds.clear();
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// Original simple oscillator playback (enhanced with volume support)
|
|
852
|
+
playSimple(sound, { pan = 0, volume = 1.0 } = {}) {
|
|
853
|
+
const oscillator = this.context.createOscillator();
|
|
854
|
+
oscillator.type = sound.oscillatorType;
|
|
855
|
+
const gainNode = this.context.createGain();
|
|
856
|
+
const stereoPanner = this.context.createStereoPanner();
|
|
857
|
+
|
|
858
|
+
// Set the oscillator type from the sound definition
|
|
859
|
+
oscillator.type = sound.oscillatorType;
|
|
860
|
+
oscillator.frequency.value = sound.frequency;
|
|
861
|
+
|
|
862
|
+
stereoPanner.pan.value = pan;
|
|
863
|
+
|
|
864
|
+
// Apply the ADSR envelope with individual volume
|
|
865
|
+
const now = this.context.currentTime;
|
|
866
|
+
const envelope = sound.envelope;
|
|
867
|
+
const finalAmp = sound.amp * volume; // Apply individual sound volume
|
|
868
|
+
|
|
869
|
+
gainNode.gain.setValueAtTime(0, now);
|
|
870
|
+
gainNode.gain.linearRampToValueAtTime(finalAmp, now + envelope.attack);
|
|
871
|
+
gainNode.gain.linearRampToValueAtTime(finalAmp * envelope.sustain, now + envelope.attack + envelope.decay);
|
|
872
|
+
gainNode.gain.linearRampToValueAtTime(0, now + envelope.attack + envelope.decay + envelope.release);
|
|
873
|
+
|
|
874
|
+
// Store gain node for potential volume changes
|
|
875
|
+
const controlObject = {
|
|
876
|
+
oscillator,
|
|
877
|
+
gainNode,
|
|
878
|
+
nodes: [oscillator, gainNode, stereoPanner]
|
|
879
|
+
};
|
|
880
|
+
|
|
881
|
+
oscillator.connect(gainNode);
|
|
882
|
+
gainNode.connect(stereoPanner);
|
|
883
|
+
stereoPanner.connect(this.masterGain);
|
|
884
|
+
|
|
885
|
+
oscillator.start();
|
|
886
|
+
oscillator.stop(now + envelope.attack + envelope.decay + envelope.release);
|
|
887
|
+
|
|
888
|
+
return controlObject;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// FM synthesis playback (enhanced with volume support)
|
|
892
|
+
playFM(sound, { pan = 0, volume = 1.0 } = {}) {
|
|
893
|
+
const carrier = this.context.createOscillator();
|
|
894
|
+
const modulator = this.context.createOscillator();
|
|
895
|
+
const modulatorGain = this.context.createGain();
|
|
896
|
+
const gainNode = this.context.createGain();
|
|
897
|
+
const stereoPanner = this.context.createStereoPanner();
|
|
898
|
+
|
|
899
|
+
carrier.type = sound.oscillatorType;
|
|
900
|
+
modulator.type = "sine";
|
|
901
|
+
|
|
902
|
+
carrier.frequency.value = sound.carrierFreq;
|
|
903
|
+
modulator.frequency.value = sound.modulatorFreq;
|
|
904
|
+
modulatorGain.gain.value = sound.modulationIndex;
|
|
905
|
+
|
|
906
|
+
stereoPanner.pan.value = pan;
|
|
907
|
+
|
|
908
|
+
modulator.connect(modulatorGain);
|
|
909
|
+
modulatorGain.connect(carrier.frequency);
|
|
910
|
+
carrier.connect(gainNode);
|
|
911
|
+
gainNode.connect(stereoPanner);
|
|
912
|
+
stereoPanner.connect(this.masterGain);
|
|
913
|
+
|
|
914
|
+
this.applyEnvelope(gainNode.gain, sound.envelope, sound.duration, volume);
|
|
915
|
+
|
|
916
|
+
carrier.start();
|
|
917
|
+
modulator.start();
|
|
918
|
+
carrier.stop(this.context.currentTime + sound.duration);
|
|
919
|
+
modulator.stop(this.context.currentTime + sound.duration);
|
|
920
|
+
|
|
921
|
+
return {
|
|
922
|
+
oscillator: carrier,
|
|
923
|
+
gainNode,
|
|
924
|
+
nodes: [carrier, modulator, gainNode, stereoPanner]
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// Multi-oscillator playback (enhanced with volume support)
|
|
929
|
+
playComplex(sound, { pan = 0, volume = 1.0 } = {}) {
|
|
930
|
+
const stereoPanner = this.context.createStereoPanner();
|
|
931
|
+
const masterGain = this.context.createGain();
|
|
932
|
+
const oscillators = [];
|
|
933
|
+
|
|
934
|
+
stereoPanner.pan.value = pan;
|
|
935
|
+
masterGain.connect(stereoPanner);
|
|
936
|
+
stereoPanner.connect(this.masterGain);
|
|
937
|
+
|
|
938
|
+
sound.frequencies.forEach((freq, i) => {
|
|
939
|
+
const osc = this.context.createOscillator();
|
|
940
|
+
const gain = this.context.createGain();
|
|
941
|
+
|
|
942
|
+
osc.type = sound.oscillatorTypes[i] || "sine";
|
|
943
|
+
osc.frequency.value = freq;
|
|
944
|
+
gain.gain.value = sound.mix[i] || 0.5;
|
|
945
|
+
|
|
946
|
+
osc.connect(gain);
|
|
947
|
+
gain.connect(masterGain);
|
|
948
|
+
oscillators.push(osc);
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
this.applyEnvelope(masterGain.gain, sound.envelope, sound.duration, volume);
|
|
952
|
+
|
|
953
|
+
oscillators.forEach((osc) => {
|
|
954
|
+
osc.start();
|
|
955
|
+
osc.stop(this.context.currentTime + sound.duration);
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
return {
|
|
959
|
+
oscillator: oscillators[0], // For compatibility
|
|
960
|
+
gainNode: masterGain,
|
|
961
|
+
nodes: [masterGain, stereoPanner, ...oscillators]
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// Noise generation and playback (enhanced with volume support)
|
|
966
|
+
playNoise(sound, { pan = 0, volume = 1.0 } = {}) {
|
|
967
|
+
const bufferSize = this.context.sampleRate * sound.duration;
|
|
968
|
+
const buffer = this.context.createBuffer(1, bufferSize, this.context.sampleRate);
|
|
969
|
+
const data = buffer.getChannelData(0);
|
|
970
|
+
|
|
971
|
+
// Generate noise based on type
|
|
972
|
+
switch (sound.noiseType) {
|
|
973
|
+
case "white":
|
|
974
|
+
for (let i = 0; i < bufferSize; i++) {
|
|
975
|
+
data[i] = Math.random() * 2 - 1;
|
|
976
|
+
}
|
|
977
|
+
break;
|
|
978
|
+
case "pink": {
|
|
979
|
+
let b0 = 0,
|
|
980
|
+
b1 = 0,
|
|
981
|
+
b2 = 0,
|
|
982
|
+
b3 = 0,
|
|
983
|
+
b4 = 0,
|
|
984
|
+
b5 = 0;
|
|
985
|
+
for (let i = 0; i < bufferSize; i++) {
|
|
986
|
+
const white = Math.random() * 2 - 1;
|
|
987
|
+
b0 = 0.99886 * b0 + white * 0.0555179;
|
|
988
|
+
b1 = 0.99332 * b1 + white * 0.0750759;
|
|
989
|
+
b2 = 0.969 * b2 + white * 0.153852;
|
|
990
|
+
b3 = 0.8665 * b3 + white * 0.3104856;
|
|
991
|
+
b4 = 0.55 * b4 + white * 0.5329522;
|
|
992
|
+
b5 = -0.7616 * b5 - white * 0.016898;
|
|
993
|
+
data[i] = b0 + b1 + b2 + b3 + b4 + b5;
|
|
994
|
+
}
|
|
995
|
+
break;
|
|
996
|
+
}
|
|
997
|
+
case "brown": {
|
|
998
|
+
let lastOut = 0;
|
|
999
|
+
for (let i = 0; i < bufferSize; i++) {
|
|
1000
|
+
const white = Math.random() * 2 - 1;
|
|
1001
|
+
data[i] = (lastOut + 0.02 * white) / 1.02;
|
|
1002
|
+
lastOut = data[i];
|
|
1003
|
+
}
|
|
1004
|
+
break;
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
const noise = this.context.createBufferSource();
|
|
1009
|
+
const filter = this.context.createBiquadFilter();
|
|
1010
|
+
const gainNode = this.context.createGain();
|
|
1011
|
+
const stereoPanner = this.context.createStereoPanner();
|
|
1012
|
+
|
|
1013
|
+
noise.buffer = buffer;
|
|
1014
|
+
|
|
1015
|
+
filter.type = sound.filterOptions.type;
|
|
1016
|
+
filter.frequency.value = sound.filterOptions.frequency;
|
|
1017
|
+
filter.Q.value = sound.filterOptions.Q;
|
|
1018
|
+
|
|
1019
|
+
stereoPanner.pan.value = pan;
|
|
1020
|
+
|
|
1021
|
+
noise.connect(filter);
|
|
1022
|
+
filter.connect(gainNode);
|
|
1023
|
+
gainNode.connect(stereoPanner);
|
|
1024
|
+
stereoPanner.connect(this.masterGain);
|
|
1025
|
+
|
|
1026
|
+
this.applyEnvelope(gainNode.gain, sound.envelope, sound.duration, volume);
|
|
1027
|
+
|
|
1028
|
+
noise.start();
|
|
1029
|
+
return {
|
|
1030
|
+
oscillator: noise,
|
|
1031
|
+
gainNode,
|
|
1032
|
+
nodes: [noise, filter, gainNode, stereoPanner]
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
// Frequency sweep playback (enhanced with volume support)
|
|
1037
|
+
playSweep(sound, { pan = 0, volume = 1.0 } = {}) {
|
|
1038
|
+
const oscillator = this.context.createOscillator();
|
|
1039
|
+
const gainNode = this.context.createGain();
|
|
1040
|
+
const stereoPanner = this.context.createStereoPanner();
|
|
1041
|
+
|
|
1042
|
+
oscillator.type = sound.oscillatorType;
|
|
1043
|
+
oscillator.frequency.setValueAtTime(sound.startFreq, this.context.currentTime);
|
|
1044
|
+
oscillator.frequency.exponentialRampToValueAtTime(sound.endFreq, this.context.currentTime + sound.duration);
|
|
1045
|
+
|
|
1046
|
+
stereoPanner.pan.value = pan;
|
|
1047
|
+
|
|
1048
|
+
oscillator.connect(gainNode);
|
|
1049
|
+
gainNode.connect(stereoPanner);
|
|
1050
|
+
stereoPanner.connect(this.masterGain);
|
|
1051
|
+
|
|
1052
|
+
this.applyEnvelope(gainNode.gain, sound.envelope, sound.duration, volume);
|
|
1053
|
+
|
|
1054
|
+
oscillator.start();
|
|
1055
|
+
oscillator.stop(this.context.currentTime + sound.duration);
|
|
1056
|
+
|
|
1057
|
+
return {
|
|
1058
|
+
oscillator,
|
|
1059
|
+
gainNode,
|
|
1060
|
+
nodes: [oscillator, gainNode, stereoPanner]
|
|
1061
|
+
};
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
parseSonicPi(script, samples) {
|
|
1065
|
+
const parser = new SonicPiParser(this);
|
|
1066
|
+
return parser.parseScript(script, samples);
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
playSonicPi(sound, options = {}) {
|
|
1070
|
+
if (!this.enabled || !this.context) return;
|
|
1071
|
+
|
|
1072
|
+
const startTime = this.context.currentTime;
|
|
1073
|
+
const allNodes = [];
|
|
1074
|
+
const midiNotes = [];
|
|
1075
|
+
let isPlaying = true;
|
|
1076
|
+
|
|
1077
|
+
// Get BPM from sound data
|
|
1078
|
+
const bpm = sound.bpm || 60;
|
|
1079
|
+
const beatDuration = 60 / bpm;
|
|
1080
|
+
|
|
1081
|
+
// Define synthConfigs first
|
|
1082
|
+
const synthConfigs = {
|
|
1083
|
+
fm: {
|
|
1084
|
+
type: "sine",
|
|
1085
|
+
modulation: {
|
|
1086
|
+
frequency: 0.5,
|
|
1087
|
+
gain: 50
|
|
1088
|
+
}
|
|
1089
|
+
},
|
|
1090
|
+
prophet: {
|
|
1091
|
+
type: "sawtooth",
|
|
1092
|
+
detune: 10,
|
|
1093
|
+
filterType: "lowpass"
|
|
1094
|
+
},
|
|
1095
|
+
saw: { type: "sawtooth" },
|
|
1096
|
+
mod_pulse: {
|
|
1097
|
+
type: "square",
|
|
1098
|
+
modulation: {
|
|
1099
|
+
frequency: 0.25,
|
|
1100
|
+
gain: 30
|
|
1101
|
+
}
|
|
1102
|
+
},
|
|
1103
|
+
piano: {
|
|
1104
|
+
type: "triangle",
|
|
1105
|
+
filterType: "bandpass"
|
|
1106
|
+
},
|
|
1107
|
+
pluck: {
|
|
1108
|
+
type: "triangle",
|
|
1109
|
+
filterType: "highpass"
|
|
1110
|
+
},
|
|
1111
|
+
kalimba: {
|
|
1112
|
+
type: "sine",
|
|
1113
|
+
filterType: "bandpass"
|
|
1114
|
+
},
|
|
1115
|
+
tb303: {
|
|
1116
|
+
type: "square",
|
|
1117
|
+
filterType: "lowpass",
|
|
1118
|
+
resonance: 10
|
|
1119
|
+
}
|
|
1120
|
+
};
|
|
1121
|
+
|
|
1122
|
+
// Effect processors
|
|
1123
|
+
const createEffect = {
|
|
1124
|
+
echo: (params) => {
|
|
1125
|
+
const delay = this.context.createDelay();
|
|
1126
|
+
const feedback = this.context.createGain();
|
|
1127
|
+
const wetGain = this.context.createGain();
|
|
1128
|
+
|
|
1129
|
+
delay.delayTime.value = params.phase || 0.25;
|
|
1130
|
+
feedback.gain.value = params.decay || 0.5;
|
|
1131
|
+
wetGain.gain.value = 0.5;
|
|
1132
|
+
|
|
1133
|
+
// Connect feedback loop
|
|
1134
|
+
delay.connect(feedback);
|
|
1135
|
+
feedback.connect(delay);
|
|
1136
|
+
|
|
1137
|
+
return [delay, feedback, wetGain];
|
|
1138
|
+
},
|
|
1139
|
+
lpf: (params) => {
|
|
1140
|
+
const filter = this.context.createBiquadFilter();
|
|
1141
|
+
filter.type = "lowpass";
|
|
1142
|
+
filter.frequency.value = params.cutoff || 1000;
|
|
1143
|
+
filter.Q.value = params.resonance || 1;
|
|
1144
|
+
return [filter];
|
|
1145
|
+
},
|
|
1146
|
+
reverb: (params) => {
|
|
1147
|
+
const convolver = this.context.createConvolver();
|
|
1148
|
+
const wetGain = this.context.createGain();
|
|
1149
|
+
wetGain.gain.value = params.room || 0.5;
|
|
1150
|
+
|
|
1151
|
+
// Create impulse response
|
|
1152
|
+
const length = this.context.sampleRate * (params.room || 0.5) * 3;
|
|
1153
|
+
const impulse = this.context.createBuffer(2, length, this.context.sampleRate);
|
|
1154
|
+
const left = impulse.getChannelData(0);
|
|
1155
|
+
const right = impulse.getChannelData(1);
|
|
1156
|
+
|
|
1157
|
+
for (let i = 0; i < length; i++) {
|
|
1158
|
+
const n = i / length;
|
|
1159
|
+
left[i] = (Math.random() * 2 - 1) * Math.pow(1 - n, params.room * 10 || 2);
|
|
1160
|
+
right[i] = (Math.random() * 2 - 1) * Math.pow(1 - n, params.room * 10 || 2);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
convolver.buffer = impulse;
|
|
1164
|
+
return [convolver, wetGain];
|
|
1165
|
+
},
|
|
1166
|
+
wobble: (params) => {
|
|
1167
|
+
const filter = this.context.createBiquadFilter();
|
|
1168
|
+
const lfo = this.context.createOscillator();
|
|
1169
|
+
const lfoGain = this.context.createGain();
|
|
1170
|
+
|
|
1171
|
+
filter.type = "lowpass";
|
|
1172
|
+
filter.frequency.value = 1000;
|
|
1173
|
+
|
|
1174
|
+
lfo.frequency.value = 1 / (params.phase || 6);
|
|
1175
|
+
lfoGain.gain.value = 2000;
|
|
1176
|
+
|
|
1177
|
+
lfo.connect(lfoGain);
|
|
1178
|
+
lfoGain.connect(filter.frequency);
|
|
1179
|
+
lfo.start();
|
|
1180
|
+
|
|
1181
|
+
return [filter];
|
|
1182
|
+
}
|
|
1183
|
+
};
|
|
1184
|
+
|
|
1185
|
+
// Apply effects chain
|
|
1186
|
+
const applyEffects = (source, effects) => {
|
|
1187
|
+
if (!effects || !effects.length) return source;
|
|
1188
|
+
|
|
1189
|
+
let currentNode = source;
|
|
1190
|
+
effects.forEach((effect) => {
|
|
1191
|
+
if (createEffect[effect.type]) {
|
|
1192
|
+
const nodes = createEffect[effect.type](effect.params);
|
|
1193
|
+
currentNode.connect(nodes[0]);
|
|
1194
|
+
currentNode = nodes[nodes.length - 1];
|
|
1195
|
+
|
|
1196
|
+
if (nodes.length > 1) {
|
|
1197
|
+
nodes[0].connect(nodes[1]);
|
|
1198
|
+
nodes[1].connect(nodes[0]);
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
});
|
|
1202
|
+
|
|
1203
|
+
return currentNode;
|
|
1204
|
+
};
|
|
1205
|
+
|
|
1206
|
+
// Create synth with configurations
|
|
1207
|
+
const createSynth = (frequency, synthType, params = {}) => {
|
|
1208
|
+
// Use createSound() for the sound definition
|
|
1209
|
+
this.createSound("temp", frequency, "simple");
|
|
1210
|
+
const soundDef = this.sounds.get("temp");
|
|
1211
|
+
|
|
1212
|
+
// Create and connect nodes like createSynth() currently does
|
|
1213
|
+
const oscillator = this.context.createOscillator();
|
|
1214
|
+
const gainNode = this.context.createGain();
|
|
1215
|
+
const stereoPanner = this.context.createStereoPanner();
|
|
1216
|
+
|
|
1217
|
+
// Use sound definition from createSound()
|
|
1218
|
+
oscillator.type = soundDef.oscillatorType;
|
|
1219
|
+
oscillator.frequency.value = soundDef.frequency;
|
|
1220
|
+
|
|
1221
|
+
// Keep existing stereo and gain setup
|
|
1222
|
+
stereoPanner.pan.value = params.pan || 0;
|
|
1223
|
+
|
|
1224
|
+
// Keep existing gain envelope setup
|
|
1225
|
+
const duration = params.duration || 1;
|
|
1226
|
+
gainNode.gain.setValueAtTime(0, this.context.currentTime);
|
|
1227
|
+
gainNode.gain.linearRampToValueAtTime(1, this.context.currentTime + 0.01);
|
|
1228
|
+
gainNode.gain.linearRampToValueAtTime(0, this.context.currentTime + duration);
|
|
1229
|
+
|
|
1230
|
+
// Keep existing connections
|
|
1231
|
+
oscillator.connect(gainNode);
|
|
1232
|
+
gainNode.connect(stereoPanner);
|
|
1233
|
+
stereoPanner.connect(this.masterGain);
|
|
1234
|
+
|
|
1235
|
+
return { oscillator, gainNode, stereoPanner };
|
|
1236
|
+
};
|
|
1237
|
+
// Create sample functionality
|
|
1238
|
+
const createSample = (sampleName, params = {}) => {
|
|
1239
|
+
const definition = this.sampleDefinitions.get(sampleName);
|
|
1240
|
+
|
|
1241
|
+
if (definition?.soundType === "midi") {
|
|
1242
|
+
const gainNode = this.context.createGain();
|
|
1243
|
+
|
|
1244
|
+
const midiPlayer = {
|
|
1245
|
+
start: (scheduleTime) => {
|
|
1246
|
+
const noteToPlay = params.note || 60;
|
|
1247
|
+
const velocity = Math.floor(definition.amp * 127);
|
|
1248
|
+
const duration = params.duration || 0.1;
|
|
1249
|
+
const delayMs = (scheduleTime - this.context.currentTime) * 1000;
|
|
1250
|
+
|
|
1251
|
+
setTimeout(() => {
|
|
1252
|
+
this.sf2Player.program = this.midiProgramMap[definition.instrument];
|
|
1253
|
+
this.sf2Player.noteOn(noteToPlay, velocity);
|
|
1254
|
+
// Track the note
|
|
1255
|
+
midiNotes.push({
|
|
1256
|
+
note: noteToPlay,
|
|
1257
|
+
velocity: velocity,
|
|
1258
|
+
channel: 0 // Or whatever channel you're using
|
|
1259
|
+
});
|
|
1260
|
+
const stopTimeout = setTimeout(() => {
|
|
1261
|
+
this.sf2Player.noteOff(noteToPlay);
|
|
1262
|
+
this.scheduledMIDIEvents.delete(stopTimeout);
|
|
1263
|
+
}, duration * 1000);
|
|
1264
|
+
this.scheduledMIDIEvents.add(stopTimeout);
|
|
1265
|
+
}, delayMs);
|
|
1266
|
+
|
|
1267
|
+
gainNode.gain.setValueAtTime(0, scheduleTime);
|
|
1268
|
+
gainNode.gain.linearRampToValueAtTime(params.amp || 1, scheduleTime + (params.attack || 0.01));
|
|
1269
|
+
gainNode.gain.linearRampToValueAtTime(
|
|
1270
|
+
0,
|
|
1271
|
+
scheduleTime + (params.attack || 0.01) + (params.release || 1)
|
|
1272
|
+
);
|
|
1273
|
+
},
|
|
1274
|
+
stop: () => {
|
|
1275
|
+
const noteToPlay = params.note || 60;
|
|
1276
|
+
this.sf2Player.noteOff(noteToPlay);
|
|
1277
|
+
},
|
|
1278
|
+
connect: (target) => {
|
|
1279
|
+
gainNode.connect(target);
|
|
1280
|
+
}
|
|
1281
|
+
};
|
|
1282
|
+
|
|
1283
|
+
return { oscillator: midiPlayer, gainNode };
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
if (!this.samples.has(sampleName)) {
|
|
1287
|
+
console.warn(`[AudioManager] No synthetic sample found for ${sampleName}`);
|
|
1288
|
+
return createSynth(440, "sine", params);
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
const source = this.context.createBufferSource();
|
|
1292
|
+
const gainNode = this.context.createGain();
|
|
1293
|
+
|
|
1294
|
+
source.buffer = this.samples.get(sampleName);
|
|
1295
|
+
source.connect(gainNode);
|
|
1296
|
+
gainNode.connect(this.masterGain);
|
|
1297
|
+
|
|
1298
|
+
source.playbackRate.value = params.rate || 1;
|
|
1299
|
+
gainNode.gain.value = params.amp || 0.5;
|
|
1300
|
+
|
|
1301
|
+
return { oscillator: source, gainNode };
|
|
1302
|
+
};
|
|
1303
|
+
|
|
1304
|
+
// Process all events sequentially
|
|
1305
|
+
sound.parsedSequence.forEach((event) => {
|
|
1306
|
+
const eventTime = startTime + event.time;
|
|
1307
|
+
|
|
1308
|
+
if (event.command !== "play" && event.command !== "sample") {
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
// Create a timeout for this event in the sequence
|
|
1313
|
+
const sequenceTimeout = setTimeout(
|
|
1314
|
+
() => {
|
|
1315
|
+
if (!isPlaying) return; // Skip if we've been stopped
|
|
1316
|
+
|
|
1317
|
+
const {
|
|
1318
|
+
oscillator: source,
|
|
1319
|
+
gainNode,
|
|
1320
|
+
stereoPanner
|
|
1321
|
+
} = event.command === "sample"
|
|
1322
|
+
? createSample(event.sample, event)
|
|
1323
|
+
: createSynth(event.note, event.synth || "simple", {
|
|
1324
|
+
oscillatorType: event.type || "sine",
|
|
1325
|
+
duration: event.duration || 1,
|
|
1326
|
+
pan: event.pan
|
|
1327
|
+
});
|
|
1328
|
+
|
|
1329
|
+
const attack = event.attack || 0.01;
|
|
1330
|
+
const release = event.release || 1;
|
|
1331
|
+
const amp = event.amp || 0.5;
|
|
1332
|
+
|
|
1333
|
+
let outputNode = gainNode;
|
|
1334
|
+
if (event.effects && event.effects.length) {
|
|
1335
|
+
outputNode = applyEffects(gainNode, event.effects);
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
if (event.pan) {
|
|
1339
|
+
const panner = this.context.createStereoPanner();
|
|
1340
|
+
panner.pan.value = event.pan;
|
|
1341
|
+
outputNode.connect(panner);
|
|
1342
|
+
panner.connect(this.masterGain);
|
|
1343
|
+
} else {
|
|
1344
|
+
outputNode.connect(this.masterGain);
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
gainNode.gain.setValueAtTime(0, eventTime);
|
|
1348
|
+
gainNode.gain.linearRampToValueAtTime(amp, eventTime + attack);
|
|
1349
|
+
gainNode.gain.linearRampToValueAtTime(0, eventTime + attack + release);
|
|
1350
|
+
|
|
1351
|
+
if (event.command === "sample") {
|
|
1352
|
+
source.start(eventTime);
|
|
1353
|
+
source.stop(eventTime + attack + release);
|
|
1354
|
+
} else {
|
|
1355
|
+
source.frequency.setValueAtTime(this.midiToFrequency(event.note), eventTime);
|
|
1356
|
+
source.start(eventTime);
|
|
1357
|
+
source.stop(eventTime + attack + release);
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
allNodes.push(source);
|
|
1361
|
+
},
|
|
1362
|
+
(eventTime - this.context.currentTime) * 1000
|
|
1363
|
+
);
|
|
1364
|
+
|
|
1365
|
+
// Track this timeout
|
|
1366
|
+
this.scheduledMIDIEvents.add(sequenceTimeout);
|
|
1367
|
+
});
|
|
1368
|
+
|
|
1369
|
+
// Return the control object with everything needed to stop the sequence
|
|
1370
|
+
return {
|
|
1371
|
+
stop: () => {
|
|
1372
|
+
isPlaying = false;
|
|
1373
|
+
allNodes.forEach((node) => {
|
|
1374
|
+
try {
|
|
1375
|
+
if (node.stop) node.stop();
|
|
1376
|
+
} catch (e) {
|
|
1377
|
+
// Node might have already stopped
|
|
1378
|
+
}
|
|
1379
|
+
});
|
|
1380
|
+
// Stop any MIDI notes
|
|
1381
|
+
midiNotes.forEach((note) => {
|
|
1382
|
+
if (this.sf2Player) {
|
|
1383
|
+
this.sf2Player.noteOff(note.note, note.velocity || 127, note.channel || 0);
|
|
1384
|
+
}
|
|
1385
|
+
});
|
|
1386
|
+
// Clear any pending timeouts
|
|
1387
|
+
this.scheduledMIDIEvents.forEach((timeout) => {
|
|
1388
|
+
clearTimeout(timeout);
|
|
1389
|
+
});
|
|
1390
|
+
this.scheduledMIDIEvents.clear();
|
|
1391
|
+
},
|
|
1392
|
+
nodes: allNodes,
|
|
1393
|
+
midiNotes: midiNotes,
|
|
1394
|
+
isPlaying: () => isPlaying
|
|
1395
|
+
};
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
// ADSR envelope utility (enhanced with volume support)
|
|
1399
|
+
applyEnvelope(gainParam, envelope, duration, volume = 1.0) {
|
|
1400
|
+
const { attack, decay, sustain, release } = envelope;
|
|
1401
|
+
const now = this.context.currentTime;
|
|
1402
|
+
|
|
1403
|
+
gainParam.setValueAtTime(0, now);
|
|
1404
|
+
gainParam.linearRampToValueAtTime(volume, now + attack);
|
|
1405
|
+
gainParam.linearRampToValueAtTime(volume * sustain, now + attack + decay);
|
|
1406
|
+
gainParam.linearRampToValueAtTime(0, now + duration);
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
setVolume(value) {
|
|
1410
|
+
if (this.masterGain) {
|
|
1411
|
+
const scaledVolume = this.baseVolume * value;
|
|
1412
|
+
this.masterGain.gain.setValueAtTime(scaledVolume, this.context.currentTime);
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
toggle() {
|
|
1417
|
+
this.enabled = !this.enabled;
|
|
1418
|
+
if (this.context) {
|
|
1419
|
+
if (this.enabled) {
|
|
1420
|
+
this.context.resume();
|
|
1421
|
+
} else {
|
|
1422
|
+
this.context.suspend();
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
// Stop any active MIDI events if disabling
|
|
1427
|
+
if (!this.enabled) {
|
|
1428
|
+
this.scheduledMIDIEvents.forEach((timeout) => {
|
|
1429
|
+
clearTimeout(timeout);
|
|
1430
|
+
});
|
|
1431
|
+
this.scheduledMIDIEvents.clear();
|
|
1432
|
+
|
|
1433
|
+
// Stop MIDI notes if sf2Player exists
|
|
1434
|
+
if (this.sf2Player) {
|
|
1435
|
+
this.stopAllSounds();
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
return this.enabled;
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
class SonicPiParser {
|
|
1444
|
+
constructor(context) {
|
|
1445
|
+
this.context = context;
|
|
1446
|
+
this.functions = new Map();
|
|
1447
|
+
this.bpm = 60;
|
|
1448
|
+
this.beatDuration = 60 / this.bpm;
|
|
1449
|
+
this.effectsStack = [];
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
parseScript(script) {
|
|
1453
|
+
const bpmMatch = script.match(/use_bpm\s+(\d+)/);
|
|
1454
|
+
if (bpmMatch) {
|
|
1455
|
+
this.bpm = parseInt(bpmMatch[1]);
|
|
1456
|
+
this.beatDuration = 60 / this.bpm;
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
const functionMatches = script.matchAll(/define\s+:(\w+)\s+do\s*([\s\S]*?)\s*end/g);
|
|
1460
|
+
for (const match of functionMatches) {
|
|
1461
|
+
this.functions.set(match[1], match[2].trim());
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
const sequence = [];
|
|
1465
|
+
let currentTime = 0;
|
|
1466
|
+
|
|
1467
|
+
const mainScript = script.replace(/define\s+:\w+\s+do[\s\S]*?end/g, "").trim();
|
|
1468
|
+
currentTime = this.processScript(mainScript, sequence, currentTime);
|
|
1469
|
+
|
|
1470
|
+
return {
|
|
1471
|
+
sequence: sequence.sort((a, b) => a.time - b.time),
|
|
1472
|
+
bpm: this.bpm,
|
|
1473
|
+
totalDuration: currentTime
|
|
1474
|
+
};
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
processScript(script, sequence, startTime) {
|
|
1478
|
+
let currentTime = startTime;
|
|
1479
|
+
const lines = script.split("\n");
|
|
1480
|
+
|
|
1481
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1482
|
+
let line = lines[i].trim();
|
|
1483
|
+
if (!line || line.startsWith("#")) continue;
|
|
1484
|
+
|
|
1485
|
+
const fxMatch = line.match(/with_fx\s+:(\w+)(?:\s*,\s*(.+))?\s+do/);
|
|
1486
|
+
if (fxMatch) {
|
|
1487
|
+
const [_, fxType, paramString] = fxMatch;
|
|
1488
|
+
const fxParams = this.parseParams(paramString || "");
|
|
1489
|
+
|
|
1490
|
+
this.effectsStack.push({
|
|
1491
|
+
type: fxType,
|
|
1492
|
+
params: fxParams
|
|
1493
|
+
});
|
|
1494
|
+
|
|
1495
|
+
let fxContent = "";
|
|
1496
|
+
let depth = 1;
|
|
1497
|
+
let j = i + 1;
|
|
1498
|
+
|
|
1499
|
+
while (depth > 0 && j < lines.length) {
|
|
1500
|
+
if (lines[j].includes("with_fx")) depth++;
|
|
1501
|
+
if (lines[j].trim() === "end") depth--;
|
|
1502
|
+
if (depth > 0) fxContent += lines[j] + "\n";
|
|
1503
|
+
j++;
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
currentTime = this.processScript(fxContent, sequence, currentTime);
|
|
1507
|
+
|
|
1508
|
+
this.effectsStack.pop();
|
|
1509
|
+
|
|
1510
|
+
i = j - 1;
|
|
1511
|
+
continue;
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
const timesMatch = line.match(/(\d+)\.times\s+do/);
|
|
1515
|
+
if (timesMatch) {
|
|
1516
|
+
const count = parseInt(timesMatch[1]);
|
|
1517
|
+
let loopContent = "";
|
|
1518
|
+
let depth = 1;
|
|
1519
|
+
let j = i + 1;
|
|
1520
|
+
|
|
1521
|
+
while (depth > 0 && j < lines.length) {
|
|
1522
|
+
if (lines[j].includes(".times do")) depth++;
|
|
1523
|
+
if (lines[j].trim() === "end") depth--;
|
|
1524
|
+
if (depth > 0) loopContent += lines[j] + "\n";
|
|
1525
|
+
j++;
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
for (let k = 0; k < count; k++) {
|
|
1529
|
+
currentTime = this.processScript(loopContent, sequence, currentTime);
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
i = j - 1;
|
|
1533
|
+
continue;
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
const sleepMatch = line.match(/sleep\s+([\d.]+)/);
|
|
1537
|
+
if (sleepMatch) {
|
|
1538
|
+
currentTime += parseFloat(sleepMatch[1]) * this.beatDuration;
|
|
1539
|
+
continue;
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
const sampleMatch = line.match(/sample\s+:(\w+)(?:\s*,\s*(.+))?/);
|
|
1543
|
+
if (sampleMatch) {
|
|
1544
|
+
const [_, name, paramString] = sampleMatch;
|
|
1545
|
+
const params = this.parseParams(paramString || "");
|
|
1546
|
+
sequence.push({
|
|
1547
|
+
command: "sample",
|
|
1548
|
+
time: currentTime,
|
|
1549
|
+
sample: name,
|
|
1550
|
+
effects: [...this.effectsStack],
|
|
1551
|
+
...params
|
|
1552
|
+
});
|
|
1553
|
+
continue;
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
const playMatch = line.match(/play\s+(\d+)(?:\s*,\s*(.+))?/);
|
|
1557
|
+
if (playMatch) {
|
|
1558
|
+
const [_, note, paramString] = playMatch;
|
|
1559
|
+
const params = this.parseParams(paramString || "");
|
|
1560
|
+
sequence.push({
|
|
1561
|
+
command: "play",
|
|
1562
|
+
time: currentTime,
|
|
1563
|
+
note: parseInt(note),
|
|
1564
|
+
effects: [...this.effectsStack],
|
|
1565
|
+
...params
|
|
1566
|
+
});
|
|
1567
|
+
continue;
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
const functionCall = line.match(/:?(\w+)/);
|
|
1571
|
+
if (functionCall && this.functions.has(functionCall[1])) {
|
|
1572
|
+
currentTime = this.processScript(this.functions.get(functionCall[1]), sequence, currentTime);
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
return currentTime;
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
parseParams(paramString) {
|
|
1580
|
+
if (!paramString) return {};
|
|
1581
|
+
const params = {};
|
|
1582
|
+
const matches = paramString.match(/(\w+):\s*([^,\s]+)/g) || [];
|
|
1583
|
+
matches.forEach((match) => {
|
|
1584
|
+
const [key, value] = match.split(":").map((s) => s.trim());
|
|
1585
|
+
params[key] = isNaN(value) ? value : parseFloat(value);
|
|
1586
|
+
});
|
|
1587
|
+
return params;
|
|
1588
|
+
}
|
|
1589
|
+
}
|