You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
0x40-web/src/js/SoundManager.js

510 lines
17 KiB

/* Copyright (c) 2015 William Toohey <will@mon.im>
*
* 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);