UNPKG

9.17 kBJavaScriptView Raw
1const {EventEmitter} = require('events');
2
3const VolumeEffect = require('./effects/VolumeEffect');
4
5/**
6 * Name of event that indicates playback has ended.
7 * @const {string}
8 */
9const ON_ENDED = 'ended';
10
11class SoundPlayer extends EventEmitter {
12 /**
13 * Play sounds that stop without audible clipping.
14 *
15 * @param {AudioEngine} audioEngine - engine to play sounds on
16 * @param {object} data - required data for sound playback
17 * @param {string} data.id - a unique id for this sound
18 * @param {ArrayBuffer} data.buffer - buffer of the sound's waveform to play
19 * @constructor
20 */
21 constructor (audioEngine, {id, buffer}) {
22 super();
23
24 /**
25 * Unique sound identifier set by AudioEngine.
26 * @type {string}
27 */
28 this.id = id;
29
30 /**
31 * AudioEngine creating this sound player.
32 * @type {AudioEngine}
33 */
34 this.audioEngine = audioEngine;
35
36 /**
37 * Decoded audio buffer from audio engine for playback.
38 * @type {AudioBuffer}
39 */
40 this.buffer = buffer;
41
42 /**
43 * Output audio node.
44 * @type {AudioNode}
45 */
46 this.outputNode = null;
47
48 /**
49 * VolumeEffect used to fade out playing sounds when stopping them.
50 * @type {VolumeEffect}
51 */
52 this.volumeEffect = null;
53
54
55 /**
56 * Target engine, effect, or chain this player directly connects to.
57 * @type {AudioEngine|Effect|EffectChain}
58 */
59 this.target = null;
60
61 /**
62 * Internally is the SoundPlayer initialized with at least its buffer
63 * source node and output node.
64 * @type {boolean}
65 */
66 this.initialized = false;
67
68 /**
69 * Is the sound playing or starting to play?
70 * @type {boolean}
71 */
72 this.isPlaying = false;
73
74 /**
75 * Timestamp sound is expected to be starting playback until. Once the
76 * future timestamp is reached the sound is considered to be playing
77 * through the audio hardware and stopping should fade out instead of
78 * cutting off playback.
79 * @type {number}
80 */
81 this.startingUntil = 0;
82
83 /**
84 * Rate to play back the audio at.
85 * @type {number}
86 */
87 this.playbackRate = 1;
88
89 // handleEvent is a EventTarget api for the DOM, however the
90 // web-audio-test-api we use uses an addEventListener that isn't
91 // compatable with object and requires us to pass this bound function
92 // instead
93 this.handleEvent = this.handleEvent.bind(this);
94 }
95
96 /**
97 * Is plaback currently starting?
98 * @type {boolean}
99 */
100 get isStarting () {
101 return this.isPlaying && this.startingUntil > this.audioEngine.currentTime;
102 }
103
104 /**
105 * Handle any event we have told the output node to listen for.
106 * @param {Event} event - dom event to handle
107 */
108 handleEvent (event) {
109 if (event.type === ON_ENDED) {
110 this.onEnded();
111 }
112 }
113
114 /**
115 * Event listener for when playback ends.
116 */
117 onEnded () {
118 this.emit('stop');
119
120 this.isPlaying = false;
121 }
122
123 /**
124 * Create the buffer source node during initialization or secondary
125 * playback.
126 */
127 _createSource () {
128 if (this.outputNode !== null) {
129 this.outputNode.removeEventListener(ON_ENDED, this.handleEvent);
130 this.outputNode.disconnect();
131 }
132
133 this.outputNode = this.audioEngine.audioContext.createBufferSource();
134 this.outputNode.playbackRate.value = this.playbackRate;
135 this.outputNode.buffer = this.buffer;
136
137 this.outputNode.addEventListener(ON_ENDED, this.handleEvent);
138
139 if (this.target !== null) {
140 this.connect(this.target);
141 }
142 }
143
144 /**
145 * Initialize the player for first playback.
146 */
147 initialize () {
148 this.initialized = true;
149
150 this._createSource();
151 }
152
153 /**
154 * Connect the player to the engine or an effect chain.
155 * @param {object} target - object to connect to
156 * @returns {object} - return this sound player
157 */
158 connect (target) {
159 if (target === this.volumeEffect) {
160 this.outputNode.disconnect();
161 this.outputNode.connect(this.volumeEffect.getInputNode());
162 return;
163 }
164
165 this.target = target;
166
167 if (!this.initialized) {
168 return;
169 }
170
171 if (this.volumeEffect === null) {
172 this.outputNode.disconnect();
173 this.outputNode.connect(target.getInputNode());
174 } else {
175 this.volumeEffect.connect(target);
176 }
177
178 return this;
179 }
180
181 /**
182 * Teardown the player.
183 */
184 dispose () {
185 if (!this.initialized) {
186 return;
187 }
188
189 this.stopImmediately();
190
191 if (this.volumeEffect !== null) {
192 this.volumeEffect.dispose();
193 this.volumeEffect = null;
194 }
195
196 this.outputNode.disconnect();
197 this.outputNode = null;
198
199 this.target = null;
200
201 this.initialized = false;
202 }
203
204 /**
205 * Take the internal state of this player and create a new player from
206 * that. Restore the state of this player to that before its first playback.
207 *
208 * The returned player can be used to stop the original playback or
209 * continue it without manipulation from the original player.
210 *
211 * @returns {SoundPlayer} - new SoundPlayer with old state
212 */
213 take () {
214 if (this.outputNode) {
215 this.outputNode.removeEventListener(ON_ENDED, this.handleEvent);
216 }
217
218 const taken = new SoundPlayer(this.audioEngine, this);
219 taken.playbackRate = this.playbackRate;
220 if (this.isPlaying) {
221 taken.startingUntil = this.startingUntil;
222 taken.isPlaying = this.isPlaying;
223 taken.initialized = this.initialized;
224 taken.outputNode = this.outputNode;
225 taken.outputNode.addEventListener(ON_ENDED, taken.handleEvent);
226 taken.volumeEffect = this.volumeEffect;
227 if (taken.volumeEffect) {
228 taken.volumeEffect.audioPlayer = taken;
229 }
230 if (this.target !== null) {
231 taken.connect(this.target);
232 }
233
234 this.emit('stop');
235 taken.emit('play');
236 }
237
238 this.outputNode = null;
239 this.volumeEffect = null;
240 this.initialized = false;
241 this.startingUntil = 0;
242 this.isPlaying = false;
243
244 return taken;
245 }
246
247 /**
248 * Start playback for this sound.
249 *
250 * If the sound is already playing it will stop playback with a quick fade
251 * out.
252 */
253 play () {
254 if (this.isStarting) {
255 this.emit('stop');
256 this.emit('play');
257 return;
258 }
259
260 if (this.isPlaying) {
261 this.stop();
262 }
263
264 if (this.initialized) {
265 this._createSource();
266 } else {
267 this.initialize();
268 }
269
270 this.outputNode.start();
271
272 this.isPlaying = true;
273
274 const {currentTime, DECAY_DURATION} = this.audioEngine;
275 this.startingUntil = currentTime + DECAY_DURATION;
276
277 this.emit('play');
278 }
279
280 /**
281 * Stop playback after quickly fading out.
282 */
283 stop () {
284 if (!this.isPlaying) {
285 return;
286 }
287
288 // always do a manual stop on a taken / volume effect fade out sound
289 // player take will emit "stop" as well as reset all of our playing
290 // statuses / remove our nodes / etc
291 const taken = this.take();
292 taken.volumeEffect = new VolumeEffect(taken.audioEngine, taken, null);
293
294 taken.volumeEffect.connect(taken.target);
295 // volumeEffect will recursively connect to us if it needs to, so this
296 // happens too:
297 // taken.connect(taken.volumeEffect);
298
299 taken.finished().then(() => taken.dispose());
300
301 taken.volumeEffect.set(0);
302 const {currentTime, DECAY_DURATION} = this.audioEngine;
303 taken.outputNode.stop(currentTime + DECAY_DURATION);
304 }
305
306 /**
307 * Stop immediately without fading out. May cause audible clipping.
308 */
309 stopImmediately () {
310 if (!this.isPlaying) {
311 return;
312 }
313
314 this.outputNode.stop();
315
316 this.isPlaying = false;
317 this.startingUntil = 0;
318
319 this.emit('stop');
320 }
321
322 /**
323 * Return a promise that resolves when the sound next finishes.
324 * @returns {Promise} - resolves when the sound finishes
325 */
326 finished () {
327 return new Promise(resolve => {
328 this.once('stop', resolve);
329 });
330 }
331
332 /**
333 * Set the sound's playback rate.
334 * @param {number} value - playback rate. Default is 1.
335 */
336 setPlaybackRate (value) {
337 this.playbackRate = value;
338
339 if (this.initialized) {
340 this.outputNode.playbackRate.value = value;
341 }
342 }
343}
344
345module.exports = SoundPlayer;