/** * @module Sound * @namespace springroll * @requires Core */ (function() { var Application = include('springroll.Application'), EventDispatcher = include('springroll.EventDispatcher'), Debug, SoundContext, SoundInstance, WebAudioPlugin = include('createjs.WebAudioPlugin'), CordovaAudioPlugin = include('createjs.CordovaAudioPlugin', false), FlashAudioPlugin = include('createjs.FlashAudioPlugin', false), SoundJS = include('createjs.Sound'), Enum = include('springroll.Enum'); /** * Acts as a wrapper for SoundJS as well as adding lots of other functionality * for managing sounds. * * @class Sound * @extends springroll.EventDispatcher */ var Sound = function() { //Import classes if (!SoundInstance) { Debug = include('springroll.Debug', false); SoundContext = include('springroll.SoundContext'); SoundInstance = include('springroll.SoundInstance'); } EventDispatcher.call(this); /** * Dictionary of sound objects, containing configuration info and playback objects. * @property {Object} _sounds * @private */ this._sounds = {}; /** * Array of SoundInstance objects that are being faded in or out. * @property {Array} _fades * @private */ this._fades = []; /** * Array of SoundInstance objects waiting to be used. * @property {Array} _pool * @private */ this._pool = []; /** * The extension of the supported sound type that will be used. * @property {string} supportedSound * @public */ this.supportedSound = null; /** * Dictionary of SoundContexts. * @property {Object} _contexts * @private */ this._contexts = {}; //Bindings this._update = this._update.bind(this); this._markLoaded = this._markLoaded.bind(this); this._playAfterLoad = this._playAfterLoad.bind(this); /** * If sound is enabled. This will only be false if Sound was unable to initialize * a SoundJS plugin. * @property {Boolean} isSupported * @readOnly */ this.isSupported = true; /** * If sound is currently muted by the system. This will only be true on iOS until * audio has been unmuted during a touch event. Listen for the 'systemUnmuted' * event on Sound to be notified when the audio is unmuted on iOS. * @property {Boolean} systemMuted * @readOnly */ this.systemMuted = createjs.BrowserDetect.isIOS; /** * If preventDefault should be called on the interaction event that unmutes the audio. * In most cases (games) you would want to leave this, but for a website you may want * to disable it. * @property {Boolean} preventDefaultOnUnmute * @default true */ this.preventDefaultOnUnmute = true; }; /** * Fired when audio is unmuted on iOS. If systemMuted is false, this will not be fired * (or already has been fired). * @event systemUnmuted */ //Reference to the prototype var s = EventDispatcher.prototype; var p = EventDispatcher.extend(Sound); function _fixAudioContext() { var activePlugin = SoundJS.activePlugin; //save audio data var _audioSources = activePlugin._audioSources; var _soundInstances = activePlugin._soundInstances; var _loaders = activePlugin._loaders; //close old context if (WebAudioPlugin.context.close) WebAudioPlugin.context.close(); var AudioContext = window.AudioContext || window.webkitAudioContext; // Reset context WebAudioPlugin.context = new AudioContext(); // Reset WebAudioPlugin WebAudioPlugin.call(activePlugin); // Copy over relevant properties activePlugin._loaders = _loaders; activePlugin._audioSources = _audioSources; activePlugin._soundInstances = _soundInstances; //update any playing instances to not have references to old audio nodes //while we could go through all of the springroll.Sound instances, it's probably //faster to go through SoundJS's stuff, as well as catching any cases where a //naughty person went over springroll.Sound's head and played audio through SoundJS //directly for (var url in _soundInstances) { var instances = _soundInstances[url]; for (var i = 0; i < instances.length; ++i) { var instance = instances[i]; //clean up old nodes instance.panNode.disconnect(0); instance.gainNode.disconnect(0); //make brand new nodes instance.gainNode = WebAudioPlugin.context.createGain(); instance.panNode = WebAudioPlugin.context.createPanner(); instance.panNode.panningModel = WebAudioPlugin._panningModel; instance.panNode.connect(instance.gainNode); instance._updatePan(); //double check that the position is a valid thing if (instance._position < 0 || instance._position === undefined) instance._position = 0; } } } var _instance = null; //sound states var LoadStates = new Enum("unloaded", "loading", "loaded"); /** * Initializes the Sound singleton. If using createjs.FlashAudioPlugin, you will * be responsible for setting createjs.FlashAudioPlugin.BASE_PATH. * @method init * @static * @param {Object|Function} options Either the options object or the ready function * @param {Array} [options.plugins=createjs.WebAudioPlugin,createjs.FlashAudioPlugin] The SoundJS * plugins to pass to createjs.Sound.registerPlugins(). * @param {Array} [options.types=['ogg','mp3']] The order in which file types are * preferred, where "ogg" becomes a ".ogg" extension on all sound file urls. * @param {String} [options.swfPath='assets/swfs/'] The required path to the * createjs.FlashAudioPlugin SWF * @param {Function} [options.ready] A function to call when initialization is complete. * @return {Sound} The new instance of the sound object */ Sound.init = function(options, readyCallback) { var appOptions = Application.instance.options; //First argument is function if (isFunction(options)) { options = { ready: options }; } var defaultOptions = { plugins: FlashAudioPlugin ? [WebAudioPlugin, FlashAudioPlugin] : [WebAudioPlugin], types: ['ogg', 'mp3'], swfPath: 'assets/swfs/', ready: null }; options = Object.merge( {}, defaultOptions, options); if (appOptions.forceFlashAudio) options.plugins = [FlashAudioPlugin]; if (CordovaAudioPlugin && (appOptions.forceNativeAudio || options.plugins.indexOf(CordovaAudioPlugin) >= 0)) { // Security CORS error can be thrown when attempting to access window.top, wrapping the check in a try/catch block to prevent // the game from crashing where there is no CORS policy setup. try { var forceNativeAudio = (window.top) ? window.top.springroll.forceNativeAudio : window.springroll.forceNativeAudio; if (forceNativeAudio) { options.plugins = [CordovaAudioPlugin]; } } catch (e) { if (DEBUG && Debug) { Debug.error("springroll.Sound.init cannot access window.top. Check for cross-origin permissions."); } } } //Check if the ready callback is the second argument //this is deprecated options.ready = options.ready || readyCallback; if (!options.ready) { throw "springroll.Sound.init requires a ready callback"; } if (FlashAudioPlugin) { //Apply the base path if available var basePath = appOptions.basePath; FlashAudioPlugin.swfPath = (basePath || "") + options.swfPath; } SoundJS.registerPlugins(options.plugins); //If on iOS, then we need to add a touch listener to unmute sounds. //playback pretty much has to be createjs.WebAudioPlugin for iOS //We cannot use touchstart in iOS 9.0 - http://www.holovaty.com/writing/ios9-web-audio/ if (createjs.BrowserDetect.isIOS && SoundJS.activePlugin instanceof WebAudioPlugin && SoundJS.activePlugin.context.state != "running") { document.addEventListener("touchstart", _playEmpty); document.addEventListener("touchend", _playEmpty); document.addEventListener("mousedown", _playEmpty); } else this.systemMuted = false; //New sound object _instance = new Sound(); //make sure the capabilities are ready (looking at you, Cordova plugin) if (SoundJS.getCapabilities()) { _instance._initComplete(options.types, options.ready); } else if (SoundJS.activePlugin) { if (DEBUG && Debug) { Debug.log("SoundJS Plugin " + SoundJS.activePlugin + " was not ready, waiting until it is"); } //if the sound plugin is not ready, then just wait until it is var waitFunction; var waitResult; waitFunction = function() { // Security CORS error can be thrown when attempting to access window.top, wrapping the check in a try/catch block to prevent // the game from crashing where there is no CORS policy setup. try { var NativeAudio = window.plugins.NativeAudio || window.top.plugins.NativeAudio || null; if (NativeAudio) { NativeAudio.getCapabilities(function(result) { waitResult = result; Application.instance.off("update", waitFunction); _instance._initComplete(options.types, options.ready); }, function(result) { waitResult = result; if (DEBUG && Debug) { Debug.error("Unable to get capabilities from Cordova Native Audio Plugin"); } }); } } catch (e) { if (DEBUG && Debug) { Debug.error("Cannot access window.top. Check for cross-origin permissions."); } } }; Application.instance.on("update", waitFunction); } else { if (DEBUG && Debug) { Debug.error("Unable to initialize SoundJS with a plugin!"); } _instance.isSupported = false; if (options.ready) { options.ready(); } } return _instance; }; /** * Satisfies the iOS event needed to initialize the audio * Note that we listen on touchend as per http://www.holovaty.com/writing/ios9-web-audio/ * @private * @method _playEmpty */ function _playEmpty(ev) { WebAudioPlugin.playEmptySound(); if (WebAudioPlugin.context.state == "running" || WebAudioPlugin.context.state === undefined) { if (_instance.preventDefaultOnUnmute) ev.preventDefault(); document.removeEventListener("touchstart", _playEmpty); document.removeEventListener("touchend", _playEmpty); document.removeEventListener("mousedown", _playEmpty); _instance.systemMuted = false; _instance.trigger("systemUnmuted"); } } /** * When the initialization as completed * @method * @private * @param {Array} filetypeOrder The list of files types * @param {Function} callback The callback function */ p._initComplete = function(filetypeOrder, callback) { if (FlashAudioPlugin && SoundJS.activePlugin instanceof FlashAudioPlugin) { _instance.supportedSound = ".mp3"; } else { var type; for (var i = 0, len = filetypeOrder.length; i < len; ++i) { type = filetypeOrder[i]; if (SoundJS.getCapability(type)) { _instance.supportedSound = "." + type; break; } } } //if on Android, using WebAudioPlugin, and the userAgent does not signify Firefox, //assume a Chrome based browser, so consider it a potential liability for the //bug in Chrome where the AudioContext is not restarted after too much silence this._fixAndroidAudio = createjs.BrowserDetect.isAndroid && SoundJS.activePlugin instanceof WebAudioPlugin && !(navigator.userAgent.indexOf("Gecko") > -1 && navigator.userAgent.indexOf("Firefox") > -1); if (this._fixAndroidAudio) { this._numPlayingAudio = 0; this._lastAudioTime = Date.now(); } if (callback) { callback(); } }; /** * The singleton instance of Sound. * @property {Sound} instance * @public * @static */ Object.defineProperty(Sound, "instance", { get: function() { return _instance; } }); /** * Loads a context config object. This should not be called until after Sound.init() is complete. * @method addContext * @public * @param {Object} config The config to load. * @param {String} [config.context] The optional sound context to load sounds into unless * otherwise specified by the individual sound. Sounds do not require a context. * @param {String} [config.path=""] The path to prepend to all sound source urls in this config. * @param {boolean} [config.preload=false] Option to preload all sound files in this context.. * @param {Array} config.sounds The list of sounds, either as String ids or Objects with settings. * @param {Object|String} config.sounds.listItem Not actually a property called listItem, * but an entry in the array. If this is a string, then it is the same as {'id':'<yourString>'}. * @param {String} config.sounds.listItem.id The id to reference the sound by. * @param {String} [config.sounds.listItem.src] The src path to the file, without an * extension. If omitted, defaults to id. * @param {Number} [config.sounds.listItem.volume=1] The default volume for the sound, from 0 to 1. * @param {Boolean} [config.sounds.listItem.loop=false] If the sound should loop by * default whenever the loop parameter in play() is not specified. * @param {String} [config.sounds.listItem.context] A context name to override config.context with. * @param {Boolean} [config.sounds.listItem.preload] If the sound should be preloaded immediately. * @return {Sound} The sound object for chaining */ p.addContext = function(config) { if (!config) { if (DEBUG && Debug) { Debug.warn("Warning - springroll.Sound was told to load a null config"); } return; } var list = config.soundManifest || config.sounds || []; var path = config.path || ""; var preloadAll = config.preload === true || false; var defaultContext = config.context; var s; var temp = {}; for (var i = 0, len = list.length; i < len; ++i) { s = list[i]; if (isString(s)) { s = { id: s }; } temp = this._sounds[s.id] = { id: s.id, src: path + (s.src ? s.src : s.id) + this.supportedSound, volume: s.volume ? s.volume : 1, loop: !!s.loop, loadState: LoadStates.unloaded, playing: [], waitingToPlay: [], context: s.context || defaultContext, playAfterLoad: false, preloadCallback: null, data: s, //save data for potential use by SoundJS plugins duration: 0 }; if (temp.context) { if (!this._contexts[temp.context]) { this._contexts[temp.context] = new SoundContext(temp.context); } this._contexts[temp.context].sounds.push(temp); } //preload the sound for immediate-ish use if (preloadAll || s.preload === true) { this.preload(temp.id); } } //return the Sound instance for chaining return this; }; /** * Links one or more sound contexts to another in a parent-child relationship, so * that the children can be controlled separately, but still be affected by * setContextMute(), stopContext(), pauseContext(), etc on the parent. * Note that sub-contexts are not currently affected by setContextVolume(). * @method linkContexts * @param {String} parent The id of the SoundContext that should be the parent. * @param {String|Array} subContext The id of a SoundContext to add to parent as a * sub-context, or an array of ids. * @return {Boolean} true if the sound exists, false otherwise. */ p.linkContexts = function(parent, subContext) { if (!this._contexts[parent]) this._contexts[parent] = new SoundContext(parent); parent = this._contexts[parent]; if (Array.isArray(subContext)) { for (var i = 0; i < subContext.length; ++i) { if (parent.subContexts.indexOf(subContext[i]) < 0) parent.subContexts.push(subContext[i]); } } else { if (parent.subContexts.indexOf(subContext) < 0) parent.subContexts.push(subContext); } }; /** * If a sound exists in the list of recognized sounds. * @method exists * @public * @param {String} alias The alias of the sound to look for. * @return {Boolean} true if the sound exists, false otherwise. */ p.exists = function(alias) { return !!this._sounds[alias]; }; /** * If a context exists * @method contextExists * @public * @param {String} context The name of context to look for. * @return {Boolean} true if the context exists, false otherwise. */ p.contextExists = function(context) { return !!this._contexts[context]; }; /** * If a sound is unloaded. * @method isUnloaded * @public * @param {String} alias The alias of the sound to look for. * @return {Boolean} true if the sound is unloaded, false if it is loaded, loading, or does not exist. */ p.isUnloaded = function(alias) { return this._sounds[alias] ? this._sounds[alias].loadState == LoadStates.unloaded : false; }; /** * If a sound is loaded. * @method isLoaded * @public * @param {String} alias The alias of the sound to look for. * @return {Boolean} true if the sound is loaded, false if it is not loaded or does not exist. */ p.isLoaded = function(alias) { return this._sounds[alias] ? this._sounds[alias].loadState == LoadStates.loaded : false; }; /** * If a sound is in the process of being loaded * @method isLoading * @public * @param {String} alias The alias of the sound to look for. * @return {Boolean} A value of true if the sound is currently loading, false if * it is loaded, unloaded, or does not exist. */ p.isLoading = function(alias) { return this._sounds[alias] ? this._sounds[alias].loadState == LoadStates.loading : false; }; /** * If a sound is playing. * @method isPlaying * @public * @param {String} alias The alias of the sound to look for. * @return {Boolean} A value of true if the sound is currently playing or loading * with an intent to play, false if it is not playing or does not exist. */ p.isPlaying = function(alias) { var sound = this._sounds[alias]; return sound ? sound.playing.length + sound.waitingToPlay.length > 0 : false; }; /** * Gets the duration of a sound in milliseconds, if it has been loaded. * @method getDuration * @public * @param {String} alias The alias of the sound to look for. * @return {int|null} The duration of the sound in milliseconds. If the sound has * not been loaded, 0 is returned. If no sound exists by that alias, null is returned. */ p.getDuration = function(alias) { var sound = this._sounds[alias]; if (!sound) return null; if (!sound.duration) //sound hasn't been loaded yet { if (sound.loadState == LoadStates.loaded) { //play the sound once to get the duration of it var channel = SoundJS.play(alias, null, null, null, null, /*volume*/ 0); sound.duration = channel.getDuration(); //stop the sound channel.stop(); } } return sound.duration; }; /** * Fades a sound from 0 to a specified volume. * @method fadeIn * @public * @param {String|SoundInstance} aliasOrInst The alias of the sound to fade the * last played instance of, or an instance returned from play(). * @param {Number} [duration=500] The duration in milliseconds to fade for. * The default is 500ms. * @param {Number} [targetVol] The volume to fade to. The default is the sound's default volume. * @param {Number} [startVol=0] The volume to start from. The default is 0. */ p.fadeIn = function(aliasOrInst, duration, targetVol, startVol) { var sound, inst; if (isString(aliasOrInst)) { sound = this._sounds[aliasOrInst]; if (!sound) return; if (sound.playing.length) { inst = sound.playing[sound.playing.length - 1]; //fade the last played instance } } else { inst = aliasOrInst; sound = this._sounds[inst.alias]; } if (!inst || !inst._channel) return; inst._fTime = 0; inst._fDur = duration > 0 ? duration : 500; inst._fEnd = targetVol || inst.curVol; inst._fStop = false; var v = startVol > 0 ? startVol : 0; inst.volume = inst._fStart = v; if (this._fades.indexOf(inst) == -1) { this._fades.push(inst); if (this._fades.length == 1) { Application.instance.on("update", this._update); } } }; /** * Fades a sound from the current volume to a specified volume. A sound that ends * at 0 volume is stopped after the fade. * @method fadeOut * @public * @param {String|SoundInstance} aliasOrInst The alias of the sound to fade the * last played instance of, or an instance returned from play(). * @param {Number} [duration=500] The duration in milliseconds to fade for. * The default is 500ms. * @param {Number} [targetVol=0] The volume to fade to. The default is 0. * @param {Number} [startVol] The volume to fade from. The default is the current volume. * @param {Boolean} [stopAtEnd] If the sound should be stopped when the fade completes. The * default is to stop it if the fade completes at a volume of 0. */ p.fadeOut = function(aliasOrInst, duration, targetVol, startVol, stopAtEnd) { var sound, inst; if (isString(aliasOrInst)) { sound = this._sounds[aliasOrInst]; if (!sound) { return; } if (sound.playing.length) { //fade the last played instance inst = sound.playing[sound.playing.length - 1]; } else if (s.loadState == LoadStates.loading) { this.stop(aliasOrInst); return; } } else { inst = aliasOrInst; } if (!inst || !inst._channel) return; inst._fTime = 0; inst._fDur = duration > 0 ? duration : 500; if (startVol > 0) { inst.volume = startVol; inst._fStart = startVol; } else { inst._fStart = inst.volume; } inst._fEnd = targetVol || 0; stopAtEnd = stopAtEnd === undefined ? inst._fEnd === 0 : !!stopAtEnd; inst._fStop = stopAtEnd; if (this._fades.indexOf(inst) == -1) { this._fades.push(inst); if (this._fades.length == 1) { Application.instance.on("update", this._update); } } }; /** * The update call, used for fading sounds. This is bound to the instance of Sound * @method _update * @private * @param {int} elapsed The time elapsed since the previous frame, in milliseconds. */ p._update = function(elapsed) { var fades = this._fades; var inst, time, sound, lerp, vol; for (var i = fades.length - 1; i >= 0; --i) { inst = fades[i]; if (inst.paused) continue; time = inst._fTime += elapsed; if (time >= inst._fDur) { if (inst._fStop) { sound = this._sounds[inst.alias]; if (sound) sound.playing.splice(sound.playing.indexOf(inst), 1); this._stopInst(inst); } else { inst.curVol = inst._fEnd; inst.updateVolume(); fades.splice(i, 1); } } else { lerp = time / inst._fDur; if (inst._fEnd > inst._fStart) { vol = inst._fStart + (inst._fEnd - inst._fStart) * lerp; } else { vol = inst._fEnd + (inst._fStart - inst._fEnd) * lerp; } inst.curVol = vol; inst.updateVolume(); } } if (fades.length === 0) { Application.instance.off("update", this._update); } }; /** * Plays a sound. * @method play * @public * @param {String} alias The alias of the sound to play. * @param {Object|function} [options] The object of optional parameters or complete * callback function. * @param {Function} [options.complete] An optional function to call when the sound is finished. * @param {Function} [options.start] An optional function to call when the sound starts * playback. If the sound is loaded, this is called immediately, if not, it calls * when the sound is finished loading. * @param {Boolean} [options.interrupt=false] If the sound should interrupt previous * sounds (SoundJS parameter). Default is false. * @param {Number} [options.delay=0] The delay to play the sound at in milliseconds * (SoundJS parameter). Default is 0. * @param {Number} [options.offset=0] The offset into the sound to play in milliseconds * (SoundJS parameter). Default is 0. * @param {int} [options.loop=0] How many times the sound should loop. Use -1 * (or true) for infinite loops (SoundJS parameter). Default is no looping. * @param {Number} [options.volume] The volume to play the sound at (0 to 1). * Omit to use the default for the sound. * @param {Number} [options.pan=0] The panning to start the sound at (-1 to 1). * Default is centered (0). * @return {SoundInstance} An internal SoundInstance object that can be used for * fading in/out as well as pausing and getting the sound's current position. */ p.play = function(alias, options, startCallback, interrupt, delay, offset, loop, volume, pan) { var completeCallback; if (options && isFunction(options)) { completeCallback = options; options = null; } completeCallback = (options ? options.complete : completeCallback) || null; startCallback = (options ? options.start : startCallback) || null; interrupt = !!(options ? options.interrupt : interrupt); delay = (options ? options.delay : delay) || 0; offset = (options ? options.offset : offset) || 0; loop = (options ? options.loop : loop); volume = (options ? options.volume : volume); pan = (options ? options.pan : pan) || 0; if (!this.isSupported) { if (completeCallback) { setTimeout(completeCallback, 0); } return; } //Replace with correct infinite looping. if (loop === true) { loop = -1; } var sound = this._sounds[alias]; if (!sound) { if (DEBUG && Debug) { Debug.error("springroll.Sound: alias '" + alias + "' not found!"); } if (completeCallback) { completeCallback(); } return; } //check for sound loop settings if (sound.loop && loop === undefined || loop === null) { loop = -1; } //check for sound volume settings volume = (typeof(volume) == "number") ? volume : sound.volume; //take action based on the sound state var loadState = sound.loadState; var inst, arr; if (loadState == LoadStates.loaded) { if (this._fixAndroidAudio) { if (this._numPlayingAudio) { this._numPlayingAudio++; this._lastAudioTime = -1; } else { if (Date.now() - this._lastAudioTime >= 30000) _fixAudioContext(); this._numPlayingAudio = 1; this._lastAudioTime = -1; } } //have Sound manage the playback of the sound var channel = SoundJS.play(alias, interrupt, delay, offset, loop, volume, pan); if (!channel || channel.playState == SoundJS.PLAY_FAILED) { if (completeCallback) { completeCallback(); } return null; } else { inst = this._getSoundInst(channel, sound.id); if (channel.handleExtraData) { channel.handleExtraData(sound.data); } inst.curVol = volume; inst._pan = pan; sound.playing.push(inst); inst._endCallback = completeCallback; inst.updateVolume(); inst.length = channel.getDuration(); if (!sound.duration) { sound.duration = inst.length; } inst._channel.addEventListener("complete", inst._endFunc); if (startCallback) { setTimeout(startCallback, 0); } return inst; } } else if (loadState == LoadStates.unloaded) { sound.playAfterLoad = true; inst = this._getSoundInst(null, sound.id); inst.curVol = volume; inst._pan = pan; sound.waitingToPlay.push(inst); inst._endCallback = completeCallback; inst._startFunc = startCallback; if (inst._startParams) { arr = inst._startParams; arr[0] = interrupt; arr[1] = delay; arr[2] = offset; arr[3] = loop; } else inst._startParams = [interrupt, delay, offset, loop]; this.preload(sound.id); return inst; } else if (loadState == LoadStates.loading) { //tell the sound to play after loading sound.playAfterLoad = true; inst = this._getSoundInst(null, sound.id); inst.curVol = volume; inst._pan = pan; sound.waitingToPlay.push(inst); inst._endCallback = completeCallback; inst._startFunc = startCallback; if (inst._startParams) { arr = inst._startParams; arr[0] = interrupt; arr[1] = delay; arr[2] = offset; arr[3] = loop; } else inst._startParams = [interrupt, delay, offset, loop]; return inst; } }; /** * Gets a SoundInstance, from the pool if available or maks a new one if not. * @method _getSoundInst * @private * @param {createjs.SoundInstance} channel A createjs SoundInstance to initialize the object * with. * @param {String} id The alias of the sound that is going to be used. * @return {SoundInstance} The SoundInstance that is ready to use. */ p._getSoundInst = function(channel, id) { var rtn; if (this._pool.length) rtn = this._pool.pop(); else { rtn = new SoundInstance(); rtn._endFunc = this._onSoundComplete.bind(this, rtn); } rtn._channel = channel; rtn.alias = id; rtn.length = channel ? channel.getDuration() : 0; //set or reset this rtn.isValid = true; return rtn; }; /** * Plays a sound after it finishes loading. * @method _playAfterload * @private * @param {String|Object} result The sound to play as an alias or load manifest. */ p._playAfterLoad = function(result) { var alias = isString(result) ? result : result.data.id; var sound = this._sounds[alias]; sound.loadState = LoadStates.loaded; //If the sound was stopped before it finished loading, then don't play anything if (!sound.playAfterLoad) return; if (this._fixAndroidAudio) { if (this._lastAudioTime > 0 && Date.now() - this._lastAudioTime >= 30000) { _fixAudioContext(); } } //Go through the list of sound instances that are waiting to start and start them var waiting = sound.waitingToPlay; var inst, startParams, volume, channel, pan; for (var i = 0, len = waiting.length; i < len; ++i) { inst = waiting[i]; startParams = inst._startParams; volume = inst.curVol; pan = inst._pan; channel = SoundJS.play( alias, startParams[0], //interrupt startParams[1], //delay startParams[2], //offset startParams[3], //loop volume, pan ); if (!channel || channel.playState == SoundJS.PLAY_FAILED) { if (DEBUG && Debug) { Debug.error("Play failed for sound '%s'", alias); } if (inst._endCallback) inst._endCallback(); this._poolInst(inst); } else { if (this._fixAndroidAudio) { if (this._numPlayingAudio) { this._numPlayingAudio++; this._lastAudioTime = -1; } else { this._numPlayingAudio = 1; this._lastAudioTime = -1; } } sound.playing.push(inst); inst._channel = channel; if (channel.handleExtraData) channel.handleExtraData(sound.data); inst.length = channel.getDuration(); if (!sound.duration) sound.duration = inst.length; inst.updateVolume(); channel.addEventListener("complete", inst._endFunc); if (inst._startFunc) inst._startFunc(); if (inst.paused) //if the sound got paused while loading, then pause it channel.pause(); } } waiting.length = 0; }; /** * The callback used for when a sound instance is complete. * @method _onSoundComplete * @private * @param {SoundInstance} inst The SoundInstance that is complete.s */ p._onSoundComplete = function(inst) { if (inst._channel) { if (this._fixAndroidAudio) { if (--this._numPlayingAudio === 0) this._lastAudioTime = Date.now(); } inst._channel.removeEventListener("complete", inst._endFunc); var sound = this._sounds[inst.alias]; var index = sound.playing.indexOf(inst); if (index > -1) sound.playing.splice(index, 1); var callback = inst._endCallback; this._poolInst(inst); if (callback) callback(); } }; /** * Stops all playing or loading instances of a given sound. * @method stop * @public * @param {String} alias The alias of the sound to stop. */ p.stop = function(alias) { var s = this._sounds[alias]; if (!s) return; if (s.playing.length) this._stopSound(s); else if (s.loadState == LoadStates.loading) { s.playAfterLoad = false; var waiting = s.waitingToPlay; var inst; for (var i = 0, len = waiting.length; i < len; ++i) { inst = waiting[i]; this._poolInst(inst); } waiting.length = 0; } }; /** * Stops all playing SoundInstances for a sound. * @method _stopSound * @private * @param {Object} s The sound (from the _sounds dictionary) to stop. */ p._stopSound = function(s) { var arr = s.playing; for (var i = arr.length - 1; i >= 0; --i) { this._stopInst(arr[i]); } arr.length = 0; }; /** * Stops and repools a specific SoundInstance. * @method _stopInst * @private * @param {SoundInstance} inst The SoundInstance to stop. */ p._stopInst = function(inst) { if (inst._channel) { if (!inst.paused && this._fixAndroidAudio) { if (--this._numPlayingAudio === 0) this._lastAudioTime = Date.now(); } inst._channel.removeEventListener("complete", inst._endFunc); inst._channel.stop(); } var fadeIdx = this._fades.indexOf(inst); if (fadeIdx > -1) this._fades.splice(fadeIdx, 1); this._poolInst(inst); }; /** * Stops all sounds in a given context. * @method stopContext * @public * @param {String} context The name of the context to stop. */ p.stopContext = function(context) { context = this._contexts[context]; if (context) { var arr = context.sounds; var s, i; for (i = arr.length - 1; i >= 0; --i) { s = arr[i]; if (s.playing.length) this._stopSound(s); else if (s.loadState == LoadStates.loading) s.playAfterLoad = false; } for (i = 0; i < context.subContexts.length; ++i) { this.stopContext(context.subContexts[i]); } } }; /** * Stop all sounds that are playing, regardless of context. * @method stopAll */ p.stopAll = function() { for (var alias in this._sounds) { this.stop(alias); } }; /** * Pauses a specific sound. * @method pause * @public * @param {String} alias The alias of the sound to pause. * Internally, this can also be the object from the _sounds dictionary directly. */ p.pause = function(sound, isGlobal) { if (isString(sound)) sound = this._sounds[sound]; isGlobal = !!isGlobal; var arr = sound.playing; var i; for (i = arr.length - 1; i >= 0; --i) { if (!arr[i].paused) { arr[i].pause(); arr[i].globallyPaused = isGlobal; } } arr = sound.waitingToPlay; for (i = arr.length - 1; i >= 0; --i) { if (!arr[i].paused) { arr[i].pause(); arr[i].globallyPaused = isGlobal; } } }; /** * Unpauses a specific sound. * @method resume * @public * @param {String} alias The alias of the sound to pause. * Internally, this can also be the object from the _sounds dictionary directly. */ p.resume = function(sound, isGlobal) { if (isString(sound)) sound = this._sounds[sound]; var arr = sound.playing; var i; for (i = arr.length - 1; i >= 0; --i) { if (arr[i].globallyPaused == isGlobal) arr[i].resume(); } arr = sound.waitingToPlay; for (i = arr.length - 1; i >= 0; --i) { if (arr[i].globallyPaused == isGlobal) arr[i].resume(); } }; /** * Pauses all sounds in a given context. Audio paused this way will not be resumed with * resumeAll(), but must be resumed individually or with resumeContext(). * @method pauseContext * @param {String} context The name of the context to pause. */ p.pauseContext = function(context) { context = this._contexts[context]; if (context) { var arr = context.sounds; var s, i; for (i = arr.length - 1; i >= 0; --i) { s = arr[i]; var j; for (j = s.playing.length - 1; j >= 0; --j) s.playing[j].pause(); for (j = s.waitingToPlay.length - 1; j >= 0; --j) s.waitingToPlay[j].pause(); } for (i = 0; i < context.subContexts.length; ++i) { this.pauseContext(context.subContexts[i]); } } }; /** * Resumes all sounds in a given context. * @method pauseContext * @param {String} context The name of the context to pause. */ p.resumeContext = function(context) { context = this._contexts[context]; if (context) { var arr = context.sounds; var s, i; for (i = arr.length - 1; i >= 0; --i) { s = arr[i]; var j; for (j = s.playing.length - 1; j >= 0; --j) s.playing[j].resume(); for (j = s.waitingToPlay.length - 1; j >= 0; --j) s.waitingToPlay[j].resume(); } for (i = 0; i < context.subContexts.length; ++i) { this.resumeContext(context.subContexts[i]); } } }; /** * Pauses all sounds. * @method pauseAll * @public */ p.pauseAll = function() { var arr = this._sounds; for (var i in arr) this.pause(arr[i], true); }; /** * Unpauses all sounds that were paused with pauseAll(). This does not unpause audio * that was paused individually or with pauseContext(). * @method resumeAll * @public */ p.resumeAll = function() { var arr = this._sounds; for (var i in arr) this.resume(arr[i], true); }; p._onInstancePaused = function() { if (this._fixAndroidAudio) { if (--this._numPlayingAudio === 0) this._lastAudioTime = Date.now(); } }; p._onInstanceResume = function() { if (this._fixAndroidAudio) { if (this._lastAudioTime > 0 && Date.now() - this._lastAudioTime > 30000) _fixAudioContext(); this._numPlayingAudio++; this._lastAudioTime = -1; } }; /** * Sets mute status of all sounds in a context * @method setContextMute * @public * @param {String} context The name of the context to modify. * @param {Boolean} muted If the context should be muted. */ p.setContextMute = function(context, muted) { context = this._contexts[context]; if (context) { context.muted = muted; var volume = context.volume; var arr = context.sounds; var s, playing, j, i; for (i = arr.length - 1; i >= 0; --i) { s = arr[i]; if (s.playing.length) { playing = s.playing; for (j = playing.length - 1; j >= 0; --j) { playing[j].updateVolume(muted ? 0 : volume); } } } for (i = 0; i < context.subContexts.length; ++i) { this.setContextMute(context.subContexts[i], muted); } } }; /** * Set the mute status of all sounds * @property {Boolean} muteAll */ Object.defineProperty(p, 'muteAll', { set: function(muted) { SoundJS.setMute(!!muted); } }); /** * Sets volume of a context. Individual sound volumes are multiplied by this value. * @method setContextVolume * @public * @param {String} context The name of the context to modify. * @param {Number} volume The volume for the context (0 to 1). */ p.setContextVolume = function(context, volume) { context = this._contexts[context]; if (context) { var muted = context.muted; context.volume = volume; var arr = context.sounds; var s, playing, j; for (var i = arr.length - 1; i >= 0; --i) { s = arr[i]; if (s.playing.length) { playing = s.playing; for (j = playing.length - 1; j >= 0; --j) { playing[j].updateVolume(muted ? 0 : volume); } } } } }; /** * Preloads a list of sounds. * @method preload * @public * @param {Array|String} list An alias or list of aliases to load. * @param {function} [callback] The function to call when all * sounds have been loaded. */ p.preload = function(list, callback) { if (!this.isSupported) { if (callback) { setTimeout(callback, 0); } return; } if (isString(list)) { list = [list]; } if (!list || list.length === 0) { if (callback) callback(); return; } var assets = []; var sound; for (var i = 0, len = list.length; i < len; ++i) { sound = this._sounds[list[i]]; if (sound) { if (sound.loadState == LoadStates.unloaded) { sound.loadState = LoadStates.loading; //sound is passed last so that SoundJS gets the sound ID assets.push( { id: sound.id, src: sound.src, complete: this._markLoaded, data: sound, advanced: true }); } } else if (DEBUG && Debug) { Debug.error("springroll.Sound was asked to preload " + list[i] + " but it is not a registered sound!"); } } if (assets.length > 0) { Application.instance.load(assets, callback); } else if (callback) { callback(); } }; /** * Marks a sound as loaded. If it needs to play after the load, then it is played. * @method _markLoaded * @private * @param {String} alias The alias of the sound to mark. * @param {function} callback A function to call to show that the sound is loaded. */ p._markLoaded = function(result) { var alias = result.data.id; var sound = this._sounds[alias]; if (sound) { sound.loadState = LoadStates.loaded; if (sound.playAfterLoad) this._playAfterLoad(alias); } var callback = sound.preloadCallback; if (callback) { sound.preloadCallback = null; callback(); } }; /** * Unloads a list of sounds to reclaim memory if possible. * If the sounds are playing, they are stopped. * @method unload * @public * @param {Array} list An array of sound aliases to unload. */ p.unload = function(list) { if (!list) return; var sound; for (var i = 0, len = list.length; i < len; ++i) { sound = this._sounds[list[i]]; if (sound) { this._stopSound(sound); sound.loadState = LoadStates.unloaded; } SoundJS.removeSound(sound.src); } }; /** * Unloads all sounds. If any sounds are playing, they are stopped. * Internally this calls `unload`. * @method unloadAll * @public */ p.unloadAll = function() { var arr = []; for (var i in this._sounds) { arr.push(i); } this.unload(arr); }; /** * Places a SoundInstance back in the pool for reuse. * @method _poolinst * @private * @param {SoundInstance} inst The instance to repool. */ p._poolInst = function(inst) { if (this._pool.indexOf(inst) == -1) { inst._endCallback = inst.alias = inst._channel = inst._startFunc = null; inst.curVol = 0; inst.globallyPaused = inst.paused = inst.isValid = false; this._pool.push(inst); } }; /** * Destroys springroll.Sound. This unloads loaded sounds in SoundJS. * @method destroy * @public */ p.destroy = function() { //Stop all sounds this.stopAll(); //Remove all sounds from memeory SoundJS.removeAllSounds(); //Remove the SWF from the page if (FlashAudioPlugin && SoundJS.activePlugin instanceof FlashAudioPlugin) { var swf = document.getElementById("SoundJSFlashContainer"); if (swf && swf.parentNode) { swf.parentNode.removeChild(swf); } } _instance = null; this._sounds = null; this._volumes = null; this._fades = null; this._contexts = null; this._pool = null; }; //Convenience methods for type checking function isString(obj) { return typeof obj == "string"; } function isFunction(obj) { return typeof obj == "function"; } namespace('springroll').Sound = Sound; }());