littlejsengine 1.18.29 → 1.20.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 (51) hide show
  1. package/FAQ.md +841 -815
  2. package/README.md +211 -199
  3. package/dist/littlejs.d.ts +4383 -812
  4. package/dist/littlejs.esm.js +31848 -16680
  5. package/dist/littlejs.esm.min.js +14 -1
  6. package/dist/littlejs.js +31230 -16166
  7. package/dist/littlejs.min.js +13 -1
  8. package/dist/littlejs.release.js +27419 -15457
  9. package/package.json +10 -5
  10. package/plugins/audioEffects.js +375 -0
  11. package/plugins/box2d.js +2516 -2073
  12. package/plugins/drawUtilities.js +206 -31
  13. package/plugins/gltf.js +740 -0
  14. package/plugins/jsconfig.json +19 -0
  15. package/plugins/lightSystem.js +719 -330
  16. package/plugins/math3d.js +911 -0
  17. package/plugins/medalSystem.js +118 -72
  18. package/plugins/newgrounds.js +400 -199
  19. package/plugins/pathFinder.js +208 -175
  20. package/plugins/pluginExport.js +196 -115
  21. package/plugins/postProcess.js +284 -166
  22. package/plugins/render3d.js +4072 -0
  23. package/plugins/render3dDebug.js +109 -0
  24. package/plugins/render3dExtras.js +1754 -0
  25. package/plugins/render3dVoxels.js +502 -0
  26. package/plugins/textureSheet.js +86 -48
  27. package/plugins/threejs.js +183 -158
  28. package/plugins/tweakables.js +411 -0
  29. package/plugins/tweenSystem.js +646 -526
  30. package/plugins/uiSystem.js +2024 -1687
  31. package/plugins/zzfxm.js +8 -15
  32. package/src/engine.js +345 -194
  33. package/src/engineAudio.js +1022 -777
  34. package/src/engineBuild.mjs +289 -253
  35. package/src/engineDebug.js +912 -735
  36. package/src/engineDraw.js +1774 -1510
  37. package/src/engineEditor.js +2431 -0
  38. package/src/engineExport.js +419 -396
  39. package/src/engineInput.js +1462 -1293
  40. package/src/engineLogo.js +4 -3
  41. package/src/engineMath.js +1274 -1183
  42. package/src/engineObject.js +674 -551
  43. package/src/engineParticles.js +583 -532
  44. package/src/engineRelease.js +67 -45
  45. package/src/engineSettings.js +808 -726
  46. package/src/engineTileLayer.js +995 -741
  47. package/src/engineUtilities.js +32 -13
  48. package/src/engineWebGL.js +1162 -941
  49. package/src/jsconfig.json +17 -13
  50. package/plugins/box2d.wasm.js +0 -630
  51. package/plugins/box2d.wasm.wasm +0 -0
@@ -1,778 +1,1023 @@
1
- /**
2
- * LittleJS Audio System
3
- * - Play audio files (mp3, ogg, wave) and generate sounds with ZzFX
4
- * - ZzFX sound generator integration: <a href=https://killedbyapixel.github.io/ZzFX/>ZzFX</a>
5
- * - Sound caching for fast playback and memory efficiency
6
- * - Volume control with attenuation and stereo panning
7
- * - 2D spatial audio based on camera position with distance-based falloff
8
- * - Sound instance management (pause, resume, stop)
9
- * - Speech synthesis for text-to-speech
10
- * - Music playback with ZzFXM support
11
- * - Web Audio API integration with master gain control
12
- * @namespace Audio
13
- */
14
-
15
- 'use strict';
16
-
17
- /** Audio context used by the engine
18
- * @type {AudioContext}
19
- * @memberof Audio */
20
- let audioContext = new AudioContext;
21
-
22
- /** Master gain node for all audio to pass through
23
- * @type {GainNode}
24
- * @memberof Audio */
25
- let audioMasterGain;
26
-
27
- /** Default sample rate used for sounds
28
- * @default 44100
29
- * @memberof Audio */
30
- const audioDefaultSampleRate = 44100;
31
-
32
- /** Check if the audio context is running and available for playback
33
- * @return {boolean} - True if the audio context is running
34
- * @memberof Audio */
35
- function audioIsRunning()
36
- { return audioContext.state === 'running'; }
37
-
38
- function audioInit()
39
- {
40
- if (!soundEnable || headlessMode) return;
41
-
42
- audioMasterGain = audioContext.createGain();
43
- audioMasterGain.connect(audioContext.destination);
44
- audioMasterGain.gain.value = soundVolume; // set starting value
45
- }
46
-
47
- ///////////////////////////////////////////////////////////////////////////////
48
-
49
- /**
50
- * Sound Object - Stores a sound for later
51
- * - this can be used to load and play wave, mp3, and ogg files
52
- * - it can also create sounds using the ZzFX sound generator
53
- * - can attenuate and apply stereo panning to sounds
54
- * - sound instance control with pause/resume capability
55
- *
56
- * <a href=https://killedbyapixel.github.io/ZzFX/>Create sounds using the ZzFX Sound Designer.</a>
57
- * @memberof Audio
58
- * @example
59
- * // load an audio asset file
60
- * const sound_example = new Sound('sound.mp3');
61
- *
62
- * // create a zzfx sound
63
- * const sound_example = new Sound([.5,.5]);
64
- *
65
- * // play a sound
66
- * sound_example.play();
67
- */
68
- class Sound
69
- {
70
- /**
71
- * @callback SoundLoadCallback - Function called when sound is loaded
72
- * @param {Sound} sound
73
- * @memberof Audio
74
- */
75
-
76
- /** Create a sound object and cache the audio for later use
77
- * @param {string|Array} [asset] - Filename of audio file or zzfx array
78
- * @param {number} [randomness] - How much to randomize frequency each time sound plays, for zzfx sounds the zzfx default is used if undefined
79
- * @param {number} [range=soundDefaultRange] - World space max range of sound
80
- * @param {number} [taper=soundDefaultTaper] - At what percentage of range should it start tapering
81
- * @param {SoundLoadCallback} [onloadCallback] - callback function to call when sound is loaded
82
- */
83
- constructor(asset, randomness, range=soundDefaultRange, taper=soundDefaultTaper, onloadCallback)
84
- {
85
- if (!soundEnable || headlessMode) return;
86
-
87
- ASSERT(!asset || isArray(asset) || isStringLike(asset), 'asset must be a file name or zzfx array');
88
- ASSERT(randomness === undefined || isNumber(randomness), 'randomness must be a number');
89
- ASSERT(randomness === undefined || randomness >= 0 && randomness <=1, 'randomness must be between 0 and 1');
90
- ASSERT(isNumber(range), 'range must be a number');
91
- ASSERT(isNumber(taper), 'taper must be a number');
92
-
93
- /** @property {number} - World space max range of sound */
94
- this.range = range;
95
- /** @property {number} - At what percentage of range should it start tapering */
96
- this.taper = taper;
97
- /** @property {number} - How much to randomize frequency each time sound plays */
98
- this.randomness = randomness ?? 0;
99
- /** @property {number} - Sample rate for this sound */
100
- this.sampleRate = audioDefaultSampleRate;
101
- /** @property {number} - How many samples per channel this sound has */
102
- this.sampleLength = 0;
103
- /** @property {AudioBuffer} - Decoded audio shared by every play of this sound
104
- * @type {AudioBuffer} */
105
- this.sampleBuffer = undefined;
106
- /** @private @type {Array<Array<number>|Float32Array>} */
107
- this._sampleChannels = undefined;
108
- /** @property {number} - Percentage of this sound currently loaded, sounds
109
- * fetched from a url stay at 0 until decoding completes */
110
- this.loadedPercent = 0;
111
- /** @property {SoundLoadCallback} - function to call when sound is loaded */
112
- this.onloadCallback = onloadCallback;
113
-
114
- if (isArray(asset))
115
- {
116
- // generate zzfx sound — copy so we don't mutate the caller's array
117
- const zzfxSound = asset.slice();
118
-
119
- // remove randomness so it can be applied on playback
120
- const defaultRandomness = randomness ?? .05;
121
- const randomnessIndex = 1;
122
- this.randomness = zzfxSound[randomnessIndex] ?? defaultRandomness;
123
- zzfxSound[randomnessIndex] = 0;
124
-
125
- // generate the zzfx samples, then hand them to an audio buffer so
126
- // the plain arrays can be released and every play shares the buffer
127
- this.sampleChannels = [zzfxG(...zzfxSound)];
128
- this.buildSampleBuffer();
129
- this.loadedPercent = 1;
130
- onloadCallback?.(this);
131
- }
132
- else if (typeof asset === 'string')
133
- {
134
- // load the audio file, report failures rather than leaving an
135
- // unhandled rejection, the sound just stays unloaded and silent
136
- const filename = asset;
137
- this.loadSound(filename).catch(e=>
138
- LOG('Sound load failed for', filename, '-', e.message));
139
- }
140
- }
141
-
142
- /** Sample data for each channel
143
- * Sounds keep their samples in an audio buffer, so reading this rebuilds
144
- * the arrays from it and caches them. The copies are safe to hold onto,
145
- * playing a sound detaches the buffer's own channel arrays.
146
- * @type {Array<Array<number>|Float32Array>} */
147
- get sampleChannels()
148
- {
149
- const buffer = this.sampleBuffer;
150
- if (!this._sampleChannels && buffer)
151
- {
152
- const channels = [];
153
- for (let i = 0; i < buffer.numberOfChannels; i++)
154
- channels.push(buffer.getChannelData(i).slice());
155
- this._sampleChannels = channels;
156
- }
157
- return this._sampleChannels;
158
- }
159
-
160
- /** @param {Array<Array<number>|Float32Array>} sampleChannels */
161
- set sampleChannels(sampleChannels)
162
- {
163
- // new samples invalidate the buffer built from the old ones
164
- this._sampleChannels = sampleChannels;
165
- this.sampleBuffer = undefined;
166
- this.sampleLength = sampleChannels?.[0]?.length || 0;
167
- }
168
-
169
- /** Move this sound's samples into an audio buffer that every play can share
170
- * Does nothing if there is already a buffer or no samples to build one from */
171
- buildSampleBuffer()
172
- {
173
- if (this.sampleBuffer || !this._sampleChannels || headlessMode) return;
174
-
175
- this.sampleBuffer = createAudioBuffer(this._sampleChannels, this.sampleRate);
176
-
177
- // the buffer owns the samples now, release the arrays we built it from
178
- this._sampleChannels = undefined;
179
- }
180
-
181
- /** Play the sound
182
- * Sounds may not play until a user interaction occurs
183
- * @param {Vector2} [pos] - World space position to play the sound if any
184
- * @param {number} [volume] - How much to scale volume by
185
- * @param {number} [pitch] - How much to scale pitch by
186
- * @param {number} [randomnessScale] - How much to scale pitch randomness
187
- * @param {boolean} [loop] - Should the sound loop?
188
- * @param {boolean} [paused] - Should the sound start paused
189
- * @return {SoundInstance} - The sound instance, or undefined if sound is disabled, not loaded, or running in headless mode
190
- */
191
- play(pos, volume=1, pitch=1, randomnessScale=1, loop=false, paused=false)
192
- {
193
- ASSERT(!pos || isVector2(pos), 'pos must be a vec2');
194
- ASSERT(isNumber(volume), 'volume must be a number');
195
- ASSERT(isNumber(pitch), 'pitch must be a number');
196
- ASSERT(isNumber(randomnessScale), 'randomnessScale must be a number');
197
-
198
- if (!soundEnable || headlessMode) return;
199
- if (!this.sampleBuffer && !this._sampleChannels) return;
200
-
201
- let pan;
202
- if (pos)
203
- {
204
- const range = this.range;
205
- if (range)
206
- {
207
- // apply range based fade
208
- const lengthSquared = cameraPos.distanceSquared(pos);
209
- if (lengthSquared > range*range)
210
- return; // out of range
211
-
212
- // attenuate volume by distance
213
- volume *= percent(lengthSquared**.5, range, range*this.taper);
214
- }
215
-
216
- // get pan from screen space coords
217
- pan = worldToScreen(pos).x * 2/mainCanvas.width - 1;
218
- }
219
-
220
- // Create sound instance
221
- const rate = pitch + pitch * this.randomness*randomnessScale*rand(-1,1);
222
- const instance = new SoundInstance(this, volume, rate, pan, loop, paused);
223
-
224
- if (debug && debugSound && pos)
225
- {
226
- // visualize where positioned sounds play and their falloff range
227
- debugCircle(pos, .5, '#0ff', .5, true);
228
- if (this.range)
229
- {
230
- debugCircle(pos, 2*this.range, '#0ff', .5); // silent radius
231
- debugCircle(pos, 2*this.range*this.taper, '#0ff', .5); // full volume radius
232
- }
233
- debugText('vol '+volume.toFixed(2)+' pitch '+rate.toFixed(2), pos, .5, '#0ff', .5);
234
- }
235
-
236
- return instance;
237
- }
238
-
239
- /** Play a music track that loops by default
240
- * @param {number} [volume] - Volume to play the music at
241
- * @param {boolean} [loop] - Should the music loop?
242
- * @param {boolean} [paused] - Should the music start paused
243
- * @return {SoundInstance} - The sound instance
244
- */
245
- playMusic(volume=1, loop=true, paused=false)
246
- { return this.play(undefined, volume, 1, 0, loop, paused); }
247
-
248
- /** Play the sound as a musical note with a semitone offset
249
- * This can be used to play music with chromatic scales
250
- * @param {number} [semitoneOffset=0] - How many semitones to offset pitch
251
- * @param {Vector2} [pos] - World space position to play the sound if any
252
- * @param {number} [volume=1] - How much to scale volume by
253
- * @return {SoundInstance} - The sound instance
254
- */
255
- playNote(semitoneOffset=0, pos, volume)
256
- {
257
- ASSERT(isNumber(semitoneOffset), 'semitoneOffset must be a number');
258
- const pitch = getNoteFrequency(semitoneOffset, 1);
259
- return this.play(pos, volume, pitch, 0);
260
- }
261
-
262
- /** Get how long this sound is in seconds
263
- * @return {number} - How long the sound is in seconds (0 if loading)
264
- */
265
- getDuration()
266
- { return this.sampleLength / this.sampleRate || 0; }
267
-
268
- /** Check if sound is loaded, for sounds fetched from a url
269
- * @return {boolean} - True if sound is loaded and ready to play
270
- */
271
- isLoaded() { return this.loadedPercent === 1; }
272
-
273
- /** Loads a sound from a URL and decodes it into sample data.
274
- * @param {string} filename
275
- * @return {Promise} */
276
- async loadSound(filename)
277
- {
278
- const response = await fetch(filename);
279
- if (!response.ok)
280
- throw new Error(`Failed to load sound from ${filename}: ${response.status} ${response.statusText}`);
281
- const arrayBuffer = await response.arrayBuffer();
282
- const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
283
-
284
- // keep the decoded buffer as is, it is exactly what playback needs and
285
- // every play shares it, no channel data is read or copied
286
- this.sampleRate = audioBuffer.sampleRate;
287
- this.sampleLength = audioBuffer.length;
288
- this.sampleBuffer = audioBuffer;
289
- this.loadedPercent = 1;
290
- this.onloadCallback?.(this);
291
- }
292
- }
293
-
294
- ///////////////////////////////////////////////////////////////////////////////
295
-
296
- /**
297
- * Sound Instance - Wraps an AudioBufferSourceNode for individual sound control
298
- * Represents a single playing instance of a sound with pause/resume capabilities
299
- * @memberof Audio
300
- * @example
301
- * // Play a sound and get an instance for control
302
- * const jumpSound = new Sound([.5,.5,220]);
303
- * const instance = jumpSound.play();
304
- *
305
- * // Control the individual instance
306
- * instance.setVolume(.5);
307
- * instance.pause();
308
- * instance.resume();
309
- * instance.stop();
310
- */
311
- class SoundInstance
312
- {
313
- /** Create a sound instance
314
- * @param {Sound} sound - The sound object
315
- * @param {number} [volume] - How much to scale volume by
316
- * @param {number} [rate] - The playback rate to use
317
- * @param {number} [pan] - How much to apply stereo panning
318
- * @param {boolean} [loop] - Should the sound loop?
319
- * @param {boolean} [paused] - Should the sound start paused? */
320
- constructor(sound, volume=1, rate=1, pan=0, loop=false, paused=false)
321
- {
322
- ASSERT(sound instanceof Sound, 'SoundInstance requires a valid Sound object');
323
- ASSERT(volume >= 0, 'Sound volume must be positive or zero');
324
- ASSERT(rate >= 0, 'Sound rate must be positive or zero');
325
- ASSERT(isNumber(pan), 'Sound pan must be a number');
326
-
327
- /** @property {Sound} - The sound object */
328
- this.sound = sound;
329
- /** @property {number} - How much to scale volume by */
330
- this.volume = volume;
331
- /** @property {number} - The playback rate to use */
332
- this.rate = rate;
333
- /** @property {number} - How much to apply stereo panning */
334
- this.pan = pan;
335
- /** @property {boolean} - Should the sound loop */
336
- this.loop = loop;
337
- /** @property {number} - Timestamp for audio context when paused */
338
- this.pausedTime = 0;
339
- /** @property {number} - Timestamp for audio context when started */
340
- this.startTime = undefined;
341
- /** @property {GainNode} - Gain node for the sound */
342
- this.gainNode = undefined;
343
- /** @property {AudioBufferSourceNode} - Source node of the audio */
344
- this.source = undefined;
345
- // setup end callback and start sound
346
- this.onendedCallback = (source)=>
347
- {
348
- if (source === this.source)
349
- this.source = undefined;
350
- };
351
- if (!paused)
352
- this.start();
353
- }
354
-
355
- /** Start playing the sound instance from the offset time
356
- * @param {number} [offset] - Offset in seconds to start playback from
357
- */
358
- start(offset=0)
359
- {
360
- ASSERT(offset >= 0, 'Sound start offset must be positive or zero');
361
- if (this.isPlaying())
362
- this.stop();
363
- this.gainNode = audioContext.createGain();
364
-
365
- // build the shared buffer if it was not made at load time, then play it
366
- this.sound.buildSampleBuffer();
367
- this.source = this.sound.sampleBuffer ?
368
- playAudioBuffer(this.sound.sampleBuffer, this.volume, this.rate, this.pan, this.loop, this.gainNode, offset, this.onendedCallback) :
369
- playSamples(this.sound.sampleChannels, this.volume, this.rate, this.pan, this.loop, this.sound.sampleRate, this.gainNode, offset, this.onendedCallback);
370
- if (this.source)
371
- {
372
- this.startTime = audioContext.currentTime - offset;
373
- this.pausedTime = undefined;
374
- }
375
- else
376
- {
377
- this.startTime = undefined;
378
- this.pausedTime = 0;
379
- }
380
- }
381
-
382
- /** Set the volume of this sound instance
383
- * @param {number} volume */
384
- setVolume(volume)
385
- {
386
- ASSERT(volume >= 0, 'Sound volume must be positive or zero');
387
- this.volume = volume;
388
- if (this.gainNode)
389
- this.gainNode.gain.value = volume;
390
- }
391
-
392
- /** Stop this sound instance and reset position to the start */
393
- stop(fadeTime=0)
394
- {
395
- ASSERT(fadeTime >= 0, 'Sound fade time must be positive or zero');
396
- if (this.isPlaying())
397
- {
398
- if (fadeTime)
399
- {
400
- // ramp off gain from current volume (not 1, or low-volume
401
- // instances would jump back up before fading);
402
- // cancel any prior scheduling so stacked stop calls don't
403
- // re-anchor partway through a previous fade
404
- const startFade = audioContext.currentTime;
405
- const endFade = startFade + fadeTime;
406
- this.gainNode.gain.cancelScheduledValues(startFade);
407
- this.gainNode.gain.setValueAtTime(this.volume, startFade);
408
- this.gainNode.gain.linearRampToValueAtTime(0, endFade);
409
- this.source.stop(endFade);
410
- }
411
- else
412
- this.source.stop();
413
- }
414
- this.pausedTime = 0;
415
- this.source = undefined;
416
- this.startTime = undefined;
417
- }
418
-
419
- /** Pause this sound instance */
420
- pause()
421
- {
422
- if (this.isPaused()) return;
423
-
424
- // save current time and stop sound
425
- this.pausedTime = this.getCurrentTime();
426
- this.source.stop();
427
- this.source = undefined;
428
- this.startTime = undefined;
429
- }
430
-
431
- /** Resume this sound instance */
432
- resume()
433
- {
434
- if (!this.isPaused()) return;
435
-
436
- // restart sound from paused time
437
- this.start(this.pausedTime);
438
- }
439
-
440
- /** Check if this instance is currently playing
441
- * @return {boolean} - True if playing
442
- */
443
- isPlaying() { return !!this.source; }
444
-
445
- /** Check if this instance is paused or stopped (not currently playing)
446
- * @return {boolean} - True if not playing
447
- */
448
- isPaused() { return !this.isPlaying(); }
449
-
450
- /** Get the current playback time in seconds
451
- * @return {number} - Current playback time
452
- */
453
- getCurrentTime()
454
- {
455
- if (!this.isPlaying()) return this.pausedTime;
456
- const duration = this.getDuration();
457
- // guard mod against 0 duration (rate=0 or sound not loaded)
458
- return duration ? mod(audioContext.currentTime - this.startTime, duration) : 0;
459
- }
460
-
461
- /** Get the total duration of this sound
462
- * @return {number} - Total duration in seconds (0 if loading)
463
- */
464
- getDuration() { return this.rate ? this.sound.getDuration() / this.rate : 0; }
465
-
466
- /** Get source of this sound instance
467
- * @return {AudioBufferSourceNode}
468
- */
469
- getSource() { return this.source; }
470
- }
471
-
472
- ///////////////////////////////////////////////////////////////////////////////
473
-
474
- /** Speak text with passed in settings
475
- * @param {string} text - The text to speak
476
- * @param {number} [volume] - How much to scale volume by
477
- * @param {number} [rate] - How quickly to speak
478
- * @param {number} [pitch] - How much to change the pitch by
479
- * @param {string} [language] - The language/accent to use (examples: en, it, ru, ja, zh)
480
- * @return {SpeechSynthesisUtterance|undefined} - The utterance that was spoken, or undefined if speech is unavailable
481
- * @memberof Audio */
482
- function speak(text, volume=1, rate=1, pitch=1, language='')
483
- {
484
- ASSERT(typeof volume !== 'string', 'speak() signature changed: language is now the last parameter, after pitch');
485
- if (!soundEnable || headlessMode) return;
486
- if (typeof speechSynthesis === 'undefined') return;
487
-
488
- // common languages (not supported by all browsers)
489
- // en - english, it - italian, fr - french, de - german, es - spanish
490
- // ja - japanese, ru - russian, zh - chinese, hi - hindi, ko - korean
491
-
492
- // build utterance and speak
493
- const utterance = new SpeechSynthesisUtterance(text);
494
- utterance.lang = language;
495
- utterance.volume = volume*soundVolume;
496
- utterance.rate = rate;
497
- utterance.pitch = pitch;
498
- speechSynthesis.speak(utterance);
499
- return utterance;
500
- }
501
-
502
- /** Stop all queued speech
503
- * @memberof Audio */
504
- function speakStop()
505
- {
506
- if (typeof speechSynthesis !== 'undefined')
507
- speechSynthesis.cancel();
508
- }
509
-
510
- /** Get frequency of a note on a musical scale
511
- * @param {number} semitoneOffset - How many semitones away from the root note
512
- * @param {number} [rootFrequency=220] - Frequency at semitone offset 0
513
- * @return {number} - The frequency of the note
514
- * @memberof Audio */
515
- function getNoteFrequency(semitoneOffset, rootFrequency=220)
516
- { return rootFrequency * 2**(semitoneOffset/12); }
517
-
518
- ///////////////////////////////////////////////////////////////////////////////
519
-
520
- /**
521
- * @callback AudioEndedCallback - Function called when a sound ends
522
- * @param {AudioBufferSourceNode} source
523
- * @memberof Audio
524
- */
525
-
526
- /** Play cached audio samples with given settings
527
- * @param {Array} sampleChannels - Array of arrays of samples to play (for stereo playback)
528
- * @param {number} [volume] - How much to scale volume by
529
- * @param {number} [rate] - The playback rate to use
530
- * @param {number} [pan] - How much to apply stereo panning
531
- * @param {boolean} [loop] - True if the sound should loop when it reaches the end
532
- * @param {number} [sampleRate=44100] - Sample rate for the sound
533
- * @param {GainNode} [gainNode] - Optional gain node for volume control while playing (disconnected when the sound ends)
534
- * @param {number} [offset] - Offset in seconds to start playback from
535
- * @param {AudioEndedCallback} [onended] - Callback for when the sound ends
536
- * @return {AudioBufferSourceNode} - The source node of the sound played, may be undefined if play fails
537
- * @memberof Audio */
538
- function playSamples(sampleChannels, volume=1, rate=1, pan=0, loop=false, sampleRate=audioDefaultSampleRate, gainNode, offset=0, onended)
539
- {
540
- if (!soundEnable || headlessMode) return;
541
-
542
- if (!audioIsRunning())
543
- {
544
- // fix stalled audio, don't build a buffer that can't be played
545
- audioContext.resume();
546
- return;
547
- }
548
-
549
- const buffer = createAudioBuffer(sampleChannels, sampleRate);
550
- return playAudioBuffer(buffer, volume, rate, pan, loop, gainNode, offset, onended);
551
- }
552
-
553
- /** Copy arrays of samples into a new audio buffer
554
- * @param {Array} sampleChannels - Array of arrays of samples (for stereo playback)
555
- * @param {number} [sampleRate=44100] - Sample rate for the sound
556
- * @return {AudioBuffer} - The audio buffer holding the samples
557
- * @memberof Audio */
558
- function createAudioBuffer(sampleChannels, sampleRate=audioDefaultSampleRate)
559
- {
560
- const channelCount = sampleChannels.length;
561
- const sampleLength = sampleChannels[0].length;
562
- const buffer = audioContext.createBuffer(channelCount, sampleLength, sampleRate);
563
- sampleChannels.forEach((c,i)=> buffer.getChannelData(i).set(c));
564
- return buffer;
565
- }
566
-
567
- /** Play an audio buffer with given settings
568
- * The buffer can be shared by any number of sounds playing at once
569
- * @param {AudioBuffer} buffer - The audio buffer to play
570
- * @param {number} [volume] - How much to scale volume by
571
- * @param {number} [rate] - The playback rate to use
572
- * @param {number} [pan] - How much to apply stereo panning
573
- * @param {boolean} [loop] - True if the sound should loop when it reaches the end
574
- * @param {GainNode} [gainNode] - Optional gain node for volume control while playing (disconnected when the sound ends)
575
- * @param {number} [offset] - Offset in seconds to start playback from
576
- * @param {AudioEndedCallback} [onended] - Callback for when the sound ends
577
- * @return {AudioBufferSourceNode} - The source node of the sound played, may be undefined if play fails
578
- * @memberof Audio */
579
- function playAudioBuffer(buffer, volume=1, rate=1, pan=0, loop=false, gainNode, offset=0, onended)
580
- {
581
- if (!soundEnable || headlessMode) return;
582
-
583
- if (!audioIsRunning())
584
- {
585
- // fix stalled audio, this sound won't be able to play
586
- audioContext.resume();
587
- return;
588
- }
589
-
590
- // setup source, many sources can share one buffer
591
- const source = audioContext.createBufferSource();
592
- source.buffer = buffer;
593
- source.playbackRate.value = rate;
594
- source.loop = loop;
595
-
596
- // create and connect gain node
597
- gainNode = gainNode || audioContext.createGain();
598
- gainNode.gain.value = volume;
599
- gainNode.connect(audioMasterGain);
600
-
601
- // connect source to stereo panner and gain
602
- const pannerNode = new StereoPannerNode(audioContext, {'pan':clamp(pan, -1, 1)});
603
- source.connect(pannerNode).connect(gainNode);
604
-
605
- // disconnect nodes when the sound ends so the audio graph doesn't grow
606
- // unbounded across many play() calls (source.stop() also fires 'ended')
607
- source.addEventListener('ended', ()=>
608
- {
609
- gainNode.disconnect();
610
- pannerNode.disconnect();
611
- if (onended) onended(source);
612
- });
613
-
614
- // play and return sound
615
- const startOffset = offset * rate;
616
- source.start(0, startOffset);
617
-
618
- if (debug && debugSound)
619
- LOG('sound', 'vol', volume.toFixed(2), 'rate', rate.toFixed(2), 'pan', pan.toFixed(2), loop ? 'loop' : '');
620
-
621
- return source;
622
- }
623
-
624
- ///////////////////////////////////////////////////////////////////////////////
625
- // ZzFXMicro - Zuper Zmall Zound Zynth - v1.3.2 by Frank Force
626
-
627
- /** Generate and play a ZzFX sound
628
- *
629
- * <a href=https://killedbyapixel.github.io/ZzFX/>Create sounds using the ZzFX Sound Designer.</a>
630
- * @param {Array} zzfxSound - Array of ZzFX parameters, ex. [.5,.5]
631
- * @return {AudioBufferSourceNode} - The audio node of the sound played
632
- * @memberof Audio */
633
- function zzfx(...zzfxSound) { return playSamples([zzfxG(...zzfxSound)]); }
634
-
635
- /** Generate samples for a ZzFX sound
636
- * @param {number} [volume] - Volume scale (percent)
637
- * @param {number} [randomness] - How much to randomize frequency (percent Hz)
638
- * @param {number} [frequency] - Frequency of sound (Hz)
639
- * @param {number} [attack] - Attack time, how fast sound starts (seconds)
640
- * @param {number} [sustain] - Sustain time, how long sound holds (seconds)
641
- * @param {number} [release] - Release time, how fast sound fades out (seconds)
642
- * @param {number} [shape] - Shape of the sound wave
643
- * @param {number} [shapeCurve] - Squareness of wave (0=square, 1=normal, 2=pointy)
644
- * @param {number} [slide] - How much to slide frequency (kHz/s)
645
- * @param {number} [deltaSlide] - How much to change slide (kHz/s/s)
646
- * @param {number} [pitchJump] - Frequency of pitch jump (Hz)
647
- * @param {number} [pitchJumpTime] - Time of pitch jump (seconds)
648
- * @param {number} [repeatTime] - Resets some parameters periodically (seconds)
649
- * @param {number} [noise] - How much random noise to add (percent)
650
- * @param {number} [modulation] - Frequency of modulation wave, negative flips phase (Hz)
651
- * @param {number} [bitCrush] - Resamples at a lower frequency in (samples*100)
652
- * @param {number} [delay] - Overlap sound with itself for reverb and flanger effects (seconds)
653
- * @param {number} [sustainVolume] - Volume level for sustain (percent)
654
- * @param {number} [decay] - Decay time, how long to reach sustain after attack (seconds)
655
- * @param {number} [tremolo] - Trembling effect, rate controlled by repeat time (percent)
656
- * @param {number} [filter] - Filter cutoff frequency, positive for HPF, negative for LPF (Hz)
657
- * @return {Array} - Array of audio samples
658
- * @memberof Audio */
659
- function zzfxG
660
- (
661
- volume = 1,
662
- randomness = .05,
663
- frequency = 220,
664
- attack = 0,
665
- sustain = 0,
666
- release = .1,
667
- shape = 0,
668
- shapeCurve = 1,
669
- slide = 0,
670
- deltaSlide = 0,
671
- pitchJump = 0,
672
- pitchJumpTime = 0,
673
- repeatTime = 0,
674
- noise = 0,
675
- modulation = 0,
676
- bitCrush = 0,
677
- delay = 0,
678
- sustainVolume = 1,
679
- decay = 0,
680
- tremolo = 0,
681
- filter = 0
682
- )
683
- {
684
- // init parameters
685
- let sampleRate = audioDefaultSampleRate,
686
- PI2 = PI*2,
687
- startSlide = slide *= 500 * PI2 / sampleRate / sampleRate,
688
- startFrequency = frequency *=
689
- (1 + rand(randomness,-randomness)) * PI2 / sampleRate,
690
- modOffset = 0, // modulation offset
691
- repeat = 0, // repeat offset
692
- crush = 0, // bit crush offset
693
- jump = 1, // pitch jump timer
694
- length, // sample length
695
- b = [], // sample buffer
696
- t = 0, // sample time
697
- i = 0, // sample index
698
- s = 0, // sample value
699
- f, // wave frequency
700
-
701
- // biquad LP/HP filter
702
- quality = 2, w = PI2 * abs(filter) * 2 / sampleRate,
703
- cosw = cos(w), alpha = sin(w) / 2 / quality,
704
- a0 = 1 + alpha, a1 = -2*cosw / a0, a2 = (1 - alpha) / a0,
705
- b0 = (1 + sign(filter) * cosw) / 2 / a0,
706
- b1 = -(sign(filter) + cosw) / a0, b2 = b0,
707
- x2 = 0, x1 = 0, y2 = 0, y1 = 0;
708
-
709
- // scale by sample rate
710
- const minAttack = 9; // prevent pop if attack is 0
711
- attack = attack * sampleRate || minAttack;
712
- decay *= sampleRate;
713
- sustain *= sampleRate;
714
- release *= sampleRate;
715
- delay *= sampleRate;
716
- deltaSlide *= 500 * PI2 / sampleRate**3;
717
- modulation *= PI2 / sampleRate;
718
- pitchJump *= PI2 / sampleRate;
719
- pitchJumpTime *= sampleRate;
720
- repeatTime = repeatTime * sampleRate | 0;
721
-
722
- // generate waveform
723
- for (length = attack + decay + sustain + release + delay | 0;
724
- i < length; b[i++] = s * volume) // sample
725
- {
726
- if (!(++crush%(bitCrush*100|0))) // bit crush
727
- {
728
- s = shape? shape>1? shape>2? shape>3? shape>4? // wave shape
729
- (t/PI2%1 < shapeCurve/2? 1 : -1) : // 5 square duty
730
- sin(t**3) : // 4 noise
731
- max(min(tan(t),1),-1): // 3 tan
732
- 1-(2*t/PI2%2+2)%2: // 2 saw
733
- 1-4*abs(round(t/PI2)-t/PI2): // 1 triangle
734
- sin(t); // 0 sin
735
-
736
- s = (repeatTime ?
737
- 1 - tremolo + tremolo*sin(PI2*i/repeatTime) // tremolo
738
- : 1) *
739
- (shape>4?s:sign(s)*abs(s)**shapeCurve) * // shape curve
740
- (i < attack ? i/attack : // attack
741
- i < attack + decay ? // decay
742
- 1-((i-attack)/decay)*(1-sustainVolume) : // decay falloff
743
- i < attack + decay + sustain ? // sustain
744
- sustainVolume : // sustain volume
745
- i < length - delay ? // release
746
- (length - i - delay)/release * // release falloff
747
- sustainVolume : // release volume
748
- 0); // post release
749
-
750
- s = delay ? s/2 + (delay > i ? 0 : // delay
751
- (i<length-delay? 1 : (length-i)/delay) * // release delay
752
- b[i-delay|0]/2/volume) : s; // sample delay
753
-
754
- if (filter) // apply filter
755
- s = y1 = b2*x2 + b1*(x2=x1) + b0*(x1=s) - a2*y2 - a1*(y2=y1);
756
- }
757
-
758
- f = (frequency += slide += deltaSlide) *// frequency
759
- cos(modulation*modOffset++); // modulation
760
- t += f + f*noise*sin(i**5); // noise
761
-
762
- if (jump && ++jump > pitchJumpTime) // pitch jump
763
- {
764
- frequency += pitchJump; // apply pitch jump
765
- startFrequency += pitchJump; // also apply to start
766
- jump = 0; // stop pitch jump time
767
- }
768
-
769
- if (repeatTime && !(++repeat % repeatTime)) // repeat
770
- {
771
- frequency = startFrequency; // reset frequency
772
- slide = startSlide; // reset slide
773
- jump ||= 1; // reset pitch jump time
774
- }
775
- }
776
-
777
- return b; // return sample buffer
1
+ /**
2
+ * LittleJS Audio System
3
+ * - Play audio files (mp3, ogg, wave) and generate sounds with ZzFX
4
+ * - ZzFX sound generator integration: <a href=https://killedbyapixel.github.io/ZzFX/>ZzFX</a>
5
+ * - Sound caching for fast playback and memory efficiency
6
+ * - Volume control with attenuation and stereo panning
7
+ * - 2D spatial audio based on camera position with distance-based falloff
8
+ * - Sound instance management (pause, resume, stop)
9
+ * - Speech synthesis for text-to-speech
10
+ * - Music playback with ZzFXM support
11
+ * - Web Audio API integration with master gain control
12
+ * - Sounds and the master bus can route through effects, see the audio effects plugin
13
+ * @namespace Audio
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ /** Audio context used by the engine, undefined outside a browser, where the engine runs headless
19
+ * @type {AudioContext}
20
+ * @memberof Audio */
21
+ let audioContext = typeof AudioContext == 'undefined' ? undefined : new AudioContext;
22
+
23
+ /** Master gain node for all audio to pass through, made at load so effects can connect to it any time
24
+ * @type {GainNode}
25
+ * @memberof Audio */
26
+ let audioMasterGain = audioContext?.createGain();
27
+ let audioMasterVolume; // the soundVolume the master gain was last set to
28
+ if (audioMasterGain)
29
+ {
30
+ audioMasterGain.connect(audioContext.destination);
31
+ audioMasterGain.gain.value = audioMasterVolume = soundVolume; // set starting value
32
+ audioContext.addEventListener?.('statechange', audioStateChange);
33
+ }
34
+
35
+ // soundVolume can be set directly, so the master gain follows it each frame
36
+ function audioUpdateVolume()
37
+ {
38
+ if (audioMasterGain && soundVolume !== audioMasterVolume)
39
+ audioMasterGain.gain.value = audioMasterVolume = soundVolume;
40
+ }
41
+
42
+ // the current master effect, kept so setAudioMasterEffect can undo the route it made,
43
+ // and whether its output came from an effect, which gets its default route back
44
+ let audioMasterEffectInput, audioMasterEffectOutput, audioMasterEffectOutputIsEffect;
45
+
46
+ /** Default sample rate used for sounds
47
+ * @default 44100
48
+ * @memberof Audio */
49
+ const audioDefaultSampleRate = 44100;
50
+
51
+ /** Check if the audio context is running and available for playback
52
+ * @return {boolean} - True if the audio context is running, false when there is none
53
+ * @memberof Audio */
54
+ function audioIsRunning()
55
+ { return audioContext?.state === 'running'; }
56
+
57
+ function audioInit()
58
+ {
59
+ if (!soundEnable || headlessMode) return;
60
+
61
+ document.addEventListener('visibilitychange', audioVisibilityChange);
62
+ }
63
+
64
+ // a hidden page stops the game, so its sound stops too, and the audio clock with it so every sound picks up
65
+ // exactly where it was; only a suspend made here is undone, not one the browser holds until the first input
66
+ let audioSuspendedWhenHidden = false;
67
+ function audioVisibilityChange()
68
+ {
69
+ if (document.hidden)
70
+ {
71
+ if (!soundPauseWhenHidden || audioContext.state != 'running') return;
72
+ audioSuspendedWhenHidden = true;
73
+ audioContext.suspend();
74
+ }
75
+ else if (audioSuspendedWhenHidden)
76
+ {
77
+ audioSuspendedWhenHidden = false;
78
+ audioContext.resume();
79
+ }
80
+ }
81
+
82
+ // sound instances whose start failed only because the context was not running, like music started in gameInit
83
+ // before the first input, each with the time it tried; they start once the context runs unless paused or stopped
84
+ // first, and a one shot drops out once it would have ended anyway, so a backlog of sounds can't all play at once
85
+ const audioWaitingInstances = new Map;
86
+ function audioWaitingPrune(now=performance.now())
87
+ {
88
+ for (const [instance, startTime] of audioWaitingInstances)
89
+ {
90
+ const remaining = (instance.getDuration() - instance.pausedTime) / instance.rate;
91
+ if (!instance.loop && now - startTime > remaining * 1e3)
92
+ audioWaitingInstances.delete(instance);
93
+ }
94
+ }
95
+ function audioStateChange()
96
+ {
97
+ if (!audioIsRunning()) return;
98
+ audioWaitingPrune();
99
+ const instances = [...audioWaitingInstances.keys()];
100
+ audioWaitingInstances.clear();
101
+ for (const instance of instances)
102
+ instance.resume();
103
+ }
104
+
105
+ /** Anything with input and output audio nodes, like an effect from the audio effects plugin
106
+ * @typedef {{input: AudioNode, output: AudioNode}} AudioEffectNodes
107
+ * @memberof Audio */
108
+
109
+ /** Route all sound through an effect between the master gain and the speakers
110
+ * - Pass a node or an effect, or the first and last of a chain, each a node or an effect
111
+ * - With one argument a node is both ends, and an effect uses its own input and output
112
+ * - The output node is disconnected from everything else first, so it only feeds the speakers
113
+ * - The two ends of a chain must already be connected to each other, like effectA.connect(effectB)
114
+ * - Call with no arguments to remove the effect, an effect that was the master goes back to feeding the master gain
115
+ * - Debug video capture records the end of the master chain, but loses its tap if the effect changes mid-capture
116
+ * @param {AudioNode|AudioEffectNodes} [input] - Node or effect the master gain connects to
117
+ * @param {AudioNode|AudioEffectNodes} [output] - Node or effect that connects to the audio destination, defaults to the input's output
118
+ * @memberof Audio */
119
+ function setAudioMasterEffect(input, output)
120
+ {
121
+ // an effect stands in for its nodes, and a node is both ends when no output is passed
122
+ // (the output resolves first since its default comes from the input effect)
123
+ const outputArg = output || input;
124
+ const outputIsEffect = !!outputArg && 'input' in outputArg;
125
+ output = audioEffectNode(output, 'output') || audioEffectNode(input, 'output');
126
+ input = audioEffectNode(input, 'input');
127
+ ASSERT(!input || typeof input.connect === 'function', 'input must be an AudioNode or an effect with input and output nodes');
128
+ ASSERT(!output || typeof output.connect === 'function', 'output must be an AudioNode or an effect with input and output nodes');
129
+ if (!audioMasterGain) return; // no audio outside a browser, where the engine runs headless
130
+
131
+ // undo the current route selectively so other taps survive; an effect's output that still feeds the speakers
132
+ // goes back to the master gain, its default, so it still works for sounds, but one that connect() already moved
133
+ // on, like the head of a longer chain being set now, stays where it was sent
134
+ audioMasterGain.disconnect(audioMasterEffectInput || audioContext.destination);
135
+ if (audioMasterEffectOutput)
136
+ {
137
+ let fedSpeakers = true;
138
+ try { audioMasterEffectOutput.disconnect(audioContext.destination); }
139
+ catch { fedSpeakers = false; }
140
+ if (audioMasterEffectOutputIsEffect && fedSpeakers)
141
+ audioMasterEffectOutput.connect(audioMasterGain);
142
+ }
143
+ audioMasterEffectInput = input;
144
+ audioMasterEffectOutput = output;
145
+ audioMasterEffectOutputIsEffect = outputIsEffect;
146
+
147
+ // connect the master gain to the speakers, through the effect if there is one
148
+ if (input)
149
+ {
150
+ audioMasterGain.connect(input);
151
+ output.disconnect();
152
+ output.connect(audioContext.destination);
153
+ }
154
+ else
155
+ audioMasterGain.connect(audioContext.destination);
156
+ }
157
+
158
+ // get one of an effect's nodes, or the thing itself when it is already a node
159
+ /** @param {AudioNode|AudioEffectNodes|undefined} effectOrNode
160
+ * @param {'input'|'output'} key
161
+ * @return {AudioNode}
162
+ * @ignore */
163
+ function audioEffectNode(effectOrNode, key)
164
+ {
165
+ if (effectOrNode && 'input' in effectOrNode)
166
+ return /** @type {AudioEffectNodes} */ (effectOrNode)[key];
167
+ return /** @type {AudioNode} */ (effectOrNode);
168
+ }
169
+
170
+ // ramp an audio param to a value, cancelling anything already scheduled so stacked calls don't fight;
171
+ // returns when the ramp ends
172
+ function audioParamRamp(param, value, fadeTime=0)
173
+ {
174
+ ASSERT(fadeTime >= 0, 'fadeTime must be positive or zero');
175
+ const startTime = audioContext.currentTime;
176
+ param.cancelScheduledValues(startTime);
177
+ if (fadeTime)
178
+ {
179
+ param.setValueAtTime(param.value, startTime);
180
+ param.linearRampToValueAtTime(value, startTime + fadeTime);
181
+ }
182
+ else
183
+ param.value = value;
184
+ return startTime + fadeTime;
185
+ }
186
+
187
+ ///////////////////////////////////////////////////////////////////////////////
188
+
189
+ /**
190
+ * @callback SoundLoadCallback - Function called when sound is loaded
191
+ * @param {Sound} sound
192
+ * @memberof Audio
193
+ */
194
+
195
+ /**
196
+ * Sound Object - Stores a sound for later
197
+ * - this can be used to load and play wave, mp3, and ogg files
198
+ * - it can also create sounds using the ZzFX sound generator
199
+ * - can attenuate and apply stereo panning to sounds
200
+ * - sound instance control with pause/resume capability
201
+ *
202
+ * <a href=https://killedbyapixel.github.io/ZzFX/>Create sounds using the ZzFX Sound Designer.</a>
203
+ * @memberof Audio
204
+ * @example
205
+ * // load an audio asset file
206
+ * const sound_example = new Sound('sound.mp3');
207
+ *
208
+ * // create a zzfx sound
209
+ * const sound_example = new Sound([.5,.5]);
210
+ *
211
+ * // play a sound
212
+ * sound_example.play();
213
+ */
214
+ class Sound
215
+ {
216
+ /** Create a sound object and cache the audio for later use
217
+ * @param {string|URL|Array} [asset] - Filename or URL of an audio file, or a zzfx array
218
+ * @param {number} [randomness] - How much to randomize frequency each time sound plays, for zzfx sounds it overrides the array's own randomness, which is used if undefined
219
+ * @param {number} [range=soundDefaultRange] - World space max range of sound
220
+ * @param {number} [taper=soundDefaultTaper] - At what percentage of range should it start tapering
221
+ * @param {SoundLoadCallback} [onloadCallback] - callback function to call when sound is loaded
222
+ */
223
+ constructor(asset, randomness, range=soundDefaultRange, taper=soundDefaultTaper, onloadCallback)
224
+ {
225
+ if (!soundEnable || headlessMode) return;
226
+
227
+ ASSERT(!asset || isArray(asset) || isStringLike(asset), 'asset must be a file name or zzfx array');
228
+ ASSERT(randomness === undefined || isNumber(randomness), 'randomness must be a number');
229
+ ASSERT(randomness === undefined || randomness >= 0 && randomness <=1, 'randomness must be between 0 and 1');
230
+ ASSERT(isNumber(range), 'range must be a number');
231
+ ASSERT(isNumber(taper), 'taper must be a number');
232
+
233
+ /** @property {number} - World space max range of sound */
234
+ this.range = range;
235
+ /** @property {number} - At what percentage of range should it start tapering */
236
+ this.taper = taper;
237
+ /** @property {number} - How much to randomize frequency each time sound plays
238
+ * @type {number} */
239
+ this.randomness = randomness ?? 0;
240
+ /** @property {number} - Sample rate for this sound */
241
+ this.sampleRate = audioDefaultSampleRate;
242
+ /** @property {number} - How many samples per channel this sound has */
243
+ this.sampleLength = 0;
244
+ /** @property {AudioBuffer|undefined} - Decoded audio shared by every play of this sound
245
+ * @type {AudioBuffer|undefined} */
246
+ this.sampleBuffer = undefined;
247
+ // internal, the 3D plugin reads it to know the sound has loaded
248
+ /** @ignore
249
+ * @type {Array<Array<number>|Float32Array>|undefined} */
250
+ this._sampleChannels = undefined;
251
+ /** @property {number} - Percentage of this sound currently loaded, sounds
252
+ * fetched from a url stay at 0 until decoding completes */
253
+ this.loadedPercent = 0;
254
+ /** @property {SoundLoadCallback|undefined} - function to call when sound is loaded
255
+ * @type {SoundLoadCallback|undefined} */
256
+ this.onloadCallback = onloadCallback;
257
+ /** @property {AudioNode|AudioEffectNodes|undefined} - Node or effect to route every play of this sound through instead of the master gain
258
+ * - Where this sound's audio goes, unlike AudioEffect.output which is an effect's own node, effects chain with connect()
259
+ * @type {AudioNode|AudioEffectNodes|undefined} */
260
+ this.output = undefined;
261
+
262
+ if (isArray(asset))
263
+ {
264
+ // generate zzfx sound — copy so we don't mutate the caller's array
265
+ const zzfxSound = asset.slice();
266
+
267
+ // remove randomness so it can be applied on playback, a value passed in wins over the array's
268
+ const randomnessIndex = 1;
269
+ this.randomness = randomness ?? zzfxSound[randomnessIndex] ?? .05;
270
+ zzfxSound[randomnessIndex] = 0;
271
+
272
+ // generate the zzfx samples, then hand them to an audio buffer so
273
+ // the plain arrays can be released and every play shares the buffer
274
+ this.sampleChannels = [zzfxG(...zzfxSound)];
275
+ this.buildSampleBuffer();
276
+ this.loadedPercent = 1;
277
+ onloadCallback?.(this);
278
+ }
279
+ else if (asset)
280
+ {
281
+ // load the audio file, a URL object as bundlers give works like its string;
282
+ // report failures rather than leaving an unhandled rejection, the sound just stays unloaded and silent
283
+ const filename = asset + '';
284
+ this.loadSound(filename).catch(e=>
285
+ LOG('Sound load failed for', filename, '-', e.message));
286
+ }
287
+ }
288
+
289
+ /** Sample data for each channel
290
+ * Sounds keep their samples in an audio buffer, so reading this rebuilds
291
+ * the arrays from it and caches them. The copies are safe to hold onto,
292
+ * playing a sound detaches the buffer's own channel arrays.
293
+ * @return {Array<Array<number>|Float32Array>|undefined} */
294
+ get sampleChannels()
295
+ {
296
+ const buffer = this.sampleBuffer;
297
+ if (!this._sampleChannels && buffer)
298
+ {
299
+ const channels = [];
300
+ for (let i = 0; i < buffer.numberOfChannels; i++)
301
+ channels.push(buffer.getChannelData(i).slice());
302
+ this._sampleChannels = channels;
303
+ }
304
+ return this._sampleChannels;
305
+ }
306
+
307
+ /** @param {Array<Array<number>|Float32Array>} sampleChannels */
308
+ set sampleChannels(sampleChannels)
309
+ {
310
+ // new samples invalidate the buffer built from the old ones
311
+ this._sampleChannels = sampleChannels;
312
+ this.sampleBuffer = undefined;
313
+ this.sampleLength = sampleChannels?.[0]?.length || 0;
314
+ }
315
+
316
+ /** Move this sound's samples into an audio buffer that every play can share
317
+ * Does nothing if there is already a buffer or no samples to build one from */
318
+ buildSampleBuffer()
319
+ {
320
+ if (this.sampleBuffer || !this._sampleChannels || headlessMode) return;
321
+
322
+ this.sampleBuffer = createAudioBuffer(this._sampleChannels, this.sampleRate);
323
+
324
+ // the buffer owns the samples now, release the arrays we built it from
325
+ this._sampleChannels = undefined;
326
+ }
327
+
328
+ /** Play the sound
329
+ * - Browsers hold audio until the first user input, a sound played before it returns a paused instance
330
+ * that starts on its own once audio runs, unless paused or stopped first; a one shot that would have
331
+ * ended by then is dropped, and only the newest play of each sound waits, so a sound played every frame
332
+ * starts once
333
+ * @param {Vector2} [pos] - World space position to play the sound if any
334
+ * @param {number} [volume] - How much to scale volume by
335
+ * @param {number} [pitch] - How much to scale pitch by
336
+ * @param {number} [randomnessScale] - How much to scale pitch randomness
337
+ * @param {boolean} [loop] - Should the sound loop?
338
+ * @param {boolean} [paused] - Should the sound start paused
339
+ * @return {SoundInstance|undefined} - The sound instance, or undefined if sound is disabled, not loaded, out of range, or running in headless mode
340
+ */
341
+ play(pos, volume=1, pitch=1, randomnessScale=1, loop=false, paused=false)
342
+ {
343
+ ASSERT(!pos || isVector2(pos), 'pos must be a vec2');
344
+ ASSERT(isNumber(volume), 'volume must be a number');
345
+ ASSERT(isNumber(pitch), 'pitch must be a number');
346
+ ASSERT(isNumber(randomnessScale), 'randomnessScale must be a number');
347
+
348
+ if (!soundEnable || headlessMode) return;
349
+ if (!this.sampleBuffer && !this._sampleChannels) return;
350
+
351
+ let pan;
352
+ if (pos)
353
+ {
354
+ const range = this.range;
355
+ if (range)
356
+ {
357
+ // apply range based fade
358
+ const lengthSquared = cameraPos.distanceSquared(pos);
359
+ if (lengthSquared > range*range)
360
+ return; // out of range
361
+
362
+ // attenuate volume by distance, full volume out to the taper and a fade past it,
363
+ // so a taper of 1 plays at full volume right up to the range
364
+ const distance = lengthSquared**.5, taperRange = range*this.taper;
365
+ if (distance > taperRange)
366
+ volume *= percent(distance, range, taperRange);
367
+ }
368
+
369
+ // get pan from screen space coords
370
+ pan = worldToScreen(pos).x * 2/mainCanvasSize.x - 1;
371
+ }
372
+
373
+ // Create sound instance
374
+ const rate = pitch + pitch * this.randomness*randomnessScale*rand(-1,1);
375
+ const instance = new SoundInstance(this, volume, rate, pan, loop, paused);
376
+
377
+ if (debug && debugSound && pos)
378
+ {
379
+ // visualize where positioned sounds play and their falloff range
380
+ debugCircle(pos, .5, '#0ff', .5, true, false);
381
+ if (this.range)
382
+ {
383
+ debugCircle(pos, 2*this.range, '#0ff', .5, false, false); // silent radius
384
+ debugCircle(pos, 2*this.range*this.taper, '#0ff', .5, false, false); // full volume radius
385
+ }
386
+ debugText('vol '+volume.toFixed(2)+' pitch '+rate.toFixed(2), pos, .5, '#0ff', .5, 0, 'monospace', false);
387
+ }
388
+
389
+ return instance;
390
+ }
391
+
392
+ /** Play the sound on a loop, the same as play with loop on; stop or change it through the SoundInstance returned
393
+ * @param {Vector2} [pos] - World space position to play the sound if any
394
+ * @param {number} [volume] - How much to scale volume by
395
+ * @param {number} [pitch] - How much to scale pitch by
396
+ * @param {number} [randomnessScale] - How much to scale pitch randomness
397
+ * @param {boolean} [paused] - Should the sound start paused
398
+ * @return {SoundInstance|undefined} - The sound instance, or undefined if sound is disabled, not loaded, out of range, or running in headless mode */
399
+ playLoop(pos, volume=1, pitch=1, randomnessScale=1, paused=false)
400
+ { return this.play(pos, volume, pitch, randomnessScale, true, paused); }
401
+
402
+ /** Play a music track that loops by default
403
+ * @param {number} [volume] - Volume to play the music at
404
+ * @param {boolean} [loop] - Should the music loop?
405
+ * @param {boolean} [paused] - Should the music start paused
406
+ * @return {SoundInstance|undefined} - The sound instance, or undefined if sound is disabled, not loaded, or running in headless mode
407
+ */
408
+ playMusic(volume=1, loop=true, paused=false)
409
+ { return this.play(undefined, volume, 1, 0, loop, paused); }
410
+
411
+ /** Play the sound as a musical note with a semitone offset
412
+ * This can be used to play music with chromatic scales
413
+ * @param {number} [semitoneOffset] - How many semitones to offset pitch
414
+ * @param {Vector2} [pos] - World space position to play the sound if any
415
+ * @param {number} [volume=1] - How much to scale volume by
416
+ * @return {SoundInstance|undefined} - The sound instance, or undefined if sound is disabled, not loaded, out of range, or running in headless mode
417
+ */
418
+ playNote(semitoneOffset=0, pos, volume)
419
+ {
420
+ ASSERT(isNumber(semitoneOffset), 'semitoneOffset must be a number');
421
+ const pitch = getNoteFrequency(semitoneOffset, 1);
422
+ return this.play(pos, volume, pitch, 0);
423
+ }
424
+
425
+ /** Get how long this sound is in seconds
426
+ * @return {number} - How long the sound is in seconds (0 if loading)
427
+ */
428
+ getDuration()
429
+ { return this.sampleLength / this.sampleRate || 0; }
430
+
431
+ /** Check if sound is loaded, for sounds fetched from a url
432
+ * @return {boolean} - True if sound is loaded and ready to play
433
+ */
434
+ isLoaded() { return this.loadedPercent === 1; }
435
+
436
+ /** Loads a sound from a URL and decodes it into sample data.
437
+ * @param {string} filename
438
+ * @return {Promise} */
439
+ async loadSound(filename)
440
+ {
441
+ const response = await fetch(filename);
442
+ if (!response.ok)
443
+ throw new Error(`Failed to load sound from ${filename}: ${response.status} ${response.statusText}`);
444
+ const arrayBuffer = await response.arrayBuffer();
445
+ const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
446
+
447
+ // keep the decoded buffer as is, it is exactly what playback needs and
448
+ // every play shares it, no channel data is read or copied
449
+ this.sampleRate = audioBuffer.sampleRate;
450
+ this.sampleLength = audioBuffer.length;
451
+ this.sampleBuffer = audioBuffer;
452
+ this._sampleChannels = undefined; // samples read from a sound loaded before are that sound's, not this one's
453
+ this.loadedPercent = 1;
454
+ this.onloadCallback?.(this);
455
+ }
456
+ }
457
+
458
+ ///////////////////////////////////////////////////////////////////////////////
459
+
460
+ /**
461
+ * Sound Instance - Wraps an AudioBufferSourceNode for individual sound control
462
+ * Represents a single playing instance of a sound with pause/resume capabilities
463
+ * @memberof Audio
464
+ * @example
465
+ * // Play a sound and get an instance for control
466
+ * const jumpSound = new Sound([.5,.5,220]);
467
+ * const instance = jumpSound.play();
468
+ *
469
+ * // Control the individual instance
470
+ * instance.setVolume(.5);
471
+ * instance.pause();
472
+ * instance.resume();
473
+ * instance.stop();
474
+ */
475
+ class SoundInstance
476
+ {
477
+ /** Create a sound instance
478
+ * @param {Sound} sound - The sound object
479
+ * @param {number} [volume] - How much to scale volume by
480
+ * @param {number} [rate] - The playback rate to use
481
+ * @param {number} [pan] - How much to apply stereo panning
482
+ * @param {boolean} [loop] - Should the sound loop?
483
+ * @param {boolean} [paused] - Should the sound start paused? */
484
+ constructor(sound, volume=1, rate=1, pan=0, loop=false, paused=false)
485
+ {
486
+ ASSERT(sound instanceof Sound, 'SoundInstance requires a valid Sound object');
487
+ ASSERT(volume >= 0, 'Sound volume must be positive or zero');
488
+ ASSERT(rate >= 0, 'Sound rate must be positive or zero');
489
+ ASSERT(isNumber(pan), 'Sound pan must be a number');
490
+
491
+ /** @property {Sound} - The sound object */
492
+ this.sound = sound;
493
+ /** @property {number} - How much to scale volume by */
494
+ this.volume = volume;
495
+ /** @property {number} - The playback rate to use */
496
+ this.rate = rate;
497
+ /** @property {number} - How much to apply stereo panning */
498
+ this.pan = pan;
499
+ /** @property {boolean} - Should the sound loop */
500
+ this.loop = loop;
501
+ /** @property {number|undefined} - Where it is in the sound while not playing, in the sound's own seconds, undefined while playing
502
+ * @type {number|undefined} */
503
+ this.pausedTime = 0;
504
+ /** @property {number} - Audio context time its place was last taken at, while playing
505
+ * @type {number|undefined} */
506
+ this.startTime = undefined;
507
+ /** @property {number} - Where it was in the sound at startTime, in the sound's own seconds */
508
+ this.startOffset = 0;
509
+ /** @property {GainNode|undefined} - Gain node for the sound, undefined once it is stopped or paused
510
+ * @type {GainNode|undefined} */
511
+ this.gainNode = undefined;
512
+ /** @property {StereoPannerNode|undefined} - Stereo panner for the sound, undefined once it is stopped or paused
513
+ * @type {StereoPannerNode|undefined} */
514
+ this.pannerNode = undefined;
515
+ /** @property {AudioBufferSourceNode|undefined} - Source node of the audio, undefined while not playing
516
+ * @type {AudioBufferSourceNode|undefined} */
517
+ this.source = undefined;
518
+ /** @property {AudioNode|AudioEffectNodes|undefined} - Node or effect to route this instance through, copied from the sound
519
+ * @type {AudioNode|AudioEffectNodes|undefined} */
520
+ this.output = sound.output;
521
+ /** @property {AudioEndedCallback|undefined} - Called when this instance plays to its end, not when it is stopped
522
+ * or paused; it is read when the sound ends, so it can be set at any time
523
+ * @type {AudioEndedCallback|undefined} */
524
+ this.onendedCallback = undefined;
525
+ /** A playback that ends on its own leaves the instance stopped, its time back at 0; the ended event of one
526
+ * stopped or replaced since is too late to change anything
527
+ * @private */
528
+ this.sourceEnded = (source)=>
529
+ {
530
+ if (source !== this.source) return;
531
+ this.source = this.gainNode = this.pannerNode = undefined;
532
+ this.startTime = undefined;
533
+ this.pausedTime = 0;
534
+ this.onendedCallback?.(source);
535
+ };
536
+
537
+ // start sound
538
+ if (!paused)
539
+ this.start();
540
+ }
541
+
542
+ /** Start playing the sound instance from a place in the sound
543
+ * @param {number} [offset] - Where to start in the sound, in its own seconds whatever the rate
544
+ */
545
+ start(offset=0)
546
+ {
547
+ ASSERT(offset >= 0, 'Sound start offset must be positive or zero');
548
+ if (this.isPlaying())
549
+ this.stop();
550
+ this.gainNode = audioContext.createGain();
551
+ this.pannerNode = new StereoPannerNode(audioContext, {'pan':clamp(this.pan, -1, 1)});
552
+
553
+ // build the shared buffer if it was not made at load time, then play it
554
+ this.sound.buildSampleBuffer();
555
+ this.source = this.sound.sampleBuffer ?
556
+ playAudioBuffer(this.sound.sampleBuffer, this.volume, this.rate, this.pan, this.loop, this.gainNode, offset, this.sourceEnded, this.output, this.pannerNode) :
557
+ playSamples(this.sound.sampleChannels, this.volume, this.rate, this.pan, this.loop, this.sound.sampleRate, this.gainNode, offset, this.sourceEnded, this.output, this.pannerNode);
558
+ audioWaitingInstances.delete(this);
559
+ if (this.source)
560
+ {
561
+ this.startTime = audioContext.currentTime;
562
+ this.startOffset = offset;
563
+ this.pausedTime = undefined;
564
+ }
565
+ else
566
+ {
567
+ // the sound could not start, keep the place so a later resume picks it up,
568
+ // which happens on its own when it failed only because audio is not running yet
569
+ this.startTime = this.gainNode = this.pannerNode = undefined;
570
+ this.pausedTime = offset;
571
+ if (!audioIsRunning())
572
+ {
573
+ // only the newest of each sound waits, a loop a game plays again each frame is one loop
574
+ audioWaitingPrune();
575
+ for (const other of audioWaitingInstances.keys())
576
+ other.sound === this.sound && audioWaitingInstances.delete(other);
577
+ audioWaitingInstances.set(this, performance.now());
578
+ }
579
+ }
580
+ }
581
+
582
+ /** Set the volume of this sound instance, with an optional fade to it
583
+ * - A fade ducks music under dialogue or cross fades two tracks without a click
584
+ * @param {number} volume
585
+ * @param {number} [fadeTime] - Seconds to fade to the new volume over */
586
+ setVolume(volume, fadeTime=0)
587
+ {
588
+ ASSERT(volume >= 0, 'Sound volume must be positive or zero');
589
+ ASSERT(fadeTime >= 0, 'Sound fade time must be positive or zero');
590
+ this.volume = volume;
591
+ this.gainNode && audioParamRamp(this.gainNode.gain, volume, fadeTime);
592
+ }
593
+
594
+ /** Set the stereo pan of this sound instance, while it plays too
595
+ * - A looping sound can follow its source across the screen this way
596
+ * @param {number} pan - -1 is left, 0 is center, 1 is right, clamped to that range */
597
+ setPan(pan)
598
+ {
599
+ ASSERT(isNumber(pan), 'Sound pan must be a number');
600
+ this.pan = pan;
601
+ if (this.pannerNode)
602
+ this.pannerNode.pan.value = clamp(pan, -1, 1);
603
+ }
604
+
605
+ /** Set the playback rate of this sound instance, its speed and pitch, while it plays
606
+ * - A looping sound can follow something smoothly this way, like an engine with the speed
607
+ * - A rate of 0 freezes the sound in place, and it carries on from there when the rate comes back
608
+ * @param {number} rate - 1 is normal, 2 is twice as fast and an octave up */
609
+ setRate(rate)
610
+ {
611
+ ASSERT(rate >= 0, 'Sound rate must be positive or zero');
612
+ // keep the place in the sound, only the speed changes from here
613
+ if (this.isPlaying())
614
+ {
615
+ this.startOffset = this.getCurrentTime();
616
+ this.startTime = audioContext.currentTime;
617
+ }
618
+ this.rate = rate;
619
+ if (this.source)
620
+ this.source.playbackRate.value = rate;
621
+ }
622
+
623
+ /** Stop this sound instance and reset position to the start
624
+ * @param {number} [fadeTime] - Seconds to fade out over before stopping */
625
+ stop(fadeTime=0)
626
+ {
627
+ ASSERT(fadeTime >= 0, 'Sound fade time must be positive or zero');
628
+ audioWaitingInstances.delete(this); // a sound waiting for audio to run no longer starts
629
+ if (this.isPlaying())
630
+ {
631
+ if (fadeTime)
632
+ {
633
+ // ramp off gain from where it is now (not 1, or low-volume
634
+ // instances would jump back up before fading, and a volume
635
+ // fade in flight carries on down from its current point);
636
+ // the ramp and the stop share one end time
637
+ this.source.stop(audioParamRamp(this.gainNode.gain, 0, fadeTime));
638
+ }
639
+ else
640
+ this.source.stop();
641
+ }
642
+ this.pausedTime = 0;
643
+ this.source = undefined;
644
+ this.startTime = undefined;
645
+ // let go of the gain node so a later setVolume can't cancel the fade out, the ended listener disconnects
646
+ // it, and start makes a new one
647
+ this.gainNode = undefined;
648
+ this.pannerNode = undefined;
649
+ }
650
+
651
+ /** Pause this sound instance */
652
+ pause()
653
+ {
654
+ audioWaitingInstances.delete(this); // a sound waiting for audio to run no longer starts
655
+ if (this.isPaused()) return;
656
+
657
+ // save current time and stop sound
658
+ this.pausedTime = this.getCurrentTime();
659
+ this.source.stop();
660
+ this.source = undefined;
661
+ this.startTime = undefined;
662
+ this.gainNode = undefined; // resume starts with a new one at the volume set meanwhile
663
+ this.pannerNode = undefined;
664
+ }
665
+
666
+ /** Resume this sound instance */
667
+ resume()
668
+ {
669
+ if (!this.isPaused()) return;
670
+
671
+ // restart sound from paused time
672
+ this.start(this.pausedTime);
673
+ }
674
+
675
+ /** Check if this instance is currently playing
676
+ * @return {boolean} - True if playing
677
+ */
678
+ isPlaying() { return !!this.source; }
679
+
680
+ /** Check if this instance is paused or stopped (not currently playing)
681
+ * @return {boolean} - True if not playing
682
+ */
683
+ isPaused() { return !this.isPlaying(); }
684
+
685
+ /** Get where it is in the sound, in the sound's own seconds: at a rate of 2 it moves two seconds for each one
686
+ * that passes, and at 0 it stays put
687
+ * @return {number} - Seconds into the sound
688
+ */
689
+ getCurrentTime()
690
+ {
691
+ if (!this.isPlaying()) return this.pausedTime;
692
+ const duration = this.getDuration();
693
+ const place = this.startOffset + (audioContext.currentTime - this.startTime) * this.rate;
694
+ return duration ? mod(place, duration) : 0; // a sound still loading has no length yet
695
+ }
696
+
697
+ /** Get the length of the sound in its own seconds, the same at any rate; divide by the rate for how long it takes to play
698
+ * @return {number} - Length in seconds (0 if loading)
699
+ */
700
+ getDuration() { return this.sound.getDuration(); }
701
+
702
+ /** Get source of this sound instance
703
+ * @return {AudioBufferSourceNode|undefined} - The source, or undefined while not playing
704
+ */
705
+ getSource() { return this.source; }
706
+ }
707
+
708
+ ///////////////////////////////////////////////////////////////////////////////
709
+
710
+ /** Speak text with passed in settings
711
+ * @param {string} text - The text to speak
712
+ * @param {number} [volume] - How much to scale volume by
713
+ * @param {number} [rate] - How quickly to speak
714
+ * @param {number} [pitch] - How much to change the pitch by
715
+ * @param {string} [language] - The language/accent to use (examples: en, it, ru, ja, zh)
716
+ * @return {SpeechSynthesisUtterance|undefined} - The utterance that was spoken, or undefined if speech is unavailable
717
+ * @memberof Audio */
718
+ function speak(text, volume=1, rate=1, pitch=1, language='')
719
+ {
720
+ ASSERT(typeof volume !== 'string', 'speak() signature changed: language is now the last parameter, after pitch');
721
+ if (!soundEnable || headlessMode) return;
722
+ if (typeof speechSynthesis === 'undefined') return;
723
+
724
+ // common languages (not supported by all browsers)
725
+ // en - english, it - italian, fr - french, de - german, es - spanish
726
+ // ja - japanese, ru - russian, zh - chinese, hi - hindi, ko - korean
727
+
728
+ // build utterance and speak
729
+ const utterance = new SpeechSynthesisUtterance(text);
730
+ utterance.lang = language;
731
+ utterance.volume = clamp(volume*soundVolume);
732
+ utterance.rate = rate;
733
+ utterance.pitch = pitch;
734
+ speechSynthesis.speak(utterance);
735
+ return utterance;
736
+ }
737
+
738
+ /** Stop all queued speech
739
+ * @memberof Audio */
740
+ function speakStop()
741
+ {
742
+ if (typeof speechSynthesis !== 'undefined')
743
+ speechSynthesis.cancel();
744
+ }
745
+
746
+ /** Get frequency of a note on a musical scale
747
+ * @param {number} semitoneOffset - How many semitones away from the root note
748
+ * @param {number} [rootFrequency] - Frequency at semitone offset 0
749
+ * @return {number} - The frequency of the note
750
+ * @memberof Audio */
751
+ function getNoteFrequency(semitoneOffset, rootFrequency=220)
752
+ { return rootFrequency * 2**(semitoneOffset/12); }
753
+
754
+ ///////////////////////////////////////////////////////////////////////////////
755
+
756
+ /**
757
+ * @callback AudioEndedCallback - Function called when a sound ends
758
+ * @param {AudioBufferSourceNode} source
759
+ * @memberof Audio
760
+ */
761
+
762
+ /** Play cached audio samples with given settings
763
+ * @param {Array} sampleChannels - Array of arrays of samples to play (for stereo playback)
764
+ * @param {number} [volume] - How much to scale volume by
765
+ * @param {number} [rate] - The playback rate to use
766
+ * @param {number} [pan] - How much to apply stereo panning
767
+ * @param {boolean} [loop] - True if the sound should loop when it reaches the end
768
+ * @param {number} [sampleRate=44100] - Sample rate for the sound
769
+ * @param {GainNode} [gainNode] - Optional gain node for volume control while playing (disconnected when the sound ends)
770
+ * @param {number} [offset] - Where to start in the sound, in its own seconds whatever the rate
771
+ * @param {AudioEndedCallback} [onended] - Callback for when the sound ends
772
+ * @param {AudioNode|AudioEffectNodes} [output] - Node or effect to connect the gain to instead of the master gain
773
+ * @param {StereoPannerNode} [pannerNode] - Optional stereo panner for panning while playing, its pan already set (disconnected when the sound ends)
774
+ * @return {AudioBufferSourceNode|undefined} - The source node of the sound played, undefined if play fails
775
+ * @memberof Audio */
776
+ function playSamples(sampleChannels, volume=1, rate=1, pan=0, loop=false, sampleRate=audioDefaultSampleRate, gainNode, offset=0, onended, output, pannerNode)
777
+ {
778
+ if (!soundEnable || headlessMode) return;
779
+
780
+ if (!audioIsRunning())
781
+ {
782
+ // fix stalled audio, don't build a buffer that can't be played;
783
+ // but a context suspended because the page is hidden stays suspended until it shows
784
+ if (!audioSuspendedWhenHidden)
785
+ audioContext.resume();
786
+ return;
787
+ }
788
+
789
+ const buffer = createAudioBuffer(sampleChannels, sampleRate);
790
+ return playAudioBuffer(buffer, volume, rate, pan, loop, gainNode, offset, onended, output, pannerNode);
791
+ }
792
+
793
+ /** Copy arrays of samples into a new audio buffer
794
+ * @param {Array} sampleChannels - Array of arrays of samples (for stereo playback)
795
+ * @param {number} [sampleRate=44100] - Sample rate for the sound
796
+ * @return {AudioBuffer} - The audio buffer holding the samples
797
+ * @memberof Audio */
798
+ function createAudioBuffer(sampleChannels, sampleRate=audioDefaultSampleRate)
799
+ {
800
+ const channelCount = sampleChannels.length;
801
+ const sampleLength = sampleChannels[0].length;
802
+ const buffer = audioContext.createBuffer(channelCount, sampleLength, sampleRate);
803
+ sampleChannels.forEach((c,i)=> buffer.getChannelData(i).set(c));
804
+ return buffer;
805
+ }
806
+
807
+ /** Play an audio buffer with given settings
808
+ * The buffer can be shared by any number of sounds playing at once
809
+ * @param {AudioBuffer} buffer - The audio buffer to play
810
+ * @param {number} [volume] - How much to scale volume by
811
+ * @param {number} [rate] - The playback rate to use
812
+ * @param {number} [pan] - How much to apply stereo panning
813
+ * @param {boolean} [loop] - True if the sound should loop when it reaches the end
814
+ * @param {GainNode} [gainNode] - Optional gain node for volume control while playing (disconnected when the sound ends)
815
+ * @param {number} [offset] - Where to start in the sound, in its own seconds whatever the rate
816
+ * @param {AudioEndedCallback} [onended] - Callback for when the sound ends
817
+ * @param {AudioNode|AudioEffectNodes} [output] - Node or effect to connect the gain to instead of the master gain
818
+ * @param {StereoPannerNode} [pannerNode] - Optional stereo panner for panning while playing, its pan already set (disconnected when the sound ends)
819
+ * @return {AudioBufferSourceNode|undefined} - The source node of the sound played, undefined if play fails
820
+ * @memberof Audio */
821
+ function playAudioBuffer(buffer, volume=1, rate=1, pan=0, loop=false, gainNode, offset=0, onended, output, pannerNode)
822
+ {
823
+ if (!soundEnable || headlessMode) return;
824
+
825
+ if (!audioIsRunning())
826
+ {
827
+ // fix stalled audio, this sound won't be able to play;
828
+ // but a context suspended because the page is hidden stays suspended until it shows
829
+ if (!audioSuspendedWhenHidden)
830
+ audioContext.resume();
831
+ return;
832
+ }
833
+
834
+ // setup source, many sources can share one buffer
835
+ const source = audioContext.createBufferSource();
836
+ source.buffer = buffer;
837
+ source.playbackRate.value = rate;
838
+ source.loop = loop;
839
+
840
+ // create and connect gain node
841
+ gainNode = gainNode || audioContext.createGain();
842
+ gainNode.gain.value = volume;
843
+ const outputNode = audioEffectNode(output, 'input') || audioMasterGain;
844
+ ASSERT(typeof outputNode.connect === 'function', 'output must be an AudioNode or an effect with input and output nodes');
845
+ gainNode.connect(outputNode);
846
+
847
+ // connect source to stereo panner and gain
848
+ const panner = pannerNode || new StereoPannerNode(audioContext, {'pan':clamp(pan, -1, 1)});
849
+ source.connect(panner).connect(gainNode);
850
+
851
+ // disconnect nodes when the sound ends so the audio graph doesn't grow
852
+ // unbounded across many play() calls (source.stop() also fires 'ended')
853
+ source.addEventListener('ended', ()=>
854
+ {
855
+ gainNode.disconnect();
856
+ panner.disconnect();
857
+ if (onended) onended(source);
858
+ });
859
+
860
+ // play and return sound, the offset is a place in the buffer whatever the rate
861
+ source.start(0, offset);
862
+
863
+ if (debug && debugSound)
864
+ LOG('sound', 'vol', volume.toFixed(2), 'rate', rate.toFixed(2), 'pan', pan.toFixed(2), loop ? 'loop' : '');
865
+
866
+ return source;
867
+ }
868
+
869
+ ///////////////////////////////////////////////////////////////////////////////
870
+ // ZzFXMicro - Zuper Zmall Zound Zynth - v1.3.2 by Frank Force
871
+
872
+ /** Generate and play a ZzFX sound
873
+ *
874
+ * <a href=https://killedbyapixel.github.io/ZzFX/>Create sounds using the ZzFX Sound Designer.</a>
875
+ * @param {Array} zzfxSound - Array of ZzFX parameters, ex. [.5,.5]
876
+ * @return {AudioBufferSourceNode|undefined} - The audio node of the sound played, undefined if play fails
877
+ * @memberof Audio */
878
+ function zzfx(...zzfxSound) { return playSamples([zzfxG(...zzfxSound)]); }
879
+
880
+ /** Generate samples for a ZzFX sound
881
+ * @param {number} [volume] - Volume scale (percent)
882
+ * @param {number} [randomness] - How much to randomize frequency (percent Hz)
883
+ * @param {number} [frequency] - Frequency of sound (Hz)
884
+ * @param {number} [attack] - Attack time, how fast sound starts (seconds)
885
+ * @param {number} [sustain] - Sustain time, how long sound holds (seconds)
886
+ * @param {number} [release] - Release time, how fast sound fades out (seconds)
887
+ * @param {number} [shape] - Shape of the sound wave
888
+ * @param {number} [shapeCurve] - Squareness of wave (0=square, 1=normal, 2=pointy)
889
+ * @param {number} [slide] - How much to slide frequency (kHz/s)
890
+ * @param {number} [deltaSlide] - How much to change slide (kHz/s/s)
891
+ * @param {number} [pitchJump] - Frequency of pitch jump (Hz)
892
+ * @param {number} [pitchJumpTime] - Time of pitch jump (seconds)
893
+ * @param {number} [repeatTime] - Resets some parameters periodically (seconds)
894
+ * @param {number} [noise] - How much random noise to add (percent)
895
+ * @param {number} [modulation] - Frequency of modulation wave, negative flips phase (Hz)
896
+ * @param {number} [bitCrush] - Resamples at a lower frequency in (samples*100)
897
+ * @param {number} [delay] - Overlap sound with itself for reverb and flanger effects (seconds)
898
+ * @param {number} [sustainVolume] - Volume level for sustain (percent)
899
+ * @param {number} [decay] - Decay time, how long to reach sustain after attack (seconds)
900
+ * @param {number} [tremolo] - Trembling effect, rate controlled by repeat time (percent)
901
+ * @param {number} [filter] - Filter cutoff frequency, positive for HPF, negative for LPF (Hz)
902
+ * @return {Array} - Array of audio samples
903
+ * @memberof Audio */
904
+ function zzfxG
905
+ (
906
+ volume = 1,
907
+ randomness = .05,
908
+ frequency = 220,
909
+ attack = 0,
910
+ sustain = 0,
911
+ release = .1,
912
+ shape = 0,
913
+ shapeCurve = 1,
914
+ slide = 0,
915
+ deltaSlide = 0,
916
+ pitchJump = 0,
917
+ pitchJumpTime = 0,
918
+ repeatTime = 0,
919
+ noise = 0,
920
+ modulation = 0,
921
+ bitCrush = 0,
922
+ delay = 0,
923
+ sustainVolume = 1,
924
+ decay = 0,
925
+ tremolo = 0,
926
+ filter = 0
927
+ )
928
+ {
929
+ // init parameters
930
+ let sampleRate = audioDefaultSampleRate,
931
+ PI2 = PI*2,
932
+ startSlide = slide *= 500 * PI2 / sampleRate / sampleRate,
933
+ startFrequency = frequency *=
934
+ (1 + rand(randomness,-randomness)) * PI2 / sampleRate,
935
+ modOffset = 0, // modulation offset
936
+ repeat = 0, // repeat offset
937
+ crush = 0, // bit crush offset
938
+ jump = 1, // pitch jump timer
939
+ length, // sample length
940
+ b = [], // sample buffer
941
+ t = 0, // sample time
942
+ i = 0, // sample index
943
+ s = 0, // sample value
944
+ f, // wave frequency
945
+
946
+ // biquad LP/HP filter
947
+ quality = 2, w = PI2 * min(abs(filter), sampleRate/4 - 1) * 2 / sampleRate, // stable below a quarter rate
948
+ cosw = cos(w), alpha = sin(w) / 2 / quality,
949
+ a0 = 1 + alpha, a1 = -2*cosw / a0, a2 = (1 - alpha) / a0,
950
+ b0 = (1 + sign(filter) * cosw) / 2 / a0,
951
+ b1 = -(sign(filter) + cosw) / a0, b2 = b0,
952
+ x2 = 0, x1 = 0, y2 = 0, y1 = 0;
953
+
954
+ // scale by sample rate
955
+ const minAttack = 9; // prevent pop if attack is 0
956
+ attack = attack * sampleRate || minAttack;
957
+ decay *= sampleRate;
958
+ sustain *= sampleRate;
959
+ release *= sampleRate;
960
+ delay *= sampleRate;
961
+ deltaSlide *= 500 * PI2 / sampleRate**3;
962
+ modulation *= PI2 / sampleRate;
963
+ pitchJump *= PI2 / sampleRate;
964
+ pitchJumpTime *= sampleRate;
965
+ repeatTime = repeatTime * sampleRate | 0;
966
+
967
+ // generate waveform
968
+ for (length = attack + decay + sustain + release + delay | 0;
969
+ i < length; b[i++] = s * volume) // sample
970
+ {
971
+ if (!(++crush%(bitCrush*100|0))) // bit crush
972
+ {
973
+ s = shape? shape>1? shape>2? shape>3? shape>4? // wave shape
974
+ (t/PI2%1 < shapeCurve/2? 1 : -1) : // 5 square duty
975
+ sin(t**3) : // 4 noise
976
+ max(min(tan(t),1),-1): // 3 tan
977
+ 1-(2*t/PI2%2+2)%2: // 2 saw
978
+ 1-4*abs(round(t/PI2)-t/PI2): // 1 triangle
979
+ sin(t); // 0 sin
980
+
981
+ s = (repeatTime ?
982
+ 1 - tremolo + tremolo*sin(PI2*i/repeatTime) // tremolo
983
+ : 1) *
984
+ (shape>4?s:sign(s)*abs(s)**shapeCurve) * // shape curve
985
+ (i < attack ? i/attack : // attack
986
+ i < attack + decay ? // decay
987
+ 1-((i-attack)/decay)*(1-sustainVolume) : // decay falloff
988
+ i < attack + decay + sustain ? // sustain
989
+ sustainVolume : // sustain volume
990
+ i < length - delay ? // release
991
+ (length - i - delay)/release * // release falloff
992
+ sustainVolume : // release volume
993
+ 0); // post release
994
+
995
+ s = delay ? s/2 + (delay > i ? 0 : // delay
996
+ (i<length-delay? 1 : (length-i)/delay) * // release delay
997
+ b[i-delay|0]/2/(volume||1)) : s; // sample delay, stored samples are 0 at volume 0
998
+
999
+ if (filter) // apply filter
1000
+ s = y1 = b2*x2 + b1*(x2=x1) + b0*(x1=s) - a2*y2 - a1*(y2=y1);
1001
+ }
1002
+
1003
+ f = (frequency += slide += deltaSlide) *// frequency
1004
+ cos(modulation*modOffset++); // modulation
1005
+ t += f + f*noise*sin(i**5); // noise
1006
+
1007
+ if (jump && ++jump > pitchJumpTime) // pitch jump
1008
+ {
1009
+ frequency += pitchJump; // apply pitch jump
1010
+ startFrequency += pitchJump; // also apply to start
1011
+ jump = 0; // stop pitch jump time
1012
+ }
1013
+
1014
+ if (repeatTime && !(++repeat % repeatTime)) // repeat
1015
+ {
1016
+ frequency = startFrequency; // reset frequency
1017
+ slide = startSlide; // reset slide
1018
+ jump ||= 1; // reset pitch jump time
1019
+ }
1020
+ }
1021
+
1022
+ return b; // return sample buffer
778
1023
  }