-
Notifications
You must be signed in to change notification settings - Fork 479
/
Sounds.js
441 lines (401 loc) · 13 KB
/
Sounds.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
import Sound from './Sound';
import _ from 'lodash';
/**
* Interface for a sound registry and playback mechanism
* @interface AudioPlayer
*/
/**
* Register a sound to a given ID
*
* @function
* @name AudioPlayer#register
* @param {Object} options
* @param {string} options.id the sound ID for playback
* @param {string} [options.mp3] path to mp3 file
* @param {string} [options.ogg] path to ogg file
* @param {string} [options.wav] path to wav file
*/
/**
* Attempt to play back a sound with a given ID (if exists)
*
* @function
* @name AudioPlayer#play
* @param {string} soundId - Name of the sound to play
* @param {Object} [options]
* @param {number} [options.volume] default 1.0, which is "no change"
* @param {boolean} [options.loop] default false
* @param {function} [options.onEnded]
*/
/**
* Simple registry for cross-browser sound effect playback.
* Will play sounds using Web Audio or HTML5 Audio element where available.
*
* Based off of blockly-core's sound loading in blockly-core/core/blockly.js
*
* Usage:
* var mySounds = new Sounds();
* mySounds.register({id: 'myFirstSound', ogg: '/mysound.ogg', mp3: '/mysound.mp3'});
* mySounds.play('myFirstSound');
* @constructor
* @implements AudioPlayer
*/
export default function Sounds() {
window.AudioContext = window.AudioContext || window.webkitAudioContext;
this.audioContext = null;
this.isMuted = false;
/**
* Detect whether audio system is "unlocked" - it usually works immediately
* on dekstop, but mobile usually restricts audio until triggered by user.
* @private {boolean}
*/
this.audioUnlocked_ = false;
if (window.AudioContext) {
try {
this.audioContext = new AudioContext();
this.initializeAudioUnlockState_();
} catch (e) {
/**
* Chrome occasionally chokes on creating singleton AudioContext instances in separate tabs
* when iframes are open, potentially related to:
* https://code.google.com/p/chromium/issues/detail?id=308784
* or https://code.google.com/p/chromium/issues/detail?id=160022
*
* In the Chrome case, this will fall-back to the `window.Audio` method
*/
}
}
this.soundsById = {};
/** @private {function[]} */
this.whenAudioUnlockedCallbacks_ = [];
/**
* Callbacks invoked when all audio is stopped (e.g., Sounds.stopAllAudio is invoked).
* @private {function[]}
*/
this.onStopAllAudioCallbacks_ = [];
}
let singleton;
Sounds.getSingleton = function () {
if (!singleton) {
singleton = new Sounds();
}
return singleton;
};
/**
* Plays a silent sound to check whether audio is unlocked (usable) in the
* current browser.
* On mobile, our initial audio unlock will fail because unlocking audio
* requires user interaction. In that case, we add a handler to catch
* the first user interaction and try unlocking audio again.
* @private
*/
Sounds.prototype.initializeAudioUnlockState_ = function () {
this.unlockAudio(
function () {
if (this.isAudioUnlocked()) {
return;
}
var unlockHandler = function () {
this.unlockAudio(
function () {
if (this.isAudioUnlocked()) {
document.removeEventListener('mousedown', unlockHandler, true);
document.removeEventListener('touchend', unlockHandler, true);
document.removeEventListener('keydown', unlockHandler, true);
}
}.bind(this)
);
}.bind(this);
document.addEventListener('mousedown', unlockHandler, true);
document.addEventListener('touchend', unlockHandler, true);
document.addEventListener('keydown', unlockHandler, true);
}.bind(this)
);
};
/**
* Whether we're allowed to play audio by the browser yet.
* @returns {boolean}
*/
Sounds.prototype.isAudioUnlocked = function () {
// Audio unlock doesn't make sense for the fallback player as used here.
return this.audioUnlocked_ || !this.audioContext;
};
/**
* Ensure that a callback occurs with the audio system unlocked.
* If the audio system is already unlocked, the callback will occur immediately.
* Otherwise it will occur after audio is successfully unlocked.
* @param {function} callback
*/
Sounds.prototype.whenAudioUnlocked = function (callback) {
if (this.isAudioUnlocked()) {
callback();
} else {
this.whenAudioUnlockedCallbacks_.push(callback);
}
};
/**
* Mobile browsers disable audio until a sound is triggered by user interaction.
* This method tries to play a brief silent clip to test whether audio is
* unlocked, and/or trigger an unlock if called inside a user interaction.
*
* Special thanks to this article for the general approach:
* https://paulbakaus.com/tutorials/html5/web-audio-on-ios/
*
* @param {function} [onComplete] callback for after we've checked whether
* audio was unlocked successfully.
*/
Sounds.prototype.unlockAudio = function (onComplete) {
if (this.isAudioUnlocked()) {
return;
}
// create empty buffer and play it
var buffer = this.audioContext.createBuffer(1, 1, 22050);
var source = this.audioContext.createBufferSource();
source.buffer = buffer;
source.connect(this.audioContext.destination);
if (source.start) {
source.start(0);
} else {
source.noteOn(0);
}
this.checkDidSourcePlay_(
source,
this.audioContext,
function (didPlay) {
if (didPlay) {
this.audioUnlocked_ = true;
this.whenAudioUnlockedCallbacks_.forEach(function (cb) {
cb();
});
this.whenAudioUnlockedCallbacks_.length = 0;
}
if (onComplete) {
onComplete();
}
}.bind(this)
);
};
/**
* Performs an asynchronous check for whether the given source and context
* actually played audio. When finished, calls provided callback passing
* success/failure as a boolean argument.
* @param {!AudioBufferSourceNode} source
* @param {!AudioContext} context
* @param {!function(boolean)} onComplete
* @private
*/
Sounds.prototype.checkDidSourcePlay_ = function (source, context, onComplete) {
// Approach 1: Although AudioBufferSourceNode.playbackState is supposedly
// deprecated, it's still the most reliable way to check whether
// playback occurred on iOS devices through iOS9, and requires
// only a 0ms timeout to work.
// We feature-check this approach by seeing if the related enums
// exist first.
if (
source.PLAYING_STATE !== undefined &&
source.FINISHED_STATE !== undefined
) {
setTimeout(
function () {
onComplete(
source.playbackState === source.PLAYING_STATE ||
source.playbackState === source.FINISHED_STATE
);
}.bind(this),
0
);
return;
}
// Approach 2: Platforms that have removed playbackState can be checked most
// reliably with a longer delay and a check against the
// AudioContext.currentTime, which should be greater than the
// time passed to source.start() (in this case, zero).
setTimeout(
function () {
onComplete(
'number' === typeof context.currentTime && context.currentTime > 0
);
}.bind(this),
50
);
};
/**
* Registers a sound from a list of sound URL paths.
* Note: you can only register one sound resource per file type
* @param {Array.<string>} soundPaths list of sound file URLs ending in their
* file format (.mp3|.ogg|.wav)
* @param {string} soundID ID for sound
* @returns {Sound}
*/
Sounds.prototype.registerByFilenamesAndID = function (soundPaths, soundID) {
var soundRegistrationConfig = {id: soundID};
for (var i = 0; i < soundPaths.length; i++) {
var soundFilePath = soundPaths[i];
var getExtensionRegexp = /\.(\w+)(\?.*)?$/;
var extensionCaptureGroups = soundFilePath.match(getExtensionRegexp);
if (extensionCaptureGroups) {
// Extend soundRegistrationConfig with format options
// so e.g. soundRegistrationConfig['mp3'] = 'file.mp3'
var extension = extensionCaptureGroups[1];
soundRegistrationConfig[extension] = soundFilePath;
}
}
return this.register(soundRegistrationConfig);
};
/**
* @param {Object} config
* @returns {Sound}
*/
Sounds.prototype.register = function (config) {
var sound = new Sound(config, this.audioContext);
this.soundsById[config.id] = sound;
sound.preloadFile();
return sound;
};
/**
* @param {string} soundId - Name of the sound to play
* @param {Object} [options]
* @param {number} [options.volume] default 1.0, which is "no change"
* @param {boolean} [options.loop] default false
* @param {function} [options.onEnded]
*/
Sounds.prototype.play = function (soundId, options) {
var sound = this.soundsById[soundId];
if (sound) {
sound.play(options);
}
};
/**
* Remove references to the specified sound so that it can be garbage collected
* to free up memory.
* @param soundId {string} Sound id to unload. This is the URL for sounds
* played via playURL.
*/
Sounds.prototype.unload = function (soundId) {
delete this.soundsById[soundId];
};
Sounds.prototype.playURL = function (url, playbackOptions) {
if (this.isMuted) {
return;
}
// Play a sound given a URL, register it using the URL as id and infer
// the file type from the extension at the end of the URL
// (NOTE: not ideal because preload happens inside first play)
var sound = this.soundsById[url];
// If the song previously failed to load, let the call to this.register()
// below replace its entry in this.soundsById and try again to load it.
if (sound && !sound.didLoadFail()) {
if (sound.isLoaded()) {
sound.play(playbackOptions);
} else {
sound.playAfterLoad(playbackOptions);
}
} else {
var soundConfig = {id: url};
var ext = Sounds.getExtensionFromUrl(url);
soundConfig[ext] = url;
// Force HTML5 audio if the caller requests it (cross-domain origin issues)
soundConfig.forceHTML5 = playbackOptions && playbackOptions.forceHTML5;
// Force HTML5 audio on mobile if the caller requests it
soundConfig.allowHTML5Mobile =
playbackOptions && playbackOptions.allowHTML5Mobile;
// since preload may be async, we set playAfterLoad in the config so we
// play the sound once it is loaded
// Also stick the playbackOptions inside the config as playAfterLoadOptions
soundConfig.playAfterLoad = true;
soundConfig.playAfterLoadOptions = playbackOptions;
this.register(soundConfig);
}
};
/**
* @param {string} id of the sound.
* @param {ArrayBuffer} bytes of the sound to play.
* @param {object} playbackOptions config for the playing of the sound.
*/
Sounds.prototype.playBytes = function (id, bytes, playbackOptions) {
if (this.isMuted) {
return;
}
let soundConfig = {};
soundConfig.forceHTML5 = playbackOptions && playbackOptions.forceHTML5;
soundConfig.allowHTML5Mobile =
playbackOptions && playbackOptions.allowHTML5Mobile;
soundConfig.playAfterLoad = true;
soundConfig.playAfterLoadOptions = playbackOptions;
soundConfig.bytes = bytes;
let sound = new Sound(soundConfig, this.audioContext);
this.soundsById[id] = sound;
sound.preloadBytes();
sound.play();
};
/**
* @param {!string} id of the sound. This is a URL for sounds played via playURL.
* @returns {boolean} whether the given sound is currently playing.
*/
Sounds.prototype.isPlaying = function (id) {
var sound = this.soundsById[id];
if (sound) {
return sound.isPlaying();
}
return false;
};
/**
* Stop playing url.
*/
Sounds.prototype.stopPlayingURL = function (url) {
var sound = this.soundsById[url];
if (sound) {
sound.stop();
}
};
/**
* While muted, playURL() has no effect.
*/
Sounds.prototype.muteURLs = function () {
this.isMuted = true;
};
Sounds.prototype.unmuteURLs = function () {
this.isMuted = false;
};
/**
* Stop all currently playing sounds, and keep track of the paused sounds so
* they can be restarted later.
*/
Sounds.prototype.pauseSounds = function () {
this.pausedSounds = Object.keys(this.soundsById).filter(
soundUrl => this.soundsById[soundUrl].isPlaying_
);
this.pausedSounds.forEach(soundUrl => this.stopPlayingURL(soundUrl));
};
/**
* Play all paused sounds and clear out the paused sounds list.
*/
Sounds.prototype.restartPausedSounds = function () {
this.pausedSounds.forEach(soundUrl => this.playURL(soundUrl));
this.pausedSounds = [];
};
/**
* Stop all playing sounds immediately.
*/
Sounds.prototype.stopAllAudio = function () {
for (let soundId in this.soundsById) {
this.soundsById[soundId].stop();
}
_.over(this.onStopAllAudioCallbacks_)();
};
/**
* Register a callback that will be invoked when all audio is stopped.
* @param {function} callback with no arguments.
*/
Sounds.prototype.onStopAllAudio = function (callback) {
this.onStopAllAudioCallbacks_.push(callback);
};
Sounds.prototype.stopLoopingAudio = function (soundId) {
var sound = this.soundsById[soundId];
sound.stop();
};
Sounds.prototype.get = function (soundId) {
return this.soundsById[soundId];
};
Sounds.getExtensionFromUrl = function (url) {
return url.substr(url.lastIndexOf('.') + 1);
};