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(); +});