SoundManager moved to Promises

Also:
MP3 decoder faster and better designed
Build and loop are kept separate (for editor)
Can now seek
master
William Toohey 10 years ago
parent 0965d80ed9
commit 393df5aae3
  1. 69
      lib/workers/mp3-worker.js
  2. 5
      src/js/HuesCore.js
  3. 328
      src/js/SoundManager.js

@ -4,57 +4,46 @@
//importScripts('mp3.js'); //importScripts('mp3.js');
importScripts('../mp3-min.js'); importScripts('../mp3-min.js');
var decodeBuffer = function(source, callback) { // Flash value
var asset = AV.Asset.fromBuffer(source); var LAME_DELAY_START = 2258;
asset.on("error", function(err) { var LAME_DELAY_END = 0;
console.log(err);
}); var deinterleaveAndTrim = function(buffer, asset) {
// because MP3 is bad, we nuke silence
asset.decodeToBuffer(function(buffer) { var channels = asset.format.channelsPerFrame,
var result = {array: buffer, len = buffer.length / channels;
sampleRate: asset.format.sampleRate, newLen = len - LAME_DELAY_START - LAME_DELAY_END;
channels: asset.format.channelsPerFrame} result = new Float32Array(newLen * channels);
callback(result);
}); for(var sample = 0; sample < newLen; sample++) {
for(var channel = 0; channel < channels; channel++) {
result[channel*newLen + sample] = buffer[(sample+LAME_DELAY_START)*channels + channel];
} }
var finish = function(result, transferrables) {
transferrables.push(result.loop.array.buffer);
if(result.song.buildup) {
transferrables.push(result.build.array.buffer);
transferrables.push(result.song.buildup);
} }
self.postMessage(result, transferrables); return result;
} }
self.addEventListener('message', function(e) { self.addEventListener('message', function(e) {
var song = e.data; // To see if things are working, we can ping the worker
if(e.data.ping) {
// To see if things are working
if(song.ping) {
self.postMessage({ping: true}); self.postMessage({ping: true});
return; return;
} }
var result = {song: song, build: null, loop: null}; var arrayBuffer = e.data;
var transferrables = [result.song.sound];
if(song.buildup) { var asset = AV.Asset.fromBuffer(arrayBuffer);
decodeBuffer(song.buildup, function(sound) { // Any errors are thrown up the chain to our Promises
result.build = sound;
// Song is finished too
if(result.loop) {
finish(result, transferrables);
}
});
}
decodeBuffer(song.sound, function(sound) { asset.decodeToBuffer(function(buffer) {
result.loop = sound; var fixedBuffer = deinterleaveAndTrim(buffer, asset);
// Either there was no build, or it's already loaded var raw = {array: fixedBuffer,
if(!song.buildup || (song.buildup && result.build)) { sampleRate: asset.format.sampleRate,
finish(result, transferrables); channels: asset.format.channelsPerFrame}
} self.postMessage({rawAudio : raw,
arrayBuffer : arrayBuffer},
// transfer objects to save a copy
[fixedBuffer.buffer, arrayBuffer]);
}); });
}, false); }, false);

@ -375,7 +375,8 @@ HuesCore.prototype.setSong = function(index) {
} }
} }
this.setInvert(false); this.setInvert(false);
this.soundManager.playSong(this.currentSong, this.doBuildup, () => { this.soundManager.playSong(this.currentSong, this.doBuildup)
.then(() => {
this.resetAudio(); this.resetAudio();
this.fillBuildup(); this.fillBuildup();
this.callEventListeners("songstarted"); this.callEventListeners("songstarted");
@ -392,7 +393,7 @@ HuesCore.prototype.updateBeatLength = function() {
HuesCore.prototype.fillBuildup = function() { HuesCore.prototype.fillBuildup = function() {
this.updateBeatLength(); this.updateBeatLength();
var buildBeats = Math.floor(this.soundManager.loopStart / this.beatLength); var buildBeats = Math.floor(this.soundManager.buildLength / this.beatLength);
if(buildBeats < 1) { if(buildBeats < 1) {
buildBeats = 1; buildBeats = 1;
} }

@ -22,10 +22,6 @@
(function(window, document) { (function(window, document) {
"use strict"; "use strict";
// Flash value
var LAME_DELAY_START = 2258;
var LAME_DELAY_END = 0;
function SoundManager(core) { function SoundManager(core) {
this.core = core; this.core = core;
this.playing = false; this.playing = false;
@ -34,11 +30,13 @@ function SoundManager(core) {
this.initPromise = null; this.initPromise = null;
/* Lower level audio and timing info */ /* Lower level audio and timing info */
this.bufSource = null;
this.buffer = null;
this.context = null; // Audio context, Web Audio API 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.startTime = 0; // File start time - 0 is loop start, not build start
this.loopStart = 0; // When the build ends, if any this.buildLength = 0;
this.loopLength = 0; // For calculating beat lengths this.loopLength = 0; // For calculating beat lengths
// Volume // Volume
@ -57,11 +55,6 @@ function SoundManager(core) {
this.linBins = 0; this.linBins = 0;
this.logBins = 0; this.logBins = 0;
this.maxBinLin = 0; this.maxBinLin = 0;
// For concatenating our files
this.tmpBuffer = null;
this.tmpBuild = null;
this.onLoadCallback = null;
} }
SoundManager.prototype.init = function() { SoundManager.prototype.init = function() {
@ -72,6 +65,9 @@ SoundManager.prototype.init = function() {
// More info at http://caniuse.com/#feat=audio-api // More info at http://caniuse.com/#feat=audio-api
window.AudioContext = window.AudioContext || window.webkitAudioContext; window.AudioContext = window.AudioContext || window.webkitAudioContext;
this.context = new window.AudioContext(); 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 = this.context.createGain();
this.gainNode.connect(this.context.destination); this.gainNode.connect(this.context.destination);
} catch(e) { } catch(e) {
@ -81,23 +77,24 @@ SoundManager.prototype.init = function() {
resolve(); resolve();
}).then(response => { }).then(response => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// Get our MP3 decoder started // See if our MP3 decoder is working
var mp3Worker;
try { try {
this.mp3Worker = new Worker(this.core.settings.defaults.workersPath + 'mp3-worker.js'); mp3Worker = this.createWorker();
} catch(e) { } catch(e) {
console.log(e); console.log(e);
reject(Error("MP3 Worker cannot be started - correct path set in defaults?")); reject(Error("MP3 Worker cannot be started - correct path set in defaults?"));
} }
var pingListener = event => { var pingListener = event => {
this.mp3Worker.removeEventListener('message', pingListener); mp3Worker.removeEventListener('message', pingListener);
this.mp3Worker.addEventListener('message', this.workerFinished.bind(this), false); mp3Worker.terminate();
resolve(); resolve();
}; };
this.mp3Worker.addEventListener('message', pingListener, false); mp3Worker.addEventListener('message', pingListener, false);
this.mp3Worker.addEventListener('error', () => { mp3Worker.addEventListener('error', () => {
reject(Error("MP3 Worker cannot be started - correct path set in defaults?")); reject(Error("MP3 Worker cannot be started - correct path set in defaults?"));
}, false); }, false);
this.mp3Worker.postMessage({ping:true}); mp3Worker.postMessage({ping:true});
}) })
}).then(response => { }).then(response => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@ -133,14 +130,15 @@ SoundManager.prototype.init = function() {
return this.initPromise; return this.initPromise;
} }
SoundManager.prototype.playSong = function(song, playBuild, callback) { SoundManager.prototype.playSong = function(song, playBuild) {
var p = Promise.resolve();
if(this.song == song) { if(this.song == song) {
return; return p;
} }
this.stop(); this.stop();
this.song = song; this.song = song;
if(!song || (!song.sound)) { // null song if(!song || (!song.sound)) { // null song
return; return p;
} }
// if there's a fadeout happening from AutoSong, kill it // if there's a fadeout happening from AutoSong, kill it
@ -151,68 +149,86 @@ SoundManager.prototype.playSong = function(song, playBuild, callback) {
this.setMute(true); this.setMute(true);
} }
this.loadBuffer(song, () => { p = p.then(() => {
return this.loadSong(song);
}).then(buffers => {
// To prevent race condition if you press "next" twice fast // To prevent race condition if you press "next" twice fast
if(song == this.song) { if(song != this.song) {
// more racing than the Melbourne Cup // Stop processing - silently ignored in the catch below
try { throw Error("Song not playable - ignoring!");
this.bufSource.stop(0); }
} catch(err) {}
this.bufSource = this.context.createBufferSource(); this.buildup = buffers.buildup;
this.bufSource.buffer = this.buffer; this.buildLength = this.buildup ? this.buildup.duration : 0;
this.bufSource.loop = true; this.loop = buffers.loop;
this.bufSource.loopStart = this.loopStart; this.loopLength = this.loop.duration;
this.bufSource.loopEnd = this.buffer.duration;
this.bufSource.connect(this.gainNode);
// This fixes sync issues on Firefox and slow machines. // This fixes sync issues on Firefox and slow machines.
if(this.context.suspend && this.context.resume) { return this.context.suspend()
this.context.suspend().then(() => { }).then(() => {
if(playBuild) { if(playBuild) {
// mobile webkit requires offset, even if 0 this.seek(-this.buildLength);
this.bufSource.start(0);
this.startTime = this.context.currentTime + this.loopStart;
} else { } else {
this.bufSource.start(0, this.loopStart); this.seek(0);
this.startTime = this.context.currentTime;
}
this.context.resume().then(() => {
this.playing = true;
if(callback)
callback();
});
});
} else {
if(playBuild) {
// mobile webkit requires offset, even if 0
this.bufSource.start(0);
this.startTime = this.context.currentTime + this.loopStart;
} else {
this.bufSource.start(0, this.loopStart);
this.startTime = this.context.currentTime;
} }
return this.context.resume();
}).then(() => {
this.playing = true; this.playing = true;
if(callback) }).catch(error => {
callback(); // 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() { SoundManager.prototype.stop = function() {
if (this.playing) { if (this.playing) {
if(this.buildSource) {
this.buildSource.stop(0);
this.buildSource.disconnect();
this.buildSource = null;
}
// arg required for mobile webkit // arg required for mobile webkit
this.bufSource.stop(0); this.loopSource.stop(0);
this.bufSource.disconnect(); // TODO needed? // TODO needed?
this.bufSource = null; this.loopSource.disconnect();
this.loopSource = null;
this.vReady = false; this.vReady = false;
this.playing = false; this.playing = false;
this.startTime = 0; this.startTime = 0;
this.loopStart = 0;
this.loopLength = 0;
} }
}; };
SoundManager.prototype.seek = function(time) {
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.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.connect(this.gainNode);
this.buildSource.start(0, this.buildLength + time);
this.loopSource.start(this.context.currentTime - time);
} else {
this.loopSource.start(0, time);
}
this.startTime = this.context.currentTime - time;
}
// In seconds, relative to the loop start // In seconds, relative to the loop start
SoundManager.prototype.currentTime = function() { SoundManager.prototype.currentTime = function() {
if(!this.playing) { if(!this.playing) {
@ -233,148 +249,79 @@ SoundManager.prototype.displayableTime = function() {
} }
}; };
SoundManager.prototype.loadBuffer = function(song, callback) { SoundManager.prototype.loadSong = function(song) {
if(callback) { if(song._loadPromise) {
this.onLoadCallback = callback;
}
if(song.sound.byteLength == 0) {
// Someone went forward then immediately back then forward again // Someone went forward then immediately back then forward again
// Either way, the sound is still loading. It'll come back when it's ready // Either way, the sound is still loading. It'll come back when it's ready
return; return;
} }
var transferrables = [song.sound];
var buffers = {loop: null, buildup: null};
var promises = [this.loadBuffer(song, "sound").then(buffer => {
buffers.loop = buffer;
})];
if(song.buildup) { if(song.buildup) {
transferrables.push(song.buildup); promises.push(this.loadBuffer(song, "buildup").then(buffer => {
buffers.buildup = buffer;
}));
} else {
this.buildLength = 0;
} }
this.mp3Worker.postMessage(song, transferrables); song._loadPromise = Promise.all(promises)
.then(() => {
song._loadPromise = null;
return buffers;
});
return song._loadPromise;
}; };
SoundManager.prototype.workerFinished = function(event) { SoundManager.prototype.loadBuffer = function(song, soundName) {
var result = event.data; return new Promise((resolve, reject) => {
var mp3Worker = this.createWorker();
// restore our old ArrayBuffers TODO race mp3Worker.addEventListener('error', () => {
var song = this.restoreBuffers(result.song); reject(Error("MP3 Worker failed to convert track"));
}, false);
// Something else started loading after we started mp3Worker.addEventListener('message', e => {
if(this.song != song) { var decoded = e.data;
console.log("Song changed before we could play it, user is impatient!"); mp3Worker.terminate();
return;
}
if(song.buildup) { // restore transferred buffer
this.tmpBuild = this.trimMP3(this.audioBufFromRaw(result.build), song.forceTrim, song.noTrim); song[soundName] = decoded.arrayBuffer;
} // Convert to real audio buffer
this.tmpBuffer = this.trimMP3(this.audioBufFromRaw(result.loop), song.forceTrim, song.noTrim); var audio = this.audioBufFromRaw(decoded.rawAudio);
this.onSongLoad(song); resolve(audio);
} }, false);
// We pass our ArrayBuffers away, so we need to put them back // transfer the buffer to save time
// We must iterate all the songs in case the player has moved on in the meantime mp3Worker.postMessage(song[soundName], [song[soundName]]);
SoundManager.prototype.restoreBuffers = function(newSong) { });
var songs = this.core.resourceManager.allSongs;
for(var i = 0; i < songs.length; i++) {
var oldSong = songs[i];
var same = true;
for(var attr in oldSong) {
if(oldSong.hasOwnProperty(attr) && attr != "buildup" && attr != "sound") {
var oldV = oldSong[attr];
var newV = newSong[attr];
if(oldV != newV) {
// Equality checks break for NaN, and isNaN coerces args to Number, which we don't want
if(!( (oldV != oldV) && (newV != newV) )) {
same = false;
break;
}
}
}
}
if(same) {
oldSong.sound = newSong.sound;
oldSong.buildup = newSong.buildup;
return oldSong;
}
}
console.log("Oh no! Original song has been lost!");
return null;
} }
// Converts interleaved PCM to Web Audio API friendly format // Converts continuous PCM array to Web Audio API friendly format
SoundManager.prototype.audioBufFromRaw = function(sound) { SoundManager.prototype.audioBufFromRaw = function(raw) {
var buffer = sound.array; var buffer = raw.array
var channels = sound.channels; var channels = raw.channels;
var samples = buffer.length/channels; var samples = buffer.length/channels;
var audioBuf = this.context.createBuffer(channels, samples, sound.sampleRate); var audioBuf = this.context.createBuffer(channels, samples, raw.sampleRate);
var audioChans = []; //var audioBuf = this.context.createBuffer(1, buffer.length, raw.sampleRate);
//audioBuf.copyToChannel(buffer, 0, 0);
for(var i = 0; i < channels; i++) { for(var i = 0; i < channels; i++) {
audioChans.push(audioBuf.getChannelData(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
for(var i = 0; i < buffer.length; i++) { var channel = new Float32Array(buffer.buffer , i * samples * 4, samples);
audioChans[i % channels][Math.round(i/channels)] = buffer[i]; //console.log(channel);
audioBuf.copyToChannel(channel, i, 0);
} }
return audioBuf; return audioBuf;
}
SoundManager.prototype.onSongLoad = function(song) {
if(song.buildup) {
this.buffer = this.concatenateAudioBuffers(this.tmpBuild, this.tmpBuffer);
this.loopStart = this.tmpBuild.duration;
} else {
this.buffer = this.tmpBuffer;
this.loopStart = 0;
}
this.loopLength = this.buffer.duration - this.loopStart;
// free dat memory
this.tmpBuild = this.tmpBuffer = null;
if(this.onLoadCallback) {
this.onLoadCallback();
this.onLoadCallback = null;
}
};
// because MP3 is bad, we nuke silence
SoundManager.prototype.trimMP3 = function(buffer, forceTrim, noTrim) {
var start = LAME_DELAY_START;
var newLength = buffer.length - LAME_DELAY_START - LAME_DELAY_END;
var ret = this.context.createBuffer(buffer.numberOfChannels, newLength, buffer.sampleRate);
for(var i=0; i<buffer.numberOfChannels; i++) {
var oldBuf = buffer.getChannelData(i);
var newBuf = ret.getChannelData(i);
for(var j=0; j<ret.length; j++) {
newBuf[j] = oldBuf[start + j];
}
}
return ret;
}; };
// This wouldn't be required if Web Audio could do gapless playback properly SoundManager.prototype.createWorker = function() {
SoundManager.prototype.concatenateAudioBuffers = function(buffer1, buffer2) { return new Worker(this.core.settings.defaults.workersPath + 'mp3-worker.js');
if (!buffer1 || !buffer2) {
console.log("no buffers!");
return null;
}
if (buffer1.numberOfChannels != buffer2.numberOfChannels) {
console.log("number of channels is not the same!");
return null;
}
if (buffer1.sampleRate != buffer2.sampleRate) {
console.log("sample rates don't match!");
return null;
}
var tmp = this.context.createBuffer(buffer1.numberOfChannels,
buffer1.length + buffer2.length, buffer1.sampleRate);
for (var i=0; i<tmp.numberOfChannels; i++) {
var data = tmp.getChannelData(i);
data.set(buffer1.getChannelData(i));
data.set(buffer2.getChannelData(i),buffer1.length);
}
return tmp;
}; };
SoundManager.prototype.initVisualiser = function(bars) { SoundManager.prototype.initVisualiser = function(bars) {
if(!bars) { if(!bars) {
return; return;
@ -405,10 +352,15 @@ SoundManager.prototype.attachVisualiser = function() {
return; return;
} }
var channels = this.bufSource.channelCount; // Get our info from the loop
var channels = this.loopSource.channelCount;
// In case channel counts change, this is changed each time // In case channel counts change, this is changed each time
this.splitter = this.context.createChannelSplitter(channels); this.splitter = this.context.createChannelSplitter(channels);
this.bufSource.connect(this.splitter); // 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 // Split display up into each channel
this.vBars = Math.floor(this.vBars/channels); this.vBars = Math.floor(this.vBars/channels);
@ -431,7 +383,7 @@ SoundManager.prototype.attachVisualiser = function() {
this.logArrays.push(new Uint8Array(this.vBars)); this.logArrays.push(new Uint8Array(this.vBars));
} }
var binCount = this.analysers[0].frequencyBinCount; var binCount = this.analysers[0].frequencyBinCount;
var binWidth = this.bufSource.buffer.sampleRate / binCount; var binWidth = this.loopSource.buffer.sampleRate / binCount;
// first 2kHz are linear // first 2kHz are linear
this.maxBinLin = Math.floor(2000/binWidth); this.maxBinLin = Math.floor(2000/binWidth);
// Don't stretch the first 2kHz, it looks awful // Don't stretch the first 2kHz, it looks awful

Loading…
Cancel
Save