mirror of
https://github.com/scratchfoundation/scratch-audio.git
synced 2024-12-22 14:02:29 -05:00
Merge pull request #84 from mzgoddard/effects-same-api
extend existing effects from Effect
This commit is contained in:
commit
2f05e45a6d
6 changed files with 360 additions and 114 deletions
|
@ -24,7 +24,8 @@
|
|||
"dependencies": {
|
||||
"audio-context": "1.0.1",
|
||||
"minilog": "^3.0.1",
|
||||
"startaudiocontext": "1.2.1"
|
||||
"startaudiocontext": "1.2.1",
|
||||
"tap": "^12.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"babel-core": "^6.24.1",
|
||||
|
|
|
@ -49,8 +49,8 @@ class AudioEngine {
|
|||
* will change the volume for all sounds.
|
||||
* @type {GainNode}
|
||||
*/
|
||||
this.input = this.audioContext.createGain();
|
||||
this.input.connect(this.audioContext.destination);
|
||||
this.inputNode = this.audioContext.createGain();
|
||||
this.inputNode.connect(this.audioContext.destination);
|
||||
|
||||
/**
|
||||
* a map of soundIds to audio buffers, holding sounds for all sprites
|
||||
|
@ -86,6 +86,14 @@ class AudioEngine {
|
|||
return 0.001;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the input node.
|
||||
* @return {AudioNode} - audio node that is the input for this effect
|
||||
*/
|
||||
getInputNode () {
|
||||
return this.inputNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a sound, decompressing it into audio samples.
|
||||
* Store a reference to it the sound in the audioBuffers dictionary, indexed by soundId
|
||||
|
|
|
@ -14,20 +14,26 @@ class AudioPlayer {
|
|||
constructor (audioEngine) {
|
||||
this.audioEngine = audioEngine;
|
||||
|
||||
// Create the audio effects
|
||||
this.pitchEffect = new PitchEffect();
|
||||
this.panEffect = new PanEffect(this.audioEngine);
|
||||
this.outputNode = this.audioEngine.audioContext.createGain();
|
||||
|
||||
// Chain the audio effects together
|
||||
// effectsNode -> panEffect -> audioEngine.input
|
||||
this.effectsNode = this.audioEngine.audioContext.createGain();
|
||||
this.effectsNode.connect(this.panEffect.input);
|
||||
this.panEffect.connect(this.audioEngine.input);
|
||||
// Create the audio effects
|
||||
const pitchEffect = new PitchEffect(this.audioEngine, this, null);
|
||||
const panEffect = new PanEffect(this.audioEngine, this, pitchEffect);
|
||||
this.effects = {
|
||||
pitch: pitchEffect,
|
||||
pan: panEffect
|
||||
};
|
||||
|
||||
// Chain the effects and player together with the audio engine.
|
||||
// outputNode -> "pitchEffect" -> panEffect -> audioEngine.input
|
||||
panEffect.connect(this.audioEngine);
|
||||
pitchEffect.connect(panEffect);
|
||||
|
||||
// reset effects to their default parameters
|
||||
this.clearEffects();
|
||||
|
||||
// sound players that are currently playing, indexed by the sound's soundId
|
||||
// sound players that are currently playing, indexed by the sound's
|
||||
// soundId
|
||||
this.activeSoundPlayers = {};
|
||||
}
|
||||
|
||||
|
@ -36,7 +42,16 @@ class AudioPlayer {
|
|||
* @return {AudioNode} the AudioNode for this sprite's input
|
||||
*/
|
||||
getInputNode () {
|
||||
return this.effectsNode;
|
||||
return this.outputNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all the sound players owned by this audio player.
|
||||
* @return {object<string, SoundPlayer>} mapping of sound ids to sound
|
||||
* players
|
||||
*/
|
||||
getSoundPlayers () {
|
||||
return this.activeSoundPlayers;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -58,14 +73,17 @@ class AudioPlayer {
|
|||
// create a new soundplayer to play the sound
|
||||
const player = new SoundPlayer(this.audioEngine.audioContext);
|
||||
player.setBuffer(this.audioEngine.audioBuffers[soundId]);
|
||||
player.connect(this.effectsNode);
|
||||
this.pitchEffect.updatePlayer(player);
|
||||
player.connect(this.outputNode);
|
||||
player.start();
|
||||
|
||||
// add it to the list of active sound players
|
||||
this.activeSoundPlayers[soundId] = player;
|
||||
for (const effectName in this.effects) {
|
||||
this.effects[effectName].update();
|
||||
}
|
||||
|
||||
// remove sounds that are not playing from the active sound players array
|
||||
// remove sounds that are not playing from the active sound players
|
||||
// array
|
||||
for (const id in this.activeSoundPlayers) {
|
||||
if (this.activeSoundPlayers.hasOwnProperty(id)) {
|
||||
if (!this.activeSoundPlayers[id].isPlaying) {
|
||||
|
@ -93,13 +111,8 @@ class AudioPlayer {
|
|||
* @param {number} value - the value to set the effect to
|
||||
*/
|
||||
setEffect (effect, value) {
|
||||
switch (effect) {
|
||||
case this.audioEngine.EFFECT_NAMES.pitch:
|
||||
this.pitchEffect.set(value, this.activeSoundPlayers);
|
||||
break;
|
||||
case this.audioEngine.EFFECT_NAMES.pan:
|
||||
this.panEffect.set(value);
|
||||
break;
|
||||
if (this.effects.hasOwnProperty(effect)) {
|
||||
this.effects[effect].set(value);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -107,10 +120,12 @@ class AudioPlayer {
|
|||
* Clear all audio effects
|
||||
*/
|
||||
clearEffects () {
|
||||
this.panEffect.set(0);
|
||||
this.pitchEffect.set(0, this.activeSoundPlayers);
|
||||
for (const effectName in this.effects) {
|
||||
this.effects[effectName].clear();
|
||||
}
|
||||
|
||||
if (this.audioEngine === null) return;
|
||||
this.effectsNode.gain.setTargetAtTime(1.0, 0, this.audioEngine.DECAY_TIME);
|
||||
this.outputNode.gain.setTargetAtTime(1.0, 0, this.audioEngine.DECAY_TIME);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -119,15 +134,26 @@ class AudioPlayer {
|
|||
*/
|
||||
setVolume (value) {
|
||||
if (this.audioEngine === null) return;
|
||||
this.effectsNode.gain.setTargetAtTime(value / 100, 0, this.audioEngine.DECAY_TIME);
|
||||
this.outputNode.gain.setTargetAtTime(value / 100, 0, this.audioEngine.DECAY_TIME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connnect this player's output to another audio node
|
||||
* @param {object} target - target whose node to should be connected
|
||||
*/
|
||||
connect (target) {
|
||||
this.outputNode.connect(target.getInputNode());
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up and disconnect audio nodes.
|
||||
*/
|
||||
dispose () {
|
||||
this.panEffect.dispose();
|
||||
this.effectsNode.disconnect();
|
||||
this.effects.pitch.dispose();
|
||||
this.effects.pan.dispose();
|
||||
|
||||
this.outputNode.disconnect();
|
||||
this.outputNode = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
145
src/effects/Effect.js
Normal file
145
src/effects/Effect.js
Normal file
|
@ -0,0 +1,145 @@
|
|||
/**
|
||||
* An effect on an AudioPlayer and all its SoundPlayers.
|
||||
*/
|
||||
class Effect {
|
||||
/**
|
||||
* @param {AudioEngine} audioEngine - audio engine this runs with
|
||||
* @param {AudioPlayer} audioPlayer - audio player this affects
|
||||
* @param {Effect} lastEffect - effect in the chain before this one
|
||||
* @constructor
|
||||
*/
|
||||
constructor (audioEngine, audioPlayer, lastEffect) {
|
||||
this.audioEngine = audioEngine;
|
||||
this.audioPlayer = audioPlayer;
|
||||
this.lastEffect = lastEffect;
|
||||
|
||||
this.value = this.DEFAULT_VALUE;
|
||||
|
||||
this.initialized = false;
|
||||
|
||||
this.inputNode = null;
|
||||
this.outputNode = null;
|
||||
|
||||
this.target = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default value to set the Effect to when constructed and when clear'ed.
|
||||
* @const {number}
|
||||
*/
|
||||
get DEFAULT_VALUE () {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should the effect be connected to the audio graph?
|
||||
* The pitch effect is an example that does not need to be patched in.
|
||||
* Instead of affecting the graph it affects the player directly.
|
||||
* @return {boolean} is the effect affecting the graph?
|
||||
*/
|
||||
get _isPatch () {
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the input node.
|
||||
* @return {AudioNode} - audio node that is the input for this effect
|
||||
*/
|
||||
getInputNode () {
|
||||
if (this.initialized) {
|
||||
return this.inputNode;
|
||||
}
|
||||
return this.target.getInputNode();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the Effect.
|
||||
* Effects start out uninitialized. Then initialize when they are first set
|
||||
* with some value.
|
||||
* @throws {Error} throws when left unimplemented
|
||||
*/
|
||||
initialize () {
|
||||
throw new Error(`${this.constructor.name}.initialize is not implemented.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the effects value.
|
||||
* @private
|
||||
* @param {number} value - new value to set effect to
|
||||
*/
|
||||
_set () {
|
||||
throw new Error(`${this.constructor.name}._set is not implemented.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the effects value.
|
||||
* @param {number} value - new value to set effect to
|
||||
*/
|
||||
set (value) {
|
||||
// Initialize the node on first set.
|
||||
if (!this.initialized) {
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
// Store whether the graph should currently affected by this effect.
|
||||
const _isPatch = this._isPatch;
|
||||
|
||||
// Call the internal implementation per this Effect.
|
||||
this._set(value);
|
||||
|
||||
// Connect or disconnect from the graph if this now applies or no longer
|
||||
// applies an effect.
|
||||
if (this._isPatch !== _isPatch && this.target !== null) {
|
||||
this.connect(this.target);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the effect for changes in the audioPlayer.
|
||||
*/
|
||||
update () {}
|
||||
|
||||
/**
|
||||
* Clear the value back to the default.
|
||||
*/
|
||||
clear () {
|
||||
this.set(this.DEFAULT_VALUE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connnect this effect's output to another audio node
|
||||
* @param {object} target - target whose node to should be connected
|
||||
*/
|
||||
connect (target) {
|
||||
this.target = target;
|
||||
|
||||
if (target === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
let nextTarget = target;
|
||||
if (this._isPatch) {
|
||||
nextTarget = this;
|
||||
this.outputNode.connect(target.getInputNode());
|
||||
}
|
||||
|
||||
if (this.lastEffect === null) {
|
||||
this.audioPlayer.connect(nextTarget);
|
||||
} else {
|
||||
this.lastEffect.connect(nextTarget);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up and disconnect audio nodes.
|
||||
*/
|
||||
dispose () {
|
||||
this.inputNode = null;
|
||||
this.outputNode = null;
|
||||
this.target = null;
|
||||
|
||||
this.initialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Effect;
|
|
@ -1,40 +1,65 @@
|
|||
const Effect = require('./Effect');
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
class PanEffect {
|
||||
/**
|
||||
* @param {AudioEngine} audioEngine - the audio engine.
|
||||
* 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.
|
||||
*/
|
||||
class PanEffect extends Effect {
|
||||
/**
|
||||
* @param {AudioEngine} audioEngine - audio engine this runs with
|
||||
* @param {AudioPlayer} audioPlayer - audio player this affects
|
||||
* @param {Effect} lastEffect - effect in the chain before this one
|
||||
* @constructor
|
||||
*/
|
||||
constructor (audioEngine) {
|
||||
this.audioEngine = audioEngine;
|
||||
this.audioContext = this.audioEngine.audioContext;
|
||||
this.value = 0;
|
||||
constructor (audioEngine, audioPlayer, lastEffect) {
|
||||
super(audioEngine, audioPlayer, lastEffect);
|
||||
|
||||
this.input = this.audioContext.createGain();
|
||||
this.leftGain = this.audioContext.createGain();
|
||||
this.rightGain = this.audioContext.createGain();
|
||||
this.channelMerger = this.audioContext.createChannelMerger(2);
|
||||
|
||||
this.input.connect(this.leftGain);
|
||||
this.input.connect(this.rightGain);
|
||||
this.leftGain.connect(this.channelMerger, 0, 0);
|
||||
this.rightGain.connect(this.channelMerger, 0, 1);
|
||||
|
||||
this.set(this.value);
|
||||
this.leftGain = null;
|
||||
this.rightGain = null;
|
||||
this.channelMerger = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the effect value
|
||||
* @param {number} val - the new value to set the effect to
|
||||
*/
|
||||
set (val) {
|
||||
this.value = val;
|
||||
* Should the effect be connected to the audio graph?
|
||||
* @return {boolean} is the effect affecting the graph?
|
||||
*/
|
||||
get _isPatch () {
|
||||
return this.initialized && this.value !== 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the Effect.
|
||||
* Effects start out uninitialized. Then initialize when they are first set
|
||||
* with some value.
|
||||
* @throws {Error} throws when left unimplemented
|
||||
*/
|
||||
initialize () {
|
||||
const audioContext = this.audioEngine.audioContext;
|
||||
|
||||
this.inputNode = audioContext.createGain();
|
||||
this.leftGain = audioContext.createGain();
|
||||
this.rightGain = audioContext.createGain();
|
||||
this.channelMerger = audioContext.createChannelMerger(2);
|
||||
this.outputNode = this.channelMerger;
|
||||
|
||||
this.inputNode.connect(this.leftGain);
|
||||
this.inputNode.connect(this.rightGain);
|
||||
this.leftGain.connect(this.channelMerger, 0, 0);
|
||||
this.rightGain.connect(this.channelMerger, 0, 1);
|
||||
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the effect value
|
||||
* @param {number} value - the new value to set the effect to
|
||||
*/
|
||||
_set (value) {
|
||||
this.value = value;
|
||||
|
||||
// Map the scratch effect value (-100 to 100) to (0 to 1)
|
||||
const p = (val + 100) / 200;
|
||||
const p = (value + 100) / 200;
|
||||
|
||||
// Use trig functions for equal-loudness panning
|
||||
// See e.g. https://docs.cycling74.com/max7/tutorials/13_panningchapter01
|
||||
|
@ -45,22 +70,23 @@ class PanEffect {
|
|||
this.rightGain.gain.setTargetAtTime(rightVal, 0, this.audioEngine.DECAY_TIME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connnect this effect's output to another audio node
|
||||
* @param {AudioNode} node - the node to connect to
|
||||
*/
|
||||
connect (node) {
|
||||
this.channelMerger.connect(node);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up and disconnect audio nodes.
|
||||
*/
|
||||
dispose () {
|
||||
this.input.disconnect();
|
||||
this.inputNode.disconnect();
|
||||
this.leftGain.disconnect();
|
||||
this.rightGain.disconnect();
|
||||
this.channelMerger.disconnect();
|
||||
|
||||
this.inputNode = null;
|
||||
this.leftGain = null;
|
||||
this.rightGain = null;
|
||||
this.channelMerger = null;
|
||||
this.outputNode = null;
|
||||
this.target = null;
|
||||
|
||||
this.initialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,53 +1,90 @@
|
|||
const Effect = require('./Effect');
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
class PitchEffect {
|
||||
constructor () {
|
||||
this.value = 0; // effect value
|
||||
this.ratio = 1; // the playback rate ratio
|
||||
* 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.
|
||||
*/
|
||||
class PitchEffect extends Effect {
|
||||
/**
|
||||
* @param {AudioEngine} audioEngine - audio engine this runs with
|
||||
* @param {AudioPlayer} audioPlayer - audio player this affects
|
||||
* @param {Effect} lastEffect - effect in the chain before this one
|
||||
* @constructor
|
||||
*/
|
||||
constructor (audioEngine, audioPlayer, lastEffect) {
|
||||
super(audioEngine, audioPlayer, lastEffect);
|
||||
|
||||
/**
|
||||
* The playback rate ratio
|
||||
* @type {Number}
|
||||
*/
|
||||
this.ratio = 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
set (val, players) {
|
||||
this.value = val;
|
||||
* Should the effect be connected to the audio graph?
|
||||
* @return {boolean} is the effect affecting the graph?
|
||||
*/
|
||||
get _isPatch () {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the input node.
|
||||
* @return {AudioNode} - audio node that is the input for this effect
|
||||
*/
|
||||
getInputNode () {
|
||||
return this.target.getInputNode();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the Effect.
|
||||
* Effects start out uninitialized. Then initialize when they are first set
|
||||
* with some value.
|
||||
* @throws {Error} throws when left unimplemented
|
||||
*/
|
||||
initialize () {
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the effect value.
|
||||
* @param {number} value - the new value to set the effect to
|
||||
*/
|
||||
_set (value) {
|
||||
this.value = value;
|
||||
this.ratio = this.getRatio(this.value);
|
||||
this.updatePlayers(players);
|
||||
this.updatePlayers(this.audioPlayer.getSoundPlayers());
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
changeBy (val, players) {
|
||||
this.set(this.value + val, players);
|
||||
* Update the effect for changes in the audioPlayer.
|
||||
*/
|
||||
update () {
|
||||
this.updatePlayers(this.audioPlayer.getSoundPlayers());
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
* 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
|
||||
*/
|
||||
getRatio (val) {
|
||||
const interval = val / 10;
|
||||
// Convert the musical interval in semitones to a frequency ratio
|
||||
|
@ -55,23 +92,26 @@ class PitchEffect {
|
|||
}
|
||||
|
||||
/**
|
||||
* Update a sound player's playback rate using the current ratio for the effect
|
||||
* @param {object} player - a SoundPlayer object
|
||||
*/
|
||||
* Update a sound player's playback rate using the current ratio for the
|
||||
* effect
|
||||
* @param {object} player - a SoundPlayer object
|
||||
*/
|
||||
updatePlayer (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
|
||||
*/
|
||||
* 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
|
||||
*/
|
||||
updatePlayers (players) {
|
||||
if (!players) return;
|
||||
|
||||
for (const md5 in players) {
|
||||
if (players.hasOwnProperty(md5)) {
|
||||
this.updatePlayer(players[md5]);
|
||||
for (const id in players) {
|
||||
if (players.hasOwnProperty(id)) {
|
||||
this.updatePlayer(players[id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue