/* Copyright (c) 2015 William Toohey * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. */ (function(window, document) { "use strict"; function SoundManager(core) { this.core = core; this.playing = false; this.playbackRate = 1; this.song = null; this.initPromise = null; /* Lower level audio and timing info */ this.context = null; // Audio context, Web Audio API this.buildSource = null; this.loopSource = null; this.buildup = null; this.loop = null; this.startTime = 0; // File start time - 0 is loop start, not build start this.buildLength = 0; this.loopLength = 0; // For calculating beat lengths // Volume this.gainNode = null; this.mute = false; this.lastVol = 1; // Visualiser this.vReady = false; this.vBars = 0; this.splitter = null; this.analysers = []; this.analyserArrays = []; this.logArrays = []; this.binCutoffs = []; this.linBins = 0; this.logBins = 0; this.maxBinLin = 0; } SoundManager.prototype.init = function() { if(!this.initPromise) { this.initPromise = new Promise((resolve, reject) => { // Check Web Audio API Support try { // More info at http://caniuse.com/#feat=audio-api window.AudioContext = window.AudioContext || window.webkitAudioContext; this.context = new window.AudioContext(); // These don't always exist this.context.suspend = this.context.suspend || Promise.resolve(); this.context.resume = this.context.resume || Promise.resolve(); this.gainNode = this.context.createGain(); this.gainNode.connect(this.context.destination); } catch(e) { reject(Error("Web Audio API not supported in this browser.")); return; } resolve(); }).then(response => { return new Promise((resolve, reject) => { // See if our MP3 decoder is working var mp3Worker; try { mp3Worker = this.createWorker(); } catch(e) { console.log(e); reject(Error("MP3 Worker cannot be started - correct path set in defaults?")); } var pingListener = event => { mp3Worker.removeEventListener('message', pingListener); mp3Worker.terminate(); resolve(); }; mp3Worker.addEventListener('message', pingListener, false); mp3Worker.addEventListener('error', () => { reject(Error("MP3 Worker cannot be started - correct path set in defaults?")); }, false); mp3Worker.postMessage({ping:true}); }) }).then(response => { return new Promise((resolve, reject) => { // iOS and other some mobile browsers - unlock the context as // it starts in a suspended state if(this.context.state != "running") { this.core.warning("We're about to load about 10MB of stuff. Tap to begin!"); var unlocker = () => { // create empty buffer var buffer = this.context.createBuffer(1, 1, 22050); var source = this.context.createBufferSource(); source.buffer = buffer; // connect to output (your speakers) source.connect( this.context.destination); // play the file source.start(0); window.removeEventListener('touchend', unlocker); window.removeEventListener('click', unlocker); this.core.clearMessage(); resolve(); }; window.addEventListener('touchend', unlocker, false); window.addEventListener('click', unlocker, false); } else { resolve(); } }) }); } return this.initPromise; } SoundManager.prototype.playSong = function(song, playBuild, forcePlay) { var p = Promise.resolve(); // Editor forces play on audio updates if(this.song == song && !forcePlay) { return p; } this.stop(); this.song = song; if(!song || (!song.sound)) { // null song return p; } // if there's a fadeout happening from AutoSong, kill it this.gainNode.gain.cancelScheduledValues(0); // Reset original volume this.setVolume(this.lastVol); if(this.mute) { this.setMute(true); } p = p.then(() => { return this.loadSong(song); }).then(buffers => { // To prevent race condition if you press "next" twice fast if(song != this.song) { // Stop processing - silently ignored in the catch below throw Error("Song not playable - ignoring!"); } this.buildup = buffers.buildup; this.buildLength = this.buildup ? this.buildup.duration : 0; this.loop = buffers.loop; this.loopLength = this.loop.duration; // This fixes sync issues on Firefox and slow machines. return this.context.suspend() }).then(() => { if(playBuild) { this.seek(-this.buildLength, true); } else { this.seek(0, true); } return this.context.resume(); }).then(() => { this.playing = true; }).catch(error => { // Just to ignore it if the song was invalid // Log it in case it's something weird console.log(error); return; }); return p; }; SoundManager.prototype.stop = function() { if (this.playing) { if(this.buildSource) { this.buildSource.stop(0); this.buildSource.disconnect(); this.buildSource = null; } // arg required for mobile webkit this.loopSource.stop(0); // TODO needed? this.loopSource.disconnect(); this.loopSource = null; this.vReady = false; this.playing = false; this.startTime = 0; } }; SoundManager.prototype.setRate = function(rate) { // Double speed is more than enough. Famous last words? rate = Math.max(Math.min(rate, 2), 0.25); var time = this.clampedTime(); this.playbackRate = rate; this.seek(time); } SoundManager.prototype.seek = function(time, noPlayingUpdate) { if(!this.song) { return; } //console.log("Seeking to " + time); // Clamp the blighter time = Math.min(Math.max(time, -this.buildLength), this.loopLength); this.stop(); this.loopSource = this.context.createBufferSource(); this.loopSource.buffer = this.loop; this.loopSource.playbackRate.value = this.playbackRate; this.loopSource.loop = true; this.loopSource.loopStart = 0; this.loopSource.loopEnd = this.loopLength; this.loopSource.connect(this.gainNode); if(time < 0) { this.buildSource = this.context.createBufferSource(); this.buildSource.buffer = this.buildup; this.buildSource.playbackRate.value = this.playbackRate; this.buildSource.connect(this.gainNode); this.buildSource.start(0, this.buildLength + time); this.loopSource.start(this.context.currentTime - (time / this.playbackRate)); } else { this.loopSource.start(0, time); } this.startTime = this.context.currentTime - (time / this.playbackRate); if(!noPlayingUpdate) { this.playing = true; } this.core.recalcBeatIndex(); } // In seconds, relative to the loop start SoundManager.prototype.currentTime = function() { if(!this.playing) { return 0; } return (this.context.currentTime - this.startTime) * this.playbackRate; }; SoundManager.prototype.clampedTime = function() { var time = this.currentTime(); if(time > 0) { time %= this.loopLength; } return time; } SoundManager.prototype.displayableTime = function() { var time = this.clampedTime(); if(time < 0) { return 0; } else { return time; } }; SoundManager.prototype.loadSong = function(song) { if(song._loadPromise) { // Someone went forward then immediately back then forward again // Either way, the sound is still loading. It'll come back when it's ready return; } var buffers = {loop: null, buildup: null}; var promises = [this.loadBuffer(song, "sound").then(buffer => { buffers.loop = buffer; })]; if(song.buildup) { promises.push(this.loadBuffer(song, "buildup").then(buffer => { buffers.buildup = buffer; })); } else { this.buildLength = 0; } song._loadPromise = Promise.all(promises) .then(() => { song._loadPromise = null; return buffers; }); return song._loadPromise; }; SoundManager.prototype.loadBuffer = function(song, soundName) { return new Promise((resolve, reject) => { var mp3Worker = this.createWorker(); mp3Worker.addEventListener('error', () => { reject(Error("MP3 Worker failed to convert track")); }, false); mp3Worker.addEventListener('message', e => { var decoded = e.data; mp3Worker.terminate(); // restore transferred buffer song[soundName] = decoded.arrayBuffer; // Convert to real audio buffer var audio = this.audioBufFromRaw(decoded.rawAudio); resolve(audio); }, false); // transfer the buffer to save time mp3Worker.postMessage(song[soundName], [song[soundName]]); }); } // Converts continuous PCM array to Web Audio API friendly format SoundManager.prototype.audioBufFromRaw = function(raw) { var buffer = raw.array var channels = raw.channels; var samples = buffer.length/channels; var audioBuf = this.context.createBuffer(channels, samples, raw.sampleRate); //var audioBuf = this.context.createBuffer(1, buffer.length, raw.sampleRate); //audioBuf.copyToChannel(buffer, 0, 0); for(var i = 0; i < channels; i++) { //console.log("Making buffer at offset",i*samples,"and length",samples,".Original buffer is",channels,"channels and",buffer.length,"elements"); // Offset is in bytes, length is in elements var channel = new Float32Array(buffer.buffer , i * samples * 4, samples); //console.log(channel); audioBuf.copyToChannel(channel, i, 0); } return audioBuf; }; SoundManager.prototype.createWorker = function() { return new Worker(this.core.settings.defaults.workersPath + 'mp3-worker.js'); }; SoundManager.prototype.initVisualiser = function(bars) { if(!bars) { return; } this.vReady = false; this.vBars = bars; for(var i = 0; i < this.analysers.length; i++) { this.analysers[i].disconnect(); } if(this.splitter) { this.splitter.disconnect(); this.splitter = null; } this.analysers = []; this.analyserArrays = []; this.logArrays = []; this.binCutoffs = []; this.linBins = 0; this.logBins = 0; this.maxBinLin = 0; this.attachVisualiser(); } SoundManager.prototype.attachVisualiser = function() { if(!this.playing || this.vReady) { return; } // Get our info from the loop var channels = this.loopSource.channelCount; // In case channel counts change, this is changed each time this.splitter = this.context.createChannelSplitter(channels); // Connect to the gainNode so we get buildup stuff too this.loopSource.connect(this.splitter); if(this.buildSource) { this.buildSource.connect(this.splitter); } // Split display up into each channel this.vBars = Math.floor(this.vBars/channels); for(var i = 0; i < channels; i++) { var analyser = this.context.createAnalyser(); // big fft buffers are new-ish try { analyser.fftSize = 8192; } catch(err) { analyser.fftSize = 2048; } // Chosen because they look nice, no maths behind it analyser.smoothingTimeConstant = 0.6; analyser.minDecibels = -70; analyser.maxDecibels = -25; this.analyserArrays.push(new Uint8Array(analyser.frequencyBinCount)); analyser.getByteTimeDomainData(this.analyserArrays[i]); this.splitter.connect(analyser, i); this.analysers.push(analyser); this.logArrays.push(new Uint8Array(this.vBars)); } var binCount = this.analysers[0].frequencyBinCount; var binWidth = this.loopSource.buffer.sampleRate / binCount; // first 2kHz are linear this.maxBinLin = Math.floor(2000/binWidth); // Don't stretch the first 2kHz, it looks awful this.linBins = Math.min(this.maxBinLin, Math.floor(this.vBars/2)); // Only go up to 22KHz var maxBinLog = Math.floor(22000/binWidth); var logBins = this.vBars - this.linBins; var logLow = Math.log2(2000); var logDiff = Math.log2(22000) - logLow; for(var i = 0; i < logBins; i++) { var cutoff = i * (logDiff/logBins) + logLow; var freqCutoff = Math.pow(2, cutoff); var binCutoff = Math.floor(freqCutoff / binWidth); this.binCutoffs.push(binCutoff); } this.vReady = true; } SoundManager.prototype.sumArray = function(array, low, high) { var total = 0; for(var i = low; i <= high; i++) { total += array[i]; } return total/(high-low+1); } SoundManager.prototype.getVisualiserData = function() { if(!this.vReady) { return null; } for(var a = 0; a < this.analyserArrays.length; a++) { var data = this.analyserArrays[a]; var result = this.logArrays[a]; this.analysers[a].getByteFrequencyData(data); for(var i = 0; i < this.linBins; i++) { var scaled = Math.round(i * this.maxBinLin / this.linBins); result[i] = data[scaled]; } result[this.linBins] = data[this.binCutoffs[0]]; for(var i = this.linBins+1; i < this.vBars; i++) { var cutoff = i - this.linBins; result[i] = this.sumArray(data, this.binCutoffs[cutoff-1], this.binCutoffs[cutoff]); } } return this.logArrays; } SoundManager.prototype.setMute = function(mute) { if(!this.mute && mute) { // muting this.lastVol = this.gainNode.gain.value; } if(mute) { this.gainNode.gain.value = 0; } else { this.gainNode.gain.value = this.lastVol; } this.core.userInterface.updateVolume(this.gainNode.gain.value); this.mute = mute; return mute; }; SoundManager.prototype.toggleMute = function() { return this.setMute(!this.mute); }; SoundManager.prototype.decreaseVolume = function() { this.setMute(false); var val = Math.max(this.gainNode.gain.value - 0.1, 0); this.setVolume(val); }; SoundManager.prototype.increaseVolume = function() { this.setMute(false); var val = Math.min(this.gainNode.gain.value + 0.1, 1); this.setVolume(val); }; SoundManager.prototype.setVolume = function(vol) { this.gainNode.gain.value = vol; this.lastVol = vol; this.core.userInterface.updateVolume(vol); }; SoundManager.prototype.fadeOut = function(callback) { if(!this.mute) { // Firefox hackery this.gainNode.gain.setValueAtTime(this.lastVol, this.context.currentTime); this.gainNode.gain.exponentialRampToValueAtTime(0.01, this.context.currentTime + 2); } setTimeout(callback, 2000); } window.SoundManager = SoundManager; })(window, document);