littlejsengine 1.18.27 → 1.18.28

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.28';
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,8 +6307,10 @@ 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
  }
@@ -6312,6 +6322,45 @@ class Sound
6312
6322
  }
6313
6323
  }
6314
6324
 
6325
+ /** Sample data for each channel
6326
+ * Sounds keep their samples in an audio buffer, so reading this rebuilds
6327
+ * the arrays from it and caches them. The copies are safe to hold onto,
6328
+ * playing a sound detaches the buffer's own channel arrays.
6329
+ * @type {Array<Array<number>|Float32Array>} */
6330
+ get sampleChannels()
6331
+ {
6332
+ const buffer = this.sampleBuffer;
6333
+ if (!this._sampleChannels && buffer)
6334
+ {
6335
+ const channels = [];
6336
+ for (let i = 0; i < buffer.numberOfChannels; i++)
6337
+ channels.push(buffer.getChannelData(i).slice());
6338
+ this._sampleChannels = channels;
6339
+ }
6340
+ return this._sampleChannels;
6341
+ }
6342
+
6343
+ /** @param {Array<Array<number>|Float32Array>} sampleChannels */
6344
+ set sampleChannels(sampleChannels)
6345
+ {
6346
+ // new samples invalidate the buffer built from the old ones
6347
+ this._sampleChannels = sampleChannels;
6348
+ this.sampleBuffer = undefined;
6349
+ this.sampleLength = sampleChannels?.[0]?.length || 0;
6350
+ }
6351
+
6352
+ /** Move this sound's samples into an audio buffer that every play can share
6353
+ * Does nothing if there is already a buffer or no samples to build one from */
6354
+ buildSampleBuffer()
6355
+ {
6356
+ if (this.sampleBuffer || !this._sampleChannels || headlessMode) return;
6357
+
6358
+ this.sampleBuffer = createAudioBuffer(this._sampleChannels, this.sampleRate);
6359
+
6360
+ // the buffer owns the samples now, release the arrays we built it from
6361
+ this._sampleChannels = undefined;
6362
+ }
6363
+
6315
6364
  /** Play the sound
6316
6365
  * Sounds may not play until a user interaction occurs
6317
6366
  * @param {Vector2} [pos] - World space position to play the sound if any
@@ -6330,7 +6379,7 @@ class Sound
6330
6379
  ASSERT(isNumber(randomnessScale), 'randomnessScale must be a number');
6331
6380
 
6332
6381
  if (!soundEnable || headlessMode) return;
6333
- if (!this.sampleChannels) return;
6382
+ if (!this.sampleBuffer && !this._sampleChannels) return;
6334
6383
 
6335
6384
  let pan;
6336
6385
  if (pos)
@@ -6397,7 +6446,7 @@ class Sound
6397
6446
  * @return {number} - How long the sound is in seconds (0 if loading)
6398
6447
  */
6399
6448
  getDuration()
6400
- { return this.sampleChannels?.[0]?.length / this.sampleRate || 0; }
6449
+ { return this.sampleLength / this.sampleRate || 0; }
6401
6450
 
6402
6451
  /** Check if sound is loaded, for sounds fetched from a url
6403
6452
  * @return {boolean} - True if sound is loaded and ready to play
@@ -6415,36 +6464,11 @@ class Sound
6415
6464
  const arrayBuffer = await response.arrayBuffer();
6416
6465
  const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
6417
6466
 
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
6467
+ // keep the decoded buffer as is, it is exactly what playback needs and
6468
+ // every play shares it, no channel data is read or copied
6446
6469
  this.sampleRate = audioBuffer.sampleRate;
6447
- this.sampleChannels = sampleChannels;
6470
+ this.sampleLength = audioBuffer.length;
6471
+ this.sampleBuffer = audioBuffer;
6448
6472
  this.loadedPercent = 1;
6449
6473
  this.onloadCallback?.(this);
6450
6474
  }
@@ -6520,7 +6544,12 @@ class SoundInstance
6520
6544
  if (this.isPlaying())
6521
6545
  this.stop();
6522
6546
  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);
6547
+
6548
+ // build the shared buffer if it was not made at load time, then play it
6549
+ this.sound.buildSampleBuffer();
6550
+ this.source = this.sound.sampleBuffer ?
6551
+ playAudioBuffer(this.sound.sampleBuffer, this.volume, this.rate, this.pan, this.loop, this.gainNode, offset, this.onendedCallback) :
6552
+ playSamples(this.sound.sampleChannels, this.volume, this.rate, this.pan, this.loop, this.sound.sampleRate, this.gainNode, offset, this.onendedCallback);
6524
6553
  if (this.source)
6525
6554
  {
6526
6555
  this.startTime = audioContext.currentTime - offset;
@@ -6695,19 +6724,54 @@ function playSamples(sampleChannels, volume=1, rate=1, pan=0, loop=false, sample
6695
6724
 
6696
6725
  if (!audioIsRunning())
6697
6726
  {
6698
- // fix stalled audio, this sound won't be able to play
6727
+ // fix stalled audio, don't build a buffer that can't be played
6699
6728
  audioContext.resume();
6700
6729
  return;
6701
6730
  }
6702
6731
 
6703
- // create buffer and source
6732
+ const buffer = createAudioBuffer(sampleChannels, sampleRate);
6733
+ return playAudioBuffer(buffer, volume, rate, pan, loop, gainNode, offset, onended);
6734
+ }
6735
+
6736
+ /** Copy arrays of samples into a new audio buffer
6737
+ * @param {Array} sampleChannels - Array of arrays of samples (for stereo playback)
6738
+ * @param {number} [sampleRate=44100] - Sample rate for the sound
6739
+ * @return {AudioBuffer} - The audio buffer holding the samples
6740
+ * @memberof Audio */
6741
+ function createAudioBuffer(sampleChannels, sampleRate=audioDefaultSampleRate)
6742
+ {
6704
6743
  const channelCount = sampleChannels.length;
6705
6744
  const sampleLength = sampleChannels[0].length;
6706
6745
  const buffer = audioContext.createBuffer(channelCount, sampleLength, sampleRate);
6707
- const source = audioContext.createBufferSource();
6708
-
6709
- // copy samples to buffer and setup source
6710
6746
  sampleChannels.forEach((c,i)=> buffer.getChannelData(i).set(c));
6747
+ return buffer;
6748
+ }
6749
+
6750
+ /** Play an audio buffer with given settings
6751
+ * The buffer can be shared by any number of sounds playing at once
6752
+ * @param {AudioBuffer} buffer - The audio buffer to play
6753
+ * @param {number} [volume] - How much to scale volume by
6754
+ * @param {number} [rate] - The playback rate to use
6755
+ * @param {number} [pan] - How much to apply stereo panning
6756
+ * @param {boolean} [loop] - True if the sound should loop when it reaches the end
6757
+ * @param {GainNode} [gainNode] - Optional gain node for volume control while playing (disconnected when the sound ends)
6758
+ * @param {number} [offset] - Offset in seconds to start playback from
6759
+ * @param {AudioEndedCallback} [onended] - Callback for when the sound ends
6760
+ * @return {AudioBufferSourceNode} - The source node of the sound played, may be undefined if play fails
6761
+ * @memberof Audio */
6762
+ function playAudioBuffer(buffer, volume=1, rate=1, pan=0, loop=false, gainNode, offset=0, onended)
6763
+ {
6764
+ if (!soundEnable || headlessMode) return;
6765
+
6766
+ if (!audioIsRunning())
6767
+ {
6768
+ // fix stalled audio, this sound won't be able to play
6769
+ audioContext.resume();
6770
+ return;
6771
+ }
6772
+
6773
+ // setup source, many sources can share one buffer
6774
+ const source = audioContext.createBufferSource();
6711
6775
  source.buffer = buffer;
6712
6776
  source.playbackRate.value = rate;
6713
6777
  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.28",
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.28';
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,8 +122,10 @@ 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
  }
@@ -127,6 +137,45 @@ class Sound
127
137
  }
128
138
  }
129
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
+
130
179
  /** Play the sound
131
180
  * Sounds may not play until a user interaction occurs
132
181
  * @param {Vector2} [pos] - World space position to play the sound if any
@@ -145,7 +194,7 @@ class Sound
145
194
  ASSERT(isNumber(randomnessScale), 'randomnessScale must be a number');
146
195
 
147
196
  if (!soundEnable || headlessMode) return;
148
- if (!this.sampleChannels) return;
197
+ if (!this.sampleBuffer && !this._sampleChannels) return;
149
198
 
150
199
  let pan;
151
200
  if (pos)
@@ -212,7 +261,7 @@ class Sound
212
261
  * @return {number} - How long the sound is in seconds (0 if loading)
213
262
  */
214
263
  getDuration()
215
- { return this.sampleChannels?.[0]?.length / this.sampleRate || 0; }
264
+ { return this.sampleLength / this.sampleRate || 0; }
216
265
 
217
266
  /** Check if sound is loaded, for sounds fetched from a url
218
267
  * @return {boolean} - True if sound is loaded and ready to play
@@ -230,36 +279,11 @@ class Sound
230
279
  const arrayBuffer = await response.arrayBuffer();
231
280
  const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
232
281
 
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
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
261
284
  this.sampleRate = audioBuffer.sampleRate;
262
- this.sampleChannels = sampleChannels;
285
+ this.sampleLength = audioBuffer.length;
286
+ this.sampleBuffer = audioBuffer;
263
287
  this.loadedPercent = 1;
264
288
  this.onloadCallback?.(this);
265
289
  }
@@ -335,7 +359,12 @@ class SoundInstance
335
359
  if (this.isPlaying())
336
360
  this.stop();
337
361
  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);
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);
339
368
  if (this.source)
340
369
  {
341
370
  this.startTime = audioContext.currentTime - offset;
@@ -510,19 +539,54 @@ function playSamples(sampleChannels, volume=1, rate=1, pan=0, loop=false, sample
510
539
 
511
540
  if (!audioIsRunning())
512
541
  {
513
- // fix stalled audio, this sound won't be able to play
542
+ // fix stalled audio, don't build a buffer that can't be played
514
543
  audioContext.resume();
515
544
  return;
516
545
  }
517
546
 
518
- // create buffer and source
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
+ {
519
558
  const channelCount = sampleChannels.length;
520
559
  const sampleLength = sampleChannels[0].length;
521
560
  const buffer = audioContext.createBuffer(channelCount, sampleLength, sampleRate);
522
- const source = audioContext.createBufferSource();
523
-
524
- // copy samples to buffer and setup source
525
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();
526
590
  source.buffer = buffer;
527
591
  source.playbackRate.value = rate;
528
592
  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