littlejsengine 1.18.28 → 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,776 +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
135
- const filename = asset;
136
- this.loadSound(filename);
137
- }
138
- }
139
-
140
- /** Sample data for each channel
141
- * Sounds keep their samples in an audio buffer, so reading this rebuilds
142
- * the arrays from it and caches them. The copies are safe to hold onto,
143
- * playing a sound detaches the buffer's own channel arrays.
144
- * @type {Array<Array<number>|Float32Array>} */
145
- get sampleChannels()
146
- {
147
- const buffer = this.sampleBuffer;
148
- if (!this._sampleChannels && buffer)
149
- {
150
- const channels = [];
151
- for (let i = 0; i < buffer.numberOfChannels; i++)
152
- channels.push(buffer.getChannelData(i).slice());
153
- this._sampleChannels = channels;
154
- }
155
- return this._sampleChannels;
156
- }
157
-
158
- /** @param {Array<Array<number>|Float32Array>} sampleChannels */
159
- set sampleChannels(sampleChannels)
160
- {
161
- // new samples invalidate the buffer built from the old ones
162
- this._sampleChannels = sampleChannels;
163
- this.sampleBuffer = undefined;
164
- this.sampleLength = sampleChannels?.[0]?.length || 0;
165
- }
166
-
167
- /** Move this sound's samples into an audio buffer that every play can share
168
- * Does nothing if there is already a buffer or no samples to build one from */
169
- buildSampleBuffer()
170
- {
171
- if (this.sampleBuffer || !this._sampleChannels || headlessMode) return;
172
-
173
- this.sampleBuffer = createAudioBuffer(this._sampleChannels, this.sampleRate);
174
-
175
- // the buffer owns the samples now, release the arrays we built it from
176
- this._sampleChannels = undefined;
177
- }
178
-
179
- /** Play the sound
180
- * Sounds may not play until a user interaction occurs
181
- * @param {Vector2} [pos] - World space position to play the sound if any
182
- * @param {number} [volume] - How much to scale volume by
183
- * @param {number} [pitch] - How much to scale pitch by
184
- * @param {number} [randomnessScale] - How much to scale pitch randomness
185
- * @param {boolean} [loop] - Should the sound loop?
186
- * @param {boolean} [paused] - Should the sound start paused
187
- * @return {SoundInstance} - The sound instance, or undefined if sound is disabled, not loaded, or running in headless mode
188
- */
189
- play(pos, volume=1, pitch=1, randomnessScale=1, loop=false, paused=false)
190
- {
191
- ASSERT(!pos || isVector2(pos), 'pos must be a vec2');
192
- ASSERT(isNumber(volume), 'volume must be a number');
193
- ASSERT(isNumber(pitch), 'pitch must be a number');
194
- ASSERT(isNumber(randomnessScale), 'randomnessScale must be a number');
195
-
196
- if (!soundEnable || headlessMode) return;
197
- if (!this.sampleBuffer && !this._sampleChannels) return;
198
-
199
- let pan;
200
- if (pos)
201
- {
202
- const range = this.range;
203
- if (range)
204
- {
205
- // apply range based fade
206
- const lengthSquared = cameraPos.distanceSquared(pos);
207
- if (lengthSquared > range*range)
208
- return; // out of range
209
-
210
- // attenuate volume by distance
211
- volume *= percent(lengthSquared**.5, range, range*this.taper);
212
- }
213
-
214
- // get pan from screen space coords
215
- pan = worldToScreen(pos).x * 2/mainCanvas.width - 1;
216
- }
217
-
218
- // Create sound instance
219
- const rate = pitch + pitch * this.randomness*randomnessScale*rand(-1,1);
220
- const instance = new SoundInstance(this, volume, rate, pan, loop, paused);
221
-
222
- if (debug && debugSound && pos)
223
- {
224
- // visualize where positioned sounds play and their falloff range
225
- debugCircle(pos, .5, '#0ff', .5, true);
226
- if (this.range)
227
- {
228
- debugCircle(pos, 2*this.range, '#0ff', .5); // silent radius
229
- debugCircle(pos, 2*this.range*this.taper, '#0ff', .5); // full volume radius
230
- }
231
- debugText('vol '+volume.toFixed(2)+' pitch '+rate.toFixed(2), pos, .5, '#0ff', .5);
232
- }
233
-
234
- return instance;
235
- }
236
-
237
- /** Play a music track that loops by default
238
- * @param {number} [volume] - Volume to play the music at
239
- * @param {boolean} [loop] - Should the music loop?
240
- * @param {boolean} [paused] - Should the music start paused
241
- * @return {SoundInstance} - The sound instance
242
- */
243
- playMusic(volume=1, loop=true, paused=false)
244
- { return this.play(undefined, volume, 1, 0, loop, paused); }
245
-
246
- /** Play the sound as a musical note with a semitone offset
247
- * This can be used to play music with chromatic scales
248
- * @param {number} [semitoneOffset=0] - How many semitones to offset pitch
249
- * @param {Vector2} [pos] - World space position to play the sound if any
250
- * @param {number} [volume=1] - How much to scale volume by
251
- * @return {SoundInstance} - The sound instance
252
- */
253
- playNote(semitoneOffset=0, pos, volume)
254
- {
255
- ASSERT(isNumber(semitoneOffset), 'semitoneOffset must be a number');
256
- const pitch = getNoteFrequency(semitoneOffset, 1);
257
- return this.play(pos, volume, pitch, 0);
258
- }
259
-
260
- /** Get how long this sound is in seconds
261
- * @return {number} - How long the sound is in seconds (0 if loading)
262
- */
263
- getDuration()
264
- { return this.sampleLength / this.sampleRate || 0; }
265
-
266
- /** Check if sound is loaded, for sounds fetched from a url
267
- * @return {boolean} - True if sound is loaded and ready to play
268
- */
269
- isLoaded() { return this.loadedPercent === 1; }
270
-
271
- /** Loads a sound from a URL and decodes it into sample data.
272
- * @param {string} filename
273
- * @return {Promise} */
274
- async loadSound(filename)
275
- {
276
- const response = await fetch(filename);
277
- if (!response.ok)
278
- throw new Error(`Failed to load sound from ${filename}: ${response.status} ${response.statusText}`);
279
- const arrayBuffer = await response.arrayBuffer();
280
- const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
281
-
282
- // keep the decoded buffer as is, it is exactly what playback needs and
283
- // every play shares it, no channel data is read or copied
284
- this.sampleRate = audioBuffer.sampleRate;
285
- this.sampleLength = audioBuffer.length;
286
- this.sampleBuffer = audioBuffer;
287
- this.loadedPercent = 1;
288
- this.onloadCallback?.(this);
289
- }
290
- }
291
-
292
- ///////////////////////////////////////////////////////////////////////////////
293
-
294
- /**
295
- * Sound Instance - Wraps an AudioBufferSourceNode for individual sound control
296
- * Represents a single playing instance of a sound with pause/resume capabilities
297
- * @memberof Audio
298
- * @example
299
- * // Play a sound and get an instance for control
300
- * const jumpSound = new Sound([.5,.5,220]);
301
- * const instance = jumpSound.play();
302
- *
303
- * // Control the individual instance
304
- * instance.setVolume(.5);
305
- * instance.pause();
306
- * instance.resume();
307
- * instance.stop();
308
- */
309
- class SoundInstance
310
- {
311
- /** Create a sound instance
312
- * @param {Sound} sound - The sound object
313
- * @param {number} [volume] - How much to scale volume by
314
- * @param {number} [rate] - The playback rate to use
315
- * @param {number} [pan] - How much to apply stereo panning
316
- * @param {boolean} [loop] - Should the sound loop?
317
- * @param {boolean} [paused] - Should the sound start paused? */
318
- constructor(sound, volume=1, rate=1, pan=0, loop=false, paused=false)
319
- {
320
- ASSERT(sound instanceof Sound, 'SoundInstance requires a valid Sound object');
321
- ASSERT(volume >= 0, 'Sound volume must be positive or zero');
322
- ASSERT(rate >= 0, 'Sound rate must be positive or zero');
323
- ASSERT(isNumber(pan), 'Sound pan must be a number');
324
-
325
- /** @property {Sound} - The sound object */
326
- this.sound = sound;
327
- /** @property {number} - How much to scale volume by */
328
- this.volume = volume;
329
- /** @property {number} - The playback rate to use */
330
- this.rate = rate;
331
- /** @property {number} - How much to apply stereo panning */
332
- this.pan = pan;
333
- /** @property {boolean} - Should the sound loop */
334
- this.loop = loop;
335
- /** @property {number} - Timestamp for audio context when paused */
336
- this.pausedTime = 0;
337
- /** @property {number} - Timestamp for audio context when started */
338
- this.startTime = undefined;
339
- /** @property {GainNode} - Gain node for the sound */
340
- this.gainNode = undefined;
341
- /** @property {AudioBufferSourceNode} - Source node of the audio */
342
- this.source = undefined;
343
- // setup end callback and start sound
344
- this.onendedCallback = (source)=>
345
- {
346
- if (source === this.source)
347
- this.source = undefined;
348
- };
349
- if (!paused)
350
- this.start();
351
- }
352
-
353
- /** Start playing the sound instance from the offset time
354
- * @param {number} [offset] - Offset in seconds to start playback from
355
- */
356
- start(offset=0)
357
- {
358
- ASSERT(offset >= 0, 'Sound start offset must be positive or zero');
359
- if (this.isPlaying())
360
- this.stop();
361
- this.gainNode = audioContext.createGain();
362
-
363
- // build the shared buffer if it was not made at load time, then play it
364
- this.sound.buildSampleBuffer();
365
- this.source = this.sound.sampleBuffer ?
366
- playAudioBuffer(this.sound.sampleBuffer, this.volume, this.rate, this.pan, this.loop, this.gainNode, offset, this.onendedCallback) :
367
- playSamples(this.sound.sampleChannels, this.volume, this.rate, this.pan, this.loop, this.sound.sampleRate, this.gainNode, offset, this.onendedCallback);
368
- if (this.source)
369
- {
370
- this.startTime = audioContext.currentTime - offset;
371
- this.pausedTime = undefined;
372
- }
373
- else
374
- {
375
- this.startTime = undefined;
376
- this.pausedTime = 0;
377
- }
378
- }
379
-
380
- /** Set the volume of this sound instance
381
- * @param {number} volume */
382
- setVolume(volume)
383
- {
384
- ASSERT(volume >= 0, 'Sound volume must be positive or zero');
385
- this.volume = volume;
386
- if (this.gainNode)
387
- this.gainNode.gain.value = volume;
388
- }
389
-
390
- /** Stop this sound instance and reset position to the start */
391
- stop(fadeTime=0)
392
- {
393
- ASSERT(fadeTime >= 0, 'Sound fade time must be positive or zero');
394
- if (this.isPlaying())
395
- {
396
- if (fadeTime)
397
- {
398
- // ramp off gain from current volume (not 1, or low-volume
399
- // instances would jump back up before fading);
400
- // cancel any prior scheduling so stacked stop calls don't
401
- // re-anchor partway through a previous fade
402
- const startFade = audioContext.currentTime;
403
- const endFade = startFade + fadeTime;
404
- this.gainNode.gain.cancelScheduledValues(startFade);
405
- this.gainNode.gain.setValueAtTime(this.volume, startFade);
406
- this.gainNode.gain.linearRampToValueAtTime(0, endFade);
407
- this.source.stop(endFade);
408
- }
409
- else
410
- this.source.stop();
411
- }
412
- this.pausedTime = 0;
413
- this.source = undefined;
414
- this.startTime = undefined;
415
- }
416
-
417
- /** Pause this sound instance */
418
- pause()
419
- {
420
- if (this.isPaused()) return;
421
-
422
- // save current time and stop sound
423
- this.pausedTime = this.getCurrentTime();
424
- this.source.stop();
425
- this.source = undefined;
426
- this.startTime = undefined;
427
- }
428
-
429
- /** Resume this sound instance */
430
- resume()
431
- {
432
- if (!this.isPaused()) return;
433
-
434
- // restart sound from paused time
435
- this.start(this.pausedTime);
436
- }
437
-
438
- /** Check if this instance is currently playing
439
- * @return {boolean} - True if playing
440
- */
441
- isPlaying() { return !!this.source; }
442
-
443
- /** Check if this instance is paused or stopped (not currently playing)
444
- * @return {boolean} - True if not playing
445
- */
446
- isPaused() { return !this.isPlaying(); }
447
-
448
- /** Get the current playback time in seconds
449
- * @return {number} - Current playback time
450
- */
451
- getCurrentTime()
452
- {
453
- if (!this.isPlaying()) return this.pausedTime;
454
- const duration = this.getDuration();
455
- // guard mod against 0 duration (rate=0 or sound not loaded)
456
- return duration ? mod(audioContext.currentTime - this.startTime, duration) : 0;
457
- }
458
-
459
- /** Get the total duration of this sound
460
- * @return {number} - Total duration in seconds (0 if loading)
461
- */
462
- getDuration() { return this.rate ? this.sound.getDuration() / this.rate : 0; }
463
-
464
- /** Get source of this sound instance
465
- * @return {AudioBufferSourceNode}
466
- */
467
- getSource() { return this.source; }
468
- }
469
-
470
- ///////////////////////////////////////////////////////////////////////////////
471
-
472
- /** Speak text with passed in settings
473
- * @param {string} text - The text to speak
474
- * @param {number} [volume] - How much to scale volume by
475
- * @param {number} [rate] - How quickly to speak
476
- * @param {number} [pitch] - How much to change the pitch by
477
- * @param {string} [language] - The language/accent to use (examples: en, it, ru, ja, zh)
478
- * @return {SpeechSynthesisUtterance|undefined} - The utterance that was spoken, or undefined if speech is unavailable
479
- * @memberof Audio */
480
- function speak(text, volume=1, rate=1, pitch=1, language='')
481
- {
482
- ASSERT(typeof volume !== 'string', 'speak() signature changed: language is now the last parameter, after pitch');
483
- if (!soundEnable || headlessMode) return;
484
- if (typeof speechSynthesis === 'undefined') return;
485
-
486
- // common languages (not supported by all browsers)
487
- // en - english, it - italian, fr - french, de - german, es - spanish
488
- // ja - japanese, ru - russian, zh - chinese, hi - hindi, ko - korean
489
-
490
- // build utterance and speak
491
- const utterance = new SpeechSynthesisUtterance(text);
492
- utterance.lang = language;
493
- utterance.volume = 2*volume*soundVolume;
494
- utterance.rate = rate;
495
- utterance.pitch = pitch;
496
- speechSynthesis.speak(utterance);
497
- return utterance;
498
- }
499
-
500
- /** Stop all queued speech
501
- * @memberof Audio */
502
- function speakStop()
503
- {
504
- if (typeof speechSynthesis !== 'undefined')
505
- speechSynthesis.cancel();
506
- }
507
-
508
- /** Get frequency of a note on a musical scale
509
- * @param {number} semitoneOffset - How many semitones away from the root note
510
- * @param {number} [rootFrequency=220] - Frequency at semitone offset 0
511
- * @return {number} - The frequency of the note
512
- * @memberof Audio */
513
- function getNoteFrequency(semitoneOffset, rootFrequency=220)
514
- { return rootFrequency * 2**(semitoneOffset/12); }
515
-
516
- ///////////////////////////////////////////////////////////////////////////////
517
-
518
- /**
519
- * @callback AudioEndedCallback - Function called when a sound ends
520
- * @param {AudioBufferSourceNode} source
521
- * @memberof Audio
522
- */
523
-
524
- /** Play cached audio samples with given settings
525
- * @param {Array} sampleChannels - Array of arrays of samples to play (for stereo playback)
526
- * @param {number} [volume] - How much to scale volume by
527
- * @param {number} [rate] - The playback rate to use
528
- * @param {number} [pan] - How much to apply stereo panning
529
- * @param {boolean} [loop] - True if the sound should loop when it reaches the end
530
- * @param {number} [sampleRate=44100] - Sample rate for the sound
531
- * @param {GainNode} [gainNode] - Optional gain node for volume control while playing (disconnected when the sound ends)
532
- * @param {number} [offset] - Offset in seconds to start playback from
533
- * @param {AudioEndedCallback} [onended] - Callback for when the sound ends
534
- * @return {AudioBufferSourceNode} - The source node of the sound played, may be undefined if play fails
535
- * @memberof Audio */
536
- function playSamples(sampleChannels, volume=1, rate=1, pan=0, loop=false, sampleRate=audioDefaultSampleRate, gainNode, offset=0, onended)
537
- {
538
- if (!soundEnable || headlessMode) return;
539
-
540
- if (!audioIsRunning())
541
- {
542
- // fix stalled audio, don't build a buffer that can't be played
543
- audioContext.resume();
544
- return;
545
- }
546
-
547
- const buffer = createAudioBuffer(sampleChannels, sampleRate);
548
- return playAudioBuffer(buffer, volume, rate, pan, loop, gainNode, offset, onended);
549
- }
550
-
551
- /** Copy arrays of samples into a new audio buffer
552
- * @param {Array} sampleChannels - Array of arrays of samples (for stereo playback)
553
- * @param {number} [sampleRate=44100] - Sample rate for the sound
554
- * @return {AudioBuffer} - The audio buffer holding the samples
555
- * @memberof Audio */
556
- function createAudioBuffer(sampleChannels, sampleRate=audioDefaultSampleRate)
557
- {
558
- const channelCount = sampleChannels.length;
559
- const sampleLength = sampleChannels[0].length;
560
- const buffer = audioContext.createBuffer(channelCount, sampleLength, sampleRate);
561
- sampleChannels.forEach((c,i)=> buffer.getChannelData(i).set(c));
562
- return buffer;
563
- }
564
-
565
- /** Play an audio buffer with given settings
566
- * The buffer can be shared by any number of sounds playing at once
567
- * @param {AudioBuffer} buffer - The audio buffer to play
568
- * @param {number} [volume] - How much to scale volume by
569
- * @param {number} [rate] - The playback rate to use
570
- * @param {number} [pan] - How much to apply stereo panning
571
- * @param {boolean} [loop] - True if the sound should loop when it reaches the end
572
- * @param {GainNode} [gainNode] - Optional gain node for volume control while playing (disconnected when the sound ends)
573
- * @param {number} [offset] - Offset in seconds to start playback from
574
- * @param {AudioEndedCallback} [onended] - Callback for when the sound ends
575
- * @return {AudioBufferSourceNode} - The source node of the sound played, may be undefined if play fails
576
- * @memberof Audio */
577
- function playAudioBuffer(buffer, volume=1, rate=1, pan=0, loop=false, gainNode, offset=0, onended)
578
- {
579
- if (!soundEnable || headlessMode) return;
580
-
581
- if (!audioIsRunning())
582
- {
583
- // fix stalled audio, this sound won't be able to play
584
- audioContext.resume();
585
- return;
586
- }
587
-
588
- // setup source, many sources can share one buffer
589
- const source = audioContext.createBufferSource();
590
- source.buffer = buffer;
591
- source.playbackRate.value = rate;
592
- source.loop = loop;
593
-
594
- // create and connect gain node
595
- gainNode = gainNode || audioContext.createGain();
596
- gainNode.gain.value = volume;
597
- gainNode.connect(audioMasterGain);
598
-
599
- // connect source to stereo panner and gain
600
- const pannerNode = new StereoPannerNode(audioContext, {'pan':clamp(pan, -1, 1)});
601
- source.connect(pannerNode).connect(gainNode);
602
-
603
- // disconnect nodes when the sound ends so the audio graph doesn't grow
604
- // unbounded across many play() calls (source.stop() also fires 'ended')
605
- source.addEventListener('ended', ()=>
606
- {
607
- gainNode.disconnect();
608
- pannerNode.disconnect();
609
- if (onended) onended(source);
610
- });
611
-
612
- // play and return sound
613
- const startOffset = offset * rate;
614
- source.start(0, startOffset);
615
-
616
- if (debug && debugSound)
617
- LOG('sound', 'vol', volume.toFixed(2), 'rate', rate.toFixed(2), 'pan', pan.toFixed(2), loop ? 'loop' : '');
618
-
619
- return source;
620
- }
621
-
622
- ///////////////////////////////////////////////////////////////////////////////
623
- // ZzFXMicro - Zuper Zmall Zound Zynth - v1.3.2 by Frank Force
624
-
625
- /** Generate and play a ZzFX sound
626
- *
627
- * <a href=https://killedbyapixel.github.io/ZzFX/>Create sounds using the ZzFX Sound Designer.</a>
628
- * @param {Array} zzfxSound - Array of ZzFX parameters, ex. [.5,.5]
629
- * @return {AudioBufferSourceNode} - The audio node of the sound played
630
- * @memberof Audio */
631
- function zzfx(...zzfxSound) { return playSamples([zzfxG(...zzfxSound)]); }
632
-
633
- /** Generate samples for a ZzFX sound
634
- * @param {number} [volume] - Volume scale (percent)
635
- * @param {number} [randomness] - How much to randomize frequency (percent Hz)
636
- * @param {number} [frequency] - Frequency of sound (Hz)
637
- * @param {number} [attack] - Attack time, how fast sound starts (seconds)
638
- * @param {number} [sustain] - Sustain time, how long sound holds (seconds)
639
- * @param {number} [release] - Release time, how fast sound fades out (seconds)
640
- * @param {number} [shape] - Shape of the sound wave
641
- * @param {number} [shapeCurve] - Squareness of wave (0=square, 1=normal, 2=pointy)
642
- * @param {number} [slide] - How much to slide frequency (kHz/s)
643
- * @param {number} [deltaSlide] - How much to change slide (kHz/s/s)
644
- * @param {number} [pitchJump] - Frequency of pitch jump (Hz)
645
- * @param {number} [pitchJumpTime] - Time of pitch jump (seconds)
646
- * @param {number} [repeatTime] - Resets some parameters periodically (seconds)
647
- * @param {number} [noise] - How much random noise to add (percent)
648
- * @param {number} [modulation] - Frequency of modulation wave, negative flips phase (Hz)
649
- * @param {number} [bitCrush] - Resamples at a lower frequency in (samples*100)
650
- * @param {number} [delay] - Overlap sound with itself for reverb and flanger effects (seconds)
651
- * @param {number} [sustainVolume] - Volume level for sustain (percent)
652
- * @param {number} [decay] - Decay time, how long to reach sustain after attack (seconds)
653
- * @param {number} [tremolo] - Trembling effect, rate controlled by repeat time (percent)
654
- * @param {number} [filter] - Filter cutoff frequency, positive for HPF, negative for LPF (Hz)
655
- * @return {Array} - Array of audio samples
656
- * @memberof Audio */
657
- function zzfxG
658
- (
659
- volume = 1,
660
- randomness = .05,
661
- frequency = 220,
662
- attack = 0,
663
- sustain = 0,
664
- release = .1,
665
- shape = 0,
666
- shapeCurve = 1,
667
- slide = 0,
668
- deltaSlide = 0,
669
- pitchJump = 0,
670
- pitchJumpTime = 0,
671
- repeatTime = 0,
672
- noise = 0,
673
- modulation = 0,
674
- bitCrush = 0,
675
- delay = 0,
676
- sustainVolume = 1,
677
- decay = 0,
678
- tremolo = 0,
679
- filter = 0
680
- )
681
- {
682
- // init parameters
683
- let sampleRate = audioDefaultSampleRate,
684
- PI2 = PI*2,
685
- startSlide = slide *= 500 * PI2 / sampleRate / sampleRate,
686
- startFrequency = frequency *=
687
- (1 + rand(randomness,-randomness)) * PI2 / sampleRate,
688
- modOffset = 0, // modulation offset
689
- repeat = 0, // repeat offset
690
- crush = 0, // bit crush offset
691
- jump = 1, // pitch jump timer
692
- length, // sample length
693
- b = [], // sample buffer
694
- t = 0, // sample time
695
- i = 0, // sample index
696
- s = 0, // sample value
697
- f, // wave frequency
698
-
699
- // biquad LP/HP filter
700
- quality = 2, w = PI2 * abs(filter) * 2 / sampleRate,
701
- cosw = cos(w), alpha = sin(w) / 2 / quality,
702
- a0 = 1 + alpha, a1 = -2*cosw / a0, a2 = (1 - alpha) / a0,
703
- b0 = (1 + sign(filter) * cosw) / 2 / a0,
704
- b1 = -(sign(filter) + cosw) / a0, b2 = b0,
705
- x2 = 0, x1 = 0, y2 = 0, y1 = 0;
706
-
707
- // scale by sample rate
708
- const minAttack = 9; // prevent pop if attack is 0
709
- attack = attack * sampleRate || minAttack;
710
- decay *= sampleRate;
711
- sustain *= sampleRate;
712
- release *= sampleRate;
713
- delay *= sampleRate;
714
- deltaSlide *= 500 * PI2 / sampleRate**3;
715
- modulation *= PI2 / sampleRate;
716
- pitchJump *= PI2 / sampleRate;
717
- pitchJumpTime *= sampleRate;
718
- repeatTime = repeatTime * sampleRate | 0;
719
-
720
- // generate waveform
721
- for (length = attack + decay + sustain + release + delay | 0;
722
- i < length; b[i++] = s * volume) // sample
723
- {
724
- if (!(++crush%(bitCrush*100|0))) // bit crush
725
- {
726
- s = shape? shape>1? shape>2? shape>3? shape>4? // wave shape
727
- (t/PI2%1 < shapeCurve/2? 1 : -1) : // 5 square duty
728
- sin(t**3) : // 4 noise
729
- max(min(tan(t),1),-1): // 3 tan
730
- 1-(2*t/PI2%2+2)%2: // 2 saw
731
- 1-4*abs(round(t/PI2)-t/PI2): // 1 triangle
732
- sin(t); // 0 sin
733
-
734
- s = (repeatTime ?
735
- 1 - tremolo + tremolo*sin(PI2*i/repeatTime) // tremolo
736
- : 1) *
737
- (shape>4?s:sign(s)*abs(s)**shapeCurve) * // shape curve
738
- (i < attack ? i/attack : // attack
739
- i < attack + decay ? // decay
740
- 1-((i-attack)/decay)*(1-sustainVolume) : // decay falloff
741
- i < attack + decay + sustain ? // sustain
742
- sustainVolume : // sustain volume
743
- i < length - delay ? // release
744
- (length - i - delay)/release * // release falloff
745
- sustainVolume : // release volume
746
- 0); // post release
747
-
748
- s = delay ? s/2 + (delay > i ? 0 : // delay
749
- (i<length-delay? 1 : (length-i)/delay) * // release delay
750
- b[i-delay|0]/2/volume) : s; // sample delay
751
-
752
- if (filter) // apply filter
753
- s = y1 = b2*x2 + b1*(x2=x1) + b0*(x1=s) - a2*y2 - a1*(y2=y1);
754
- }
755
-
756
- f = (frequency += slide += deltaSlide) *// frequency
757
- cos(modulation*modOffset++); // modulation
758
- t += f + f*noise*sin(i**5); // noise
759
-
760
- if (jump && ++jump > pitchJumpTime) // pitch jump
761
- {
762
- frequency += pitchJump; // apply pitch jump
763
- startFrequency += pitchJump; // also apply to start
764
- jump = 0; // stop pitch jump time
765
- }
766
-
767
- if (repeatTime && !(++repeat % repeatTime)) // repeat
768
- {
769
- frequency = startFrequency; // reset frequency
770
- slide = startSlide; // reset slide
771
- jump ||= 1; // reset pitch jump time
772
- }
773
- }
774
-
775
- 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
776
919
  }