2018-06-19 13:37:00 -04:00
|
|
|
const StartAudioContext = require('./StartAudioContext');
|
2018-06-04 18:11:44 -04:00
|
|
|
const AudioContext = require('audio-context');
|
|
|
|
|
|
|
|
const log = require('./log');
|
|
|
|
const uid = require('./uid');
|
|
|
|
|
|
|
|
const ADPCMSoundDecoder = require('./ADPCMSoundDecoder');
|
|
|
|
const AudioPlayer = require('./AudioPlayer');
|
|
|
|
const Loudness = require('./Loudness');
|
2018-06-11 15:26:45 -04:00
|
|
|
const SoundPlayer = require('./GreenPlayer');
|
2018-06-04 18:11:44 -04:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Wrapper to ensure that audioContext.decodeAudioData is a promise
|
|
|
|
* @param {object} audioContext The current AudioContext
|
|
|
|
* @param {ArrayBuffer} buffer Audio data buffer to decode
|
|
|
|
* @return {Promise} A promise that resolves to the decoded audio
|
|
|
|
*/
|
|
|
|
const decodeAudioData = function (audioContext, buffer) {
|
|
|
|
// Check for newer promise-based API
|
|
|
|
if (audioContext.decodeAudioData.length === 1) {
|
|
|
|
return audioContext.decodeAudioData(buffer);
|
|
|
|
}
|
|
|
|
// Fall back to callback API
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
audioContext.decodeAudioData(buffer,
|
|
|
|
decodedAudio => resolve(decodedAudio),
|
|
|
|
error => reject(error)
|
|
|
|
);
|
|
|
|
});
|
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* There is a single instance of the AudioEngine. It handles global audio
|
|
|
|
* properties and effects, loads all the audio buffers for sounds belonging to
|
|
|
|
* sprites.
|
|
|
|
*/
|
|
|
|
class AudioEngine {
|
2018-06-19 13:37:00 -04:00
|
|
|
constructor (audioContext = new AudioContext()) {
|
2018-06-04 18:11:44 -04:00
|
|
|
/**
|
|
|
|
* AudioContext to play and manipulate sounds with a graph of source
|
|
|
|
* and effect nodes.
|
|
|
|
* @type {AudioContext}
|
|
|
|
*/
|
2018-06-19 13:37:00 -04:00
|
|
|
this.audioContext = audioContext;
|
2018-06-04 18:11:44 -04:00
|
|
|
StartAudioContext(this.audioContext);
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Master GainNode that all sounds plays through. Changing this node
|
|
|
|
* will change the volume for all sounds.
|
|
|
|
* @type {GainNode}
|
|
|
|
*/
|
2018-06-05 10:57:27 -04:00
|
|
|
this.inputNode = this.audioContext.createGain();
|
|
|
|
this.inputNode.connect(this.audioContext.destination);
|
2018-06-04 18:11:44 -04:00
|
|
|
|
|
|
|
/**
|
|
|
|
* a map of soundIds to audio buffers, holding sounds for all sprites
|
|
|
|
* @type {Object<String, ArrayBuffer>}
|
|
|
|
*/
|
|
|
|
this.audioBuffers = {};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* A Loudness detector.
|
|
|
|
* @type {Loudness}
|
|
|
|
*/
|
|
|
|
this.loudness = null;
|
|
|
|
}
|
2018-06-19 13:37:00 -04:00
|
|
|
|
|
|
|
get currentTime () {
|
|
|
|
return this.audioContext.currentTime;
|
|
|
|
}
|
2018-06-04 18:11:44 -04:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Names of the audio effects.
|
|
|
|
* @enum {string}
|
|
|
|
*/
|
|
|
|
get EFFECT_NAMES () {
|
|
|
|
return {
|
|
|
|
pitch: 'pitch',
|
|
|
|
pan: 'pan'
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2018-06-11 15:23:12 -04:00
|
|
|
* A short duration to transition audio prarameters.
|
|
|
|
*
|
|
|
|
* Used as a time constant for exponential transitions. A general value
|
|
|
|
* must be large enough that it does not cute off lower frequency, or bass,
|
|
|
|
* sounds. Human hearing lower limit is ~20Hz making a safe value 25
|
|
|
|
* milliseconds or 0.025 seconds, where half of a 20Hz wave will play along
|
|
|
|
* with the DECAY. Higher frequencies will play multiple waves during the
|
|
|
|
* same amount of time and avoid clipping.
|
|
|
|
*
|
|
|
|
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/AudioParam/setTargetAtTime}
|
2018-06-04 18:11:44 -04:00
|
|
|
* @const {number}
|
|
|
|
*/
|
|
|
|
get DECAY_TIME () {
|
2018-06-11 15:23:12 -04:00
|
|
|
return 0.025;
|
2018-06-04 18:11:44 -04:00
|
|
|
}
|
|
|
|
|
2018-06-05 13:59:53 -04:00
|
|
|
/**
|
|
|
|
* Get the input node.
|
|
|
|
* @return {AudioNode} - audio node that is the input for this effect
|
|
|
|
*/
|
|
|
|
getInputNode () {
|
|
|
|
return this.inputNode;
|
|
|
|
}
|
|
|
|
|
2018-06-04 18:11:44 -04:00
|
|
|
/**
|
|
|
|
* Decode a sound, decompressing it into audio samples.
|
2018-06-11 15:26:45 -04:00
|
|
|
* @param {object} sound - an object containing audio data and metadata for
|
|
|
|
* a sound
|
|
|
|
* @param {Buffer} sound.data - sound data loaded from scratch-storage
|
|
|
|
* @returns {?Promise} - a promise which will resolve to the sound id and
|
|
|
|
* buffer if decoded
|
2018-06-04 18:11:44 -04:00
|
|
|
*/
|
2018-06-11 15:26:45 -04:00
|
|
|
_decodeSound (sound) {
|
|
|
|
// Make a copy of the buffer because decoding detaches the original
|
|
|
|
// buffer
|
2018-06-04 18:11:44 -04:00
|
|
|
const bufferCopy1 = sound.data.buffer.slice(0);
|
|
|
|
|
2018-06-11 15:26:45 -04:00
|
|
|
// todo: multiple decodings of the same buffer create duplicate decoded
|
|
|
|
// copies in audioBuffers. Create a hash id of the buffer or deprecate
|
|
|
|
// audioBuffers to avoid memory issues for large audio buffers.
|
2018-06-04 18:11:44 -04:00
|
|
|
const soundId = uid();
|
2018-06-11 15:26:45 -04:00
|
|
|
|
|
|
|
// Attempt to decode the sound using the browser's native audio data
|
|
|
|
// decoder If that fails, attempt to decode as ADPCM
|
|
|
|
const decoding = decodeAudioData(this.audioContext, bufferCopy1)
|
|
|
|
.catch(() => {
|
2018-06-04 18:11:44 -04:00
|
|
|
// The audio context failed to parse the sound data
|
|
|
|
// we gave it, so try to decode as 'adpcm'
|
|
|
|
|
|
|
|
// First we need to create another copy of our original data
|
|
|
|
const bufferCopy2 = sound.data.buffer.slice(0);
|
|
|
|
// Try decoding as adpcm
|
2018-06-11 15:26:45 -04:00
|
|
|
return new ADPCMSoundDecoder(this.audioContext).decode(bufferCopy2);
|
|
|
|
})
|
|
|
|
.then(
|
|
|
|
buffer => ([soundId, buffer]),
|
|
|
|
error => {
|
|
|
|
log.warn('audio data could not be decoded', error);
|
|
|
|
}
|
|
|
|
);
|
|
|
|
|
|
|
|
return decoding;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Decode a sound, decompressing it into audio samples.
|
|
|
|
*
|
|
|
|
* Store a reference to it the sound in the audioBuffers dictionary,
|
|
|
|
* indexed by soundId.
|
|
|
|
*
|
|
|
|
* @param {object} sound - an object containing audio data and metadata for
|
|
|
|
* a sound
|
|
|
|
* @param {Buffer} sound.data - sound data loaded from scratch-storage
|
|
|
|
* @returns {?Promise} - a promise which will resolve to the sound id
|
|
|
|
*/
|
|
|
|
decodeSound (sound) {
|
|
|
|
return this._decodeSound(sound)
|
|
|
|
.then(([id, buffer]) => {
|
|
|
|
this.audioBuffers[id] = buffer;
|
|
|
|
return id;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Decode a sound, decompressing it into audio samples.
|
|
|
|
*
|
|
|
|
* Create a SoundPlayer instance that can be used to play the sound and
|
|
|
|
* stop and fade out playback.
|
|
|
|
*
|
|
|
|
* @param {object} sound - an object containing audio data and metadata for
|
|
|
|
* a sound
|
|
|
|
* @param {Buffer} sound.data - sound data loaded from scratch-storage
|
|
|
|
* @returns {?Promise} - a promise which will resolve to the buffer
|
|
|
|
*/
|
|
|
|
decodeSoundPlayer (sound) {
|
|
|
|
return this._decodeSound(sound)
|
|
|
|
.then(([id, buffer]) => new SoundPlayer(this, {id, buffer}));
|
2018-06-04 18:11:44 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Retrieve the audio buffer as held in memory for a given sound id.
|
|
|
|
* @param {!string} soundId - the id of the sound buffer to get
|
|
|
|
* @return {AudioBuffer} the buffer corresponding to the given sound id.
|
|
|
|
*/
|
|
|
|
getSoundBuffer (soundId) {
|
2018-06-11 15:26:45 -04:00
|
|
|
// todo: Deprecate audioBuffers. If something wants to hold onto the
|
|
|
|
// buffer, it should. Otherwise buffers need to be able to release their
|
|
|
|
// decoded memory to avoid running out of memory which is possible with
|
|
|
|
// enough large audio buffers as they are full 16bit pcm waveforms for
|
|
|
|
// each audio channel.
|
2018-06-04 18:11:44 -04:00
|
|
|
return this.audioBuffers[soundId];
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Add or update the in-memory audio buffer to a new one by soundId.
|
|
|
|
* @param {!string} soundId - the id of the sound buffer to update.
|
|
|
|
* @param {AudioBuffer} newBuffer - the new buffer to swap in.
|
|
|
|
* @return {string} The uid of the sound that was updated or added
|
|
|
|
*/
|
|
|
|
updateSoundBuffer (soundId, newBuffer) {
|
|
|
|
this.audioBuffers[soundId] = newBuffer;
|
|
|
|
return soundId;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* An older version of the AudioEngine had this function to load all sounds
|
|
|
|
* This is a stub to provide a warning when it is called
|
|
|
|
* @todo remove this
|
|
|
|
*/
|
|
|
|
loadSounds () {
|
|
|
|
log.warn('The loadSounds function is no longer available. Please use Scratch Storage.');
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Get the current loudness of sound received by the microphone.
|
|
|
|
* Sound is measured in RMS and smoothed.
|
|
|
|
* @return {number} loudness scaled 0 to 100
|
|
|
|
*/
|
|
|
|
getLoudness () {
|
|
|
|
// The microphone has not been set up, so try to connect to it
|
|
|
|
if (!this.loudness) {
|
|
|
|
this.loudness = new Loudness(this.audioContext);
|
|
|
|
}
|
|
|
|
|
|
|
|
return this.loudness.getLoudness();
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Create an AudioPlayer. Each sprite or clone has an AudioPlayer.
|
|
|
|
* It includes a reference to the AudioEngine so it can use global
|
|
|
|
* functionality such as playing notes.
|
|
|
|
* @return {AudioPlayer} new AudioPlayer instance
|
|
|
|
*/
|
|
|
|
createPlayer () {
|
|
|
|
return new AudioPlayer(this);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
module.exports = AudioEngine;
|