littlejsengine 1.18.27 → 1.18.29

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.
@@ -35,7 +35,7 @@ const engineName = 'LittleJS';
35
35
  * @type {string}
36
36
  * @default
37
37
  * @memberof Engine */
38
- const engineVersion = '1.18.27';
38
+ const engineVersion = '1.18.29';
39
39
 
40
40
  /** Frames per second to update
41
41
  * @type {number}
@@ -6283,7 +6283,15 @@ class Sound
6283
6283
  this.randomness = randomness ?? 0;
6284
6284
  /** @property {number} - Sample rate for this sound */
6285
6285
  this.sampleRate = audioDefaultSampleRate;
6286
- /** @property {number} - Percentage of this sound currently loaded */
6286
+ /** @property {number} - How many samples per channel this sound has */
6287
+ this.sampleLength = 0;
6288
+ /** @property {AudioBuffer} - Decoded audio shared by every play of this sound
6289
+ * @type {AudioBuffer} */
6290
+ this.sampleBuffer = undefined;
6291
+ /** @private @type {Array<Array<number>|Float32Array>} */
6292
+ this._sampleChannels = undefined;
6293
+ /** @property {number} - Percentage of this sound currently loaded, sounds
6294
+ * fetched from a url stay at 0 until decoding completes */
6287
6295
  this.loadedPercent = 0;
6288
6296
  /** @property {SoundLoadCallback} - function to call when sound is loaded */
6289
6297
  this.onloadCallback = onloadCallback;
@@ -6299,19 +6307,62 @@ class Sound
6299
6307
  this.randomness = zzfxSound[randomnessIndex] ?? defaultRandomness;
6300
6308
  zzfxSound[randomnessIndex] = 0;
6301
6309
 
6302
- // generate the zzfx samples
6310
+ // generate the zzfx samples, then hand them to an audio buffer so
6311
+ // the plain arrays can be released and every play shares the buffer
6303
6312
  this.sampleChannels = [zzfxG(...zzfxSound)];
6313
+ this.buildSampleBuffer();
6304
6314
  this.loadedPercent = 1;
6305
6315
  onloadCallback?.(this);
6306
6316
  }
6307
6317
  else if (typeof asset === 'string')
6308
6318
  {
6309
- // load the audio file
6319
+ // load the audio file, report failures rather than leaving an
6320
+ // unhandled rejection, the sound just stays unloaded and silent
6310
6321
  const filename = asset;
6311
- this.loadSound(filename);
6322
+ this.loadSound(filename).catch(e=>
6323
+ LOG('Sound load failed for', filename, '-', e.message));
6312
6324
  }
6313
6325
  }
6314
6326
 
6327
+ /** Sample data for each channel
6328
+ * Sounds keep their samples in an audio buffer, so reading this rebuilds
6329
+ * the arrays from it and caches them. The copies are safe to hold onto,
6330
+ * playing a sound detaches the buffer's own channel arrays.
6331
+ * @type {Array<Array<number>|Float32Array>} */
6332
+ get sampleChannels()
6333
+ {
6334
+ const buffer = this.sampleBuffer;
6335
+ if (!this._sampleChannels && buffer)
6336
+ {
6337
+ const channels = [];
6338
+ for (let i = 0; i < buffer.numberOfChannels; i++)
6339
+ channels.push(buffer.getChannelData(i).slice());
6340
+ this._sampleChannels = channels;
6341
+ }
6342
+ return this._sampleChannels;
6343
+ }
6344
+
6345
+ /** @param {Array<Array<number>|Float32Array>} sampleChannels */
6346
+ set sampleChannels(sampleChannels)
6347
+ {
6348
+ // new samples invalidate the buffer built from the old ones
6349
+ this._sampleChannels = sampleChannels;
6350
+ this.sampleBuffer = undefined;
6351
+ this.sampleLength = sampleChannels?.[0]?.length || 0;
6352
+ }
6353
+
6354
+ /** Move this sound's samples into an audio buffer that every play can share
6355
+ * Does nothing if there is already a buffer or no samples to build one from */
6356
+ buildSampleBuffer()
6357
+ {
6358
+ if (this.sampleBuffer || !this._sampleChannels || headlessMode) return;
6359
+
6360
+ this.sampleBuffer = createAudioBuffer(this._sampleChannels, this.sampleRate);
6361
+
6362
+ // the buffer owns the samples now, release the arrays we built it from
6363
+ this._sampleChannels = undefined;
6364
+ }
6365
+
6315
6366
  /** Play the sound
6316
6367
  * Sounds may not play until a user interaction occurs
6317
6368
  * @param {Vector2} [pos] - World space position to play the sound if any
@@ -6330,7 +6381,7 @@ class Sound
6330
6381
  ASSERT(isNumber(randomnessScale), 'randomnessScale must be a number');
6331
6382
 
6332
6383
  if (!soundEnable || headlessMode) return;
6333
- if (!this.sampleChannels) return;
6384
+ if (!this.sampleBuffer && !this._sampleChannels) return;
6334
6385
 
6335
6386
  let pan;
6336
6387
  if (pos)
@@ -6397,7 +6448,7 @@ class Sound
6397
6448
  * @return {number} - How long the sound is in seconds (0 if loading)
6398
6449
  */
6399
6450
  getDuration()
6400
- { return this.sampleChannels?.[0]?.length / this.sampleRate || 0; }
6451
+ { return this.sampleLength / this.sampleRate || 0; }
6401
6452
 
6402
6453
  /** Check if sound is loaded, for sounds fetched from a url
6403
6454
  * @return {boolean} - True if sound is loaded and ready to play
@@ -6415,36 +6466,11 @@ class Sound
6415
6466
  const arrayBuffer = await response.arrayBuffer();
6416
6467
  const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
6417
6468
 
6418
- // convert audio buffer to sample channels across multiple frames
6419
- const channelCount = audioBuffer.numberOfChannels;
6420
- const samplesPerFrame = 1e5;
6421
- const sampleChannels = [];
6422
- for (let channel = 0; channel < channelCount; channel++)
6423
- {
6424
- const channelData = audioBuffer.getChannelData(channel);
6425
- const channelLength = channelData.length;
6426
- sampleChannels[channel] = new Array(channelLength);
6427
- let sampleIndex = 0;
6428
- while (sampleIndex < channelLength)
6429
- {
6430
- // yield to next frame
6431
- await new Promise(resolve => setTimeout(resolve, 0));
6432
-
6433
- // copy chunk of samples
6434
- const endIndex = min(sampleIndex + samplesPerFrame, channelLength);
6435
- for (; sampleIndex < endIndex; sampleIndex++)
6436
- sampleChannels[channel][sampleIndex] = channelData[sampleIndex];
6437
-
6438
- // update loaded percent
6439
- const samplesTotal = channelCount * channelLength;
6440
- const samplesProcessed = channel * channelLength + sampleIndex;
6441
- this.loadedPercent = samplesProcessed / samplesTotal;
6442
- }
6443
- }
6444
-
6445
- // setup the sound to be played
6469
+ // keep the decoded buffer as is, it is exactly what playback needs and
6470
+ // every play shares it, no channel data is read or copied
6446
6471
  this.sampleRate = audioBuffer.sampleRate;
6447
- this.sampleChannels = sampleChannels;
6472
+ this.sampleLength = audioBuffer.length;
6473
+ this.sampleBuffer = audioBuffer;
6448
6474
  this.loadedPercent = 1;
6449
6475
  this.onloadCallback?.(this);
6450
6476
  }
@@ -6520,7 +6546,12 @@ class SoundInstance
6520
6546
  if (this.isPlaying())
6521
6547
  this.stop();
6522
6548
  this.gainNode = audioContext.createGain();
6523
- this.source = playSamples(this.sound.sampleChannels, this.volume, this.rate, this.pan, this.loop, this.sound.sampleRate, this.gainNode, offset, this.onendedCallback);
6549
+
6550
+ // build the shared buffer if it was not made at load time, then play it
6551
+ this.sound.buildSampleBuffer();
6552
+ this.source = this.sound.sampleBuffer ?
6553
+ playAudioBuffer(this.sound.sampleBuffer, this.volume, this.rate, this.pan, this.loop, this.gainNode, offset, this.onendedCallback) :
6554
+ playSamples(this.sound.sampleChannels, this.volume, this.rate, this.pan, this.loop, this.sound.sampleRate, this.gainNode, offset, this.onendedCallback);
6524
6555
  if (this.source)
6525
6556
  {
6526
6557
  this.startTime = audioContext.currentTime - offset;
@@ -6646,7 +6677,7 @@ function speak(text, volume=1, rate=1, pitch=1, language='')
6646
6677
  // build utterance and speak
6647
6678
  const utterance = new SpeechSynthesisUtterance(text);
6648
6679
  utterance.lang = language;
6649
- utterance.volume = 2*volume*soundVolume;
6680
+ utterance.volume = volume*soundVolume;
6650
6681
  utterance.rate = rate;
6651
6682
  utterance.pitch = pitch;
6652
6683
  speechSynthesis.speak(utterance);
@@ -6695,19 +6726,54 @@ function playSamples(sampleChannels, volume=1, rate=1, pan=0, loop=false, sample
6695
6726
 
6696
6727
  if (!audioIsRunning())
6697
6728
  {
6698
- // fix stalled audio, this sound won't be able to play
6729
+ // fix stalled audio, don't build a buffer that can't be played
6699
6730
  audioContext.resume();
6700
6731
  return;
6701
6732
  }
6702
6733
 
6703
- // create buffer and source
6734
+ const buffer = createAudioBuffer(sampleChannels, sampleRate);
6735
+ return playAudioBuffer(buffer, volume, rate, pan, loop, gainNode, offset, onended);
6736
+ }
6737
+
6738
+ /** Copy arrays of samples into a new audio buffer
6739
+ * @param {Array} sampleChannels - Array of arrays of samples (for stereo playback)
6740
+ * @param {number} [sampleRate=44100] - Sample rate for the sound
6741
+ * @return {AudioBuffer} - The audio buffer holding the samples
6742
+ * @memberof Audio */
6743
+ function createAudioBuffer(sampleChannels, sampleRate=audioDefaultSampleRate)
6744
+ {
6704
6745
  const channelCount = sampleChannels.length;
6705
6746
  const sampleLength = sampleChannels[0].length;
6706
6747
  const buffer = audioContext.createBuffer(channelCount, sampleLength, sampleRate);
6707
- const source = audioContext.createBufferSource();
6708
-
6709
- // copy samples to buffer and setup source
6710
6748
  sampleChannels.forEach((c,i)=> buffer.getChannelData(i).set(c));
6749
+ return buffer;
6750
+ }
6751
+
6752
+ /** Play an audio buffer with given settings
6753
+ * The buffer can be shared by any number of sounds playing at once
6754
+ * @param {AudioBuffer} buffer - The audio buffer to play
6755
+ * @param {number} [volume] - How much to scale volume by
6756
+ * @param {number} [rate] - The playback rate to use
6757
+ * @param {number} [pan] - How much to apply stereo panning
6758
+ * @param {boolean} [loop] - True if the sound should loop when it reaches the end
6759
+ * @param {GainNode} [gainNode] - Optional gain node for volume control while playing (disconnected when the sound ends)
6760
+ * @param {number} [offset] - Offset in seconds to start playback from
6761
+ * @param {AudioEndedCallback} [onended] - Callback for when the sound ends
6762
+ * @return {AudioBufferSourceNode} - The source node of the sound played, may be undefined if play fails
6763
+ * @memberof Audio */
6764
+ function playAudioBuffer(buffer, volume=1, rate=1, pan=0, loop=false, gainNode, offset=0, onended)
6765
+ {
6766
+ if (!soundEnable || headlessMode) return;
6767
+
6768
+ if (!audioIsRunning())
6769
+ {
6770
+ // fix stalled audio, this sound won't be able to play
6771
+ audioContext.resume();
6772
+ return;
6773
+ }
6774
+
6775
+ // setup source, many sources can share one buffer
6776
+ const source = audioContext.createBufferSource();
6711
6777
  source.buffer = buffer;
6712
6778
  source.playbackRate.value = rate;
6713
6779
  source.loop = loop;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "littlejsengine",
3
- "version": "1.18.27",
3
+ "version": "1.18.29",
4
4
  "description": "LittleJS - Tiny and Fast HTML5 Game Engine",
5
5
  "main": "dist/littlejs.esm.js",
6
6
  "types": "dist/littlejs.d.ts",
package/src/engine.js CHANGED
@@ -32,7 +32,7 @@ const engineName = 'LittleJS';
32
32
  * @type {string}
33
33
  * @default
34
34
  * @memberof Engine */
35
- const engineVersion = '1.18.27';
35
+ const engineVersion = '1.18.29';
36
36
 
37
37
  /** Frames per second to update
38
38
  * @type {number}
@@ -98,7 +98,15 @@ class Sound
98
98
  this.randomness = randomness ?? 0;
99
99
  /** @property {number} - Sample rate for this sound */
100
100
  this.sampleRate = audioDefaultSampleRate;
101
- /** @property {number} - Percentage of this sound currently loaded */
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 */
102
110
  this.loadedPercent = 0;
103
111
  /** @property {SoundLoadCallback} - function to call when sound is loaded */
104
112
  this.onloadCallback = onloadCallback;
@@ -114,19 +122,62 @@ class Sound
114
122
  this.randomness = zzfxSound[randomnessIndex] ?? defaultRandomness;
115
123
  zzfxSound[randomnessIndex] = 0;
116
124
 
117
- // generate the zzfx samples
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
118
127
  this.sampleChannels = [zzfxG(...zzfxSound)];
128
+ this.buildSampleBuffer();
119
129
  this.loadedPercent = 1;
120
130
  onloadCallback?.(this);
121
131
  }
122
132
  else if (typeof asset === 'string')
123
133
  {
124
- // load the audio file
134
+ // load the audio file, report failures rather than leaving an
135
+ // unhandled rejection, the sound just stays unloaded and silent
125
136
  const filename = asset;
126
- this.loadSound(filename);
137
+ this.loadSound(filename).catch(e=>
138
+ LOG('Sound load failed for', filename, '-', e.message));
127
139
  }
128
140
  }
129
141
 
142
+ /** Sample data for each channel
143
+ * Sounds keep their samples in an audio buffer, so reading this rebuilds
144
+ * the arrays from it and caches them. The copies are safe to hold onto,
145
+ * playing a sound detaches the buffer's own channel arrays.
146
+ * @type {Array<Array<number>|Float32Array>} */
147
+ get sampleChannels()
148
+ {
149
+ const buffer = this.sampleBuffer;
150
+ if (!this._sampleChannels && buffer)
151
+ {
152
+ const channels = [];
153
+ for (let i = 0; i < buffer.numberOfChannels; i++)
154
+ channels.push(buffer.getChannelData(i).slice());
155
+ this._sampleChannels = channels;
156
+ }
157
+ return this._sampleChannels;
158
+ }
159
+
160
+ /** @param {Array<Array<number>|Float32Array>} sampleChannels */
161
+ set sampleChannels(sampleChannels)
162
+ {
163
+ // new samples invalidate the buffer built from the old ones
164
+ this._sampleChannels = sampleChannels;
165
+ this.sampleBuffer = undefined;
166
+ this.sampleLength = sampleChannels?.[0]?.length || 0;
167
+ }
168
+
169
+ /** Move this sound's samples into an audio buffer that every play can share
170
+ * Does nothing if there is already a buffer or no samples to build one from */
171
+ buildSampleBuffer()
172
+ {
173
+ if (this.sampleBuffer || !this._sampleChannels || headlessMode) return;
174
+
175
+ this.sampleBuffer = createAudioBuffer(this._sampleChannels, this.sampleRate);
176
+
177
+ // the buffer owns the samples now, release the arrays we built it from
178
+ this._sampleChannels = undefined;
179
+ }
180
+
130
181
  /** Play the sound
131
182
  * Sounds may not play until a user interaction occurs
132
183
  * @param {Vector2} [pos] - World space position to play the sound if any
@@ -145,7 +196,7 @@ class Sound
145
196
  ASSERT(isNumber(randomnessScale), 'randomnessScale must be a number');
146
197
 
147
198
  if (!soundEnable || headlessMode) return;
148
- if (!this.sampleChannels) return;
199
+ if (!this.sampleBuffer && !this._sampleChannels) return;
149
200
 
150
201
  let pan;
151
202
  if (pos)
@@ -212,7 +263,7 @@ class Sound
212
263
  * @return {number} - How long the sound is in seconds (0 if loading)
213
264
  */
214
265
  getDuration()
215
- { return this.sampleChannels?.[0]?.length / this.sampleRate || 0; }
266
+ { return this.sampleLength / this.sampleRate || 0; }
216
267
 
217
268
  /** Check if sound is loaded, for sounds fetched from a url
218
269
  * @return {boolean} - True if sound is loaded and ready to play
@@ -230,36 +281,11 @@ class Sound
230
281
  const arrayBuffer = await response.arrayBuffer();
231
282
  const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
232
283
 
233
- // convert audio buffer to sample channels across multiple frames
234
- const channelCount = audioBuffer.numberOfChannels;
235
- const samplesPerFrame = 1e5;
236
- const sampleChannels = [];
237
- for (let channel = 0; channel < channelCount; channel++)
238
- {
239
- const channelData = audioBuffer.getChannelData(channel);
240
- const channelLength = channelData.length;
241
- sampleChannels[channel] = new Array(channelLength);
242
- let sampleIndex = 0;
243
- while (sampleIndex < channelLength)
244
- {
245
- // yield to next frame
246
- await new Promise(resolve => setTimeout(resolve, 0));
247
-
248
- // copy chunk of samples
249
- const endIndex = min(sampleIndex + samplesPerFrame, channelLength);
250
- for (; sampleIndex < endIndex; sampleIndex++)
251
- sampleChannels[channel][sampleIndex] = channelData[sampleIndex];
252
-
253
- // update loaded percent
254
- const samplesTotal = channelCount * channelLength;
255
- const samplesProcessed = channel * channelLength + sampleIndex;
256
- this.loadedPercent = samplesProcessed / samplesTotal;
257
- }
258
- }
259
-
260
- // setup the sound to be played
284
+ // keep the decoded buffer as is, it is exactly what playback needs and
285
+ // every play shares it, no channel data is read or copied
261
286
  this.sampleRate = audioBuffer.sampleRate;
262
- this.sampleChannels = sampleChannels;
287
+ this.sampleLength = audioBuffer.length;
288
+ this.sampleBuffer = audioBuffer;
263
289
  this.loadedPercent = 1;
264
290
  this.onloadCallback?.(this);
265
291
  }
@@ -335,7 +361,12 @@ class SoundInstance
335
361
  if (this.isPlaying())
336
362
  this.stop();
337
363
  this.gainNode = audioContext.createGain();
338
- this.source = playSamples(this.sound.sampleChannels, this.volume, this.rate, this.pan, this.loop, this.sound.sampleRate, this.gainNode, offset, this.onendedCallback);
364
+
365
+ // build the shared buffer if it was not made at load time, then play it
366
+ this.sound.buildSampleBuffer();
367
+ this.source = this.sound.sampleBuffer ?
368
+ playAudioBuffer(this.sound.sampleBuffer, this.volume, this.rate, this.pan, this.loop, this.gainNode, offset, this.onendedCallback) :
369
+ playSamples(this.sound.sampleChannels, this.volume, this.rate, this.pan, this.loop, this.sound.sampleRate, this.gainNode, offset, this.onendedCallback);
339
370
  if (this.source)
340
371
  {
341
372
  this.startTime = audioContext.currentTime - offset;
@@ -461,7 +492,7 @@ function speak(text, volume=1, rate=1, pitch=1, language='')
461
492
  // build utterance and speak
462
493
  const utterance = new SpeechSynthesisUtterance(text);
463
494
  utterance.lang = language;
464
- utterance.volume = 2*volume*soundVolume;
495
+ utterance.volume = volume*soundVolume;
465
496
  utterance.rate = rate;
466
497
  utterance.pitch = pitch;
467
498
  speechSynthesis.speak(utterance);
@@ -510,19 +541,54 @@ function playSamples(sampleChannels, volume=1, rate=1, pan=0, loop=false, sample
510
541
 
511
542
  if (!audioIsRunning())
512
543
  {
513
- // fix stalled audio, this sound won't be able to play
544
+ // fix stalled audio, don't build a buffer that can't be played
514
545
  audioContext.resume();
515
546
  return;
516
547
  }
517
548
 
518
- // create buffer and source
549
+ const buffer = createAudioBuffer(sampleChannels, sampleRate);
550
+ return playAudioBuffer(buffer, volume, rate, pan, loop, gainNode, offset, onended);
551
+ }
552
+
553
+ /** Copy arrays of samples into a new audio buffer
554
+ * @param {Array} sampleChannels - Array of arrays of samples (for stereo playback)
555
+ * @param {number} [sampleRate=44100] - Sample rate for the sound
556
+ * @return {AudioBuffer} - The audio buffer holding the samples
557
+ * @memberof Audio */
558
+ function createAudioBuffer(sampleChannels, sampleRate=audioDefaultSampleRate)
559
+ {
519
560
  const channelCount = sampleChannels.length;
520
561
  const sampleLength = sampleChannels[0].length;
521
562
  const buffer = audioContext.createBuffer(channelCount, sampleLength, sampleRate);
522
- const source = audioContext.createBufferSource();
523
-
524
- // copy samples to buffer and setup source
525
563
  sampleChannels.forEach((c,i)=> buffer.getChannelData(i).set(c));
564
+ return buffer;
565
+ }
566
+
567
+ /** Play an audio buffer with given settings
568
+ * The buffer can be shared by any number of sounds playing at once
569
+ * @param {AudioBuffer} buffer - The audio buffer to play
570
+ * @param {number} [volume] - How much to scale volume by
571
+ * @param {number} [rate] - The playback rate to use
572
+ * @param {number} [pan] - How much to apply stereo panning
573
+ * @param {boolean} [loop] - True if the sound should loop when it reaches the end
574
+ * @param {GainNode} [gainNode] - Optional gain node for volume control while playing (disconnected when the sound ends)
575
+ * @param {number} [offset] - Offset in seconds to start playback from
576
+ * @param {AudioEndedCallback} [onended] - Callback for when the sound ends
577
+ * @return {AudioBufferSourceNode} - The source node of the sound played, may be undefined if play fails
578
+ * @memberof Audio */
579
+ function playAudioBuffer(buffer, volume=1, rate=1, pan=0, loop=false, gainNode, offset=0, onended)
580
+ {
581
+ if (!soundEnable || headlessMode) return;
582
+
583
+ if (!audioIsRunning())
584
+ {
585
+ // fix stalled audio, this sound won't be able to play
586
+ audioContext.resume();
587
+ return;
588
+ }
589
+
590
+ // setup source, many sources can share one buffer
591
+ const source = audioContext.createBufferSource();
526
592
  source.buffer = buffer;
527
593
  source.playbackRate.value = rate;
528
594
  source.loop = loop;
@@ -371,6 +371,8 @@ export
371
371
  speakStop,
372
372
  getNoteFrequency,
373
373
  playSamples,
374
+ playAudioBuffer,
375
+ createAudioBuffer,
374
376
  zzfx,
375
377
  zzfxG,
376
378