mirror of
https://github.com/scratchfoundation/scratch-audio.git
synced 2025-01-09 06:12:20 -05:00
commit
2a63b0b027
5 changed files with 354 additions and 33 deletions
|
@ -5,9 +5,9 @@ const log = require('./log');
|
||||||
const uid = require('./uid');
|
const uid = require('./uid');
|
||||||
|
|
||||||
const ADPCMSoundDecoder = require('./ADPCMSoundDecoder');
|
const ADPCMSoundDecoder = require('./ADPCMSoundDecoder');
|
||||||
|
|
||||||
const AudioPlayer = require('./AudioPlayer');
|
const AudioPlayer = require('./AudioPlayer');
|
||||||
const Loudness = require('./Loudness');
|
const Loudness = require('./Loudness');
|
||||||
|
const SoundPlayer = require('./GreenPlayer');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrapper to ensure that audioContext.decodeAudioData is a promise
|
* Wrapper to ensure that audioContext.decodeAudioData is a promise
|
||||||
|
@ -77,13 +77,20 @@ class AudioEngine {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A short duration, for use as a time constant for exponential audio parameter transitions.
|
* A short duration to transition audio prarameters.
|
||||||
* See:
|
*
|
||||||
* https://developer.mozilla.org/en-US/docs/Web/API/AudioParam/setTargetAtTime
|
* 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}
|
||||||
* @const {number}
|
* @const {number}
|
||||||
*/
|
*/
|
||||||
get DECAY_TIME () {
|
get DECAY_TIME () {
|
||||||
return 0.001;
|
return 0.025;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -96,40 +103,77 @@ class AudioEngine {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decode a sound, decompressing it into audio samples.
|
* 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
|
||||||
* @param {object} sound - an object containing audio data and metadata for a sound
|
* a sound
|
||||||
* @property {Buffer} data - sound data loaded from scratch-storage.
|
* @param {Buffer} sound.data - sound data loaded from scratch-storage
|
||||||
* @returns {?Promise} - a promise which will resolve to the soundId if decoded and stored.
|
* @returns {?Promise} - a promise which will resolve to the sound id and
|
||||||
|
* buffer if decoded
|
||||||
*/
|
*/
|
||||||
decodeSound (sound) {
|
_decodeSound (sound) {
|
||||||
// Make a copy of the buffer because decoding detaches the original buffer
|
// Make a copy of the buffer because decoding detaches the original
|
||||||
|
// buffer
|
||||||
const bufferCopy1 = sound.data.buffer.slice(0);
|
const bufferCopy1 = sound.data.buffer.slice(0);
|
||||||
|
|
||||||
|
// 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.
|
||||||
const soundId = uid();
|
const soundId = uid();
|
||||||
// Partially apply updateSoundBuffer function with the current
|
|
||||||
// soundId so that it's ready to be used on successfully decoded audio
|
|
||||||
const addDecodedAudio = this.updateSoundBuffer.bind(this, soundId);
|
|
||||||
|
|
||||||
// Attempt to decode the sound using the browser's native audio data decoder
|
// Attempt to decode the sound using the browser's native audio data
|
||||||
// If that fails, attempt to decode as ADPCM
|
// decoder If that fails, attempt to decode as ADPCM
|
||||||
return decodeAudioData(this.audioContext, bufferCopy1).then(
|
const decoding = decodeAudioData(this.audioContext, bufferCopy1)
|
||||||
addDecodedAudio,
|
.catch(() => {
|
||||||
() => {
|
|
||||||
// The audio context failed to parse the sound data
|
// The audio context failed to parse the sound data
|
||||||
// we gave it, so try to decode as 'adpcm'
|
// we gave it, so try to decode as 'adpcm'
|
||||||
|
|
||||||
// First we need to create another copy of our original data
|
// First we need to create another copy of our original data
|
||||||
const bufferCopy2 = sound.data.buffer.slice(0);
|
const bufferCopy2 = sound.data.buffer.slice(0);
|
||||||
// Try decoding as adpcm
|
// Try decoding as adpcm
|
||||||
return (new ADPCMSoundDecoder(this.audioContext)).decode(bufferCopy2)
|
return new ADPCMSoundDecoder(this.audioContext).decode(bufferCopy2);
|
||||||
|
})
|
||||||
.then(
|
.then(
|
||||||
addDecodedAudio,
|
buffer => ([soundId, buffer]),
|
||||||
error => {
|
error => {
|
||||||
log.warn('audio data could not be decoded', 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}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -138,6 +182,11 @@ class AudioEngine {
|
||||||
* @return {AudioBuffer} the buffer corresponding to the given sound id.
|
* @return {AudioBuffer} the buffer corresponding to the given sound id.
|
||||||
*/
|
*/
|
||||||
getSoundBuffer (soundId) {
|
getSoundBuffer (soundId) {
|
||||||
|
// 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.
|
||||||
return this.audioBuffers[soundId];
|
return this.audioBuffers[soundId];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
258
src/GreenPlayer.js
Normal file
258
src/GreenPlayer.js
Normal file
|
@ -0,0 +1,258 @@
|
||||||
|
const {EventEmitter} = require('events');
|
||||||
|
|
||||||
|
const VolumeEffect = require('./effects/VolumeEffect');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Name of event that indicates playback has ended.
|
||||||
|
* @const {string}
|
||||||
|
*/
|
||||||
|
const ON_ENDED = 'ended';
|
||||||
|
|
||||||
|
class SoundPlayer extends EventEmitter {
|
||||||
|
/**
|
||||||
|
* Play sounds that stop without audible clipping.
|
||||||
|
*
|
||||||
|
* @param {AudioEngine} audioEngine - engine to play sounds on
|
||||||
|
* @param {object} data - required data for sound playback
|
||||||
|
* @param {string} data.id - a unique id for this sound
|
||||||
|
* @param {ArrayBuffer} data.buffer - buffer of the sound's waveform to play
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
constructor (audioEngine, {id, buffer}) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.id = id;
|
||||||
|
|
||||||
|
this.audioEngine = audioEngine;
|
||||||
|
this.buffer = buffer;
|
||||||
|
|
||||||
|
this.outputNode = null;
|
||||||
|
this.target = null;
|
||||||
|
|
||||||
|
this.initialized = false;
|
||||||
|
this.isPlaying = false;
|
||||||
|
this.playbackRate = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle any event we have told the output node to listen for.
|
||||||
|
* @param {Event} event - dom event to handle
|
||||||
|
*/
|
||||||
|
handleEvent (event) {
|
||||||
|
if (event.type === ON_ENDED) {
|
||||||
|
this.onEnded();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event listener for when playback ends.
|
||||||
|
*/
|
||||||
|
onEnded () {
|
||||||
|
this.emit('stop');
|
||||||
|
|
||||||
|
this.isPlaying = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the buffer source node during initialization or secondary
|
||||||
|
* playback.
|
||||||
|
*/
|
||||||
|
_createSource () {
|
||||||
|
if (this.outputNode !== null) {
|
||||||
|
this.outputNode.removeEventListener(ON_ENDED, this);
|
||||||
|
this.outputNode.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.outputNode = this.audioEngine.audioContext.createBufferSource();
|
||||||
|
this.outputNode.playbackRate.value = this.playbackRate;
|
||||||
|
this.outputNode.buffer = this.buffer;
|
||||||
|
|
||||||
|
this.outputNode.addEventListener(ON_ENDED, this);
|
||||||
|
|
||||||
|
if (this.target !== null) {
|
||||||
|
this.connect(this.target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the player for first playback.
|
||||||
|
*/
|
||||||
|
initialize () {
|
||||||
|
this.initialized = true;
|
||||||
|
|
||||||
|
this.volumeEffect = new VolumeEffect(this.audioEngine, this, null);
|
||||||
|
|
||||||
|
this._createSource();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect the player to the engine or an effect chain.
|
||||||
|
* @param {object} target - object to connect to
|
||||||
|
* @returns {object} - return this sound player
|
||||||
|
*/
|
||||||
|
connect (target) {
|
||||||
|
if (target === this.volumeEffect) {
|
||||||
|
this.outputNode.disconnect();
|
||||||
|
this.outputNode.connect(this.volumeEffect.getInputNode());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.target = target;
|
||||||
|
|
||||||
|
if (!this.initialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.volumeEffect.connect(target);
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Teardown the player.
|
||||||
|
*/
|
||||||
|
dispose () {
|
||||||
|
if (!this.initialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.stopImmediately();
|
||||||
|
|
||||||
|
this.volumeEffect.dispose();
|
||||||
|
this.volumeEffect = null;
|
||||||
|
|
||||||
|
this.outputNode.disconnect();
|
||||||
|
this.outputNode = null;
|
||||||
|
|
||||||
|
this.target = null;
|
||||||
|
|
||||||
|
this.initialized = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take the internal state of this player and create a new player from
|
||||||
|
* that. Restore the state of this player to that before its first playback.
|
||||||
|
*
|
||||||
|
* The returned player can be used to stop the original playback or
|
||||||
|
* continue it without manipulation from the original player.
|
||||||
|
*
|
||||||
|
* @returns {SoundPlayer} - new SoundPlayer with old state
|
||||||
|
*/
|
||||||
|
take () {
|
||||||
|
if (this.outputNode) {
|
||||||
|
this.outputNode.removeEventListener(ON_ENDED, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
const taken = new SoundPlayer(this.audioEngine, this);
|
||||||
|
taken.playbackRate = this.playbackRate;
|
||||||
|
if (this.isPlaying) {
|
||||||
|
taken.isPlaying = this.isPlaying;
|
||||||
|
taken.initialize();
|
||||||
|
taken.outputNode.disconnect();
|
||||||
|
taken.outputNode = this.outputNode;
|
||||||
|
taken.outputNode.addEventListener(ON_ENDED, taken);
|
||||||
|
taken.volumeEffect.set(this.volumeEffect.value);
|
||||||
|
if (this.target !== null) {
|
||||||
|
taken.connect(this.target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isPlaying) {
|
||||||
|
this.emit('stop');
|
||||||
|
taken.emit('play');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.outputNode = null;
|
||||||
|
if (this.volumeEffect !== null) {
|
||||||
|
this.volumeEffect.dispose();
|
||||||
|
}
|
||||||
|
this.volumeEffect = null;
|
||||||
|
this.initialized = false;
|
||||||
|
this.isPlaying = false;
|
||||||
|
|
||||||
|
return taken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start playback for this sound.
|
||||||
|
*
|
||||||
|
* If the sound is already playing it will stop playback with a quick fade
|
||||||
|
* out.
|
||||||
|
*/
|
||||||
|
play () {
|
||||||
|
if (this.isPlaying) {
|
||||||
|
// Spawn a Player with the current buffer source, and play for a
|
||||||
|
// short period until its volume is 0 and release it to be
|
||||||
|
// eventually garbage collected.
|
||||||
|
this.take().stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.initialized) {
|
||||||
|
this.initialize();
|
||||||
|
} else {
|
||||||
|
this._createSource();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.volumeEffect.set(this.volumeEffect.DEFAULT_VALUE);
|
||||||
|
this.outputNode.start();
|
||||||
|
|
||||||
|
this.isPlaying = true;
|
||||||
|
|
||||||
|
this.emit('play');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop playback after quickly fading out.
|
||||||
|
*/
|
||||||
|
stop () {
|
||||||
|
if (!this.isPlaying) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.volumeEffect.set(0);
|
||||||
|
this.outputNode.stop(this.audioEngine.audioContext.currentTime + this.audioEngine.DECAY_TIME);
|
||||||
|
|
||||||
|
this.isPlaying = false;
|
||||||
|
|
||||||
|
this.emit('stop');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop immediately without fading out. May cause audible clipping.
|
||||||
|
*/
|
||||||
|
stopImmediately () {
|
||||||
|
if (!this.isPlaying) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.outputNode.stop();
|
||||||
|
|
||||||
|
this.isPlaying = false;
|
||||||
|
|
||||||
|
this.emit('stop');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a promise that resolves when the sound next finishes.
|
||||||
|
* @returns {Promise} - resolves when the sound finishes
|
||||||
|
*/
|
||||||
|
finished () {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
this.once('stop', resolve);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the sound's playback rate.
|
||||||
|
* @param {number} value - playback rate. Default is 1.
|
||||||
|
*/
|
||||||
|
setPlaybackRate (value) {
|
||||||
|
this.playbackRate = value;
|
||||||
|
|
||||||
|
if (this.initialized) {
|
||||||
|
this.outputNode.playbackRate.value = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = SoundPlayer;
|
|
@ -38,7 +38,7 @@ class Effect {
|
||||||
* @return {boolean} is the effect affecting the graph?
|
* @return {boolean} is the effect affecting the graph?
|
||||||
*/
|
*/
|
||||||
get _isPatch () {
|
get _isPatch () {
|
||||||
return this.initialized && this.value !== this.DEFAULT_VALUE;
|
return this.initialized && (this.value !== this.DEFAULT_VALUE || this.audioPlayer === null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -138,7 +138,9 @@ class Effect {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.lastEffect === null) {
|
if (this.lastEffect === null) {
|
||||||
|
if (this.audioPlayer !== null) {
|
||||||
this.audioPlayer.connect(this);
|
this.audioPlayer.connect(this);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
this.lastEffect.connect(this);
|
this.lastEffect.connect(this);
|
||||||
}
|
}
|
||||||
|
|
|
@ -58,8 +58,16 @@ class PanEffect extends Effect {
|
||||||
const leftVal = Math.cos(p * Math.PI / 2);
|
const leftVal = Math.cos(p * Math.PI / 2);
|
||||||
const rightVal = Math.sin(p * Math.PI / 2);
|
const rightVal = Math.sin(p * Math.PI / 2);
|
||||||
|
|
||||||
this.leftGain.gain.setTargetAtTime(leftVal, 0, this.audioEngine.DECAY_TIME);
|
this.leftGain.gain.setTargetAtTime(
|
||||||
this.rightGain.gain.setTargetAtTime(rightVal, 0, this.audioEngine.DECAY_TIME);
|
leftVal,
|
||||||
|
this.audioEngine.audioContext.currentTime,
|
||||||
|
this.audioEngine.DECAY_TIME
|
||||||
|
);
|
||||||
|
this.rightGain.gain.setTargetAtTime(
|
||||||
|
rightVal,
|
||||||
|
this.audioEngine.audioContext.currentTime,
|
||||||
|
this.audioEngine.DECAY_TIME
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -34,7 +34,11 @@ class VolumeEffect extends Effect {
|
||||||
this.value = value;
|
this.value = value;
|
||||||
// A gain of 1 is normal. Scale down scratch's volume value. Apply the
|
// A gain of 1 is normal. Scale down scratch's volume value. Apply the
|
||||||
// change over a tiny period of time.
|
// change over a tiny period of time.
|
||||||
this.outputNode.gain.setTargetAtTime(value / 100, 0, this.audioEngine.DECAY_TIME);
|
this.outputNode.gain.setTargetAtTime(
|
||||||
|
value / 100,
|
||||||
|
this.audioEngine.audioContext.currentTime,
|
||||||
|
this.audioEngine.DECAY_TIME
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
Loading…
Reference in a new issue