pizzicato-40hz
Version:
A web-audio library to simplify using and manipulating sounds.
634 lines (474 loc) • 17 kB
JavaScript
Pizzicato.Sound = function(description, callback) {
var self = this;
var util = Pizzicato.Util;
var descriptionError = getDescriptionError(description);
var hasOptions = util.isObject(description) && util.isObject(description.options);
var defaultAttack = 0.04;
var defaultRelease = 0.04;
if (descriptionError) {
console.error(descriptionError);
throw new Error('Error initializing Pizzicato Sound: ' + descriptionError);
}
this.detached = hasOptions && description.options.detached;
this.masterVolume = Pizzicato.context.createGain();
this.fadeNode = Pizzicato.context.createGain();
this.fadeNode.gain.value = 0;
if (!this.detached)
this.masterVolume.connect(Pizzicato.masterGainNode);
this.lastTimePlayed = 0;
this.effects = [];
this.effectConnectors = [];
this.playing = this.paused = false;
this.loop = hasOptions && description.options.loop;
this.attack = hasOptions && util.isNumber(description.options.attack) ? description.options.attack : defaultAttack;
this.volume = hasOptions && util.isNumber(description.options.volume) ? description.options.volume : 1;
if (hasOptions && util.isNumber(description.options.release)) {
this.release = description.options.release;
} else if (hasOptions && util.isNumber(description.options.sustain)) {
console.warn('\'sustain\' is deprecated. Use \'release\' instead.');
this.release = description.options.sustain;
} else {
this.release = defaultRelease;
}
if (!description)
(initializeWithWave.bind(this))({}, callback);
else if (util.isString(description))
(initializeWithUrl.bind(this))(description, callback);
else if (util.isFunction(description))
(initializeWithFunction.bind(this))(description, callback);
else if (description.source === 'file')
(initializeWithUrl.bind(this))(description.options.path, callback);
else if (description.source === 'wave')
(initializeWithWave.bind(this))(description.options, callback);
else if (description.source === 'input')
(initializeWithInput.bind(this))(description, callback);
else if (description.source === 'script')
(initializeWithFunction.bind(this))(description.options, callback);
else if (description.source === 'sound')
(initializeWithSoundObject.bind(this))(description.options, callback);
function getDescriptionError(description) {
var supportedSources = ['wave', 'file', 'input', 'script', 'sound'];
if (description && (!util.isFunction(description) && !util.isString(description) && !util.isObject(description)))
return 'Description type not supported. Initialize a sound using an object, a function or a string.';
if (util.isObject(description)) {
if (!util.isString(description.source) || supportedSources.indexOf(description.source) === -1)
return 'Specified source not supported. Sources can be wave, file, input or script';
if (description.source === 'file' && (!description.options || !description.options.path))
return 'A path is needed for sounds with a file source';
if (description.source === 'script' && (!description.options || !description.options.audioFunction))
return 'An audio function is needed for sounds with a script source';
}
}
function initializeWithWave(waveOptions, callback) {
waveOptions = waveOptions || {};
this.getRawSourceNode = function() {
var frequency = this.sourceNode ? this.sourceNode.frequency.value : waveOptions.frequency;
var node = Pizzicato.context.createOscillator();
node.type = waveOptions.type || 'sine';
node.frequency.value = (frequency || 440);
return node;
};
this.sourceNode = this.getRawSourceNode();
this.sourceNode.gainSuccessor = Pz.context.createGain();
this.sourceNode.connect(this.sourceNode.gainSuccessor);
if (util.isFunction(callback))
callback();
}
function initializeWithUrl(paths, callback) {
paths = util.isArray(paths) ? paths : [paths];
var request = new XMLHttpRequest();
request.open('GET', paths[0], true);
request.responseType = 'arraybuffer';
request.onload = function(progressEvent) {
Pizzicato.context.decodeAudioData(progressEvent.target.response, (function(buffer) {
self.getRawSourceNode = function() {
var node = Pizzicato.context.createBufferSource();
node.loop = this.loop;
node.buffer = buffer;
return node;
};
if (util.isFunction(callback))
callback();
}).bind(self), (function(error) {
console.error('Error decoding audio file ' + paths[0]);
if (paths.length > 1) {
paths.shift();
initializeWithUrl(paths, callback);
return;
}
error = error || new Error('Error decoding audio file ' + paths[0]);
if (util.isFunction(callback))
callback(error);
}).bind(self));
};
request.onreadystatechange = function(event) {
if (request.readyState === 4 && request.status !== 200) {
console.error('Error while fetching ' + paths[0] + '. ' + request.statusText);
}
};
request.send();
}
function initializeWithInput(options, callback) {
navigator.getUserMedia = (navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia || navigator.msGetUserMedia);
if (!navigator.getUserMedia && !navigator.mediaDevices.getUserMedia) {
console.error('Your browser does not support getUserMedia');
return;
}
var handleStream = (function(stream) {
self.getRawSourceNode = function() {
return Pizzicato.context.createMediaStreamSource(stream);
};
if (util.isFunction(callback))
callback();
}).bind(self);
var handleError = function(error) {
if (util.isFunction(callback))
callback(error);
};
if (!!navigator.mediaDevices.getUserMedia)
navigator.mediaDevices.getUserMedia({ audio: true }).then(handleStream).catch(handleError);
else
navigator.getUserMedia({ audio: true }, handleStream, handleError);
}
function initializeWithFunction(options, callback) {
var audioFunction = util.isFunction(options) ? options : options.audioFunction;
var bufferSize = util.isObject(options) && options.bufferSize ? options.bufferSize : null;
if (!bufferSize) {
try { // Webkit does not automatically chose the buffer size
var test = Pizzicato.context.createScriptProcessor();
} catch (e) {
bufferSize = 2048;
}
}
this.getRawSourceNode = function() {
var node = Pizzicato.context.createScriptProcessor(bufferSize, 1, 1);
node.onaudioprocess = audioFunction;
return node;
};
}
function initializeWithSoundObject(options, callback) {
this.getRawSourceNode = options.sound.getRawSourceNode;
if (options.sound.sourceNode && Pz.Util.isOscillator(options.sound.sourceNode)) {
this.sourceNode = this.getRawSourceNode();
this.frequency = options.sound.frequency;
}
}
};
Pizzicato.Sound.prototype = Object.create(Pizzicato.Events, {
play: {
enumerable: true,
value: function(when, offset) {
if (this.playing)
return;
if (!Pz.Util.isNumber(offset))
offset = this.offsetTime || 0;
if (!Pz.Util.isNumber(when))
when = 0;
this.playing = true;
this.paused = false;
this.sourceNode = this.getSourceNode();
this.applyAttack();
if (Pz.Util.isFunction(this.sourceNode.start)) {
this.lastTimePlayed = Pizzicato.context.currentTime - offset;
this.sourceNode.start(Pz.context.currentTime + when, offset);
}
this.trigger('play');
}
},
stop: {
enumerable: true,
value: function() {
if (!this.paused && !this.playing)
return;
this.paused = this.playing = false;
this.stopWithRelease();
this.offsetTime = 0;
this.trigger('stop');
}
},
pause: {
enumerable: true,
value: function() {
if (this.paused || !this.playing)
return;
this.paused = true;
this.playing = false;
this.stopWithRelease();
var elapsedTime = Pz.context.currentTime - this.lastTimePlayed;
// If we are using a buffer node - potentially in loop mode - we need to
// know where to re-start the sound independently of the loop it is in.
if (this.sourceNode.buffer)
this.offsetTime = elapsedTime % (this.sourceNode.buffer.length / Pz.context.sampleRate);
else
this.offsetTime = elapsedTime;
this.trigger('pause');
}
},
clone: {
enumerable: true,
value: function() {
var clone = new Pizzicato.Sound({
source: 'sound',
options: {
loop: this.loop,
attack: this.attack,
release: this.release,
volume: this.volume,
sound: this
}
});
for (var i = 0; i < this.effects.length; i++)
clone.addEffect(this.effects[i]);
return clone;
}
},
onEnded: {
enumerable: true,
value: function(node) {
return function() {
// This function may've been called from the release
// end. If in that time the Sound has been played again,
// no action should be taken.
if (!!this.sourceNode && this.sourceNode !== node)
return;
if (this.playing)
this.stop();
if (!this.paused)
this.trigger('end');
};
}
},
/**
* Adding effects will create a graph in which there will be a
* gain node (effectConnector) in between every effect added. For example:
* [fadeNode]--->[effect 1]->[connector 1]--->[effect 2]->[connector 2]--->[masterGain]
*
* Connectors are used to know what nodes to disconnect and not disrupt the
* connections of another Pz.Sound object using the same effect.
*/
addEffect: {
enumerable: true,
value: function(effect) {
if (!Pz.Util.isEffect(effect)) {
console.error('The object provided is not a Pizzicato effect.');
return this;
}
this.effects.push(effect);
// Connects effect in the last position
var previousNode = this.effectConnectors.length > 0 ? this.effectConnectors[this.effectConnectors.length - 1] : this.fadeNode;
previousNode.disconnect();
previousNode.connect(effect);
// Creates connector for the newly added effect
var gain = Pz.context.createGain();
this.effectConnectors.push(gain);
effect.connect(gain);
gain.connect(this.masterVolume);
return this;
}
},
/**
* When removing effects, the graph in which there will be a
* gain node (effectConnector) in between every effect should be
* conserved. For example:
* [fadeNode]--->[effect 1]->[connector 1]--->[effect 2]->[connector 2]--->[masterGain]
*
* Connectors are used to know what nodes to disconnect and not disrupt the
* connections of another Pz.Sound object using the same effect.
*/
removeEffect: {
enumerable: true,
value: function(effect) {
var index = this.effects.indexOf(effect);
if (index === -1) {
console.warn('Cannot remove effect that is not applied to this sound.');
return this;
}
var shouldResumePlaying = this.playing;
if (shouldResumePlaying)
this.pause();
var previousNode = (index === 0) ? this.fadeNode : this.effectConnectors[index - 1];
previousNode.disconnect();
// Disconnect connector and effect
var effectConnector = this.effectConnectors[index];
effectConnector.disconnect();
effect.disconnect(effectConnector);
// Remove connector and effect from our arrays
this.effectConnectors.splice(index, 1);
this.effects.splice(index, 1);
var targetNode;
if (index > this.effects.length - 1 || this.effects.length === 0)
targetNode = this.masterVolume;
else
targetNode = this.effects[index];
previousNode.connect(targetNode);
if (shouldResumePlaying)
this.play();
return this;
}
},
connect: {
enumerable: true,
value: function(audioNode) {
this.masterVolume.connect(audioNode);
return this;
}
},
disconnect: {
enumerable: true,
value: function(audioNode) {
this.masterVolume.disconnect(audioNode);
return this;
}
},
connectEffects: {
enumerable: true,
value: function() {
var connectors = [];
for (var i = 0; i < this.effects.length; i++) {
var isLastEffect = i === this.effects.length - 1;
var destinationNode = isLastEffect ? this.masterVolume : this.effects[i + 1].inputNode;
connectors[i] = Pz.context.createGain();
this.effects[i].outputNode.disconnect(this.effectConnectors[i]);
this.effects[i].outputNode.connect(destinationNode);
}
}
},
volume: {
enumerable: true,
get: function() {
if (this.masterVolume)
return this.masterVolume.gain.value;
},
set: function(volume) {
if (Pz.Util.isInRange(volume, 0, 1) && this.masterVolume)
this.masterVolume.gain.value = volume;
}
},
frequency: {
enumerable: true,
get: function() {
if (this.sourceNode && Pz.Util.isOscillator(this.sourceNode)) {
return this.sourceNode.frequency.value;
}
return null;
},
set: function(frequency) {
if (this.sourceNode && Pz.Util.isOscillator(this.sourceNode)) {
this.sourceNode.frequency.value = frequency;
}
}
},
/**
* @deprecated - Use "release"
*/
sustain: {
enumerable: true,
get: function() {
console.warn('\'sustain\' is deprecated. Use \'release\' instead.');
return this.release;
},
set: function(sustain){
console.warn('\'sustain\' is deprecated. Use \'release\' instead.');
if (Pz.Util.isInRange(sustain, 0, 10))
this.release = sustain;
}
},
/**
* Returns the node that produces the sound. For example, an oscillator
* if the Sound object was initialized with a wave option.
*/
getSourceNode: {
enumerable: true,
value: function() {
if (!!this.sourceNode) {
// Directly disconnecting the previous source node causes a
// 'click' noise, especially noticeable if the sound is played
// while the release is ongoing. To address this, we fadeout the
// old source node before disonnecting it.
var previousSourceNode = this.sourceNode;
previousSourceNode.gainSuccessor.gain.setValueAtTime(previousSourceNode.gainSuccessor.gain.value, Pz.context.currentTime);
previousSourceNode.gainSuccessor.gain.linearRampToValueAtTime(0.0001, Pz.context.currentTime + 0.2);
setTimeout(function() {
previousSourceNode.disconnect();
previousSourceNode.gainSuccessor.disconnect();
}, 200);
}
var sourceNode = this.getRawSourceNode();
// A gain node will be placed after the source node to avoid
// clicking noises (by fading out the sound)
sourceNode.gainSuccessor = Pz.context.createGain();
sourceNode.connect(sourceNode.gainSuccessor);
sourceNode.gainSuccessor.connect(this.fadeNode);
this.fadeNode.connect(this.getInputNode());
if (Pz.Util.isAudioBufferSourceNode(sourceNode))
sourceNode.onended = this.onEnded(sourceNode).bind(this);
return sourceNode;
}
},
/**
* Returns the first node in the graph. When there are effects,
* the first node is the input node of the first effect.
*/
getInputNode: {
enumerable: true,
value: function() {
if (this.effects.length > 0)
return this.effects[0].inputNode;
return this.masterVolume;
}
},
/**
* Will take the current source node and work up the volume
* gradually in as much time as specified in the attack property
* of the sound.
*/
applyAttack: {
enumerable: false,
value: function() {
var currentValue = this.fadeNode.gain.value;
this.fadeNode.gain.cancelScheduledValues(Pz.context.currentTime);
if (!this.attack) {
this.fadeNode.gain.setTargetAtTime(1.0, Pz.context.currentTime, 0.001);
return;
}
// We can't calculate the remaining attack time
// in Firefox due to https://bugzilla.mozilla.org/show_bug.cgi?id=893020
var isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
var remainingAttackTime = this.attack;
if (!isFirefox)
remainingAttackTime = (1 - this.fadeNode.gain.value) * this.attack;
this.fadeNode.gain.setTargetAtTime(1.0, Pz.context.currentTime, remainingAttackTime * 2);
}
},
/**
* Will take the current source node and work down the volume
* gradually in as much time as specified in the release property
* of the sound before stopping the source node.
*/
stopWithRelease: {
enumerable: false,
value: function(callback) {
var node = this.sourceNode;
var stopSound = function() {
return Pz.Util.isFunction(node.stop) ? node.stop(0) : node.disconnect();
};
var currentValue = this.fadeNode.gain.value;
this.fadeNode.gain.cancelScheduledValues(Pz.context.currentTime);
if (!this.release) {
this.fadeNode.gain.setTargetAtTime(0.0, Pz.context.currentTime, 0.001);
stopSound();
return;
}
// We can't calculate the remaining attack time
// in Firefox due to https://bugzilla.mozilla.org/show_bug.cgi?id=893020
var isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
var remainingReleaseTime = this.release;
if (!isFirefox)
remainingReleaseTime = this.fadeNode.gain.value * this.release;
this.fadeNode.gain.setTargetAtTime(0.00001, Pz.context.currentTime, remainingReleaseTime / 5);
window.setTimeout(function() {
stopSound();
}, remainingReleaseTime * 1000);
}
}
});