Merge pull request #14 from LLK/feature/refactor

Refactor sound engine (wip)
This commit is contained in:
Eric Rosenbaum 2017-02-02 17:42:29 -05:00 committed by GitHub
commit 83c4072e59
13 changed files with 552 additions and 254 deletions

View file

@ -1,19 +1,24 @@
/*
ADPCMSoundLoader loads wav files that have been compressed with the ADPCM format
based on code from Scratch-Flash:
https://github.com/LLK/scratch-flash/blob/master/src/sound/WAVFile.as
*/
var ArrayBufferStream = require('./ArrayBufferStream');
var Tone = require('tone');
var log = require('./log');
/**
* Load wav audio files that have been compressed with the ADPCM format.
* This is necessary because, while web browsers have native decoders for many audio
* formats, ADPCM is a non-standard format used by Scratch since its early days.
* This decoder is based on code from Scratch-Flash:
* https://github.com/LLK/scratch-flash/blob/master/src/sound/WAVFile.as
* @constructor
*/
function ADPCMSoundLoader () {
}
/**
* Load an ADPCM sound file from a URL, decode it, and return a promise
* with the audio buffer.
* @param {string} url - a url pointing to the ADPCM wav file
* @return {Tone.Buffer}
*/
ADPCMSoundLoader.prototype.load = function (url) {
return new Promise(function (resolve, reject) {
@ -56,7 +61,7 @@ ADPCMSoundLoader.prototype.load = function (url) {
var samples = this.imaDecompress(this.extractChunk('data', stream), this.adpcmBlockSize);
// this line is the only place Tone is used here, should be possible to remove
// todo: this line is the only place Tone is used here, should be possible to remove
var buffer = Tone.context.createBuffer(1, samples.length, this.samplesPerSecond);
// todo: optimize this? e.g. replace the divide by storing 1/32768 and multiply?
@ -73,7 +78,10 @@ ADPCMSoundLoader.prototype.load = function (url) {
}.bind(this));
};
/**
* Data used by the decompression algorithm
* @type {Array}
*/
ADPCMSoundLoader.prototype.stepTable = [
7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 19, 21, 23, 25, 28, 31, 34, 37, 41, 45,
50, 55, 60, 66, 73, 80, 88, 97, 107, 118, 130, 143, 157, 173, 190, 209, 230,
@ -82,10 +90,20 @@ ADPCMSoundLoader.prototype.stepTable = [
3660, 4026, 4428, 4871, 5358, 5894, 6484, 7132, 7845, 8630, 9493, 10442, 11487,
12635, 13899, 15289, 16818, 18500, 20350, 22385, 24623, 27086, 29794, 32767];
/**
* Data used by the decompression algorithm
* @type {Array}
*/
ADPCMSoundLoader.prototype.indexTable = [
-1, -1, -1, -1, 2, 4, 6, 8,
-1, -1, -1, -1, 2, 4, 6, 8];
/**
* Extract a chunk of audio data from the stream, consisting of a set of audio data bytes
* @param {string} chunkType - the type of chunk to extract. 'data' or 'fmt' (format)
* @param {ArrayBufferStream} stream - an stream containing the audio data
* @return {ArrayBufferStream} a stream containing the desired chunk
*/
ADPCMSoundLoader.prototype.extractChunk = function (chunkType, stream) {
stream.position = 12;
while (stream.position < (stream.getLength() - 8)) {
@ -100,9 +118,14 @@ ADPCMSoundLoader.prototype.extractChunk = function (chunkType, stream) {
}
};
/**
* Decompress sample data using the IMA ADPCM algorithm.
* Note: Handles only one channel, 4-bits per sample.
* @param {ArrayBufferStream} compressedData - a stream of compressed audio samples
* @param {number} blockSize - the number of bytes in the stream
* @return {Int16Array} the uncompressed audio samples
*/
ADPCMSoundLoader.prototype.imaDecompress = function (compressedData, blockSize) {
// Decompress sample data using the IMA ADPCM algorithm.
// Note: Handles only one channel, 4-bits/sample.
var sample, step, code, delta;
var index = 0;
var lastByte = -1; // -1 indicates that there is no saved lastByte

View file

@ -1,39 +1,59 @@
/*
ArrayBufferStream wraps the built-in javascript ArrayBuffer, adding the ability to access
data in it like a stream. You can request to read a value from the front of the array,
such as an 8 bit unsigned int, a 16 bit int, etc, and it will keep track of the position
within the byte array, so that successive reads are consecutive.
*/
/**
* ArrayBufferStream wraps the built-in javascript ArrayBuffer, adding the ability to access
* data in it like a stream, tracking its position.
* You can request to read a value from the front of the array, and it will keep track of the position
* within the byte array, so that successive reads are consecutive.
* The available types to read include:
* Uint8, Uint8String, Int16, Uint16, Int32, Uint32
* @param {ArrayBuffer} arrayBuffer - array to use as a stream
* @constructor
*/
function ArrayBufferStream (arrayBuffer) {
this.arrayBuffer = arrayBuffer;
this.position = 0;
}
// return a new ArrayBufferStream that is a slice of the existing one
/**
* Return a new ArrayBufferStream that is a slice of the existing one
* @param {number} length - the number of bytes of extract
* @return {ArrayBufferStream} the extracted stream
*/
ArrayBufferStream.prototype.extract = function (length) {
var slicedArrayBuffer = this.arrayBuffer.slice(this.position, this.position+length);
var newStream = new ArrayBufferStream(slicedArrayBuffer);
return newStream;
};
/**
* @return {number} the length of the stream in bytes
*/
ArrayBufferStream.prototype.getLength = function () {
return this.arrayBuffer.byteLength;
};
/**
* @return {number} the number of bytes available after the current position in the stream
*/
ArrayBufferStream.prototype.getBytesAvailable = function () {
return (this.arrayBuffer.byteLength - this.position);
};
/**
* Read an unsigned 8 bit integer from the stream
* @return {number}
*/
ArrayBufferStream.prototype.readUint8 = function () {
var val = new Uint8Array(this.arrayBuffer, this.position, 1)[0];
this.position += 1;
return val;
};
// convert a sequence of bytes of the given length to a string
// for small length strings only
/**
* Read a sequence of bytes of the given length and convert to a string.
* This is a convenience method for use with short strings.
* @param {number} length - the number of bytes to convert
* @return {String} a String made by concatenating the chars in the input
*/
ArrayBufferStream.prototype.readUint8String = function (length) {
var arr = new Uint8Array(this.arrayBuffer, this.position, length);
this.position += length;
@ -44,24 +64,40 @@ ArrayBufferStream.prototype.readUint8String = function (length) {
return str;
};
/**
* Read a 16 bit integer from the stream
* @return {number}
*/
ArrayBufferStream.prototype.readInt16 = function () {
var val = new Int16Array(this.arrayBuffer, this.position, 1)[0];
this.position += 2; // one 16 bit int is 2 bytes
return val;
};
/**
* Read an unsigned 16 bit integer from the stream
* @return {number}
*/
ArrayBufferStream.prototype.readUint16 = function () {
var val = new Uint16Array(this.arrayBuffer, this.position, 1)[0];
this.position += 2; // one 16 bit int is 2 bytes
return val;
};
/**
* Read a 32 bit integer from the stream
* @return {number}
*/
ArrayBufferStream.prototype.readInt32 = function () {
var val = new Int32Array(this.arrayBuffer, this.position, 1)[0];
this.position += 4; // one 32 bit int is 4 bytes
return val;
};
/**
* Read an unsigned 32 bit integer from the stream
* @return {number}
*/
ArrayBufferStream.prototype.readUint32 = function () {
var val = new Uint32Array(this.arrayBuffer, this.position, 1)[0];
this.position += 4; // one 32 bit int is 4 bytes

View file

@ -1,6 +1,11 @@
var SoundPlayer = require('./SoundPlayer');
var Tone = require('tone');
/**
* A prototype for the drum sound functionality that can load drum sounds, play, and stop them.
* @param {Tone.Gain} outputNode - a webAudio node that the drum sounds will send their output to
* @constructor
*/
function DrumPlayer (outputNode) {
this.outputNode = outputNode;
@ -35,11 +40,21 @@ function DrumPlayer (outputNode) {
}
}
/**
* Play a drum sound.
* The parameter for output node allows sprites or clones to send the drum sound
* to their individual audio effect chains.
* @param {number} drum - the drum number to play (0-indexed)
* @param {Tone.Gain} outputNode - a node to send the output to
*/
DrumPlayer.prototype.play = function (drum, outputNode) {
this.drumSounds[drum].outputNode = outputNode;
this.drumSounds[drum].start();
};
/**
* Stop all drum sounds.
*/
DrumPlayer.prototype.stopAll = function () {
for (var i=0; i<this.drumSounds.length; i++) {
this.drumSounds[i].stop();

View file

@ -1,10 +1,21 @@
var Tone = require('tone');
var Soundfont = require('soundfont-player');
/**
* A prototype for the instrument sound functionality that can play notes.
* This prototype version (which will be replaced at some point) uses an
* existing soundfont library that creates several limitations:
* The sound files are high quality but large, so they are loaded 'on demand,' at the time the
* play note or set instrument block runs, causing a delay of a few seconds.
* Using this library we don't have a way to set the volume, sustain the note beyond the sample
* duration, or run it through the sprite-specific audio effects.
* @param {Tone.Gain} outputNode - a webAudio node that the instrument will send its output to
* @constructor
*/
function InstrumentPlayer (outputNode) {
this.outputNode = outputNode;
// instrument names used by Musyng Kite soundfont, in order to
// Instrument names used by Musyng Kite soundfont, in order to
// match scratch instruments
this.instrumentNames = ['acoustic_grand_piano', 'electric_piano_1',
'drawbar_organ', 'acoustic_guitar_nylon', 'electric_guitar_clean',
@ -15,6 +26,15 @@ function InstrumentPlayer (outputNode) {
this.instruments = [];
}
/**
* Play a note for some number of seconds with a particular instrument.
* Load the instrument first, if it has not already been loaded.
* The duration is in seconds because the AudioEngine manages the tempo,
* and converts beats to seconds.
* @param {number} note - a MIDI note number
* @param {number} sec - a duration in seconds
* @param {number} instrumentNum - an instrument number (0-indexed)
*/
InstrumentPlayer.prototype.playNoteForSecWithInst = function (note, sec, instrumentNum) {
this.loadInstrument(instrumentNum)
.then(() => {
@ -24,6 +44,11 @@ InstrumentPlayer.prototype.playNoteForSecWithInst = function (note, sec, instrum
});
};
/**
* Load an instrument by number
* @param {number} instrumentNum - an instrument number (0-indexed)
* @return {Promise} a Promise that resolves once the instrument audio data has been loaded
*/
InstrumentPlayer.prototype.loadInstrument = function (instrumentNum) {
if (this.instruments[instrumentNum]) {
return Promise.resolve();
@ -36,6 +61,9 @@ InstrumentPlayer.prototype.loadInstrument = function (instrumentNum) {
}
};
/**
* Stop all notes being played on all instruments
*/
InstrumentPlayer.prototype.stopAll = function () {
for (var i=0; i<this.instruments.length; i++) {
if (this.instruments[i]) {

View file

@ -1,18 +1,38 @@
var Tone = require('tone');
var log = require('./log');
function SoundPlayer (outputNode) {
this.outputNode = outputNode;
this.buffer; // a Tone.Buffer
this.bufferSource;
/**
* A SoundPlayer stores an audio buffer, and plays it
* @constructor
*/
function SoundPlayer () {
this.outputNode = null;
this.buffer = new Tone.Buffer();
this.bufferSource = null;
this.playbackRate = 1;
this.isPlaying = false;
}
/**
* Connect the SoundPlayer to an output node
* @param {Tone.Gain} node - an output node to connect to
*/
SoundPlayer.prototype.connect = function (node) {
this.outputNode = node;
};
/**
* Set an audio buffer
* @param {Tone.Buffer} buffer
*/
SoundPlayer.prototype.setBuffer = function (buffer) {
this.buffer = buffer;
};
/**
* Set the playback rate for the sound
* @param {number} playbackRate - a ratio where 1 is normal playback, 0.5 is half speed, 2 is double speed, etc.
*/
SoundPlayer.prototype.setPlaybackRate = function (playbackRate) {
this.playbackRate = playbackRate;
if (this.bufferSource && this.bufferSource.playbackRate) {
@ -20,32 +40,47 @@ SoundPlayer.prototype.setPlaybackRate = function (playbackRate) {
}
};
/**
* Stop the sound
*/
SoundPlayer.prototype.stop = function () {
if (this.isPlaying){
if (this.bufferSource) {
this.bufferSource.stop();
}
this.isPlaying = false;
};
/**
* Start playing the sound
* The web audio framework requires a new audio buffer source node for each playback
*/
SoundPlayer.prototype.start = function () {
if (!this.buffer || !this.buffer.loaded) {
log.warn('tried to play a sound that was not loaded yet');
return;
}
this.stop();
this.bufferSource = new Tone.BufferSource(this.buffer.get());
this.bufferSource.playbackRate.value = this.playbackRate;
this.bufferSource.connect(this.outputNode);
this.bufferSource.start();
this.isPlaying = true;
};
SoundPlayer.prototype.onEnded = function (callback) {
this.bufferSource.onended = function () {
this.isPlaying = false;
callback();
};
/**
* The sound has finished playing. This is called at the correct time even if the playback rate
* has been changed
* @return {Promise} a Promise that resolves when the sound finishes playing
*/
SoundPlayer.prototype.finished = function () {
var storedContext = this;
return new Promise(function (resolve) {
storedContext.bufferSource.onended = function () {
this.isPlaying = false;
resolve();
}.bind(storedContext);
});
};
module.exports = SoundPlayer;

View file

@ -1,17 +1,13 @@
/*
An echo effect
0 mutes the effect
Values up to 100 set the echo feedback amount,
increasing the time it takes the echo to fade away
Clamped 0-100
*/
var Tone = require('tone');
/**
* An echo effect (aka 'delay effect' in audio terms)
* Effect value of 0 mutes the effect
* Values up to 100 set the echo feedback amount,
* increasing the time it takes the echo to fade away
* Clamped 0-100
* @constructor
*/
function EchoEffect () {
Tone.Effect.call(this);
@ -24,6 +20,10 @@ function EchoEffect () {
Tone.extend(EchoEffect, Tone.Effect);
/**
* Set the effect value
* @param {number} val - the new value to set the effect to
*/
EchoEffect.prototype.set = function (val) {
this.value = val;
@ -40,10 +40,20 @@ EchoEffect.prototype.set = function (val) {
this.delay.feedback.rampTo(feedback, 1/60);
};
/**
* Change the effect value
* @param {number} val - the value to change the effect by
*/
EchoEffect.prototype.changeBy = function (val) {
this.set(this.value + val);
};
/**
* Clamp the input to a range
* @param {number} input - the input to clamp
* @param {number} min - the min value to clamp to
* @param {number} max - the max value to clamp to
*/
EchoEffect.prototype.clamp = function (input, min, max) {
return Math.min(Math.max(input, min), max);
};

View file

@ -1,17 +1,12 @@
/*
A fuzz effect
Distortion
the value controls the wet/dry amount
Clamped 0-100
*/
var Tone = require('tone');
/**
* A fuzz effect (aka 'distortion effect' in audio terms)
* Effect value controls the wet/dry amount:
* 0 passes through none of the effect, 100 passes through all effect
* Clamped 0-100
* @constructor
*/
function FuzzEffect () {
Tone.Effect.call(this);
@ -24,6 +19,10 @@ function FuzzEffect () {
Tone.extend(FuzzEffect, Tone.Effect);
/**
* Set the effect value
* @param {number} val - the new value to set the effect to
*/
FuzzEffect.prototype.set = function (val) {
this.value = val;
@ -32,10 +31,19 @@ FuzzEffect.prototype.set = function (val) {
this.distortion.wet.value = this.value / 100;
};
/**
* Change the effect value
* @param {number} val - the value to change the effect by
*/
FuzzEffect.prototype.changeBy = function (val) {
this.set(this.value + val);
};
/**
* @param {number} input - the input to clamp
* @param {number} min - the min value to clamp to
* @param {number} max - the max value to clamp to
*/
FuzzEffect.prototype.clamp = function (input, min, max) {
return Math.min(Math.max(input, min), max);
};

View file

@ -1,15 +1,12 @@
/*
A Pan effect
-100 puts the audio on the left channel, 0 centers it, 100 puts it on the right.
Clamped -100 to 100
*/
var Tone = require('tone');
/**
* A pan effect, which moves the sound to the left or right between the speakers
* Effect value of -100 puts the audio entirely on the left channel,
* 0 centers it, 100 puts it on the right.
* Clamped -100 to 100
* @constructor
*/
function PanEffect () {
Tone.Effect.call(this);
@ -22,6 +19,10 @@ function PanEffect () {
Tone.extend(PanEffect, Tone.Effect);
/**
* Set the effect value
* @param {number} val - the new value to set the effect to
*/
PanEffect.prototype.set = function (val) {
this.value = val;
@ -30,10 +31,20 @@ PanEffect.prototype.set = function (val) {
this.panner.pan.value = this.value / 100;
};
/**
* Change the effect value
* @param {number} val - the value to change the effect by
*/
PanEffect.prototype.changeBy = function (val) {
this.set(this.value + val);
};
/**
* Clamp the input to a range
* @param {number} input - the input to clamp
* @param {number} min - the min value to clamp to
* @param {number} max - the max value to clamp to
*/
PanEffect.prototype.clamp = function (input, min, max) {
return Math.min(Math.max(input, min), max);
};

View file

@ -1,38 +1,81 @@
/*
A Pitch effect
*/
var Tone = require('tone');
/**
* A pitch change effect, which changes the playback rate of the sound in order
* to change its pitch: reducing the playback rate lowers the pitch, increasing the rate
* raises the pitch. The duration of the sound is also changed.
*
* Changing the value of the pitch effect by 10 causes a change in pitch by 1 semitone
* (i.e. a musical half-step, such as the difference between C and C#)
* Changing the pitch effect by 120 changes the pitch by one octave (12 semitones)
*
* The value of this effect is not clamped (i.e. it is typically between -120 and 120,
* but can be set much higher or much lower, with weird and fun results).
* We should consider what extreme values to use for clamping it.
*
* Note that this effect functions differently from the other audio effects. It is
* not part of a chain of audio nodes. Instead, it provides a way to set the playback
* on one SoundPlayer or a group of them.
* @constructor
*/
function PitchEffect () {
this.value = 0;
this.value = 0; // effect value
this.ratio = 1; // the playback rate ratio
this.tone = new Tone();
}
/**
* Set the effect value
* @param {number} val - the new value to set the effect to
* @param {object} players - a dictionary of SoundPlayer objects to apply the effect to, indexed by md5
*/
PitchEffect.prototype.set = function (val, players) {
this.value = val;
this.ratio = this.getRatio(this.value);
this.updatePlayers(players);
};
/**
* Change the effect value
* @param {number} val - the value to change the effect by
* @param {Object} players - a dictionary of SoundPlayer objects indexed by md5
*/
PitchEffect.prototype.changeBy = function (val, players) {
this.set(this.value + val, players);
};
PitchEffect.prototype.getRatio = function () {
return this.tone.intervalToFrequencyRatio(this.value / 10);
/**
* Compute the playback ratio for an effect value.
* The playback ratio is scaled so that a change of 10 in the effect value
* gives a change of 1 semitone in the ratio.
* @param {number} val - an effect value
* @returns {number} a playback ratio
*/
PitchEffect.prototype.getRatio = function (val) {
return this.tone.intervalToFrequencyRatio(val / 10);
};
/**
* Update a sound player's playback rate using the current ratio for the effect
* @param {Object} player - a SoundPlayer object
*/
PitchEffect.prototype.updatePlayer = function (player) {
player.setPlaybackRate(this.ratio);
};
/**
* Update a sound player's playback rate using the current ratio for the effect
* @param {object} players - a dictionary of SoundPlayer objects to update, indexed by md5
*/
PitchEffect.prototype.updatePlayers = function (players) {
if (!players) return;
var ratio = this.getRatio();
for (var i=0; i<players.length; i++) {
players[i].setPlaybackRate(ratio);
for (var md5 in players) {
if (players.hasOwnProperty(md5)) {
this.updatePlayer(players[md5]);
}
}
};
module.exports = PitchEffect;

View file

@ -1,15 +1,12 @@
/*
A Reverb effect
The value controls the wet/dry amount of the effect
Clamped 0 to 100
*/
var Tone = require('tone');
/**
* A reverb effect, simulating reverberation in a room
* Effect value controls the wet/dry amount:
* 0 passes through none of the effect, 100 passes through all effect
* Clamped 0 to 100
* @constructor
*/
function ReverbEffect () {
Tone.Effect.call(this);
@ -22,6 +19,10 @@ function ReverbEffect () {
Tone.extend(ReverbEffect, Tone.Effect);
/**
* Set the effect value
* @param {number} val - the new value to set the effect to
*/
ReverbEffect.prototype.set = function (val) {
this.value = val;
@ -30,10 +31,20 @@ ReverbEffect.prototype.set = function (val) {
this.reverb.wet.value = this.value / 100;
};
/**
* Change the effect value
* @param {number} val - the value to change the effect by
*/
ReverbEffect.prototype.changeBy = function (val) {
this.set(this.value + val);
};
/**
* Clamp the input to a range
* @param {number} input - the input to clamp
* @param {number} min - the min value to clamp to
* @param {number} max - the max value to clamp to
*/
ReverbEffect.prototype.clamp = function (input, min, max) {
return Math.min(Math.max(input, min), max);
};

View file

@ -1,20 +1,17 @@
/*
A robot-voice effect
A feedback comb filter with a short delay time creates a low-pitched buzzing
The effect value controls the length of this delay time, changing the pitch
0 mutes the effect
Other values changes the pitch of the effect, in units of 10 steps per semitone
Not clamped
*/
var Tone = require('tone');
/**
* A "robotic" effect that adds a low-pitched buzzing to the sound, reminiscent of the
* voice of the daleks from Dr. Who.
* In audio terms it is a feedback comb filter with a short delay time.
* The effect value controls the length of this delay time, changing the pitch of the buzz
* A value of 0 mutes the effect.
* Other values change the pitch of the effect, in units of 10 steps per semitone.
* The effect value is not clamped (but probably should be).
* Exterminate.
* @constructor
*/
function RoboticEffect () {
Tone.Effect.call(this);
@ -28,6 +25,10 @@ function RoboticEffect () {
Tone.extend(RoboticEffect, Tone.Effect);
/**
* Set the effect value
* @param {number} val - the new value to set the effect to
*/
RoboticEffect.prototype.set = function (val) {
this.value = val;
@ -43,13 +44,22 @@ RoboticEffect.prototype.set = function (val) {
this.feedbackCombFilter.delayTime.rampTo(time, 1/60);
};
/**
* Change the effect value
* @param {number} val - the value to change the effect by
*/
RoboticEffect.prototype.changeBy = function (val) {
this.set(this.value + val);
};
/**
* Compute the delay time for an effect value.
* Convert the effect value to a musical note (in units of 10 per semitone),
* and return the period (single cycle duration) of the frequency of that note.
* @param {number} val - the effect value
* @returns {number} a delay time in seconds
*/
RoboticEffect.prototype._delayTimeForValue = function (val) {
// convert effect setting range, typically 0-100 but can be outside that,
// to a musical note, and return the period of the frequency of that note
var midiNote = ((val - 100) / 10) + 36;
var freq = Tone.Frequency(midiNote, 'midi').eval();
return 1 / freq;

View file

@ -1,16 +1,16 @@
/*
A wobble effect
A low frequency oscillator (LFO) controls a gain node
This creates an effect like tremolo
Clamped 0 to 100
*/
var Tone = require('tone');
/**
* A wobble effect. In audio terms, it sounds like tremolo.
* It is implemented using a low frequency oscillator (LFO) controlling
* a gain node, which causes the loudness of the signal passing through
* to increase and decrease rapidly.
* Effect value controls the wet/dry amount:
* 0 passes through none of the effect, 100 passes through all effect
* Effect value also controls the frequency of the LFO.
* Clamped 0 to 100
* @constructor
*/
function WobbleEffect () {
Tone.Effect.call(this);
@ -25,6 +25,10 @@ function WobbleEffect () {
Tone.extend(WobbleEffect, Tone.Effect);
/**
* Set the effect value
* @param {number} val - the new value to set the effect to
*/
WobbleEffect.prototype.set = function (val) {
this.value = val;
@ -35,10 +39,20 @@ WobbleEffect.prototype.set = function (val) {
this.wobbleLFO.frequency.rampTo(this.value / 10, 1/60);
};
/**
* Change the effect value
* @param {number} val - the value to change the effect by
*/
WobbleEffect.prototype.changeBy = function (val) {
this.set(this.value + val);
};
/**
* Clamp the input to a range
* @param {number} input - the input to clamp
* @param {number} min - the min value to clamp to
* @param {number} max - the max value to clamp to
*/
WobbleEffect.prototype.clamp = function (input, min, max) {
return Math.min(Math.max(input, min), max);
};

View file

@ -14,13 +14,17 @@ var ADPCMSoundLoader = require('./ADPCMSoundLoader');
var InstrumentPlayer = require('./InstrumentPlayer');
var DrumPlayer = require('./DrumPlayer');
/* Audio Engine
The Scratch runtime has a single audio engine that handles global audio properties and effects,
and creates the instrument player and a drum player, used by all play note and play drum blocks
*/
/**
* @fileOverview Scratch Audio is divided into a single AudioEngine,
* that handles global functionality, and AudioPlayers, belonging to individual sprites and clones.
*/
/**
* 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, and creates a single instrument player
* and a drum player, used by all play note and play drum blocks.
* @constructor
*/
function AudioEngine () {
// create the global audio effects
@ -38,8 +42,6 @@ function AudioEngine () {
// global tempo in bpm (beats per minute)
this.currentTempo = 60;
this.minTempo = 10;
this.maxTempo = 1000;
// instrument player for play note blocks
this.instrumentPlayer = new InstrumentPlayer(this.input);
@ -48,30 +50,130 @@ function AudioEngine () {
// drum player for play drum blocks
this.drumPlayer = new DrumPlayer(this.input);
this.numDrums = this.drumPlayer.drumSounds.length;
// a map of md5s to audio buffers, holding sounds for all sprites
this.audioBuffers = {};
}
/**
* Load all sounds for a sprite and store them in the audioBuffers dictionary, indexed by md5
* @param {Object} sounds - an array of objects containing metadata for sound files of a sprite
*/
AudioEngine.prototype.loadSounds = function (sounds) {
var storedContext = this;
for (var i=0; i<sounds.length; i++) {
var md5 = sounds[i].md5;
var buffer = new Tone.Buffer();
this.audioBuffers[md5] = buffer;
// Squeak sound format (not implemented yet)
if (sounds[i].format == 'squeak') {
log.warn('unable to load sound in squeak format');
continue;
}
// most sounds decode natively, but for adpcm sounds we use our own decoder
if (sounds[i].format == 'adpcm') {
log.warn('loading sound in adpcm format');
// create a closure to store the sound md5, to use when the
// decoder completes and resolves the promise
(function () {
var storedMd5 = sounds[i].md5;
var loader = new ADPCMSoundLoader();
loader.load(sounds[i].fileUrl).then(function (audioBuffer) {
storedContext.audioBuffers[storedMd5] = new Tone.Buffer(audioBuffer);
});
}());
} else {
this.audioBuffers[md5] = new Tone.Buffer(sounds[i].fileUrl);
}
}
};
/**
* Play a note for a duration on an instrument
* @param {number} note - a MIDI note number
* @param {number} beats - a duration in beats
* @param {number} inst - an instrument number (0-indexed)
* @return {Promise} a Promise that resolves after the duration has elapsed
*/
AudioEngine.prototype.playNoteForBeatsWithInst = function (note, beats, inst) {
var sec = this.beatsToSec(beats);
this.instrumentPlayer.playNoteForSecWithInst(note, sec, inst);
return this.waitForBeats(beats);
};
/**
* Convert a number of beats to a number of seconds, using the current tempo
* @param {number} beats
* @return {number} seconds
*/
AudioEngine.prototype.beatsToSec = function (beats) {
return (60 / this.currentTempo) * beats;
};
/**
* Wait for some number of beats
* @param {number} beats
* @return {Promise} a Promise that resolves after the duration has elapsed
*/
AudioEngine.prototype.waitForBeats = function (beats) {
var storedContext = this;
return new Promise(function (resolve) {
setTimeout(function () {
resolve();
}, storedContext.beatsToSec(beats) * 1000);
});
};
/**
* Set the global tempo in bpm (beats per minute)
* @param {number} value - the new tempo to set
*/
AudioEngine.prototype.setTempo = function (value) {
// var newTempo = this._clamp(value, this.minTempo, this.maxTempo);
this.currentTempo = value;
};
/**
* Change the tempo by some number of bpm (beats per minute)
* @param {number} value - the number of bpm to change the tempo by
*/
AudioEngine.prototype.changeTempo = function (value) {
this.setTempo(this.currentTempo + value);
};
/**
* Names of the audio effects.
* @readonly
* @enum {string}
*/
AudioEngine.prototype.EFFECT_NAMES = {
pitch: 'pitch',
pan: 'pan',
echo: 'echo',
reverb: 'reverb',
fuzz: 'fuzz',
robot: 'robot'
};
/**
* 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}
*/
AudioEngine.prototype.createPlayer = function () {
return new AudioPlayer(this);
};
/* Audio Player
Each sprite has an audio player
Clones receive a reference to their parent's audio player
the audio player currently handles sound loading and playback, sprite-specific effects
(pitch and pan) and volume
*/
/**
* Each sprite or clone has an audio player
* the audio player handles sound playback, volume, and the sprite-specific audio effects:
* pitch and pan
* @param {AudioEngine}
* @constructor
*/
function AudioPlayer (audioEngine) {
this.audioEngine = audioEngine;
@ -89,88 +191,67 @@ function AudioPlayer (audioEngine) {
// reset effects to their default parameters
this.clearEffects();
this.effectNames = ['PITCH', 'PAN', 'ECHO', 'REVERB', 'FUZZ', 'ROBOT'];
this.currentVolume = 100;
this.currentInstrument = 0;
// sound players that are currently playing, indexed by the sound's md5
this.activeSoundPlayers = {};
}
AudioPlayer.prototype.loadSounds = function (sounds) {
this.soundPlayers = [];
// create a set of empty sound player objects
// the sound buffers will be added asynchronously as they load
for (var i=0; i<sounds.length; i++){
this.soundPlayers[i] = new SoundPlayer(this.effectsNode);
/**
* Play a sound
* @param {string} md5 - the md5 id of a sound file
* @return {Promise} a Promise that resolves when the sound finishes playing
*/
AudioPlayer.prototype.playSound = function (md5) {
// if this sound is not in the audio engine, return
if (!this.audioEngine.audioBuffers[md5]) {
return;
}
// load the sounds
// most sounds decode natively, but for adpcm sounds we use our own decoder
var storedContext = this;
for (var index=0; index<sounds.length; index++) {
if (sounds[index].format == 'squeak') {
log.warn('unable to load sound in squeak format');
continue;
}
if (sounds[index].format == 'adpcm') {
log.warn('loading sound in adpcm format');
// create a closure to store the sound index, to use when the
// decoder completes and resolves the promise
(function () {
var storedIndex = index;
var loader = new ADPCMSoundLoader();
loader.load(sounds[storedIndex].fileUrl).then(function (audioBuffer) {
storedContext.soundPlayers[storedIndex].setBuffer(new Tone.Buffer(audioBuffer));
});
}());
} else {
this.soundPlayers[index].setBuffer(new Tone.Buffer(sounds[index].fileUrl));
// if this sprite or clone is already playing this sound, stop it first
if (this.activeSoundPlayers[md5]) {
this.activeSoundPlayers[md5].stop();
}
// create a new soundplayer to play the sound
var player = new SoundPlayer();
player.setBuffer(this.audioEngine.audioBuffers[md5]);
player.connect(this.effectsNode);
this.pitchEffect.updatePlayer(player);
player.start();
// add it to the list of active sound players
this.activeSoundPlayers[md5] = player;
// remove sounds that are not playing from the active sound players array
for (var id in this.activeSoundPlayers) {
if (this.activeSoundPlayers.hasOwnProperty(id)) {
if (!this.activeSoundPlayers[id].isPlaying) {
delete this.activeSoundPlayers[id];
}
}
}
return player.finished();
};
AudioPlayer.prototype.playSound = function (index) {
if (!this.soundPlayers[index]) return;
this.soundPlayers[index].start();
var storedContext = this;
return new Promise(function (resolve) {
storedContext.soundPlayers[index].onEnded(resolve);
});
};
AudioPlayer.prototype.playNoteForBeats = function (note, beats) {
var sec = this.beatsToSec(beats);
this.audioEngine.instrumentPlayer.playNoteForSecWithInst(note, sec, this.currentInstrument);
return this.waitForBeats(beats);
};
/**
* Play a drum sound. The AudioEngine contains the DrumPlayer, but the AudioPlayer
* calls this function so that it can pass a reference to its own effects node.
* @param {number} drum - a drum number (0-indexed)
* @param {number} beats - a duration in beats
* @return {Promise} a Promise that resolves after the duration has elapsed
*/
AudioPlayer.prototype.playDrumForBeats = function (drum, beats) {
this.audioEngine.drumPlayer.play(drum, this.effectsNode);
return this.waitForBeats(beats);
};
AudioPlayer.prototype.waitForBeats = function (beats) {
var storedContext = this;
return new Promise(function (resolve) {
setTimeout(function () {
resolve();
}, storedContext.beatsToSec(beats) * 1000);
});
};
AudioPlayer.prototype.beatsToSec = function (beats) {
return (60 / this.audioEngine.currentTempo) * beats;
return this.audioEngine.waitForBeats(beats);
};
/**
* Stop all sounds, notes and drums that are playing
*/
AudioPlayer.prototype.stopAllSounds = function () {
// stop all sound players
for (var i=0; i<this.soundPlayers.length; i++) {
this.soundPlayers[i].stop();
// stop all active sound players
for (var md5 in this.activeSoundPlayers) {
this.activeSoundPlayers[md5].stop();
}
// stop all instruments
@ -178,59 +259,42 @@ AudioPlayer.prototype.stopAllSounds = function () {
// stop drum notes
this.audioEngine.drumPlayer.stopAll();
};
/**
* Set an audio effect to a value
* @param {string} effect - the name of the effect
* @param {number} value - the value to set the effect to
*/
AudioPlayer.prototype.setEffect = function (effect, value) {
switch (effect) {
case 'PITCH':
this.pitchEffect.set(value, this.soundPlayers);
case this.audioEngine.EFFECT_NAMES.pitch:
this.pitchEffect.set(value, this.activeSoundPlayers);
break;
case 'PAN':
case this.audioEngine.EFFECT_NAMES.pan:
this.panEffect.set(value);
break;
case 'ECHO':
case this.audioEngine.EFFECT_NAMES.echo:
this.audioEngine.echoEffect.set(value);
break;
case 'REVERB':
case this.audioEngine.EFFECT_NAMES.reverb:
this.audioEngine.reverbEffect.set(value);
break;
case 'FUZZ' :
case this.audioEngine.EFFECT_NAMES.fuzz:
this.audioEngine.fuzzEffect.set(value);
break;
case 'ROBOT' :
case this.audioEngine.EFFECT_NAMES.robot:
this.audioEngine.roboticEffect.set(value);
break;
}
};
AudioPlayer.prototype.changeEffect = function (effect, value) {
switch (effect) {
case 'PITCH':
this.pitchEffect.changeBy(value, this.soundPlayers);
break;
case 'PAN':
this.panEffect.changeBy(value);
break;
case 'ECHO':
this.audioEngine.echoEffect.changeBy(value);
break;
case 'REVERB':
this.audioEngine.reverbEffect.changeBy(value);
break;
case 'FUZZ' :
this.audioEngine.fuzzEffect.changeBy(value);
break;
case 'ROBOT' :
this.audioEngine.roboticEffect.changeBy(value);
break;
}
};
/**
* Clear all audio effects
*/
AudioPlayer.prototype.clearEffects = function () {
this.panEffect.set(0);
this.pitchEffect.set(0, this.soundPlayers);
this.pitchEffect.set(0, this.activeSoundPlayers);
this.effectsNode.gain.value = 1;
this.audioEngine.echoEffect.set(0);
@ -239,22 +303,12 @@ AudioPlayer.prototype.clearEffects = function () {
this.audioEngine.roboticEffect.set(0);
};
AudioPlayer.prototype.setInstrument = function (instrumentNum) {
this.currentInstrument = instrumentNum;
return this.audioEngine.instrumentPlayer.loadInstrument(this.currentInstrument);
};
/**
* Set the volume for sounds played by this AudioPlayer
* @param {number} value - the volume in range 0-100
*/
AudioPlayer.prototype.setVolume = function (value) {
this.currentVolume = this._clamp(value, 0, 100);
this.effectsNode.gain.value = this.currentVolume / 100;
};
AudioPlayer.prototype.changeVolume = function (value) {
this.setVolume(this.currentVolume + value);
};
AudioPlayer.prototype._clamp = function (input, min, max) {
return Math.min(Math.max(input, min), max);
this.effectsNode.gain.value = value / 100;
};
module.exports = AudioEngine;