littlejsengine 1.18.29 → 1.19.3

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