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": {
"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",

View file

@ -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

View file

@ -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.inputNode);
pitchEffect.connect(panEffect.inputNode);
// 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,22 @@ 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);
}
connect (node) {
this.outputNode.connect(node);
}
/**
* 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;
}
}

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
* 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.leftGain = null;
this.rightGain = null;
this.channelMerger = null;
}
this.input.connect(this.leftGain);
this.input.connect(this.rightGain);
get isNeutral () {
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.rightGain.connect(this.channelMerger, 0, 1);
this.set(this.value);
this.initialized = true;
}
/**
* 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) {
this.value = val;
_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 +60,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.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
* 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;
* 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.
* @returns {boolean} is the effect affecting the graph?
*/
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.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 +80,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]);
}
}
}