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.
@@ -2587,11 +2587,29 @@ declare module "littlejsengine" {
2587
2587
  randomness: any;
2588
2588
  /** @property {number} - Sample rate for this sound */
2589
2589
  sampleRate: number;
2590
- /** @property {number} - Percentage of this sound currently loaded */
2590
+ /** @property {number} - How many samples per channel this sound has */
2591
+ sampleLength: number;
2592
+ /** @property {AudioBuffer} - Decoded audio shared by every play of this sound
2593
+ * @type {AudioBuffer} */
2594
+ sampleBuffer: AudioBuffer;
2595
+ /** @private @type {Array<Array<number>|Float32Array>} */
2596
+ private _sampleChannels;
2597
+ /** @property {number} - Percentage of this sound currently loaded, sounds
2598
+ * fetched from a url stay at 0 until decoding completes */
2591
2599
  loadedPercent: number;
2592
2600
  /** @property {SoundLoadCallback} - function to call when sound is loaded */
2593
2601
  onloadCallback: (sound: Sound) => Sound;
2594
- sampleChannels: any[][];
2602
+ /** @param {Array<Array<number>|Float32Array>} sampleChannels */
2603
+ set sampleChannels(arg: (number[] | Float32Array)[]);
2604
+ /** Sample data for each channel
2605
+ * Sounds keep their samples in an audio buffer, so reading this rebuilds
2606
+ * the arrays from it and caches them. The copies are safe to hold onto,
2607
+ * playing a sound detaches the buffer's own channel arrays.
2608
+ * @type {Array<Array<number>|Float32Array>} */
2609
+ get sampleChannels(): (number[] | Float32Array)[];
2610
+ /** Move this sound's samples into an audio buffer that every play can share
2611
+ * Does nothing if there is already a buffer or no samples to build one from */
2612
+ buildSampleBuffer(): void;
2595
2613
  /** Play the sound
2596
2614
  * Sounds may not play until a user interaction occurs
2597
2615
  * @param {Vector2} [pos] - World space position to play the sound if any
@@ -2744,6 +2762,25 @@ declare module "littlejsengine" {
2744
2762
  * @return {AudioBufferSourceNode} - The source node of the sound played, may be undefined if play fails
2745
2763
  * @memberof Audio */
2746
2764
  export function playSamples(sampleChannels: any[], volume?: number, rate?: number, pan?: number, loop?: boolean, sampleRate?: number, gainNode?: GainNode, offset?: number, onended?: AudioEndedCallback): AudioBufferSourceNode;
2765
+ /** Play an audio buffer with given settings
2766
+ * The buffer can be shared by any number of sounds playing at once
2767
+ * @param {AudioBuffer} buffer - The audio buffer to play
2768
+ * @param {number} [volume] - How much to scale volume by
2769
+ * @param {number} [rate] - The playback rate to use
2770
+ * @param {number} [pan] - How much to apply stereo panning
2771
+ * @param {boolean} [loop] - True if the sound should loop when it reaches the end
2772
+ * @param {GainNode} [gainNode] - Optional gain node for volume control while playing (disconnected when the sound ends)
2773
+ * @param {number} [offset] - Offset in seconds to start playback from
2774
+ * @param {AudioEndedCallback} [onended] - Callback for when the sound ends
2775
+ * @return {AudioBufferSourceNode} - The source node of the sound played, may be undefined if play fails
2776
+ * @memberof Audio */
2777
+ export function playAudioBuffer(buffer: AudioBuffer, volume?: number, rate?: number, pan?: number, loop?: boolean, gainNode?: GainNode, offset?: number, onended?: AudioEndedCallback): AudioBufferSourceNode;
2778
+ /** Copy arrays of samples into a new audio buffer
2779
+ * @param {Array} sampleChannels - Array of arrays of samples (for stereo playback)
2780
+ * @param {number} [sampleRate=44100] - Sample rate for the sound
2781
+ * @return {AudioBuffer} - The audio buffer holding the samples
2782
+ * @memberof Audio */
2783
+ export function createAudioBuffer(sampleChannels: any[], sampleRate?: number): AudioBuffer;
2747
2784
  /** Generate and play a ZzFX sound
2748
2785
  *
2749
2786
  * <a href=https://killedbyapixel.github.io/ZzFX/>Create sounds using the ZzFX Sound Designer.</a>
@@ -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}
@@ -6974,7 +6974,15 @@ class Sound
6974
6974
  this.randomness = randomness ?? 0;
6975
6975
  /** @property {number} - Sample rate for this sound */
6976
6976
  this.sampleRate = audioDefaultSampleRate;
6977
- /** @property {number} - Percentage of this sound currently loaded */
6977
+ /** @property {number} - How many samples per channel this sound has */
6978
+ this.sampleLength = 0;
6979
+ /** @property {AudioBuffer} - Decoded audio shared by every play of this sound
6980
+ * @type {AudioBuffer} */
6981
+ this.sampleBuffer = undefined;
6982
+ /** @private @type {Array<Array<number>|Float32Array>} */
6983
+ this._sampleChannels = undefined;
6984
+ /** @property {number} - Percentage of this sound currently loaded, sounds
6985
+ * fetched from a url stay at 0 until decoding completes */
6978
6986
  this.loadedPercent = 0;
6979
6987
  /** @property {SoundLoadCallback} - function to call when sound is loaded */
6980
6988
  this.onloadCallback = onloadCallback;
@@ -6990,19 +6998,62 @@ class Sound
6990
6998
  this.randomness = zzfxSound[randomnessIndex] ?? defaultRandomness;
6991
6999
  zzfxSound[randomnessIndex] = 0;
6992
7000
 
6993
- // generate the zzfx samples
7001
+ // generate the zzfx samples, then hand them to an audio buffer so
7002
+ // the plain arrays can be released and every play shares the buffer
6994
7003
  this.sampleChannels = [zzfxG(...zzfxSound)];
7004
+ this.buildSampleBuffer();
6995
7005
  this.loadedPercent = 1;
6996
7006
  onloadCallback?.(this);
6997
7007
  }
6998
7008
  else if (typeof asset === 'string')
6999
7009
  {
7000
- // load the audio file
7010
+ // load the audio file, report failures rather than leaving an
7011
+ // unhandled rejection, the sound just stays unloaded and silent
7001
7012
  const filename = asset;
7002
- this.loadSound(filename);
7013
+ this.loadSound(filename).catch(e=>
7014
+ LOG('Sound load failed for', filename, '-', e.message));
7003
7015
  }
7004
7016
  }
7005
7017
 
7018
+ /** Sample data for each channel
7019
+ * Sounds keep their samples in an audio buffer, so reading this rebuilds
7020
+ * the arrays from it and caches them. The copies are safe to hold onto,
7021
+ * playing a sound detaches the buffer's own channel arrays.
7022
+ * @type {Array<Array<number>|Float32Array>} */
7023
+ get sampleChannels()
7024
+ {
7025
+ const buffer = this.sampleBuffer;
7026
+ if (!this._sampleChannels && buffer)
7027
+ {
7028
+ const channels = [];
7029
+ for (let i = 0; i < buffer.numberOfChannels; i++)
7030
+ channels.push(buffer.getChannelData(i).slice());
7031
+ this._sampleChannels = channels;
7032
+ }
7033
+ return this._sampleChannels;
7034
+ }
7035
+
7036
+ /** @param {Array<Array<number>|Float32Array>} sampleChannels */
7037
+ set sampleChannels(sampleChannels)
7038
+ {
7039
+ // new samples invalidate the buffer built from the old ones
7040
+ this._sampleChannels = sampleChannels;
7041
+ this.sampleBuffer = undefined;
7042
+ this.sampleLength = sampleChannels?.[0]?.length || 0;
7043
+ }
7044
+
7045
+ /** Move this sound's samples into an audio buffer that every play can share
7046
+ * Does nothing if there is already a buffer or no samples to build one from */
7047
+ buildSampleBuffer()
7048
+ {
7049
+ if (this.sampleBuffer || !this._sampleChannels || headlessMode) return;
7050
+
7051
+ this.sampleBuffer = createAudioBuffer(this._sampleChannels, this.sampleRate);
7052
+
7053
+ // the buffer owns the samples now, release the arrays we built it from
7054
+ this._sampleChannels = undefined;
7055
+ }
7056
+
7006
7057
  /** Play the sound
7007
7058
  * Sounds may not play until a user interaction occurs
7008
7059
  * @param {Vector2} [pos] - World space position to play the sound if any
@@ -7021,7 +7072,7 @@ class Sound
7021
7072
  ASSERT(isNumber(randomnessScale), 'randomnessScale must be a number');
7022
7073
 
7023
7074
  if (!soundEnable || headlessMode) return;
7024
- if (!this.sampleChannels) return;
7075
+ if (!this.sampleBuffer && !this._sampleChannels) return;
7025
7076
 
7026
7077
  let pan;
7027
7078
  if (pos)
@@ -7088,7 +7139,7 @@ class Sound
7088
7139
  * @return {number} - How long the sound is in seconds (0 if loading)
7089
7140
  */
7090
7141
  getDuration()
7091
- { return this.sampleChannels?.[0]?.length / this.sampleRate || 0; }
7142
+ { return this.sampleLength / this.sampleRate || 0; }
7092
7143
 
7093
7144
  /** Check if sound is loaded, for sounds fetched from a url
7094
7145
  * @return {boolean} - True if sound is loaded and ready to play
@@ -7106,36 +7157,11 @@ class Sound
7106
7157
  const arrayBuffer = await response.arrayBuffer();
7107
7158
  const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
7108
7159
 
7109
- // convert audio buffer to sample channels across multiple frames
7110
- const channelCount = audioBuffer.numberOfChannels;
7111
- const samplesPerFrame = 1e5;
7112
- const sampleChannels = [];
7113
- for (let channel = 0; channel < channelCount; channel++)
7114
- {
7115
- const channelData = audioBuffer.getChannelData(channel);
7116
- const channelLength = channelData.length;
7117
- sampleChannels[channel] = new Array(channelLength);
7118
- let sampleIndex = 0;
7119
- while (sampleIndex < channelLength)
7120
- {
7121
- // yield to next frame
7122
- await new Promise(resolve => setTimeout(resolve, 0));
7123
-
7124
- // copy chunk of samples
7125
- const endIndex = min(sampleIndex + samplesPerFrame, channelLength);
7126
- for (; sampleIndex < endIndex; sampleIndex++)
7127
- sampleChannels[channel][sampleIndex] = channelData[sampleIndex];
7128
-
7129
- // update loaded percent
7130
- const samplesTotal = channelCount * channelLength;
7131
- const samplesProcessed = channel * channelLength + sampleIndex;
7132
- this.loadedPercent = samplesProcessed / samplesTotal;
7133
- }
7134
- }
7135
-
7136
- // setup the sound to be played
7160
+ // keep the decoded buffer as is, it is exactly what playback needs and
7161
+ // every play shares it, no channel data is read or copied
7137
7162
  this.sampleRate = audioBuffer.sampleRate;
7138
- this.sampleChannels = sampleChannels;
7163
+ this.sampleLength = audioBuffer.length;
7164
+ this.sampleBuffer = audioBuffer;
7139
7165
  this.loadedPercent = 1;
7140
7166
  this.onloadCallback?.(this);
7141
7167
  }
@@ -7211,7 +7237,12 @@ class SoundInstance
7211
7237
  if (this.isPlaying())
7212
7238
  this.stop();
7213
7239
  this.gainNode = audioContext.createGain();
7214
- this.source = playSamples(this.sound.sampleChannels, this.volume, this.rate, this.pan, this.loop, this.sound.sampleRate, this.gainNode, offset, this.onendedCallback);
7240
+
7241
+ // build the shared buffer if it was not made at load time, then play it
7242
+ this.sound.buildSampleBuffer();
7243
+ this.source = this.sound.sampleBuffer ?
7244
+ playAudioBuffer(this.sound.sampleBuffer, this.volume, this.rate, this.pan, this.loop, this.gainNode, offset, this.onendedCallback) :
7245
+ playSamples(this.sound.sampleChannels, this.volume, this.rate, this.pan, this.loop, this.sound.sampleRate, this.gainNode, offset, this.onendedCallback);
7215
7246
  if (this.source)
7216
7247
  {
7217
7248
  this.startTime = audioContext.currentTime - offset;
@@ -7337,7 +7368,7 @@ function speak(text, volume=1, rate=1, pitch=1, language='')
7337
7368
  // build utterance and speak
7338
7369
  const utterance = new SpeechSynthesisUtterance(text);
7339
7370
  utterance.lang = language;
7340
- utterance.volume = 2*volume*soundVolume;
7371
+ utterance.volume = volume*soundVolume;
7341
7372
  utterance.rate = rate;
7342
7373
  utterance.pitch = pitch;
7343
7374
  speechSynthesis.speak(utterance);
@@ -7386,19 +7417,54 @@ function playSamples(sampleChannels, volume=1, rate=1, pan=0, loop=false, sample
7386
7417
 
7387
7418
  if (!audioIsRunning())
7388
7419
  {
7389
- // fix stalled audio, this sound won't be able to play
7420
+ // fix stalled audio, don't build a buffer that can't be played
7390
7421
  audioContext.resume();
7391
7422
  return;
7392
7423
  }
7393
7424
 
7394
- // create buffer and source
7425
+ const buffer = createAudioBuffer(sampleChannels, sampleRate);
7426
+ return playAudioBuffer(buffer, volume, rate, pan, loop, gainNode, offset, onended);
7427
+ }
7428
+
7429
+ /** Copy arrays of samples into a new audio buffer
7430
+ * @param {Array} sampleChannels - Array of arrays of samples (for stereo playback)
7431
+ * @param {number} [sampleRate=44100] - Sample rate for the sound
7432
+ * @return {AudioBuffer} - The audio buffer holding the samples
7433
+ * @memberof Audio */
7434
+ function createAudioBuffer(sampleChannels, sampleRate=audioDefaultSampleRate)
7435
+ {
7395
7436
  const channelCount = sampleChannels.length;
7396
7437
  const sampleLength = sampleChannels[0].length;
7397
7438
  const buffer = audioContext.createBuffer(channelCount, sampleLength, sampleRate);
7398
- const source = audioContext.createBufferSource();
7399
-
7400
- // copy samples to buffer and setup source
7401
7439
  sampleChannels.forEach((c,i)=> buffer.getChannelData(i).set(c));
7440
+ return buffer;
7441
+ }
7442
+
7443
+ /** Play an audio buffer with given settings
7444
+ * The buffer can be shared by any number of sounds playing at once
7445
+ * @param {AudioBuffer} buffer - The audio buffer to play
7446
+ * @param {number} [volume] - How much to scale volume by
7447
+ * @param {number} [rate] - The playback rate to use
7448
+ * @param {number} [pan] - How much to apply stereo panning
7449
+ * @param {boolean} [loop] - True if the sound should loop when it reaches the end
7450
+ * @param {GainNode} [gainNode] - Optional gain node for volume control while playing (disconnected when the sound ends)
7451
+ * @param {number} [offset] - Offset in seconds to start playback from
7452
+ * @param {AudioEndedCallback} [onended] - Callback for when the sound ends
7453
+ * @return {AudioBufferSourceNode} - The source node of the sound played, may be undefined if play fails
7454
+ * @memberof Audio */
7455
+ function playAudioBuffer(buffer, volume=1, rate=1, pan=0, loop=false, gainNode, offset=0, onended)
7456
+ {
7457
+ if (!soundEnable || headlessMode) return;
7458
+
7459
+ if (!audioIsRunning())
7460
+ {
7461
+ // fix stalled audio, this sound won't be able to play
7462
+ audioContext.resume();
7463
+ return;
7464
+ }
7465
+
7466
+ // setup source, many sources can share one buffer
7467
+ const source = audioContext.createBufferSource();
7402
7468
  source.buffer = buffer;
7403
7469
  source.playbackRate.value = rate;
7404
7470
  source.loop = loop;
@@ -17339,6 +17405,8 @@ export
17339
17405
  speakStop,
17340
17406
  getNoteFrequency,
17341
17407
  playSamples,
17408
+ playAudioBuffer,
17409
+ createAudioBuffer,
17342
17410
  zzfx,
17343
17411
  zzfxG,
17344
17412