extend existing effects from Effect

Add Effect class that manages connecting the effects in a chain from
their AudioPlayer to the AudioEngine.
This commit is contained in:
Michael "Z" Goddard 2018-06-05 10:57:27 -04:00
parent d5b6290d45
commit 61e54b2457
No known key found for this signature in database
GPG key ID: 762CD40DD5349872
6 changed files with 312 additions and 108 deletions

View file

@ -24,7 +24,8 @@
"dependencies": { "dependencies": {
"audio-context": "1.0.1", "audio-context": "1.0.1",
"minilog": "^3.0.1", "minilog": "^3.0.1",
"startaudiocontext": "1.2.1" "startaudiocontext": "1.2.1",
"tap": "^12.0.1"
}, },
"devDependencies": { "devDependencies": {
"babel-core": "^6.24.1", "babel-core": "^6.24.1",

View file

@ -49,8 +49,8 @@ class AudioEngine {
* will change the volume for all sounds. * will change the volume for all sounds.
* @type {GainNode} * @type {GainNode}
*/ */
this.input = this.audioContext.createGain(); this.inputNode = this.audioContext.createGain();
this.input.connect(this.audioContext.destination); this.inputNode.connect(this.audioContext.destination);
/** /**
* a map of soundIds to audio buffers, holding sounds for all sprites * a map of soundIds to audio buffers, holding sounds for all sprites

View file

@ -14,20 +14,26 @@ class AudioPlayer {
constructor (audioEngine) { constructor (audioEngine) {
this.audioEngine = audioEngine; this.audioEngine = audioEngine;
// Create the audio effects this.outputNode = this.audioEngine.audioContext.createGain();
this.pitchEffect = new PitchEffect();
this.panEffect = new PanEffect(this.audioEngine);
// Chain the audio effects together // Create the audio effects
// effectsNode -> panEffect -> audioEngine.input const pitchEffect = new PitchEffect(this.audioEngine, this, null);
this.effectsNode = this.audioEngine.audioContext.createGain(); const panEffect = new PanEffect(this.audioEngine, this, pitchEffect);
this.effectsNode.connect(this.panEffect.input); this.effects = {
this.panEffect.connect(this.audioEngine.input); pitch: pitchEffect,
pan: panEffect
};
// Chain the effects and player together with the audio engine.
// outputNode -> "pitchEffect" -> panEffect -> audioEngine.input
panEffect.connect(this.audioEngine.inputNode);
pitchEffect.connect(panEffect.inputNode);
// reset effects to their default parameters // reset effects to their default parameters
this.clearEffects(); 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 = {}; this.activeSoundPlayers = {};
} }
@ -36,7 +42,16 @@ class AudioPlayer {
* @return {AudioNode} the AudioNode for this sprite's input * @return {AudioNode} the AudioNode for this sprite's input
*/ */
getInputNode () { 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 // create a new soundplayer to play the sound
const player = new SoundPlayer(this.audioEngine.audioContext); const player = new SoundPlayer(this.audioEngine.audioContext);
player.setBuffer(this.audioEngine.audioBuffers[soundId]); player.setBuffer(this.audioEngine.audioBuffers[soundId]);
player.connect(this.effectsNode); player.connect(this.outputNode);
this.pitchEffect.updatePlayer(player);
player.start(); player.start();
// add it to the list of active sound players // add it to the list of active sound players
this.activeSoundPlayers[soundId] = player; 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) { for (const id in this.activeSoundPlayers) {
if (this.activeSoundPlayers.hasOwnProperty(id)) { if (this.activeSoundPlayers.hasOwnProperty(id)) {
if (!this.activeSoundPlayers[id].isPlaying) { if (!this.activeSoundPlayers[id].isPlaying) {
@ -93,13 +111,8 @@ class AudioPlayer {
* @param {number} value - the value to set the effect to * @param {number} value - the value to set the effect to
*/ */
setEffect (effect, value) { setEffect (effect, value) {
switch (effect) { if (this.effects.hasOwnProperty(effect)) {
case this.audioEngine.EFFECT_NAMES.pitch: this.effects[effect].set(value);
this.pitchEffect.set(value, this.activeSoundPlayers);
break;
case this.audioEngine.EFFECT_NAMES.pan:
this.panEffect.set(value);
break;
} }
} }
@ -107,10 +120,12 @@ class AudioPlayer {
* Clear all audio effects * Clear all audio effects
*/ */
clearEffects () { clearEffects () {
this.panEffect.set(0); for (const effectName in this.effects) {
this.pitchEffect.set(0, this.activeSoundPlayers); this.effects[effectName].clear();
}
if (this.audioEngine === null) return; 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,22 @@ class AudioPlayer {
*/ */
setVolume (value) { setVolume (value) {
if (this.audioEngine === null) return; 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);
}
connect (node) {
this.outputNode.connect(node);
} }
/** /**
* Clean up and disconnect audio nodes. * Clean up and disconnect audio nodes.
*/ */
dispose () { dispose () {
this.panEffect.dispose(); this.effects.pitch.dispose();
this.effectsNode.disconnect(); this.effects.pan.dispose();
this.outputNode.disconnect();
this.outputNode = null;
} }
} }

137
src/effects/Effect.js Normal file
View file

@ -0,0 +1,137 @@
/**
* 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.targetNode = null;
}
/**
* Default value to set the Effect to when constructed and when clear'ed.
* @const {number}
*/
get DEFAULT_VALUE () {
return 0;
}
/**
* Does the effect currently affect the player's graph.
* The pitch effect is always neutral. Instead of affecting the graph it
* affects the player directly.
* @return {boolean} is the effect affecting the graph?
*/
get isNeutral () {
return !this.initialized;
}
/**
* 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 isNeutral = this.isNeutral;
// 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.isNeutral !== isNeutral && this.targetNode !== null) {
this.connect(this.targetNode);
}
}
/**
* 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 {AudioNode} node - the node to connect to
*/
connect (node) {
this.targetNode = node;
if (node === null) {
return;
}
if (this.isNeutral) {
if (this.lastEffect === null) {
this.audioPlayer.connect(node);
} else {
this.lastEffect.connect(node);
}
} else {
if (this.lastEffect === null) {
this.audioPlayer.connect(this.inputNode);
} else {
this.lastEffect.connect(this.inputNode);
}
this.outputNode.connect(node);
}
}
/**
* Clean up and disconnect audio nodes.
*/
dispose () {
this.inputNode = null;
this.outputNode = null;
this.targetNode = null;
this.initialized = false;
}
}
module.exports = Effect;

View file

@ -1,40 +1,55 @@
const Effect = require('./Effect');
/** /**
* A pan effect, which moves the sound to the left or right between the speakers * 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, * Effect value of -100 puts the audio entirely on the left channel,
* 0 centers it, 100 puts it on the right. * 0 centers it, 100 puts it on the right.
*/ */
class PanEffect { class PanEffect extends Effect {
/** /**
* @param {AudioEngine} audioEngine - the audio engine. * @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
*/ */
constructor (audioEngine) { constructor (audioEngine, audioPlayer, lastEffect) {
this.audioEngine = audioEngine; super(audioEngine, audioPlayer, lastEffect);
this.audioContext = this.audioEngine.audioContext;
this.value = 0;
this.input = this.audioContext.createGain(); this.leftGain = null;
this.leftGain = this.audioContext.createGain(); this.rightGain = null;
this.rightGain = this.audioContext.createGain(); this.channelMerger = null;
this.channelMerger = this.audioContext.createChannelMerger(2); }
this.input.connect(this.leftGain); get isNeutral () {
this.input.connect(this.rightGain); return !this.initialized || this.value === 0;
}
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.leftGain.connect(this.channelMerger, 0, 0);
this.rightGain.connect(this.channelMerger, 0, 1); this.rightGain.connect(this.channelMerger, 0, 1);
this.set(this.value); this.initialized = true;
} }
/** /**
* Set the effect value * Set the effect value
* @param {number} val - the new value to set the effect to * @param {number} value - the new value to set the effect to
*/ */
set (val) { _set (value) {
this.value = val; this.value = value;
// Map the scratch effect value (-100 to 100) to (0 to 1) // 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 // Use trig functions for equal-loudness panning
// See e.g. https://docs.cycling74.com/max7/tutorials/13_panningchapter01 // See e.g. https://docs.cycling74.com/max7/tutorials/13_panningchapter01
@ -45,22 +60,23 @@ class PanEffect {
this.rightGain.gain.setTargetAtTime(rightVal, 0, this.audioEngine.DECAY_TIME); 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. * Clean up and disconnect audio nodes.
*/ */
dispose () { dispose () {
this.input.disconnect(); this.inputNode.disconnect();
this.leftGain.disconnect(); this.leftGain.disconnect();
this.rightGain.disconnect(); this.rightGain.disconnect();
this.channelMerger.disconnect(); this.channelMerger.disconnect();
this.inputNode = null;
this.leftGain = null;
this.rightGain = null;
this.channelMerger = null;
this.outputNode = null;
this.targetNode = null;
this.initialized = false;
} }
} }

View file

@ -1,53 +1,78 @@
const Effect = require('./Effect');
/** /**
* A pitch change effect, which changes the playback rate of the sound in order * 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 * to change its pitch: reducing the playback rate lowers the pitch, increasing
* raises the pitch. The duration of the sound is also changed. * 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 * Changing the value of the pitch effect by 10 causes a change in pitch by 1
* (i.e. a musical half-step, such as the difference between C and C#) * 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) * 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). * The value of this effect is not clamped (i.e. it is typically between -120
* We should consider what extreme values to use for clamping it. * 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 * Note that this effect functions differently from the other audio effects. It
* on one SoundPlayer or a group of them. * 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 () { class PitchEffect extends Effect {
this.value = 0; // effect value /**
this.ratio = 1; // the playback rate ratio * @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 * Does the effect currently affect the player's graph.
* @param {number} val - the new value to set the effect to * The pitch effect is always neutral. Instead of affecting the graph it
* @param {object} players - a dictionary of SoundPlayer objects to apply the effect to, indexed by md5 * affects the player directly.
*/ * @returns {boolean} is the effect affecting the graph?
set (val, players) { */
this.value = val; get isNeutral () {
return true;
}
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.ratio = this.getRatio(this.value);
this.updatePlayers(players); this.updatePlayers(this.audioPlayer.getSoundPlayers());
} }
/** /**
* Change the effect value * Update the effect for changes in the audioPlayer.
* @param {number} val - the value to change the effect by */
* @param {object} players - a dictionary of SoundPlayer objects indexed by md5 update () {
*/ this.updatePlayers(this.audioPlayer.getSoundPlayers());
changeBy (val, players) {
this.set(this.value + val, players);
} }
/** /**
* Compute the playback ratio for an effect value. * Compute the playback ratio for an effect value.
* The playback ratio is scaled so that a change of 10 in the 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. * gives a change of 1 semitone in the ratio.
* @param {number} val - an effect value * @param {number} val - an effect value
* @returns {number} a playback ratio * @returns {number} a playback ratio
*/ */
getRatio (val) { getRatio (val) {
const interval = val / 10; const interval = val / 10;
// Convert the musical interval in semitones to a frequency ratio // Convert the musical interval in semitones to a frequency ratio
@ -55,23 +80,26 @@ class PitchEffect {
} }
/** /**
* Update a sound player's playback rate using the current ratio for the effect * Update a sound player's playback rate using the current ratio for the
* @param {object} player - a SoundPlayer object * effect
*/ * @param {object} player - a SoundPlayer object
*/
updatePlayer (player) { updatePlayer (player) {
player.setPlaybackRate(this.ratio); player.setPlaybackRate(this.ratio);
} }
/** /**
* Update a sound player's playback rate using the current ratio for the effect * Update a sound player's playback rate using the current ratio for the
* @param {object} players - a dictionary of SoundPlayer objects to update, indexed by md5 * effect
*/ * @param {object} players - a dictionary of SoundPlayer objects to update,
* indexed by md5
*/
updatePlayers (players) { updatePlayers (players) {
if (!players) return; if (!players) return;
for (const md5 in players) { for (const id in players) {
if (players.hasOwnProperty(md5)) { if (players.hasOwnProperty(id)) {
this.updatePlayer(players[md5]); this.updatePlayer(players[id]);
} }
} }
} }