ig.module( 'impact.sound' ) .defines(function(){ "use strict"; ig.SoundManager = ig.Class.extend({ clips: {}, volume: 1, format: null, init: function() { // Quick sanity check if the Browser supports the Audio tag if( !ig.Sound.enabled || !window.Audio ) { ig.Sound.enabled = false; return; } // Probe sound formats and determine the file extension to load var probe = new Audio(); for( var i = 0; i < ig.Sound.use.length; i++ ) { var format = ig.Sound.use[i]; if( probe.canPlayType(format.mime) ) { this.format = format; break; } } // No compatible format found? -> Disable sound if( !this.format ) { ig.Sound.enabled = false; } // Create WebAudio Context if( ig.Sound.enabled && ig.Sound.useWebAudio ) { this.audioContext = new AudioContext(); this.boundWebAudioUnlock = this.unlockWebAudio.bind(this); document.addEventListener('touchstart', this.boundWebAudioUnlock, false); } }, unlockWebAudio: function() { document.removeEventListener('touchstart', this.boundWebAudioUnlock, false); // create empty buffer var buffer = this.audioContext.createBuffer(1, 1, 22050); var source = this.audioContext.createBufferSource(); source.buffer = buffer; source.connect(this.audioContext.destination); source.start(0); }, load: function( path, multiChannel, loadCallback ) { if( multiChannel && ig.Sound.useWebAudio ) { // Requested as Multichannel and we're using WebAudio? return this.loadWebAudio( path, multiChannel, loadCallback ); } else { // Oldschool HTML5 Audio - always used for Music return this.loadHTML5Audio( path, multiChannel, loadCallback ); } }, loadWebAudio: function( path, multiChannel, loadCallback ) { // Path to the soundfile with the right extension (.ogg or .mp3) var realPath = ig.prefix + path.replace(/[^\.]+$/, this.format.ext) + ig.nocache; if( this.clips[path] ) { return this.clips[path]; } var audioSource = new ig.Sound.WebAudioSource() this.clips[path] = audioSource; var request = new XMLHttpRequest(); request.open('GET', realPath, true); request.responseType = 'arraybuffer'; var that = this; request.onload = function(ev) { that.audioContext.decodeAudioData(request.response, function(buffer) { audioSource.buffer = buffer; loadCallback( path, true, ev ); }, function(ev) { loadCallback( path, false, ev ); } ); }; request.onerror = function(ev) { loadCallback( path, false, ev ); }; request.send(); return audioSource; }, loadHTML5Audio: function( path, multiChannel, loadCallback ) { // Path to the soundfile with the right extension (.ogg or .mp3) var realPath = ig.prefix + path.replace(/[^\.]+$/, this.format.ext) + ig.nocache; // Sound file already loaded? if( this.clips[path] ) { // Loaded as WebAudio, but now requested as HTML5 Audio? Probably Music? if( this.clips[path] instanceof ig.Sound.WebAudioSource ) { return this.clips[path]; } // Only loaded as single channel and now requested as multichannel? if( multiChannel && this.clips[path].length < ig.Sound.channels ) { for( var i = this.clips[path].length; i < ig.Sound.channels; i++ ) { var a = new Audio( realPath ); a.load(); this.clips[path].push( a ); } } return this.clips[path][0]; } var clip = new Audio( realPath ); if( loadCallback ) { // The canplaythrough event is dispatched when the browser determines // that the sound can be played without interuption, provided the // download rate doesn't change. // Mobile browsers stubbornly refuse to preload HTML5, so we simply // ignore the canplaythrough event and immediately "fake" a successful // load callback if( ig.ua.mobile ) { setTimeout(function(){ loadCallback( path, true, null ); }, 0); } else { clip.addEventListener( 'canplaythrough', function cb(ev){ clip.removeEventListener('canplaythrough', cb, false); loadCallback( path, true, ev ); }, false ); clip.addEventListener( 'error', function(ev){ loadCallback( path, false, ev ); }, false); } } clip.preload = 'auto'; clip.load(); this.clips[path] = [clip]; if( multiChannel ) { for( var i = 1; i < ig.Sound.channels; i++ ) { var a = new Audio(realPath); a.load(); this.clips[path].push( a ); } } return clip; }, get: function( path ) { // Find and return a channel that is not currently playing var channels = this.clips[path]; // Is this a WebAudio source? We only ever have one for each Sound if( channels && channels instanceof ig.Sound.WebAudioSource ) { return channels; } // Oldschool HTML5 Audio - find a channel that's not currently // playing or, if all are playing, rewind one for( var i = 0, clip; clip = channels[i++]; ) { if( clip.paused || clip.ended ) { if( clip.ended ) { clip.currentTime = 0; } return clip; } } // Still here? Pause and rewind the first channel channels[0].pause(); channels[0].currentTime = 0; return channels[0]; } }); ig.Music = ig.Class.extend({ tracks: [], namedTracks: {}, currentTrack: null, currentIndex: 0, random: false, _volume: 1, _loop: false, _fadeInterval: 0, _fadeTimer: null, _endedCallbackBound: null, init: function() { this._endedCallbackBound = this._endedCallback.bind(this); Object.defineProperty(this,"volume", { get: this.getVolume.bind(this), set: this.setVolume.bind(this) }); Object.defineProperty(this,"loop", { get: this.getLooping.bind(this), set: this.setLooping.bind(this) }); }, add: function( music, name ) { if( !ig.Sound.enabled ) { return; } var path = music instanceof ig.Sound ? music.path : music; var track = ig.soundManager.load(path, false); // Did we get a WebAudio Source? This is suboptimal; Music should be loaded // as HTML5 Audio so it can be streamed if( track instanceof ig.Sound.WebAudioSource ) { // Since this error will likely occour at game start, we stop the game // to not produce any more errors. ig.system.stopRunLoop(); throw( "Sound '"+path+"' loaded as Multichannel but used for Music. " + "Set the multiChannel param to false when loading, e.g.: new ig.Sound(path, false)" ); } track.loop = this._loop; track.volume = this._volume; track.addEventListener( 'ended', this._endedCallbackBound, false ); this.tracks.push( track ); if( name ) { this.namedTracks[name] = track; } if( !this.currentTrack ) { this.currentTrack = track; } }, next: function() { if( !this.tracks.length ) { return; } this.stop(); this.currentIndex = this.random ? Math.floor(Math.random() * this.tracks.length) : (this.currentIndex + 1) % this.tracks.length; this.currentTrack = this.tracks[this.currentIndex]; this.play(); }, pause: function() { if( !this.currentTrack ) { return; } this.currentTrack.pause(); }, stop: function() { if( !this.currentTrack ) { return; } this.currentTrack.pause(); this.currentTrack.currentTime = 0; }, play: function( name ) { // If a name was provided, stop playing the current track (if any) // and play the named track if( name && this.namedTracks[name] ) { var newTrack = this.namedTracks[name]; if( newTrack != this.currentTrack ) { this.stop(); this.currentTrack = newTrack; } } else if( !this.currentTrack ) { return; } this.currentTrack.play(); }, getLooping: function() { return this._loop; }, setLooping: function( l ) { this._loop = l; for( var i in this.tracks ) { this.tracks[i].loop = l; } }, getVolume: function() { return this._volume; }, setVolume: function( v ) { this._volume = v.limit(0,1); for( var i in this.tracks ) { this.tracks[i].volume = this._volume; } }, fadeOut: function( time ) { if( !this.currentTrack ) { return; } clearInterval( this._fadeInterval ); this.fadeTimer = new ig.Timer( time ); this._fadeInterval = setInterval( this._fadeStep.bind(this), 50 ); }, _fadeStep: function() { var v = this.fadeTimer.delta() .map(-this.fadeTimer.target, 0, 1, 0) .limit( 0, 1 ) * this._volume; if( v <= 0.01 ) { this.stop(); this.currentTrack.volume = this._volume; clearInterval( this._fadeInterval ); } else { this.currentTrack.volume = v; } }, _endedCallback: function() { if( this._loop ) { this.play(); } else { this.next(); } } }); ig.Sound = ig.Class.extend({ path: '', volume: 1, currentClip: null, multiChannel: true, _loop: false, init: function( path, multiChannel ) { this.path = path; this.multiChannel = (multiChannel !== false); Object.defineProperty(this,"loop", { get: this.getLooping.bind(this), set: this.setLooping.bind(this) }); this.load(); }, getLooping: function() { return this._loop; }, setLooping: function( loop ) { this._loop = loop; if( this.currentClip ) { this.currentClip.loop = loop; } }, load: function( loadCallback ) { if( !ig.Sound.enabled ) { if( loadCallback ) { loadCallback( this.path, true ); } return; } if( ig.ready ) { ig.soundManager.load( this.path, this.multiChannel, loadCallback ); } else { ig.addResource( this ); } }, play: function() { if( !ig.Sound.enabled ) { return; } this.currentClip = ig.soundManager.get( this.path ); this.currentClip.loop = this._loop; this.currentClip.volume = ig.soundManager.volume * this.volume; this.currentClip.play(); }, stop: function() { if( this.currentClip ) { this.currentClip.pause(); this.currentClip.currentTime = 0; } } }); ig.Sound.WebAudioSource = ig.Class.extend({ sources: [], gain: null, buffer: null, _loop: false, init: function() { this.gain = ig.soundManager.audioContext.createGain(); this.gain.connect(ig.soundManager.audioContext.destination); Object.defineProperty(this,"loop", { get: this.getLooping.bind(this), set: this.setLooping.bind(this) }); Object.defineProperty(this,"volume", { get: this.getVolume.bind(this), set: this.setVolume.bind(this) }); }, play: function() { if( !this.buffer ) { return; } var source = ig.soundManager.audioContext.createBufferSource(); source.buffer = this.buffer; source.connect(this.gain); source.loop = this._loop; // Add this new source to our sources array and remove it again // later when it has finished playing. var that = this; this.sources.push(source); source.onended = function(){ that.sources.erase(source); } source.start(0); }, pause: function() { for( var i = 0; i < this.sources.length; i++ ) { try{ this.sources[i].stop(); } catch(err){} } }, getLooping: function() { return this._loop; }, setLooping: function( loop ) { this._loop = loop; for( var i = 0; i < this.sources.length; i++ ) { this.sources[i].loop = loop; } }, getVolume: function() { return this.gain.gain.value; }, setVolume: function( volume ) { this.gain.gain.value = volume; } }); ig.Sound.FORMAT = { MP3: {ext: 'mp3', mime: 'audio/mpeg'}, M4A: {ext: 'm4a', mime: 'audio/mp4; codecs=mp4a'}, OGG: {ext: 'ogg', mime: 'audio/ogg; codecs=vorbis'}, WEBM: {ext: 'webm', mime: 'audio/webm; codecs=vorbis'}, CAF: {ext: 'caf', mime: 'audio/x-caf'} }; ig.Sound.use = [ig.Sound.FORMAT.OGG, ig.Sound.FORMAT.MP3]; ig.Sound.channels = 4; ig.Sound.enabled = true; ig.normalizeVendorAttribute(window, 'AudioContext'); ig.Sound.useWebAudio = !!window.AudioContext && !window.nwf; });