From 18cb9787ae139fa4766855721a8b1c0faa198f4e Mon Sep 17 00:00:00 2001 From: Corey Frang Date: Tue, 19 Jun 2018 13:37:00 -0400 Subject: [PATCH 01/13] wip: greenplayer tests --- package.json | 5 +- src/AudioEngine.js | 10 ++-- src/GreenPlayer.js | 16 +++--- src/StartAudioContext.js | 10 ++++ src/effects/Effect.js | 9 ++-- src/effects/VolumeEffect.js | 12 ++--- test/AudioEngine.js | 17 ++++++ test/SoundPlayer.js | 102 ++++++++++++++++++++++++++++++++++++ 8 files changed, 159 insertions(+), 22 deletions(-) create mode 100644 src/StartAudioContext.js create mode 100644 test/AudioEngine.js create mode 100644 test/SoundPlayer.js diff --git a/package.json b/package.json index 74ef596..c633a71 100644 --- a/package.json +++ b/package.json @@ -33,8 +33,9 @@ "babel-preset-env": "^1.6.1", "eslint": "^3.19.0", "eslint-config-scratch": "^3.1.0", + "tap": "^12.0.1", + "web-audio-test-api": "^0.5.2", "webpack": "^4.8.0", - "webpack-cli": "^2.0.15", - "tap": "^12.0.1" + "webpack-cli": "^2.0.15" } } diff --git a/src/AudioEngine.js b/src/AudioEngine.js index 8c7e142..84b7af7 100644 --- a/src/AudioEngine.js +++ b/src/AudioEngine.js @@ -1,4 +1,4 @@ -const StartAudioContext = require('startaudiocontext'); +const StartAudioContext = require('./StartAudioContext'); const AudioContext = require('audio-context'); const log = require('./log'); @@ -35,13 +35,13 @@ const decodeAudioData = function (audioContext, buffer) { * sprites. */ class AudioEngine { - constructor () { + constructor (audioContext = new AudioContext()) { /** * AudioContext to play and manipulate sounds with a graph of source * and effect nodes. * @type {AudioContext} */ - this.audioContext = new AudioContext(); + this.audioContext = audioContext; StartAudioContext(this.audioContext); /** @@ -65,6 +65,10 @@ class AudioEngine { this.loudness = null; } + get currentTime () { + return this.audioContext.currentTime; + } + /** * Names of the audio effects. * @enum {string} diff --git a/src/GreenPlayer.js b/src/GreenPlayer.js index e73954a..93d53ac 100644 --- a/src/GreenPlayer.js +++ b/src/GreenPlayer.js @@ -33,6 +33,8 @@ class SoundPlayer extends EventEmitter { this.isPlaying = false; this.startingUntil = 0; this.playbackRate = 1; + + this.handleEvent = this.handleEvent.bind(this); } /** @@ -68,7 +70,7 @@ class SoundPlayer extends EventEmitter { */ _createSource () { if (this.outputNode !== null) { - this.outputNode.removeEventListener(ON_ENDED, this); + this.outputNode.removeEventListener(ON_ENDED, this.handleEvent); this.outputNode.disconnect(); } @@ -76,7 +78,7 @@ class SoundPlayer extends EventEmitter { this.outputNode.playbackRate.value = this.playbackRate; this.outputNode.buffer = this.buffer; - this.outputNode.addEventListener(ON_ENDED, this); + this.outputNode.addEventListener(ON_ENDED, this.handleEvent); if (this.target !== null) { this.connect(this.target); @@ -149,7 +151,7 @@ class SoundPlayer extends EventEmitter { */ take () { if (this.outputNode) { - this.outputNode.removeEventListener(ON_ENDED, this); + this.outputNode.removeEventListener(ON_ENDED, this.handleEvent); } const taken = new SoundPlayer(this.audioEngine, this); @@ -160,7 +162,7 @@ class SoundPlayer extends EventEmitter { taken.initialize(); taken.outputNode.disconnect(); taken.outputNode = this.outputNode; - taken.outputNode.addEventListener(ON_ENDED, taken); + taken.outputNode.addEventListener(ON_ENDED, taken.handleEvent); taken.volumeEffect.set(this.volumeEffect.value); if (this.target !== null) { taken.connect(this.target); @@ -202,10 +204,10 @@ class SoundPlayer extends EventEmitter { this.take().stop(); } - if (!this.initialized) { - this.initialize(); - } else { + if (this.initialized) { this._createSource(); + } else { + this.initialize(); } this.volumeEffect.set(this.volumeEffect.DEFAULT_VALUE); diff --git a/src/StartAudioContext.js b/src/StartAudioContext.js new file mode 100644 index 0000000..6959d19 --- /dev/null +++ b/src/StartAudioContext.js @@ -0,0 +1,10 @@ +// StartAudioContext assumes that we are in a window/document setting and messes with the unit +// tests, this is our own version just checking to see if we have a global document to listen +// to before we even try to "start" it. Our test api audio context is started by default. +const StartAudioContext = require('startaudiocontext'); + +module.exports = function (context) { + if (typeof document !== 'undefined') { + return StartAudioContext(context); + } +}; diff --git a/src/effects/Effect.js b/src/effects/Effect.js index 8a4da12..584e843 100644 --- a/src/effects/Effect.js +++ b/src/effects/Effect.js @@ -82,14 +82,17 @@ class Effect { } // Store whether the graph should currently affected by this effect. - const _isPatch = this._isPatch; + const wasPatch = this._isPatch; + if (wasPatch) { + this._lastPatch = this.audioEngine.currentTime; + } // 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) { + if (this._isPatch !== wasPatch && this.target !== null) { this.connect(this.target); } } @@ -133,7 +136,7 @@ class Effect { this.outputNode.disconnect(); } - if (this._isPatch) { + if (this._isPatch || this._lastPatch + this.audioEngine.DECAY_TIME < this.audioEngine.currentTime) { this.outputNode.connect(target.getInputNode()); } diff --git a/src/effects/VolumeEffect.js b/src/effects/VolumeEffect.js index 32204f0..c5ec72e 100644 --- a/src/effects/VolumeEffect.js +++ b/src/effects/VolumeEffect.js @@ -32,13 +32,11 @@ class VolumeEffect extends Effect { */ _set (value) { this.value = value; - // A gain of 1 is normal. Scale down scratch's volume value. Apply the - // change over a tiny period of time. - this.outputNode.gain.setTargetAtTime( - value / 100, - this.audioEngine.audioContext.currentTime, - this.audioEngine.DECAY_TIME - ); + + const {gain} = this.outputNode; + const {audioContext: {currentTime}, DECAY_TIME} = this.audioEngine; + gain.setValueAtTime(gain.value, currentTime); + gain.linearRampToValueAtTime(value / 100, currentTime + DECAY_TIME); } /** diff --git a/test/AudioEngine.js b/test/AudioEngine.js new file mode 100644 index 0000000..f9b1b5e --- /dev/null +++ b/test/AudioEngine.js @@ -0,0 +1,17 @@ +const tap = require('tap'); +const AudioEngine = require('../src/AudioEngine'); + +const {AudioContext} = require('web-audio-test-api'); + +tap.test('AudioEngine', t => { + const audioEngine = new AudioEngine(new AudioContext()); + + t.deepEqual(audioEngine.inputNode.toJSON(), { + gain: { + inputs: [], + value: 1 + }, + inputs: [], + name: 'GainNode' + }, 'JSON Representation of inputNode'); +}); diff --git a/test/SoundPlayer.js b/test/SoundPlayer.js new file mode 100644 index 0000000..b9cf661 --- /dev/null +++ b/test/SoundPlayer.js @@ -0,0 +1,102 @@ +/* global Uint8Array */ +const tap = require('tap'); +const {AudioContext} = require('web-audio-test-api'); + +const AudioEngine = require('../src/AudioEngine'); + + +tap.test('SoundPlayer', suite => { + + let audioContext; + let audioEngine; + let soundPlayer; + + suite.beforeEach(async () => { + audioContext = new AudioContext(); + audioEngine = new AudioEngine(audioContext); + audioEngine.DECODE_AUDIO_DATA_RESULT = audioContext.createBuffer(2, 1024, 44100); + const data = new Uint8Array(1024); + soundPlayer = await audioEngine.decodeSoundPlayer({data}); + }); + + suite.afterEach(() => { + soundPlayer.dispose(); + soundPlayer = null; + audioEngine = null; + audioContext.$reset(); + audioContext = null; + }); + + suite.plan(3); + + suite.test('play initializes and creates chain', t => { + t.plan(3); + t.equal(soundPlayer.initialized, false, 'not yet initialized'); + soundPlayer.play(); + t.equal(soundPlayer.initialized, true, 'now is initialized'); + let buffer = audioEngine.DECODE_AUDIO_DATA_RESULT.toJSON(); + t.deepEqual(soundPlayer.outputNode.toJSON(), { + buffer, + inputs: [], + loop: false, + loopEnd: 0, + loopStart: 0, + name: 'AudioBufferSourceNode', + playbackRate: { + inputs: [], + value: 1 + } + }); + + t.end(); + }); + + suite.test('connect', t => { + t.plan(1); + soundPlayer.play(); + soundPlayer.connect(audioEngine); + t.deepEqual(audioEngine.inputNode.toJSON().inputs, [ + soundPlayer.outputNode.toJSON() + ], 'output node connects to input node'); + t.end(); + }); + + suite.test('stop decay', t => { + t.plan(6); + soundPlayer.play(); + soundPlayer.connect(audioEngine); + + audioContext.$processTo(0); + soundPlayer.stop(); + t.deepEqual(audioEngine.inputNode.toJSON().inputs, [{ + name: 'GainNode', + gain: { + value: 1, + inputs: [] + }, + inputs: [soundPlayer.outputNode.toJSON()] + }], 'output node connects to gain node to input node'); + + audioContext.$processTo(audioEngine.DECAY_TIME / 2); + const engineInputs = audioEngine.inputNode.toJSON().inputs; + t.notEqual(engineInputs[0].gain.value, 1, 'gain value should not be 1'); + t.notEqual(engineInputs[0].gain.value, 0, 'gain value should not be 0'); + t.equal(soundPlayer.outputNode.$state, 'PLAYING'); + + audioContext.$processTo(audioEngine.DECAY_TIME); + t.deepEqual(audioEngine.inputNode.toJSON().inputs, [{ + name: 'GainNode', + gain: { + value: 0, + inputs: [] + }, + inputs: [soundPlayer.outputNode.toJSON()] + }], 'output node connects to gain node to input node decayed'); + + t.equal(soundPlayer.outputNode.$state, 'FINISHED'); + + t.end(); + }); + + suite.end(); +}); From 8110885a6201add20e5692542e361364b2108936 Mon Sep 17 00:00:00 2001 From: Corey Frang Date: Tue, 19 Jun 2018 15:09:03 -0400 Subject: [PATCH 02/13] add a play while playing test --- test/SoundPlayer.js | 63 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 58 insertions(+), 5 deletions(-) diff --git a/test/SoundPlayer.js b/test/SoundPlayer.js index b9cf661..de06401 100644 --- a/test/SoundPlayer.js +++ b/test/SoundPlayer.js @@ -1,4 +1,4 @@ -/* global Uint8Array */ +/* global Uint8Array Promise */ const tap = require('tap'); const {AudioContext} = require('web-audio-test-api'); @@ -11,11 +11,19 @@ tap.test('SoundPlayer', suite => { let audioEngine; let soundPlayer; + const help = { + get engineInputs () { + return audioEngine.inputNode.toJSON().inputs; + } + }; + suite.beforeEach(async () => { audioContext = new AudioContext(); audioEngine = new AudioEngine(audioContext); - audioEngine.DECODE_AUDIO_DATA_RESULT = audioContext.createBuffer(2, 1024, 44100); - const data = new Uint8Array(1024); + // sound will be 0.1 seconds long + audioContext.DECODE_AUDIO_DATA_RESULT = audioContext.createBuffer(2, 4410, 44100); + audioContext.DECODE_AUDIO_DATA_FAILED = false; + const data = new Uint8Array(44100); soundPlayer = await audioEngine.decodeSoundPlayer({data}); }); @@ -27,14 +35,14 @@ tap.test('SoundPlayer', suite => { audioContext = null; }); - suite.plan(3); + suite.plan(4); suite.test('play initializes and creates chain', t => { t.plan(3); t.equal(soundPlayer.initialized, false, 'not yet initialized'); soundPlayer.play(); t.equal(soundPlayer.initialized, true, 'now is initialized'); - let buffer = audioEngine.DECODE_AUDIO_DATA_RESULT.toJSON(); + let buffer = audioContext.DECODE_AUDIO_DATA_RESULT.toJSON(); t.deepEqual(soundPlayer.outputNode.toJSON(), { buffer, inputs: [], @@ -98,5 +106,50 @@ tap.test('SoundPlayer', suite => { t.end(); }); + suite.test('play while playing', async t => { + t.plan(14); + const log = []; + soundPlayer.play(); + soundPlayer.finished().then(() => log.push('play 1 finished')); + soundPlayer.connect(audioEngine); + + + audioContext.$processTo(0.005); + t.equal(soundPlayer.outputNode.$state, 'PLAYING'); + + const oldPlayerNode = soundPlayer.outputNode; + soundPlayer.play(); + soundPlayer.finished().then(() => log.push('play 2 finished')); + + // wait for a micro-task loop to fire our previous events + await Promise.resolve(); + t.equal(log[0], 'play 1 finished'); + t.notEqual(soundPlayer.outputNode, oldPlayerNode, 'created new player node'); + + t.equal(help.engineInputs.length, 2, 'there should be 2 players connected'); + t.equal(oldPlayerNode.$state, 'PLAYING'); + t.equal(soundPlayer.outputNode.$state, 'PLAYING'); + t.equal(help.engineInputs[0].gain.value, 1, 'old sound connectect to gain node with volume 1'); + + audioContext.$processTo(audioContext.currentTime + 0.001); + t.notEqual(help.engineInputs[0].gain.value, 1, + 'old sound connected to gain node which will fade'); + + audioContext.$processTo(audioContext.currentTime + audioEngine.DECAY_TIME + 0.001); + t.equal(soundPlayer.outputNode.$state, 'PLAYING'); + t.equal(oldPlayerNode.$state, 'FINISHED'); + + t.equal(help.engineInputs[0].gain.value, 0, 'faded old sound to 0'); + + t.equal(log.length, 1); + audioContext.$processTo(audioContext.currentTime + 0.2); + + await Promise.resolve(); + t.equal(log[1], 'play 2 finished'); + t.equal(log.length, 2); + + t.end(); + }); + suite.end(); }); From 2c4edf288f61ebbc6b95ec8dce89718508c28b73 Mon Sep 17 00:00:00 2001 From: Corey Frang Date: Wed, 20 Jun 2018 13:42:38 -0400 Subject: [PATCH 03/13] update test to work with new isPlaying problem --- test/SoundPlayer.js | 1 + 1 file changed, 1 insertion(+) diff --git a/test/SoundPlayer.js b/test/SoundPlayer.js index de06401..a2f896f 100644 --- a/test/SoundPlayer.js +++ b/test/SoundPlayer.js @@ -113,6 +113,7 @@ tap.test('SoundPlayer', suite => { soundPlayer.finished().then(() => log.push('play 1 finished')); soundPlayer.connect(audioEngine); + await Promise.resolve(); audioContext.$processTo(0.005); t.equal(soundPlayer.outputNode.$state, 'PLAYING'); From 5b693f6708d18c8ba6bf771a9f3289d30068901e Mon Sep 17 00:00:00 2001 From: Corey Frang Date: Wed, 20 Jun 2018 14:02:05 -0400 Subject: [PATCH 04/13] move take to stop, play is always green / non effected until stopped --- src/GreenPlayer.js | 44 +++++++++++++++++++++++--------------------- test/SoundPlayer.js | 12 +++++++----- 2 files changed, 30 insertions(+), 26 deletions(-) diff --git a/src/GreenPlayer.js b/src/GreenPlayer.js index 93d53ac..32446c4 100644 --- a/src/GreenPlayer.js +++ b/src/GreenPlayer.js @@ -27,6 +27,7 @@ class SoundPlayer extends EventEmitter { this.buffer = buffer; this.outputNode = null; + this.volumeEffect = null; this.target = null; this.initialized = false; @@ -91,8 +92,6 @@ class SoundPlayer extends EventEmitter { initialize () { this.initialized = true; - this.volumeEffect = new VolumeEffect(this.audioEngine, this, null); - this._createSource(); } @@ -114,7 +113,12 @@ class SoundPlayer extends EventEmitter { return; } - this.volumeEffect.connect(target); + if (this.volumeEffect) { + this.volumeEffect.connect(target); + } else { + this.outputNode.disconnect(); + this.outputNode.connect(target.getInputNode()); + } return this; } @@ -129,8 +133,10 @@ class SoundPlayer extends EventEmitter { this.stopImmediately(); - this.volumeEffect.dispose(); - this.volumeEffect = null; + if (this.volumeEffect) { + this.volumeEffect.dispose(); + this.volumeEffect = null; + } this.outputNode.disconnect(); this.outputNode = null; @@ -159,17 +165,14 @@ class SoundPlayer extends EventEmitter { if (this.isPlaying) { taken.startingUntil = this.startingUntil; taken.isPlaying = this.isPlaying; - taken.initialize(); - taken.outputNode.disconnect(); + taken.initialized = this.initialized; taken.outputNode = this.outputNode; taken.outputNode.addEventListener(ON_ENDED, taken.handleEvent); - taken.volumeEffect.set(this.volumeEffect.value); + taken.volumeEffect = this.volumeEffect; if (this.target !== null) { taken.connect(this.target); } - } - if (this.isPlaying) { this.emit('stop'); taken.emit('play'); } @@ -198,10 +201,7 @@ class SoundPlayer extends EventEmitter { } if (this.isPlaying) { - // Spawn a Player with the current buffer source, and play for a - // short period until its volume is 0 and release it to be - // eventually garbage collected. - this.take().stop(); + this.stop(); } if (this.initialized) { @@ -210,7 +210,6 @@ class SoundPlayer extends EventEmitter { this.initialize(); } - this.volumeEffect.set(this.volumeEffect.DEFAULT_VALUE); this.outputNode.start(); this.isPlaying = true; @@ -228,13 +227,16 @@ class SoundPlayer extends EventEmitter { return; } - this.volumeEffect.set(0); - this.outputNode.stop(this.audioEngine.audioContext.currentTime + this.audioEngine.DECAY_TIME); + // always do a manual stop on a taken / volume effect fade out sound player + // take will emit "stop" as well as reset all of our playing statuses / remove our + // nodes / etc + const taken = this.take(); + taken.volumeEffect = new VolumeEffect(taken.audioEngine, taken, null); + taken.volumeEffect.connect(taken.target); + taken.connect(taken.volumeEffect); - this.isPlaying = false; - this.startingUntil = 0; - - this.emit('stop'); + taken.volumeEffect.set(0); + taken.outputNode.stop(this.audioEngine.audioContext.currentTime + this.audioEngine.DECAY_TIME); } /** diff --git a/test/SoundPlayer.js b/test/SoundPlayer.js index a2f896f..d979b58 100644 --- a/test/SoundPlayer.js +++ b/test/SoundPlayer.js @@ -70,26 +70,28 @@ tap.test('SoundPlayer', suite => { }); suite.test('stop decay', t => { - t.plan(6); + t.plan(7); soundPlayer.play(); soundPlayer.connect(audioEngine); + const outputNode = soundPlayer.outputNode; audioContext.$processTo(0); soundPlayer.stop(); + t.equal(soundPlayer.outputNode, null, 'nullify outputNode immediately (taken sound is stopping)'); t.deepEqual(audioEngine.inputNode.toJSON().inputs, [{ name: 'GainNode', gain: { value: 1, inputs: [] }, - inputs: [soundPlayer.outputNode.toJSON()] + inputs: [outputNode.toJSON()] }], 'output node connects to gain node to input node'); audioContext.$processTo(audioEngine.DECAY_TIME / 2); const engineInputs = audioEngine.inputNode.toJSON().inputs; t.notEqual(engineInputs[0].gain.value, 1, 'gain value should not be 1'); t.notEqual(engineInputs[0].gain.value, 0, 'gain value should not be 0'); - t.equal(soundPlayer.outputNode.$state, 'PLAYING'); + t.equal(outputNode.$state, 'PLAYING'); audioContext.$processTo(audioEngine.DECAY_TIME); t.deepEqual(audioEngine.inputNode.toJSON().inputs, [{ @@ -98,10 +100,10 @@ tap.test('SoundPlayer', suite => { value: 0, inputs: [] }, - inputs: [soundPlayer.outputNode.toJSON()] + inputs: [outputNode.toJSON()] }], 'output node connects to gain node to input node decayed'); - t.equal(soundPlayer.outputNode.$state, 'FINISHED'); + t.equal(outputNode.$state, 'FINISHED'); t.end(); }); From 7f2b9786ee840ba0ee1d230136ec9797e602cc40 Mon Sep 17 00:00:00 2001 From: Corey Frang Date: Wed, 20 Jun 2018 14:16:39 -0400 Subject: [PATCH 05/13] Fix broken tests (mock change for effects and plan audioengine) --- test/AudioEngine.js | 1 + test/__mocks__/AudioParam.js | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/test/AudioEngine.js b/test/AudioEngine.js index f9b1b5e..0e12087 100644 --- a/test/AudioEngine.js +++ b/test/AudioEngine.js @@ -6,6 +6,7 @@ const {AudioContext} = require('web-audio-test-api'); tap.test('AudioEngine', t => { const audioEngine = new AudioEngine(new AudioContext()); + t.plan(1); t.deepEqual(audioEngine.inputNode.toJSON(), { gain: { inputs: [], diff --git a/test/__mocks__/AudioParam.js b/test/__mocks__/AudioParam.js index 8ddc9a5..7cfd0b7 100644 --- a/test/__mocks__/AudioParam.js +++ b/test/__mocks__/AudioParam.js @@ -2,6 +2,12 @@ class AudioParamMock { setTargetAtTime (value /* , start, stop */) { this.value = value; } + setValueAtTime (value) { + this.value = value; + } + linearRampToValueAtTime (value) { + this.value = value; + } } module.exports = AudioParamMock; From c62adcab3b18cd1cb26c508be8ffa73dbfb45e76 Mon Sep 17 00:00:00 2001 From: Corey Frang Date: Wed, 20 Jun 2018 14:20:11 -0400 Subject: [PATCH 06/13] dispose taken sound player after it fades --- src/GreenPlayer.js | 8 +++----- test/SoundPlayer.js | 3 ++- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/GreenPlayer.js b/src/GreenPlayer.js index 32446c4..024a905 100644 --- a/src/GreenPlayer.js +++ b/src/GreenPlayer.js @@ -113,7 +113,7 @@ class SoundPlayer extends EventEmitter { return; } - if (this.volumeEffect) { + if (this.volumeEffect !== null) { this.volumeEffect.connect(target); } else { this.outputNode.disconnect(); @@ -133,7 +133,7 @@ class SoundPlayer extends EventEmitter { this.stopImmediately(); - if (this.volumeEffect) { + if (this.volumeEffect !== null) { this.volumeEffect.dispose(); this.volumeEffect = null; } @@ -178,9 +178,6 @@ class SoundPlayer extends EventEmitter { } this.outputNode = null; - if (this.volumeEffect !== null) { - this.volumeEffect.dispose(); - } this.volumeEffect = null; this.initialized = false; this.startingUntil = 0; @@ -234,6 +231,7 @@ class SoundPlayer extends EventEmitter { taken.volumeEffect = new VolumeEffect(taken.audioEngine, taken, null); taken.volumeEffect.connect(taken.target); taken.connect(taken.volumeEffect); + taken.finished().then(() => taken.dispose()); taken.volumeEffect.set(0); taken.outputNode.stop(this.audioEngine.audioContext.currentTime + this.audioEngine.DECAY_TIME); diff --git a/test/SoundPlayer.js b/test/SoundPlayer.js index d979b58..774538e 100644 --- a/test/SoundPlayer.js +++ b/test/SoundPlayer.js @@ -109,7 +109,7 @@ tap.test('SoundPlayer', suite => { }); suite.test('play while playing', async t => { - t.plan(14); + t.plan(15); const log = []; soundPlayer.play(); soundPlayer.finished().then(() => log.push('play 1 finished')); @@ -149,6 +149,7 @@ tap.test('SoundPlayer', suite => { await Promise.resolve(); t.equal(log[1], 'play 2 finished'); + t.equal(help.engineInputs.length, 1, 'old sound disconneted itself after done'); t.equal(log.length, 2); t.end(); From 1d5d6bfbb2a112d78f093c78e0962f23648fb761 Mon Sep 17 00:00:00 2001 From: Corey Frang Date: Wed, 20 Jun 2018 14:45:09 -0400 Subject: [PATCH 07/13] Feedback from review fixes --- src/GreenPlayer.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/GreenPlayer.js b/src/GreenPlayer.js index 024a905..24d281f 100644 --- a/src/GreenPlayer.js +++ b/src/GreenPlayer.js @@ -35,6 +35,9 @@ class SoundPlayer extends EventEmitter { this.startingUntil = 0; this.playbackRate = 1; + // handleEvent is a EventTarget api for the DOM, however the web-audio-test-api we use + // uses an addEventListener that isn't compatable with object and requires us to pass + // this bound function instead this.handleEvent = this.handleEvent.bind(this); } @@ -169,6 +172,9 @@ class SoundPlayer extends EventEmitter { taken.outputNode = this.outputNode; taken.outputNode.addEventListener(ON_ENDED, taken.handleEvent); taken.volumeEffect = this.volumeEffect; + if (taken.volumeEffect) { + taken.volumeEffect.audioPlayer = taken; + } if (this.target !== null) { taken.connect(this.target); } @@ -229,8 +235,11 @@ class SoundPlayer extends EventEmitter { // nodes / etc const taken = this.take(); taken.volumeEffect = new VolumeEffect(taken.audioEngine, taken, null); + taken.volumeEffect.connect(taken.target); - taken.connect(taken.volumeEffect); + // volumeEffect will recursively connect to us if it needs to, so this happens too: + // taken.connect(taken.volumeEffect); + taken.finished().then(() => taken.dispose()); taken.volumeEffect.set(0); From cdd35b5296388c2de6425ab76ac512322e9f905b Mon Sep 17 00:00:00 2001 From: Corey Frang Date: Wed, 20 Jun 2018 14:51:48 -0400 Subject: [PATCH 08/13] convert async/await to promise chain --- test/SoundPlayer.js | 66 ++++++++++++++++++++++++++------------------- 1 file changed, 38 insertions(+), 28 deletions(-) diff --git a/test/SoundPlayer.js b/test/SoundPlayer.js index 774538e..5d7a98e 100644 --- a/test/SoundPlayer.js +++ b/test/SoundPlayer.js @@ -108,51 +108,61 @@ tap.test('SoundPlayer', suite => { t.end(); }); - suite.test('play while playing', async t => { + suite.test('play while playing', t => { t.plan(15); const log = []; soundPlayer.play(); soundPlayer.finished().then(() => log.push('play 1 finished')); soundPlayer.connect(audioEngine); + let oldPlayerNode; - await Promise.resolve(); + return Promise.resolve() + .then(() => { - audioContext.$processTo(0.005); - t.equal(soundPlayer.outputNode.$state, 'PLAYING'); + audioContext.$processTo(0.005); + t.equal(soundPlayer.outputNode.$state, 'PLAYING'); - const oldPlayerNode = soundPlayer.outputNode; - soundPlayer.play(); - soundPlayer.finished().then(() => log.push('play 2 finished')); + oldPlayerNode = soundPlayer.outputNode; + soundPlayer.play(); + soundPlayer.finished().then(() => log.push('play 2 finished')); - // wait for a micro-task loop to fire our previous events - await Promise.resolve(); - t.equal(log[0], 'play 1 finished'); - t.notEqual(soundPlayer.outputNode, oldPlayerNode, 'created new player node'); + // wait for a micro-task loop to fire our previous events + return Promise.resolve(); + }) + .then(() => { - t.equal(help.engineInputs.length, 2, 'there should be 2 players connected'); - t.equal(oldPlayerNode.$state, 'PLAYING'); - t.equal(soundPlayer.outputNode.$state, 'PLAYING'); - t.equal(help.engineInputs[0].gain.value, 1, 'old sound connectect to gain node with volume 1'); + t.equal(log[0], 'play 1 finished'); + t.notEqual(soundPlayer.outputNode, oldPlayerNode, 'created new player node'); - audioContext.$processTo(audioContext.currentTime + 0.001); - t.notEqual(help.engineInputs[0].gain.value, 1, + t.equal(help.engineInputs.length, 2, 'there should be 2 players connected'); + t.equal(oldPlayerNode.$state, 'PLAYING'); + t.equal(soundPlayer.outputNode.$state, 'PLAYING'); + t.equal(help.engineInputs[0].gain.value, 1, 'old sound connectect to gain node with volume 1'); + + audioContext.$processTo(audioContext.currentTime + 0.001); + t.notEqual(help.engineInputs[0].gain.value, 1, 'old sound connected to gain node which will fade'); - audioContext.$processTo(audioContext.currentTime + audioEngine.DECAY_TIME + 0.001); - t.equal(soundPlayer.outputNode.$state, 'PLAYING'); - t.equal(oldPlayerNode.$state, 'FINISHED'); + audioContext.$processTo(audioContext.currentTime + audioEngine.DECAY_TIME + 0.001); + t.equal(soundPlayer.outputNode.$state, 'PLAYING'); + t.equal(oldPlayerNode.$state, 'FINISHED'); - t.equal(help.engineInputs[0].gain.value, 0, 'faded old sound to 0'); + t.equal(help.engineInputs[0].gain.value, 0, 'faded old sound to 0'); - t.equal(log.length, 1); - audioContext.$processTo(audioContext.currentTime + 0.2); + t.equal(log.length, 1); + audioContext.$processTo(audioContext.currentTime + 0.2); - await Promise.resolve(); - t.equal(log[1], 'play 2 finished'); - t.equal(help.engineInputs.length, 1, 'old sound disconneted itself after done'); - t.equal(log.length, 2); + // wait for a micro-task loop to fire our previous events + return Promise.resolve(); + }) + .then(() => { - t.end(); + t.equal(log[1], 'play 2 finished'); + t.equal(help.engineInputs.length, 1, 'old sound disconneted itself after done'); + t.equal(log.length, 2); + + t.end(); + }); }); suite.end(); From 2bd946423b2f854eb633e7dac2799e514c217c21 Mon Sep 17 00:00:00 2001 From: Corey Frang Date: Wed, 20 Jun 2018 15:13:05 -0400 Subject: [PATCH 09/13] remove rest of async/await in tests --- test/SoundPlayer.js | 70 +++++++++++++++++++++++---------------------- 1 file changed, 36 insertions(+), 34 deletions(-) diff --git a/test/SoundPlayer.js b/test/SoundPlayer.js index 5d7a98e..c55fe6f 100644 --- a/test/SoundPlayer.js +++ b/test/SoundPlayer.js @@ -17,14 +17,16 @@ tap.test('SoundPlayer', suite => { } }; - suite.beforeEach(async () => { + suite.beforeEach(() => { audioContext = new AudioContext(); audioEngine = new AudioEngine(audioContext); // sound will be 0.1 seconds long audioContext.DECODE_AUDIO_DATA_RESULT = audioContext.createBuffer(2, 4410, 44100); audioContext.DECODE_AUDIO_DATA_FAILED = false; const data = new Uint8Array(44100); - soundPlayer = await audioEngine.decodeSoundPlayer({data}); + return audioEngine.decodeSoundPlayer({data}).then(result => { + soundPlayer = result; + }); }); suite.afterEach(() => { @@ -114,55 +116,55 @@ tap.test('SoundPlayer', suite => { soundPlayer.play(); soundPlayer.finished().then(() => log.push('play 1 finished')); soundPlayer.connect(audioEngine); - let oldPlayerNode; + const firstPlayNode = soundPlayer.outputNode; + + audioContext.$processTo(0.005); return Promise.resolve() - .then(() => { + .then(() => { - audioContext.$processTo(0.005); - t.equal(soundPlayer.outputNode.$state, 'PLAYING'); + t.equal(soundPlayer.outputNode.$state, 'PLAYING'); - oldPlayerNode = soundPlayer.outputNode; - soundPlayer.play(); - soundPlayer.finished().then(() => log.push('play 2 finished')); + soundPlayer.play(); + soundPlayer.finished().then(() => log.push('play 2 finished')); // wait for a micro-task loop to fire our previous events - return Promise.resolve(); - }) - .then(() => { + return Promise.resolve(); + }) + .then(() => { - t.equal(log[0], 'play 1 finished'); - t.notEqual(soundPlayer.outputNode, oldPlayerNode, 'created new player node'); + t.equal(log[0], 'play 1 finished'); + t.notEqual(soundPlayer.outputNode, firstPlayNode, 'created new player node'); - t.equal(help.engineInputs.length, 2, 'there should be 2 players connected'); - t.equal(oldPlayerNode.$state, 'PLAYING'); - t.equal(soundPlayer.outputNode.$state, 'PLAYING'); - t.equal(help.engineInputs[0].gain.value, 1, 'old sound connectect to gain node with volume 1'); + t.equal(help.engineInputs.length, 2, 'there should be 2 players connected'); + t.equal(firstPlayNode.$state, 'PLAYING'); + t.equal(soundPlayer.outputNode.$state, 'PLAYING'); + t.equal(help.engineInputs[0].gain.value, 1, 'old sound connectect to gain node with volume 1'); - audioContext.$processTo(audioContext.currentTime + 0.001); - t.notEqual(help.engineInputs[0].gain.value, 1, + audioContext.$processTo(audioContext.currentTime + 0.001); + t.notEqual(help.engineInputs[0].gain.value, 1, 'old sound connected to gain node which will fade'); - audioContext.$processTo(audioContext.currentTime + audioEngine.DECAY_TIME + 0.001); - t.equal(soundPlayer.outputNode.$state, 'PLAYING'); - t.equal(oldPlayerNode.$state, 'FINISHED'); + audioContext.$processTo(audioContext.currentTime + audioEngine.DECAY_TIME + 0.001); + t.equal(soundPlayer.outputNode.$state, 'PLAYING'); + t.equal(firstPlayNode.$state, 'FINISHED'); - t.equal(help.engineInputs[0].gain.value, 0, 'faded old sound to 0'); + t.equal(help.engineInputs[0].gain.value, 0, 'faded old sound to 0'); - t.equal(log.length, 1); - audioContext.$processTo(audioContext.currentTime + 0.2); + t.equal(log.length, 1); + audioContext.$processTo(audioContext.currentTime + 0.2); // wait for a micro-task loop to fire our previous events - return Promise.resolve(); - }) - .then(() => { + return Promise.resolve(); + }) + .then(() => { - t.equal(log[1], 'play 2 finished'); - t.equal(help.engineInputs.length, 1, 'old sound disconneted itself after done'); - t.equal(log.length, 2); + t.equal(log[1], 'play 2 finished'); + t.equal(help.engineInputs.length, 1, 'old sound disconneted itself after done'); + t.equal(log.length, 2); - t.end(); - }); + t.end(); + }); }); suite.end(); From e2da92fae71435c2994ebe25d704c4c304356112 Mon Sep 17 00:00:00 2001 From: Corey Frang Date: Thu, 21 Jun 2018 11:25:27 -0400 Subject: [PATCH 10/13] cleanup tests a bit --- src/GreenPlayer.js | 6 +++--- test/SoundPlayer.js | 13 ++++++------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/src/GreenPlayer.js b/src/GreenPlayer.js index 24d281f..882b067 100644 --- a/src/GreenPlayer.js +++ b/src/GreenPlayer.js @@ -116,11 +116,11 @@ class SoundPlayer extends EventEmitter { return; } - if (this.volumeEffect !== null) { - this.volumeEffect.connect(target); - } else { + if (this.volumeEffect === null) { this.outputNode.disconnect(); this.outputNode.connect(target.getInputNode()); + } else { + this.volumeEffect.connect(target); } return this; diff --git a/test/SoundPlayer.js b/test/SoundPlayer.js index c55fe6f..51b2efe 100644 --- a/test/SoundPlayer.js +++ b/test/SoundPlayer.js @@ -39,14 +39,13 @@ tap.test('SoundPlayer', suite => { suite.plan(4); - suite.test('play initializes and creates chain', t => { + suite.test('play initializes and creates source node', t => { t.plan(3); t.equal(soundPlayer.initialized, false, 'not yet initialized'); soundPlayer.play(); t.equal(soundPlayer.initialized, true, 'now is initialized'); - let buffer = audioContext.DECODE_AUDIO_DATA_RESULT.toJSON(); t.deepEqual(soundPlayer.outputNode.toJSON(), { - buffer, + buffer: audioContext.DECODE_AUDIO_DATA_RESULT.toJSON(), inputs: [], loop: false, loopEnd: 0, @@ -65,7 +64,7 @@ tap.test('SoundPlayer', suite => { t.plan(1); soundPlayer.play(); soundPlayer.connect(audioEngine); - t.deepEqual(audioEngine.inputNode.toJSON().inputs, [ + t.deepEqual(help.engineInputs, [ soundPlayer.outputNode.toJSON() ], 'output node connects to input node'); t.end(); @@ -80,7 +79,7 @@ tap.test('SoundPlayer', suite => { audioContext.$processTo(0); soundPlayer.stop(); t.equal(soundPlayer.outputNode, null, 'nullify outputNode immediately (taken sound is stopping)'); - t.deepEqual(audioEngine.inputNode.toJSON().inputs, [{ + t.deepEqual(help.engineInputs, [{ name: 'GainNode', gain: { value: 1, @@ -90,13 +89,13 @@ tap.test('SoundPlayer', suite => { }], 'output node connects to gain node to input node'); audioContext.$processTo(audioEngine.DECAY_TIME / 2); - const engineInputs = audioEngine.inputNode.toJSON().inputs; + const engineInputs = help.engineInputs; t.notEqual(engineInputs[0].gain.value, 1, 'gain value should not be 1'); t.notEqual(engineInputs[0].gain.value, 0, 'gain value should not be 0'); t.equal(outputNode.$state, 'PLAYING'); audioContext.$processTo(audioEngine.DECAY_TIME); - t.deepEqual(audioEngine.inputNode.toJSON().inputs, [{ + t.deepEqual(help.engineInputs, [{ name: 'GainNode', gain: { value: 0, From aa77d8d3761546e4a69b24023a50079a22bbe7a2 Mon Sep 17 00:00:00 2001 From: Corey Frang Date: Thu, 21 Jun 2018 15:05:08 -0400 Subject: [PATCH 11/13] Add tests for play debounce --- src/GreenPlayer.js | 3 +++ test/SoundPlayer.js | 44 ++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/GreenPlayer.js b/src/GreenPlayer.js index 882b067..0abc7c5 100644 --- a/src/GreenPlayer.js +++ b/src/GreenPlayer.js @@ -200,6 +200,9 @@ class SoundPlayer extends EventEmitter { */ play () { if (this.isStarting) { + this.emit('stop'); + this.emit('play'); + this.isPlaying = true; return; } diff --git a/test/SoundPlayer.js b/test/SoundPlayer.js index 51b2efe..aa4588c 100644 --- a/test/SoundPlayer.js +++ b/test/SoundPlayer.js @@ -37,7 +37,7 @@ tap.test('SoundPlayer', suite => { audioContext = null; }); - suite.plan(4); + suite.plan(5); suite.test('play initializes and creates source node', t => { t.plan(3); @@ -109,6 +109,45 @@ tap.test('SoundPlayer', suite => { t.end(); }); + suite.test('play while playing debounces', t => { + t.plan(7); + const log = []; + soundPlayer.connect(audioEngine); + soundPlayer.play(); + t.equal(soundPlayer.isStarting, true, 'player.isStarting'); + const originalNode = soundPlayer.outputNode; + // the second play should still "finish" this play + soundPlayer.finished().then(() => log.push('finished first')); + soundPlayer.play(); + soundPlayer.finished().then(() => log.push('finished second')); + soundPlayer.play(); + soundPlayer.finished().then(() => log.push('finished third')); + soundPlayer.play(); + t.equal(originalNode, soundPlayer.outputNode, 'same output node'); + t.equal(soundPlayer.outputNode.$state, 'PLAYING'); + return Promise.resolve().then(() => { + t.deepEqual(log, ['finished first', 'finished second', 'finished third'], 'finished in order'); + + // fast forward to one ms before decay time + audioContext.$processTo(audioEngine.DECAY_TIME - 0.001); + soundPlayer.play(); + + t.equal(originalNode, soundPlayer.outputNode, 'same output node'); + + + // now at DECAY_TIME, we should meet a new player as the old one is taken/stopped + audioContext.$processTo(audioEngine.DECAY_TIME); + + t.equal(soundPlayer.isStarting, false, 'player.isStarting now false'); + + soundPlayer.play(); + t.notEqual(originalNode, soundPlayer.outputNode, 'New output node'); + + t.end(); + }); + + }); + suite.test('play while playing', t => { t.plan(15); const log = []; @@ -117,7 +156,8 @@ tap.test('SoundPlayer', suite => { soundPlayer.connect(audioEngine); const firstPlayNode = soundPlayer.outputNode; - audioContext.$processTo(0.005); + // go past debounce time and play again + audioContext.$processTo(audioEngine.DECAY_TIME); return Promise.resolve() .then(() => { From 741817ad62ae4acff8151d607b0045c7a0f42d5f Mon Sep 17 00:00:00 2001 From: Corey Frang Date: Thu, 21 Jun 2018 15:07:57 -0400 Subject: [PATCH 12/13] No need to set isPlaying here --- src/GreenPlayer.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/GreenPlayer.js b/src/GreenPlayer.js index 0abc7c5..7cabf2d 100644 --- a/src/GreenPlayer.js +++ b/src/GreenPlayer.js @@ -202,7 +202,6 @@ class SoundPlayer extends EventEmitter { if (this.isStarting) { this.emit('stop'); this.emit('play'); - this.isPlaying = true; return; } From ed0ef8e4dce2409548670903a1cf6237a43bf415 Mon Sep 17 00:00:00 2001 From: "Michael \"Z\" Goddard" Date: Thu, 21 Jun 2018 15:42:57 -0400 Subject: [PATCH 13/13] add docblock to AudioEngine.currentTime --- src/AudioEngine.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/AudioEngine.js b/src/AudioEngine.js index 84b7af7..7325f86 100644 --- a/src/AudioEngine.js +++ b/src/AudioEngine.js @@ -65,6 +65,10 @@ class AudioEngine { this.loudness = null; } + /** + * Current time in the AudioEngine. + * @type {number} + */ get currentTime () { return this.audioContext.currentTime; }