wavesurfer
Version:
Interactive navigable audio visualization using Web Audio and Canvas
616 lines (527 loc) • 18.3 kB
JavaScript
/**
* wavesurfer.js
*
* https://github.com/katspaugh/wavesurfer.js
*
* This work is licensed under a Creative Commons Attribution 3.0 Unported License.
*/
'use strict';
var WaveSurfer = {
defaultParams: {
audioContext : null,
audioRate : 1,
autoCenter : true,
backend : 'WebAudio',
container : null,
cursorColor : '#333',
cursorWidth : 1,
dragSelection : true,
fillParent : true,
forceDecode : false,
height : 128,
hideScrollbar : false,
interact : true,
loopSelection : true,
mediaContainer: null,
mediaControls : false,
mediaType : 'audio',
minPxPerSec : 20,
partialRender : false,
pixelRatio : window.devicePixelRatio || screen.deviceXDPI / screen.logicalXDPI,
progressColor : '#555',
normalize : false,
renderer : 'MultiCanvas',
scrollParent : false,
skipLength : 2,
splitChannels : false,
waveColor : '#999',
},
init: function (params) {
// Extract relevant parameters (or defaults)
this.params = WaveSurfer.util.extend({}, this.defaultParams, params);
this.container = 'string' == typeof params.container ?
document.querySelector(this.params.container) :
this.params.container;
if (!this.container) {
throw new Error('Container element not found');
}
if (this.params.mediaContainer == null) {
this.mediaContainer = this.container;
} else if (typeof this.params.mediaContainer == 'string') {
this.mediaContainer = document.querySelector(this.params.mediaContainer);
} else {
this.mediaContainer = this.params.mediaContainer;
}
if (!this.mediaContainer) {
throw new Error('Media Container element not found');
}
// Used to save the current volume when muting so we can
// restore once unmuted
this.savedVolume = 0;
// The current muted state
this.isMuted = false;
// Will hold a list of event descriptors that need to be
// cancelled on subsequent loads of audio
this.tmpEvents = [];
// Holds any running audio downloads
this.currentAjax = null;
this.createDrawer();
this.createBackend();
this.createPeakCache();
this.isDestroyed = false;
},
createDrawer: function () {
var my = this;
this.drawer = Object.create(WaveSurfer.Drawer[this.params.renderer]);
this.drawer.init(this.container, this.params);
this.drawer.on('redraw', function () {
my.drawBuffer();
my.drawer.progress(my.backend.getPlayedPercents());
});
// Click-to-seek
this.drawer.on('click', function (e, progress) {
setTimeout(function () {
my.seekTo(progress);
}, 0);
});
// Relay the scroll event from the drawer
this.drawer.on('scroll', function (e) {
if (my.params.partialRender) {
my.drawBuffer();
}
my.fireEvent('scroll', e);
});
},
createBackend: function () {
var my = this;
if (this.backend) {
this.backend.destroy();
}
// Back compat
if (this.params.backend == 'AudioElement') {
this.params.backend = 'MediaElement';
}
if (this.params.backend == 'WebAudio' && !WaveSurfer.WebAudio.supportsWebAudio()) {
this.params.backend = 'MediaElement';
}
this.backend = Object.create(WaveSurfer[this.params.backend]);
this.backend.init(this.params);
this.backend.on('finish', function () { my.fireEvent('finish'); });
this.backend.on('play', function () { my.fireEvent('play'); });
this.backend.on('pause', function () { my.fireEvent('pause'); });
this.backend.on('audioprocess', function (time) {
my.drawer.progress(my.backend.getPlayedPercents());
my.fireEvent('audioprocess', time);
});
},
createPeakCache: function() {
if (this.params.partialRender) {
this.peakCache = Object.create(WaveSurfer.PeakCache);
this.peakCache.init();
}
},
getDuration: function () {
return this.backend.getDuration();
},
getCurrentTime: function () {
return this.backend.getCurrentTime();
},
play: function (start, end) {
this.fireEvent('interaction', this.play.bind(this, start, end));
this.backend.play(start, end);
},
pause: function () {
this.backend.isPaused() || this.backend.pause();
},
playPause: function () {
this.backend.isPaused() ? this.play() : this.pause();
},
isPlaying: function () {
return !this.backend.isPaused();
},
skipBackward: function (seconds) {
this.skip(-seconds || -this.params.skipLength);
},
skipForward: function (seconds) {
this.skip(seconds || this.params.skipLength);
},
skip: function (offset) {
var position = this.getCurrentTime() || 0;
var duration = this.getDuration() || 1;
position = Math.max(0, Math.min(duration, position + (offset || 0)));
this.seekAndCenter(position / duration);
},
seekAndCenter: function (progress) {
this.seekTo(progress);
this.drawer.recenter(progress);
},
seekTo: function (progress) {
this.fireEvent('interaction', this.seekTo.bind(this, progress));
var paused = this.backend.isPaused();
// avoid draw wrong position while playing backward seeking
if (!paused) {
this.backend.pause();
}
// avoid small scrolls while paused seeking
var oldScrollParent = this.params.scrollParent;
this.params.scrollParent = false;
this.backend.seekTo(progress * this.getDuration());
this.drawer.progress(this.backend.getPlayedPercents());
if (!paused) {
this.backend.play();
}
this.params.scrollParent = oldScrollParent;
this.fireEvent('seek', progress);
},
stop: function () {
this.pause();
this.seekTo(0);
this.drawer.progress(0);
},
/**
* Set the playback volume.
*
* @param {Number} newVolume A value between 0 and 1, 0 being no
* volume and 1 being full volume.
*/
setVolume: function (newVolume) {
this.backend.setVolume(newVolume);
},
/**
* Get the playback volume.
*/
getVolume: function () {
return this.backend.getVolume();
},
/**
* Set the playback rate.
*
* @param {Number} rate A positive number. E.g. 0.5 means half the
* normal speed, 2 means double speed and so on.
*/
setPlaybackRate: function (rate) {
this.backend.setPlaybackRate(rate);
},
/**
* Get the playback rate.
*/
getPlaybackRate: function () {
return this.backend.getPlaybackRate();
},
/**
* Toggle the volume on and off. It not currenly muted it will
* save the current volume value and turn the volume off.
* If currently muted then it will restore the volume to the saved
* value, and then rest the saved value.
*/
toggleMute: function () {
this.setMute(!this.isMuted);
},
setMute: function (mute) {
// ignore all muting requests if the audio is already in that state
if (mute === this.isMuted) {
return;
}
if (mute) {
// If currently not muted then save current volume,
// turn off the volume and update the mute properties
this.savedVolume = this.backend.getVolume();
this.backend.setVolume(0);
this.isMuted = true;
} else {
// If currently muted then restore to the saved volume
// and update the mute properties
this.backend.setVolume(this.savedVolume);
this.isMuted = false;
}
},
/**
* Get the current mute status.
*/
getMute: function () {
return this.isMuted;
},
/**
* Get the list of current set filters as an array.
*
* Filters must be set with setFilters method first
*/
getFilters: function() {
return this.backend.filters || [];
},
toggleScroll: function () {
this.params.scrollParent = !this.params.scrollParent;
this.drawBuffer();
},
toggleInteraction: function () {
this.params.interact = !this.params.interact;
},
drawBuffer: function () {
var nominalWidth = Math.round(
this.getDuration() * this.params.minPxPerSec * this.params.pixelRatio
);
var parentWidth = this.drawer.getWidth();
var width = nominalWidth;
var start = this.drawer.getScrollX();
var end = Math.min(start + parentWidth, width);
// Fill container
if (this.params.fillParent && (!this.params.scrollParent || nominalWidth < parentWidth)) {
width = parentWidth;
start = 0;
end = width;
}
if (this.params.partialRender) {
var newRanges = this.peakCache.addRangeToPeakCache(width, start, end);
for (var i = 0; i < newRanges.length; i++) {
var peaks = this.backend.getPeaks(width, newRanges[i][0], newRanges[i][1]);
this.drawer.drawPeaks(peaks, width, newRanges[i][0], newRanges[i][1]);
}
} else {
start = 0;
end = width;
var peaks = this.backend.getPeaks(width, start, end);
this.drawer.drawPeaks(peaks, width, start, end);
}
this.fireEvent('redraw', peaks, width);
},
zoom: function (pxPerSec) {
this.params.minPxPerSec = pxPerSec;
this.params.scrollParent = true;
this.drawBuffer();
this.drawer.progress(this.backend.getPlayedPercents());
this.drawer.recenter(
this.getCurrentTime() / this.getDuration()
);
this.fireEvent('zoom', pxPerSec);
},
/**
* Internal method.
*/
loadArrayBuffer: function (arraybuffer) {
this.decodeArrayBuffer(arraybuffer, function (data) {
if (!this.isDestroyed) {
this.loadDecodedBuffer(data);
}
}.bind(this));
},
/**
* Directly load an externally decoded AudioBuffer.
*/
loadDecodedBuffer: function (buffer) {
this.backend.load(buffer);
this.drawBuffer();
this.fireEvent('ready');
},
/**
* Loads audio data from a Blob or File object.
*
* @param {Blob|File} blob Audio data.
*/
loadBlob: function (blob) {
var my = this;
// Create file reader
var reader = new FileReader();
reader.addEventListener('progress', function (e) {
my.onProgress(e);
});
reader.addEventListener('load', function (e) {
my.loadArrayBuffer(e.target.result);
});
reader.addEventListener('error', function () {
my.fireEvent('error', 'Error reading file');
});
reader.readAsArrayBuffer(blob);
this.empty();
},
/**
* Loads audio and re-renders the waveform.
*/
load: function (url, peaks, preload) {
this.empty();
switch (this.params.backend) {
case 'WebAudio': return this.loadBuffer(url, peaks);
case 'MediaElement': return this.loadMediaElement(url, peaks, preload);
}
},
/**
* Loads audio using Web Audio buffer backend.
*/
loadBuffer: function (url, peaks) {
var load = (function (action) {
if (action) {
this.tmpEvents.push(this.once('ready', action));
}
return this.getArrayBuffer(url, this.loadArrayBuffer.bind(this));
}).bind(this);
if (peaks) {
this.backend.setPeaks(peaks);
this.drawBuffer();
this.tmpEvents.push(this.once('interaction', load));
} else {
return load();
}
},
/**
* Either create a media element, or load
* an existing media element.
* @param {String|HTMLElement} urlOrElt Either a path to a media file,
* or an existing HTML5 Audio/Video
* Element
* @param {Array} [peaks] Array of peaks. Required to bypass
* web audio dependency
*/
loadMediaElement: function (urlOrElt, peaks, preload) {
var url = urlOrElt;
if (typeof urlOrElt === 'string') {
this.backend.load(url, this.mediaContainer, peaks, preload);
} else {
var elt = urlOrElt;
this.backend.loadElt(elt, peaks);
// If peaks are not provided,
// url = element.src so we can get peaks with web audio
url = elt.src;
}
this.tmpEvents.push(
this.backend.once('canplay', (function () {
this.drawBuffer();
this.fireEvent('ready');
}).bind(this)),
this.backend.once('error', (function (err) {
this.fireEvent('error', err);
}).bind(this))
);
// If no pre-decoded peaks provided or pre-decoded peaks are
// provided with forceDecode flag, attempt to download the
// audio file and decode it with Web Audio.
if (peaks) { this.backend.setPeaks(peaks); }
if ((!peaks || this.params.forceDecode) && this.backend.supportsWebAudio()) {
this.getArrayBuffer(url, (function (arraybuffer) {
this.decodeArrayBuffer(arraybuffer, (function (buffer) {
this.backend.buffer = buffer;
this.backend.setPeaks(null);
this.drawBuffer();
this.fireEvent('waveform-ready');
}).bind(this));
}).bind(this));
}
},
decodeArrayBuffer: function (arraybuffer, callback) {
this.arraybuffer = arraybuffer;
this.backend.decodeArrayBuffer(
arraybuffer,
(function (data) {
// Only use the decoded data if we haven't been destroyed or another decode started in the meantime
if (!this.isDestroyed && this.arraybuffer == arraybuffer) {
callback(data);
this.arraybuffer = null;
}
}).bind(this),
this.fireEvent.bind(this, 'error', 'Error decoding audiobuffer')
);
},
getArrayBuffer: function (url, callback) {
var my = this;
var ajax = WaveSurfer.util.ajax({
url: url,
responseType: 'arraybuffer'
});
this.currentAjax = ajax;
this.tmpEvents.push(
ajax.on('progress', function (e) {
my.onProgress(e);
}),
ajax.on('success', function (data, e) {
callback(data);
my.currentAjax = null;
}),
ajax.on('error', function (e) {
my.fireEvent('error', 'XHR error: ' + e.target.statusText);
my.currentAjax = null;
})
);
return ajax;
},
onProgress: function (e) {
if (e.lengthComputable) {
var percentComplete = e.loaded / e.total;
} else {
// Approximate progress with an asymptotic
// function, and assume downloads in the 1-3 MB range.
percentComplete = e.loaded / (e.loaded + 1000000);
}
this.fireEvent('loading', Math.round(percentComplete * 100), e.target);
},
/**
* Exports PCM data into a JSON array and opens in a new window.
*/
exportPCM: function (length, accuracy, noWindow) {
length = length || 1024;
accuracy = accuracy || 10000;
noWindow = noWindow || false;
var peaks = this.backend.getPeaks(length, accuracy);
var arr = [].map.call(peaks, function (val) {
return Math.round(val * accuracy) / accuracy;
});
var json = JSON.stringify(arr);
if (!noWindow) {
window.open('data:application/json;charset=utf-8,' +
encodeURIComponent(json));
}
return json;
},
/**
* Save waveform image as data URI.
*
* The default format is 'image/png'. Other supported types are
* 'image/jpeg' and 'image/webp'.
*/
exportImage: function(format, quality) {
if (!format) {
format = 'image/png';
}
if (!quality) {
quality = 1;
}
return this.drawer.getImage(format, quality);
},
cancelAjax: function () {
if (this.currentAjax) {
this.currentAjax.xhr.abort();
this.currentAjax = null;
}
},
clearTmpEvents: function () {
this.tmpEvents.forEach(function (e) { e.un(); });
},
/**
* Display empty waveform.
*/
empty: function () {
if (!this.backend.isPaused()) {
this.stop();
this.backend.disconnectSource();
}
this.cancelAjax();
this.clearTmpEvents();
this.drawer.progress(0);
this.drawer.setWidth(0);
this.drawer.drawPeaks({ length: this.drawer.getWidth() }, 0);
},
/**
* Remove events, elements and disconnect WebAudio nodes.
*/
destroy: function () {
this.fireEvent('destroy');
this.cancelAjax();
this.clearTmpEvents();
this.unAll();
this.backend.destroy();
this.drawer.destroy();
this.isDestroyed = true;
}
};
WaveSurfer.create = function (params) {
var wavesurfer = Object.create(WaveSurfer);
wavesurfer.init(params);
return wavesurfer;
};