littlejsengine 1.19.3 → 1.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/FAQ.md +841 -820
- package/README.md +29 -25
- package/dist/littlejs.d.ts +2455 -935
- package/dist/littlejs.esm.js +30344 -21110
- package/dist/littlejs.esm.min.js +4 -3
- package/dist/littlejs.js +30259 -21052
- package/dist/littlejs.min.js +2 -2
- package/dist/littlejs.release.js +26567 -20455
- package/package.json +10 -5
- package/plugins/audioEffects.js +28 -24
- package/plugins/box2d.js +2516 -2073
- package/plugins/drawUtilities.js +206 -31
- package/plugins/gltf.js +740 -0
- package/plugins/jsconfig.json +19 -0
- package/plugins/lightSystem.js +719 -332
- package/plugins/math3d.js +58 -17
- package/plugins/medalSystem.js +326 -280
- package/plugins/newgrounds.js +400 -199
- package/plugins/pathFinder.js +208 -175
- package/plugins/pluginExport.js +15 -0
- package/plugins/postProcess.js +56 -13
- package/plugins/render3d.js +1643 -1577
- package/plugins/render3dDebug.js +109 -0
- package/plugins/render3dExtras.js +1754 -0
- package/plugins/render3dVoxels.js +502 -0
- package/plugins/textureSheet.js +496 -458
- package/plugins/threejs.js +183 -163
- package/plugins/tweakables.js +411 -0
- package/plugins/tweenSystem.js +263 -145
- package/plugins/uiSystem.js +2024 -1687
- package/plugins/zzfxm.js +159 -159
- package/src/engine.js +191 -75
- package/src/engineAudio.js +229 -125
- package/src/engineBuild.mjs +289 -256
- package/src/engineDebug.js +912 -742
- package/src/engineDraw.js +295 -193
- package/src/engineEditor.js +2431 -0
- package/src/engineExport.js +12 -0
- package/src/engineInput.js +1462 -1293
- package/src/engineMath.js +1274 -1252
- package/src/engineObject.js +194 -80
- package/src/engineParticles.js +583 -532
- package/src/engineRelease.js +67 -45
- package/src/engineSettings.js +77 -29
- package/src/engineTileLayer.js +995 -741
- package/src/engineUtilities.js +19 -13
- package/src/engineWebGL.js +239 -82
- package/src/jsconfig.json +17 -13
- package/plugins/box2d.wasm.js +0 -630
- package/plugins/box2d.wasm.wasm +0 -0
package/src/engineAudio.js
CHANGED
|
@@ -15,17 +15,29 @@
|
|
|
15
15
|
|
|
16
16
|
'use strict';
|
|
17
17
|
|
|
18
|
-
/** Audio context used by the engine
|
|
18
|
+
/** Audio context used by the engine, undefined outside a browser, where the engine runs headless
|
|
19
19
|
* @type {AudioContext}
|
|
20
20
|
* @memberof Audio */
|
|
21
|
-
let audioContext = new AudioContext;
|
|
21
|
+
let audioContext = typeof AudioContext == 'undefined' ? undefined : new AudioContext;
|
|
22
22
|
|
|
23
23
|
/** Master gain node for all audio to pass through, made at load so effects can connect to it any time
|
|
24
24
|
* @type {GainNode}
|
|
25
25
|
* @memberof Audio */
|
|
26
|
-
let audioMasterGain = audioContext
|
|
27
|
-
|
|
28
|
-
audioMasterGain
|
|
26
|
+
let audioMasterGain = audioContext?.createGain();
|
|
27
|
+
let audioMasterVolume; // the soundVolume the master gain was last set to
|
|
28
|
+
if (audioMasterGain)
|
|
29
|
+
{
|
|
30
|
+
audioMasterGain.connect(audioContext.destination);
|
|
31
|
+
audioMasterGain.gain.value = audioMasterVolume = soundVolume; // set starting value
|
|
32
|
+
audioContext.addEventListener?.('statechange', audioStateChange);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// soundVolume can be set directly, so the master gain follows it each frame
|
|
36
|
+
function audioUpdateVolume()
|
|
37
|
+
{
|
|
38
|
+
if (audioMasterGain && soundVolume !== audioMasterVolume)
|
|
39
|
+
audioMasterGain.gain.value = audioMasterVolume = soundVolume;
|
|
40
|
+
}
|
|
29
41
|
|
|
30
42
|
// the current master effect, kept so setAudioMasterEffect can undo the route it made,
|
|
31
43
|
// and whether its output came from an effect, which gets its default route back
|
|
@@ -37,10 +49,10 @@ let audioMasterEffectInput, audioMasterEffectOutput, audioMasterEffectOutputIsEf
|
|
|
37
49
|
const audioDefaultSampleRate = 44100;
|
|
38
50
|
|
|
39
51
|
/** Check if the audio context is running and available for playback
|
|
40
|
-
* @return {boolean} - True if the audio context is running
|
|
52
|
+
* @return {boolean} - True if the audio context is running, false when there is none
|
|
41
53
|
* @memberof Audio */
|
|
42
54
|
function audioIsRunning()
|
|
43
|
-
{ return audioContext
|
|
55
|
+
{ return audioContext?.state === 'running'; }
|
|
44
56
|
|
|
45
57
|
function audioInit()
|
|
46
58
|
{
|
|
@@ -67,6 +79,29 @@ function audioVisibilityChange()
|
|
|
67
79
|
}
|
|
68
80
|
}
|
|
69
81
|
|
|
82
|
+
// sound instances whose start failed only because the context was not running, like music started in gameInit
|
|
83
|
+
// before the first input, each with the time it tried; they start once the context runs unless paused or stopped
|
|
84
|
+
// first, and a one shot drops out once it would have ended anyway, so a backlog of sounds can't all play at once
|
|
85
|
+
const audioWaitingInstances = new Map;
|
|
86
|
+
function audioWaitingPrune(now=performance.now())
|
|
87
|
+
{
|
|
88
|
+
for (const [instance, startTime] of audioWaitingInstances)
|
|
89
|
+
{
|
|
90
|
+
const remaining = (instance.getDuration() - instance.pausedTime) / instance.rate;
|
|
91
|
+
if (!instance.loop && now - startTime > remaining * 1e3)
|
|
92
|
+
audioWaitingInstances.delete(instance);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function audioStateChange()
|
|
96
|
+
{
|
|
97
|
+
if (!audioIsRunning()) return;
|
|
98
|
+
audioWaitingPrune();
|
|
99
|
+
const instances = [...audioWaitingInstances.keys()];
|
|
100
|
+
audioWaitingInstances.clear();
|
|
101
|
+
for (const instance of instances)
|
|
102
|
+
instance.resume();
|
|
103
|
+
}
|
|
104
|
+
|
|
70
105
|
/** Anything with input and output audio nodes, like an effect from the audio effects plugin
|
|
71
106
|
* @typedef {{input: AudioNode, output: AudioNode}} AudioEffectNodes
|
|
72
107
|
* @memberof Audio */
|
|
@@ -91,14 +126,20 @@ function setAudioMasterEffect(input, output)
|
|
|
91
126
|
input = audioEffectNode(input, 'input');
|
|
92
127
|
ASSERT(!input || typeof input.connect === 'function', 'input must be an AudioNode or an effect with input and output nodes');
|
|
93
128
|
ASSERT(!output || typeof output.connect === 'function', 'output must be an AudioNode or an effect with input and output nodes');
|
|
129
|
+
if (!audioMasterGain) return; // no audio outside a browser, where the engine runs headless
|
|
94
130
|
|
|
95
|
-
// undo the current route
|
|
96
|
-
//
|
|
97
|
-
//
|
|
131
|
+
// undo the current route selectively so other taps survive; an effect's output that still feeds the speakers
|
|
132
|
+
// goes back to the master gain, its default, so it still works for sounds, but one that connect() already moved
|
|
133
|
+
// on, like the head of a longer chain being set now, stays where it was sent
|
|
98
134
|
audioMasterGain.disconnect(audioMasterEffectInput || audioContext.destination);
|
|
99
|
-
audioMasterEffectOutput
|
|
100
|
-
|
|
101
|
-
|
|
135
|
+
if (audioMasterEffectOutput)
|
|
136
|
+
{
|
|
137
|
+
let fedSpeakers = true;
|
|
138
|
+
try { audioMasterEffectOutput.disconnect(audioContext.destination); }
|
|
139
|
+
catch { fedSpeakers = false; }
|
|
140
|
+
if (audioMasterEffectOutputIsEffect && fedSpeakers)
|
|
141
|
+
audioMasterEffectOutput.connect(audioMasterGain);
|
|
142
|
+
}
|
|
102
143
|
audioMasterEffectInput = input;
|
|
103
144
|
audioMasterEffectOutput = output;
|
|
104
145
|
audioMasterEffectOutputIsEffect = outputIsEffect;
|
|
@@ -117,7 +158,8 @@ function setAudioMasterEffect(input, output)
|
|
|
117
158
|
// get one of an effect's nodes, or the thing itself when it is already a node
|
|
118
159
|
/** @param {AudioNode|AudioEffectNodes|undefined} effectOrNode
|
|
119
160
|
* @param {'input'|'output'} key
|
|
120
|
-
* @return {AudioNode}
|
|
161
|
+
* @return {AudioNode}
|
|
162
|
+
* @ignore */
|
|
121
163
|
function audioEffectNode(effectOrNode, key)
|
|
122
164
|
{
|
|
123
165
|
if (effectOrNode && 'input' in effectOrNode)
|
|
@@ -125,8 +167,31 @@ function audioEffectNode(effectOrNode, key)
|
|
|
125
167
|
return /** @type {AudioNode} */ (effectOrNode);
|
|
126
168
|
}
|
|
127
169
|
|
|
170
|
+
// ramp an audio param to a value, cancelling anything already scheduled so stacked calls don't fight;
|
|
171
|
+
// returns when the ramp ends
|
|
172
|
+
function audioParamRamp(param, value, fadeTime=0)
|
|
173
|
+
{
|
|
174
|
+
ASSERT(fadeTime >= 0, 'fadeTime must be positive or zero');
|
|
175
|
+
const startTime = audioContext.currentTime;
|
|
176
|
+
param.cancelScheduledValues(startTime);
|
|
177
|
+
if (fadeTime)
|
|
178
|
+
{
|
|
179
|
+
param.setValueAtTime(param.value, startTime);
|
|
180
|
+
param.linearRampToValueAtTime(value, startTime + fadeTime);
|
|
181
|
+
}
|
|
182
|
+
else
|
|
183
|
+
param.value = value;
|
|
184
|
+
return startTime + fadeTime;
|
|
185
|
+
}
|
|
186
|
+
|
|
128
187
|
///////////////////////////////////////////////////////////////////////////////
|
|
129
188
|
|
|
189
|
+
/**
|
|
190
|
+
* @callback SoundLoadCallback - Function called when sound is loaded
|
|
191
|
+
* @param {Sound} sound
|
|
192
|
+
* @memberof Audio
|
|
193
|
+
*/
|
|
194
|
+
|
|
130
195
|
/**
|
|
131
196
|
* Sound Object - Stores a sound for later
|
|
132
197
|
* - this can be used to load and play wave, mp3, and ogg files
|
|
@@ -148,15 +213,9 @@ function audioEffectNode(effectOrNode, key)
|
|
|
148
213
|
*/
|
|
149
214
|
class Sound
|
|
150
215
|
{
|
|
151
|
-
/**
|
|
152
|
-
* @callback SoundLoadCallback - Function called when sound is loaded
|
|
153
|
-
* @param {Sound} sound
|
|
154
|
-
* @memberof Audio
|
|
155
|
-
*/
|
|
156
|
-
|
|
157
216
|
/** 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
|
|
217
|
+
* @param {string|URL|Array} [asset] - Filename or URL of an audio file, or a zzfx array
|
|
218
|
+
* @param {number} [randomness] - How much to randomize frequency each time sound plays, for zzfx sounds it overrides the array's own randomness, which is used if undefined
|
|
160
219
|
* @param {number} [range=soundDefaultRange] - World space max range of sound
|
|
161
220
|
* @param {number} [taper=soundDefaultTaper] - At what percentage of range should it start tapering
|
|
162
221
|
* @param {SoundLoadCallback} [onloadCallback] - callback function to call when sound is loaded
|
|
@@ -175,26 +234,29 @@ class Sound
|
|
|
175
234
|
this.range = range;
|
|
176
235
|
/** @property {number} - At what percentage of range should it start tapering */
|
|
177
236
|
this.taper = taper;
|
|
178
|
-
/** @property {number} - How much to randomize frequency each time sound plays
|
|
237
|
+
/** @property {number} - How much to randomize frequency each time sound plays
|
|
238
|
+
* @type {number} */
|
|
179
239
|
this.randomness = randomness ?? 0;
|
|
180
240
|
/** @property {number} - Sample rate for this sound */
|
|
181
241
|
this.sampleRate = audioDefaultSampleRate;
|
|
182
242
|
/** @property {number} - How many samples per channel this sound has */
|
|
183
243
|
this.sampleLength = 0;
|
|
184
|
-
/** @property {AudioBuffer} - Decoded audio shared by every play of this sound
|
|
185
|
-
* @type {AudioBuffer} */
|
|
244
|
+
/** @property {AudioBuffer|undefined} - Decoded audio shared by every play of this sound
|
|
245
|
+
* @type {AudioBuffer|undefined} */
|
|
186
246
|
this.sampleBuffer = undefined;
|
|
187
|
-
|
|
188
|
-
|
|
247
|
+
// internal, the 3D plugin reads it to know the sound has loaded
|
|
248
|
+
/** @ignore
|
|
249
|
+
* @type {Array<Array<number>|Float32Array>|undefined} */
|
|
189
250
|
this._sampleChannels = undefined;
|
|
190
251
|
/** @property {number} - Percentage of this sound currently loaded, sounds
|
|
191
252
|
* fetched from a url stay at 0 until decoding completes */
|
|
192
253
|
this.loadedPercent = 0;
|
|
193
|
-
/** @property {SoundLoadCallback} - function to call when sound is loaded
|
|
254
|
+
/** @property {SoundLoadCallback|undefined} - function to call when sound is loaded
|
|
255
|
+
* @type {SoundLoadCallback|undefined} */
|
|
194
256
|
this.onloadCallback = onloadCallback;
|
|
195
|
-
/** @property {AudioNode|AudioEffectNodes} - Node or effect to route every play of this sound through instead of the master gain
|
|
257
|
+
/** @property {AudioNode|AudioEffectNodes|undefined} - Node or effect to route every play of this sound through instead of the master gain
|
|
196
258
|
* - Where this sound's audio goes, unlike AudioEffect.output which is an effect's own node, effects chain with connect()
|
|
197
|
-
* @type {AudioNode|AudioEffectNodes} */
|
|
259
|
+
* @type {AudioNode|AudioEffectNodes|undefined} */
|
|
198
260
|
this.output = undefined;
|
|
199
261
|
|
|
200
262
|
if (isArray(asset))
|
|
@@ -202,10 +264,9 @@ class Sound
|
|
|
202
264
|
// generate zzfx sound — copy so we don't mutate the caller's array
|
|
203
265
|
const zzfxSound = asset.slice();
|
|
204
266
|
|
|
205
|
-
// remove randomness so it can be applied on playback
|
|
206
|
-
const defaultRandomness = randomness ?? .05;
|
|
267
|
+
// remove randomness so it can be applied on playback, a value passed in wins over the array's
|
|
207
268
|
const randomnessIndex = 1;
|
|
208
|
-
this.randomness = zzfxSound[randomnessIndex] ??
|
|
269
|
+
this.randomness = randomness ?? zzfxSound[randomnessIndex] ?? .05;
|
|
209
270
|
zzfxSound[randomnessIndex] = 0;
|
|
210
271
|
|
|
211
272
|
// generate the zzfx samples, then hand them to an audio buffer so
|
|
@@ -215,11 +276,11 @@ class Sound
|
|
|
215
276
|
this.loadedPercent = 1;
|
|
216
277
|
onloadCallback?.(this);
|
|
217
278
|
}
|
|
218
|
-
else if (
|
|
279
|
+
else if (asset)
|
|
219
280
|
{
|
|
220
|
-
// load the audio file,
|
|
221
|
-
// unhandled rejection, the sound just stays unloaded and silent
|
|
222
|
-
const filename = asset;
|
|
281
|
+
// load the audio file, a URL object as bundlers give works like its string;
|
|
282
|
+
// report failures rather than leaving an unhandled rejection, the sound just stays unloaded and silent
|
|
283
|
+
const filename = asset + '';
|
|
223
284
|
this.loadSound(filename).catch(e=>
|
|
224
285
|
LOG('Sound load failed for', filename, '-', e.message));
|
|
225
286
|
}
|
|
@@ -229,7 +290,7 @@ class Sound
|
|
|
229
290
|
* Sounds keep their samples in an audio buffer, so reading this rebuilds
|
|
230
291
|
* the arrays from it and caches them. The copies are safe to hold onto,
|
|
231
292
|
* playing a sound detaches the buffer's own channel arrays.
|
|
232
|
-
* @
|
|
293
|
+
* @return {Array<Array<number>|Float32Array>|undefined} */
|
|
233
294
|
get sampleChannels()
|
|
234
295
|
{
|
|
235
296
|
const buffer = this.sampleBuffer;
|
|
@@ -265,14 +326,17 @@ class Sound
|
|
|
265
326
|
}
|
|
266
327
|
|
|
267
328
|
/** Play the sound
|
|
268
|
-
*
|
|
329
|
+
* - Browsers hold audio until the first user input, a sound played before it returns a paused instance
|
|
330
|
+
* that starts on its own once audio runs, unless paused or stopped first; a one shot that would have
|
|
331
|
+
* ended by then is dropped, and only the newest play of each sound waits, so a sound played every frame
|
|
332
|
+
* starts once
|
|
269
333
|
* @param {Vector2} [pos] - World space position to play the sound if any
|
|
270
334
|
* @param {number} [volume] - How much to scale volume by
|
|
271
335
|
* @param {number} [pitch] - How much to scale pitch by
|
|
272
336
|
* @param {number} [randomnessScale] - How much to scale pitch randomness
|
|
273
337
|
* @param {boolean} [loop] - Should the sound loop?
|
|
274
338
|
* @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
|
|
339
|
+
* @return {SoundInstance|undefined} - The sound instance, or undefined if sound is disabled, not loaded, out of range, or running in headless mode
|
|
276
340
|
*/
|
|
277
341
|
play(pos, volume=1, pitch=1, randomnessScale=1, loop=false, paused=false)
|
|
278
342
|
{
|
|
@@ -295,8 +359,11 @@ class Sound
|
|
|
295
359
|
if (lengthSquared > range*range)
|
|
296
360
|
return; // out of range
|
|
297
361
|
|
|
298
|
-
// attenuate volume by distance
|
|
299
|
-
volume
|
|
362
|
+
// attenuate volume by distance, full volume out to the taper and a fade past it,
|
|
363
|
+
// so a taper of 1 plays at full volume right up to the range
|
|
364
|
+
const distance = lengthSquared**.5, taperRange = range*this.taper;
|
|
365
|
+
if (distance > taperRange)
|
|
366
|
+
volume *= percent(distance, range, taperRange);
|
|
300
367
|
}
|
|
301
368
|
|
|
302
369
|
// get pan from screen space coords
|
|
@@ -310,13 +377,13 @@ class Sound
|
|
|
310
377
|
if (debug && debugSound && pos)
|
|
311
378
|
{
|
|
312
379
|
// visualize where positioned sounds play and their falloff range
|
|
313
|
-
debugCircle(pos, .5, '#0ff', .5, true);
|
|
380
|
+
debugCircle(pos, .5, '#0ff', .5, true, false);
|
|
314
381
|
if (this.range)
|
|
315
382
|
{
|
|
316
|
-
debugCircle(pos, 2*this.range, '#0ff', .5); // silent radius
|
|
317
|
-
debugCircle(pos, 2*this.range*this.taper, '#0ff', .5); // full volume radius
|
|
383
|
+
debugCircle(pos, 2*this.range, '#0ff', .5, false, false); // silent radius
|
|
384
|
+
debugCircle(pos, 2*this.range*this.taper, '#0ff', .5, false, false); // full volume radius
|
|
318
385
|
}
|
|
319
|
-
debugText('vol '+volume.toFixed(2)+' pitch '+rate.toFixed(2), pos, .5, '#0ff', .5);
|
|
386
|
+
debugText('vol '+volume.toFixed(2)+' pitch '+rate.toFixed(2), pos, .5, '#0ff', .5, 0, 'monospace', false);
|
|
320
387
|
}
|
|
321
388
|
|
|
322
389
|
return instance;
|
|
@@ -328,7 +395,7 @@ class Sound
|
|
|
328
395
|
* @param {number} [pitch] - How much to scale pitch by
|
|
329
396
|
* @param {number} [randomnessScale] - How much to scale pitch randomness
|
|
330
397
|
* @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 */
|
|
398
|
+
* @return {SoundInstance|undefined} - The sound instance, or undefined if sound is disabled, not loaded, out of range, or running in headless mode */
|
|
332
399
|
playLoop(pos, volume=1, pitch=1, randomnessScale=1, paused=false)
|
|
333
400
|
{ return this.play(pos, volume, pitch, randomnessScale, true, paused); }
|
|
334
401
|
|
|
@@ -336,7 +403,7 @@ class Sound
|
|
|
336
403
|
* @param {number} [volume] - Volume to play the music at
|
|
337
404
|
* @param {boolean} [loop] - Should the music loop?
|
|
338
405
|
* @param {boolean} [paused] - Should the music start paused
|
|
339
|
-
* @return {SoundInstance} - The sound instance
|
|
406
|
+
* @return {SoundInstance|undefined} - The sound instance, or undefined if sound is disabled, not loaded, or running in headless mode
|
|
340
407
|
*/
|
|
341
408
|
playMusic(volume=1, loop=true, paused=false)
|
|
342
409
|
{ return this.play(undefined, volume, 1, 0, loop, paused); }
|
|
@@ -346,7 +413,7 @@ class Sound
|
|
|
346
413
|
* @param {number} [semitoneOffset] - How many semitones to offset pitch
|
|
347
414
|
* @param {Vector2} [pos] - World space position to play the sound if any
|
|
348
415
|
* @param {number} [volume=1] - How much to scale volume by
|
|
349
|
-
* @return {SoundInstance} - The sound instance
|
|
416
|
+
* @return {SoundInstance|undefined} - The sound instance, or undefined if sound is disabled, not loaded, out of range, or running in headless mode
|
|
350
417
|
*/
|
|
351
418
|
playNote(semitoneOffset=0, pos, volume)
|
|
352
419
|
{
|
|
@@ -382,6 +449,7 @@ class Sound
|
|
|
382
449
|
this.sampleRate = audioBuffer.sampleRate;
|
|
383
450
|
this.sampleLength = audioBuffer.length;
|
|
384
451
|
this.sampleBuffer = audioBuffer;
|
|
452
|
+
this._sampleChannels = undefined; // samples read from a sound loaded before are that sound's, not this one's
|
|
385
453
|
this.loadedPercent = 1;
|
|
386
454
|
this.onloadCallback?.(this);
|
|
387
455
|
}
|
|
@@ -430,33 +498,49 @@ class SoundInstance
|
|
|
430
498
|
this.pan = pan;
|
|
431
499
|
/** @property {boolean} - Should the sound loop */
|
|
432
500
|
this.loop = loop;
|
|
433
|
-
/** @property {number} -
|
|
501
|
+
/** @property {number|undefined} - Where it is in the sound while not playing, in the sound's own seconds, undefined while playing
|
|
502
|
+
* @type {number|undefined} */
|
|
434
503
|
this.pausedTime = 0;
|
|
435
|
-
/** @property {number} -
|
|
504
|
+
/** @property {number} - Audio context time its place was last taken at, while playing
|
|
505
|
+
* @type {number|undefined} */
|
|
436
506
|
this.startTime = undefined;
|
|
437
|
-
/** @property {
|
|
507
|
+
/** @property {number} - Where it was in the sound at startTime, in the sound's own seconds */
|
|
508
|
+
this.startOffset = 0;
|
|
509
|
+
/** @property {GainNode|undefined} - Gain node for the sound, undefined once it is stopped or paused
|
|
510
|
+
* @type {GainNode|undefined} */
|
|
438
511
|
this.gainNode = undefined;
|
|
439
|
-
/** @property {
|
|
512
|
+
/** @property {StereoPannerNode|undefined} - Stereo panner for the sound, undefined once it is stopped or paused
|
|
513
|
+
* @type {StereoPannerNode|undefined} */
|
|
514
|
+
this.pannerNode = undefined;
|
|
515
|
+
/** @property {AudioBufferSourceNode|undefined} - Source node of the audio, undefined while not playing
|
|
516
|
+
* @type {AudioBufferSourceNode|undefined} */
|
|
440
517
|
this.source = undefined;
|
|
441
|
-
/** @property {AudioNode|AudioEffectNodes} - Node or effect to route this instance through, copied from the sound
|
|
442
|
-
* @type {AudioNode|AudioEffectNodes} */
|
|
518
|
+
/** @property {AudioNode|AudioEffectNodes|undefined} - Node or effect to route this instance through, copied from the sound
|
|
519
|
+
* @type {AudioNode|AudioEffectNodes|undefined} */
|
|
443
520
|
this.output = sound.output;
|
|
444
|
-
|
|
445
|
-
|
|
521
|
+
/** @property {AudioEndedCallback|undefined} - Called when this instance plays to its end, not when it is stopped
|
|
522
|
+
* or paused; it is read when the sound ends, so it can be set at any time
|
|
523
|
+
* @type {AudioEndedCallback|undefined} */
|
|
524
|
+
this.onendedCallback = undefined;
|
|
525
|
+
/** A playback that ends on its own leaves the instance stopped, its time back at 0; the ended event of one
|
|
526
|
+
* stopped or replaced since is too late to change anything
|
|
527
|
+
* @private */
|
|
528
|
+
this.sourceEnded = (source)=>
|
|
446
529
|
{
|
|
447
|
-
if (source
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
}
|
|
530
|
+
if (source !== this.source) return;
|
|
531
|
+
this.source = this.gainNode = this.pannerNode = undefined;
|
|
532
|
+
this.startTime = undefined;
|
|
533
|
+
this.pausedTime = 0;
|
|
534
|
+
this.onendedCallback?.(source);
|
|
453
535
|
};
|
|
536
|
+
|
|
537
|
+
// start sound
|
|
454
538
|
if (!paused)
|
|
455
539
|
this.start();
|
|
456
540
|
}
|
|
457
541
|
|
|
458
|
-
/** Start playing the sound instance from the
|
|
459
|
-
* @param {number} [offset] -
|
|
542
|
+
/** Start playing the sound instance from a place in the sound
|
|
543
|
+
* @param {number} [offset] - Where to start in the sound, in its own seconds whatever the rate
|
|
460
544
|
*/
|
|
461
545
|
start(offset=0)
|
|
462
546
|
{
|
|
@@ -464,22 +548,34 @@ class SoundInstance
|
|
|
464
548
|
if (this.isPlaying())
|
|
465
549
|
this.stop();
|
|
466
550
|
this.gainNode = audioContext.createGain();
|
|
551
|
+
this.pannerNode = new StereoPannerNode(audioContext, {'pan':clamp(this.pan, -1, 1)});
|
|
467
552
|
|
|
468
553
|
// build the shared buffer if it was not made at load time, then play it
|
|
469
554
|
this.sound.buildSampleBuffer();
|
|
470
555
|
this.source = this.sound.sampleBuffer ?
|
|
471
|
-
playAudioBuffer(this.sound.sampleBuffer, this.volume, this.rate, this.pan, this.loop, this.gainNode, offset, this.
|
|
472
|
-
playSamples(this.sound.sampleChannels, this.volume, this.rate, this.pan, this.loop, this.sound.sampleRate, this.gainNode, offset, this.
|
|
556
|
+
playAudioBuffer(this.sound.sampleBuffer, this.volume, this.rate, this.pan, this.loop, this.gainNode, offset, this.sourceEnded, this.output, this.pannerNode) :
|
|
557
|
+
playSamples(this.sound.sampleChannels, this.volume, this.rate, this.pan, this.loop, this.sound.sampleRate, this.gainNode, offset, this.sourceEnded, this.output, this.pannerNode);
|
|
558
|
+
audioWaitingInstances.delete(this);
|
|
473
559
|
if (this.source)
|
|
474
560
|
{
|
|
475
|
-
this.startTime = audioContext.currentTime
|
|
561
|
+
this.startTime = audioContext.currentTime;
|
|
562
|
+
this.startOffset = offset;
|
|
476
563
|
this.pausedTime = undefined;
|
|
477
564
|
}
|
|
478
565
|
else
|
|
479
566
|
{
|
|
480
|
-
// the sound could not start, keep the place so a later resume picks it up
|
|
481
|
-
|
|
567
|
+
// the sound could not start, keep the place so a later resume picks it up,
|
|
568
|
+
// which happens on its own when it failed only because audio is not running yet
|
|
569
|
+
this.startTime = this.gainNode = this.pannerNode = undefined;
|
|
482
570
|
this.pausedTime = offset;
|
|
571
|
+
if (!audioIsRunning())
|
|
572
|
+
{
|
|
573
|
+
// only the newest of each sound waits, a loop a game plays again each frame is one loop
|
|
574
|
+
audioWaitingPrune();
|
|
575
|
+
for (const other of audioWaitingInstances.keys())
|
|
576
|
+
other.sound === this.sound && audioWaitingInstances.delete(other);
|
|
577
|
+
audioWaitingInstances.set(this, performance.now());
|
|
578
|
+
}
|
|
483
579
|
}
|
|
484
580
|
}
|
|
485
581
|
|
|
@@ -492,32 +588,33 @@ class SoundInstance
|
|
|
492
588
|
ASSERT(volume >= 0, 'Sound volume must be positive or zero');
|
|
493
589
|
ASSERT(fadeTime >= 0, 'Sound fade time must be positive or zero');
|
|
494
590
|
this.volume = volume;
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
else
|
|
508
|
-
gain.value = volume;
|
|
591
|
+
this.gainNode && audioParamRamp(this.gainNode.gain, volume, fadeTime);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** Set the stereo pan of this sound instance, while it plays too
|
|
595
|
+
* - A looping sound can follow its source across the screen this way
|
|
596
|
+
* @param {number} pan - -1 is left, 0 is center, 1 is right, clamped to that range */
|
|
597
|
+
setPan(pan)
|
|
598
|
+
{
|
|
599
|
+
ASSERT(isNumber(pan), 'Sound pan must be a number');
|
|
600
|
+
this.pan = pan;
|
|
601
|
+
if (this.pannerNode)
|
|
602
|
+
this.pannerNode.pan.value = clamp(pan, -1, 1);
|
|
509
603
|
}
|
|
510
604
|
|
|
511
605
|
/** Set the playback rate of this sound instance, its speed and pitch, while it plays
|
|
512
606
|
* - 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,
|
|
607
|
+
* - A rate of 0 freezes the sound in place, and it carries on from there when the rate comes back
|
|
514
608
|
* @param {number} rate - 1 is normal, 2 is twice as fast and an octave up */
|
|
515
609
|
setRate(rate)
|
|
516
610
|
{
|
|
517
611
|
ASSERT(rate >= 0, 'Sound rate must be positive or zero');
|
|
518
|
-
// keep the place in the sound, only the speed changes from here
|
|
519
|
-
if (this.isPlaying()
|
|
520
|
-
|
|
612
|
+
// keep the place in the sound, only the speed changes from here
|
|
613
|
+
if (this.isPlaying())
|
|
614
|
+
{
|
|
615
|
+
this.startOffset = this.getCurrentTime();
|
|
616
|
+
this.startTime = audioContext.currentTime;
|
|
617
|
+
}
|
|
521
618
|
this.rate = rate;
|
|
522
619
|
if (this.source)
|
|
523
620
|
this.source.playbackRate.value = rate;
|
|
@@ -528,6 +625,7 @@ class SoundInstance
|
|
|
528
625
|
stop(fadeTime=0)
|
|
529
626
|
{
|
|
530
627
|
ASSERT(fadeTime >= 0, 'Sound fade time must be positive or zero');
|
|
628
|
+
audioWaitingInstances.delete(this); // a sound waiting for audio to run no longer starts
|
|
531
629
|
if (this.isPlaying())
|
|
532
630
|
{
|
|
533
631
|
if (fadeTime)
|
|
@@ -535,15 +633,8 @@ class SoundInstance
|
|
|
535
633
|
// ramp off gain from where it is now (not 1, or low-volume
|
|
536
634
|
// instances would jump back up before fading, and a volume
|
|
537
635
|
// fade in flight carries on down from its current point);
|
|
538
|
-
//
|
|
539
|
-
|
|
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);
|
|
636
|
+
// the ramp and the stop share one end time
|
|
637
|
+
this.source.stop(audioParamRamp(this.gainNode.gain, 0, fadeTime));
|
|
547
638
|
}
|
|
548
639
|
else
|
|
549
640
|
this.source.stop();
|
|
@@ -551,11 +642,16 @@ class SoundInstance
|
|
|
551
642
|
this.pausedTime = 0;
|
|
552
643
|
this.source = undefined;
|
|
553
644
|
this.startTime = undefined;
|
|
645
|
+
// let go of the gain node so a later setVolume can't cancel the fade out, the ended listener disconnects
|
|
646
|
+
// it, and start makes a new one
|
|
647
|
+
this.gainNode = undefined;
|
|
648
|
+
this.pannerNode = undefined;
|
|
554
649
|
}
|
|
555
650
|
|
|
556
651
|
/** Pause this sound instance */
|
|
557
652
|
pause()
|
|
558
653
|
{
|
|
654
|
+
audioWaitingInstances.delete(this); // a sound waiting for audio to run no longer starts
|
|
559
655
|
if (this.isPaused()) return;
|
|
560
656
|
|
|
561
657
|
// save current time and stop sound
|
|
@@ -563,6 +659,8 @@ class SoundInstance
|
|
|
563
659
|
this.source.stop();
|
|
564
660
|
this.source = undefined;
|
|
565
661
|
this.startTime = undefined;
|
|
662
|
+
this.gainNode = undefined; // resume starts with a new one at the volume set meanwhile
|
|
663
|
+
this.pannerNode = undefined;
|
|
566
664
|
}
|
|
567
665
|
|
|
568
666
|
/** Resume this sound instance */
|
|
@@ -584,24 +682,25 @@ class SoundInstance
|
|
|
584
682
|
*/
|
|
585
683
|
isPaused() { return !this.isPlaying(); }
|
|
586
684
|
|
|
587
|
-
/** Get
|
|
588
|
-
*
|
|
685
|
+
/** Get where it is in the sound, in the sound's own seconds: at a rate of 2 it moves two seconds for each one
|
|
686
|
+
* that passes, and at 0 it stays put
|
|
687
|
+
* @return {number} - Seconds into the sound
|
|
589
688
|
*/
|
|
590
689
|
getCurrentTime()
|
|
591
690
|
{
|
|
592
691
|
if (!this.isPlaying()) return this.pausedTime;
|
|
593
692
|
const duration = this.getDuration();
|
|
594
|
-
|
|
595
|
-
return duration ? mod(
|
|
693
|
+
const place = this.startOffset + (audioContext.currentTime - this.startTime) * this.rate;
|
|
694
|
+
return duration ? mod(place, duration) : 0; // a sound still loading has no length yet
|
|
596
695
|
}
|
|
597
696
|
|
|
598
|
-
/** Get the
|
|
599
|
-
* @return {number} -
|
|
697
|
+
/** Get the length of the sound in its own seconds, the same at any rate; divide by the rate for how long it takes to play
|
|
698
|
+
* @return {number} - Length in seconds (0 if loading)
|
|
600
699
|
*/
|
|
601
|
-
getDuration() { return this.
|
|
700
|
+
getDuration() { return this.sound.getDuration(); }
|
|
602
701
|
|
|
603
702
|
/** Get source of this sound instance
|
|
604
|
-
* @return {AudioBufferSourceNode}
|
|
703
|
+
* @return {AudioBufferSourceNode|undefined} - The source, or undefined while not playing
|
|
605
704
|
*/
|
|
606
705
|
getSource() { return this.source; }
|
|
607
706
|
}
|
|
@@ -668,24 +767,27 @@ function getNoteFrequency(semitoneOffset, rootFrequency=220)
|
|
|
668
767
|
* @param {boolean} [loop] - True if the sound should loop when it reaches the end
|
|
669
768
|
* @param {number} [sampleRate=44100] - Sample rate for the sound
|
|
670
769
|
* @param {GainNode} [gainNode] - Optional gain node for volume control while playing (disconnected when the sound ends)
|
|
671
|
-
* @param {number} [offset] -
|
|
770
|
+
* @param {number} [offset] - Where to start in the sound, in its own seconds whatever the rate
|
|
672
771
|
* @param {AudioEndedCallback} [onended] - Callback for when the sound ends
|
|
673
772
|
* @param {AudioNode|AudioEffectNodes} [output] - Node or effect to connect the gain to instead of the master gain
|
|
674
|
-
* @
|
|
773
|
+
* @param {StereoPannerNode} [pannerNode] - Optional stereo panner for panning while playing, its pan already set (disconnected when the sound ends)
|
|
774
|
+
* @return {AudioBufferSourceNode|undefined} - The source node of the sound played, undefined if play fails
|
|
675
775
|
* @memberof Audio */
|
|
676
|
-
function playSamples(sampleChannels, volume=1, rate=1, pan=0, loop=false, sampleRate=audioDefaultSampleRate, gainNode, offset=0, onended, output)
|
|
776
|
+
function playSamples(sampleChannels, volume=1, rate=1, pan=0, loop=false, sampleRate=audioDefaultSampleRate, gainNode, offset=0, onended, output, pannerNode)
|
|
677
777
|
{
|
|
678
778
|
if (!soundEnable || headlessMode) return;
|
|
679
779
|
|
|
680
780
|
if (!audioIsRunning())
|
|
681
781
|
{
|
|
682
|
-
// fix stalled audio, don't build a buffer that can't be played
|
|
683
|
-
|
|
782
|
+
// fix stalled audio, don't build a buffer that can't be played;
|
|
783
|
+
// but a context suspended because the page is hidden stays suspended until it shows
|
|
784
|
+
if (!audioSuspendedWhenHidden)
|
|
785
|
+
audioContext.resume();
|
|
684
786
|
return;
|
|
685
787
|
}
|
|
686
788
|
|
|
687
789
|
const buffer = createAudioBuffer(sampleChannels, sampleRate);
|
|
688
|
-
return playAudioBuffer(buffer, volume, rate, pan, loop, gainNode, offset, onended, output);
|
|
790
|
+
return playAudioBuffer(buffer, volume, rate, pan, loop, gainNode, offset, onended, output, pannerNode);
|
|
689
791
|
}
|
|
690
792
|
|
|
691
793
|
/** Copy arrays of samples into a new audio buffer
|
|
@@ -710,19 +812,22 @@ function createAudioBuffer(sampleChannels, sampleRate=audioDefaultSampleRate)
|
|
|
710
812
|
* @param {number} [pan] - How much to apply stereo panning
|
|
711
813
|
* @param {boolean} [loop] - True if the sound should loop when it reaches the end
|
|
712
814
|
* @param {GainNode} [gainNode] - Optional gain node for volume control while playing (disconnected when the sound ends)
|
|
713
|
-
* @param {number} [offset] -
|
|
815
|
+
* @param {number} [offset] - Where to start in the sound, in its own seconds whatever the rate
|
|
714
816
|
* @param {AudioEndedCallback} [onended] - Callback for when the sound ends
|
|
715
817
|
* @param {AudioNode|AudioEffectNodes} [output] - Node or effect to connect the gain to instead of the master gain
|
|
716
|
-
* @
|
|
818
|
+
* @param {StereoPannerNode} [pannerNode] - Optional stereo panner for panning while playing, its pan already set (disconnected when the sound ends)
|
|
819
|
+
* @return {AudioBufferSourceNode|undefined} - The source node of the sound played, undefined if play fails
|
|
717
820
|
* @memberof Audio */
|
|
718
|
-
function playAudioBuffer(buffer, volume=1, rate=1, pan=0, loop=false, gainNode, offset=0, onended, output)
|
|
821
|
+
function playAudioBuffer(buffer, volume=1, rate=1, pan=0, loop=false, gainNode, offset=0, onended, output, pannerNode)
|
|
719
822
|
{
|
|
720
823
|
if (!soundEnable || headlessMode) return;
|
|
721
824
|
|
|
722
825
|
if (!audioIsRunning())
|
|
723
826
|
{
|
|
724
|
-
// fix stalled audio, this sound won't be able to play
|
|
725
|
-
|
|
827
|
+
// fix stalled audio, this sound won't be able to play;
|
|
828
|
+
// but a context suspended because the page is hidden stays suspended until it shows
|
|
829
|
+
if (!audioSuspendedWhenHidden)
|
|
830
|
+
audioContext.resume();
|
|
726
831
|
return;
|
|
727
832
|
}
|
|
728
833
|
|
|
@@ -740,21 +845,20 @@ function playAudioBuffer(buffer, volume=1, rate=1, pan=0, loop=false, gainNode,
|
|
|
740
845
|
gainNode.connect(outputNode);
|
|
741
846
|
|
|
742
847
|
// connect source to stereo panner and gain
|
|
743
|
-
const
|
|
744
|
-
source.connect(
|
|
848
|
+
const panner = pannerNode || new StereoPannerNode(audioContext, {'pan':clamp(pan, -1, 1)});
|
|
849
|
+
source.connect(panner).connect(gainNode);
|
|
745
850
|
|
|
746
851
|
// disconnect nodes when the sound ends so the audio graph doesn't grow
|
|
747
852
|
// unbounded across many play() calls (source.stop() also fires 'ended')
|
|
748
853
|
source.addEventListener('ended', ()=>
|
|
749
854
|
{
|
|
750
855
|
gainNode.disconnect();
|
|
751
|
-
|
|
856
|
+
panner.disconnect();
|
|
752
857
|
if (onended) onended(source);
|
|
753
858
|
});
|
|
754
859
|
|
|
755
|
-
// play and return sound
|
|
756
|
-
|
|
757
|
-
source.start(0, startOffset);
|
|
860
|
+
// play and return sound, the offset is a place in the buffer whatever the rate
|
|
861
|
+
source.start(0, offset);
|
|
758
862
|
|
|
759
863
|
if (debug && debugSound)
|
|
760
864
|
LOG('sound', 'vol', volume.toFixed(2), 'rate', rate.toFixed(2), 'pan', pan.toFixed(2), loop ? 'loop' : '');
|
|
@@ -769,7 +873,7 @@ function playAudioBuffer(buffer, volume=1, rate=1, pan=0, loop=false, gainNode,
|
|
|
769
873
|
*
|
|
770
874
|
* <a href=https://killedbyapixel.github.io/ZzFX/>Create sounds using the ZzFX Sound Designer.</a>
|
|
771
875
|
* @param {Array} zzfxSound - Array of ZzFX parameters, ex. [.5,.5]
|
|
772
|
-
* @return {AudioBufferSourceNode} - The audio node of the sound played
|
|
876
|
+
* @return {AudioBufferSourceNode|undefined} - The audio node of the sound played, undefined if play fails
|
|
773
877
|
* @memberof Audio */
|
|
774
878
|
function zzfx(...zzfxSound) { return playSamples([zzfxG(...zzfxSound)]); }
|
|
775
879
|
|
|
@@ -840,7 +944,7 @@ function zzfxG
|
|
|
840
944
|
f, // wave frequency
|
|
841
945
|
|
|
842
946
|
// biquad LP/HP filter
|
|
843
|
-
quality = 2, w = PI2 * abs(filter) * 2 / sampleRate,
|
|
947
|
+
quality = 2, w = PI2 * min(abs(filter), sampleRate/4 - 1) * 2 / sampleRate, // stable below a quarter rate
|
|
844
948
|
cosw = cos(w), alpha = sin(w) / 2 / quality,
|
|
845
949
|
a0 = 1 + alpha, a1 = -2*cosw / a0, a2 = (1 - alpha) / a0,
|
|
846
950
|
b0 = (1 + sign(filter) * cosw) / 2 / a0,
|
|
@@ -890,7 +994,7 @@ function zzfxG
|
|
|
890
994
|
|
|
891
995
|
s = delay ? s/2 + (delay > i ? 0 : // delay
|
|
892
996
|
(i<length-delay? 1 : (length-i)/delay) * // release delay
|
|
893
|
-
b[i-delay|0]/2/volume) : s;
|
|
997
|
+
b[i-delay|0]/2/(volume||1)) : s; // sample delay, stored samples are 0 at volume 0
|
|
894
998
|
|
|
895
999
|
if (filter) // apply filter
|
|
896
1000
|
s = y1 = b2*x2 + b1*(x2=x1) + b0*(x1=s) - a2*y2 - a1*(y2=y1);
|