From 61e54b24574b0525a0a3a4b4251ee66d3bedb720 Mon Sep 17 00:00:00 2001 From: "Michael \"Z\" Goddard" Date: Tue, 5 Jun 2018 10:57:27 -0400 Subject: [PATCH] extend existing effects from Effect Add Effect class that manages connecting the effects in a chain from their AudioPlayer to the AudioEngine. --- package.json | 3 +- src/AudioEngine.js | 4 +- src/AudioPlayer.js | 74 +++++++++++++------- src/effects/Effect.js | 137 +++++++++++++++++++++++++++++++++++++ src/effects/PanEffect.js | 78 ++++++++++++--------- src/effects/PitchEffect.js | 124 ++++++++++++++++++++------------- 6 files changed, 312 insertions(+), 108 deletions(-) create mode 100644 src/effects/Effect.js diff --git a/package.json b/package.json index 7adf05e..bde8aad 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/AudioEngine.js b/src/AudioEngine.js index 8746980..e021306 100644 --- a/src/AudioEngine.js +++ b/src/AudioEngine.js @@ -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 diff --git a/src/AudioPlayer.js b/src/AudioPlayer.js index 27d7310..0ba8f3d 100644 --- a/src/AudioPlayer.js +++ b/src/AudioPlayer.js @@ -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} 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; } } diff --git a/src/effects/Effect.js b/src/effects/Effect.js new file mode 100644 index 0000000..fa8e9dd --- /dev/null +++ b/src/effects/Effect.js @@ -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; diff --git a/src/effects/PanEffect.js b/src/effects/PanEffect.js index 73a25b9..7fc2113 100644 --- a/src/effects/PanEffect.js +++ b/src/effects/PanEffect.js @@ -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; } } diff --git a/src/effects/PitchEffect.js b/src/effects/PitchEffect.js index 4d04736..24e5383 100644 --- a/src/effects/PitchEffect.js +++ b/src/effects/PitchEffect.js @@ -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]); } } }