wavesurfer.js
Version:
Interactive navigable audio visualization using Web Audio and Canvas
512 lines (449 loc) • 14.6 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: {
height : 128,
waveColor : '#999',
progressColor : '#555',
cursorColor : '#333',
cursorWidth : 1,
skipLength : 2,
minPxPerSec : 20,
pixelRatio : window.devicePixelRatio,
fillParent : true,
scrollParent : false,
hideScrollbar : false,
normalize : false,
audioContext : null,
container : null,
dragSelection : true,
loopSelection : true,
audioRate : 1,
interact : true,
renderer : 'Canvas',
backend : 'WebAudio'
},
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');
}
// Used to save the current volume when muting so we can
// restore once unmuted
this.savedVolume = 0;
// The current muted state
this.isMuted = false;
this.createDrawer();
this.createBackend();
},
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) {
my.fireEvent('scroll', e);
});
},
createBackend: function () {
var my = this;
if (this.backend) {
this.backend.destroy();
}
this.backend = Object.create(WaveSurfer[this.params.backend]);
this.backend.on('finish', function () {
my.fireEvent('finish');
});
this.backend.on('audioprocess', function (time) {
my.fireEvent('audioprocess', time);
});
try {
this.backend.init(this.params);
} catch (e) {
if (e.message == "Your browser doesn't support Web Audio") {
this.params.backend = 'AudioElement';
this.backend = null;
this.createBackend();
}
}
},
restartAnimationLoop: function () {
var my = this;
var requestFrame = window.requestAnimationFrame ||
window.webkitRequestAnimationFrame;
var frame = function () {
if (!my.backend.isPaused()) {
my.drawer.progress(my.backend.getPlayedPercents());
requestFrame(frame);
}
};
frame();
},
getDuration: function () {
return this.backend.getDuration();
},
getCurrentTime: function () {
return this.backend.getCurrentTime();
},
play: function (start, end) {
this.backend.play(start, end);
this.restartAnimationLoop();
this.fireEvent('play');
},
pause: function () {
this.backend.pause();
this.fireEvent('pause');
},
playPause: function () {
this.backend.isPaused() ? this.play() : this.pause();
},
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) {
var paused = this.backend.isPaused();
// avoid small scrolls while paused seeking
var oldScrollParent = this.params.scrollParent;
if (paused) {
this.params.scrollParent = false;
}
this.backend.seekTo(progress * this.getDuration());
this.drawer.progress(this.backend.getPlayedPercents());
if (!paused) {
this.backend.pause();
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);
},
/**
* Set the playback volume.
*
* @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);
},
/**
* 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 () {
if (this.isMuted) {
// If currently muted then restore to the saved volume
// and update the mute properties
this.backend.setVolume(this.savedVolume);
this.isMuted = false;
} else {
// 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;
}
},
toggleScroll: function () {
this.params.scrollParent = !this.params.scrollParent;
this.drawBuffer();
},
toggleInteraction: function () {
this.params.interact = !this.params.interact;
},
drawBuffer: function () {
if (this.params.fillParent && !this.params.scrollParent) {
var length = this.drawer.getWidth();
} else {
length = Math.round(this.getDuration() * this.params.minPxPerSec * this.params.pixelRatio);
}
var peaks = this.backend.getPeaks(length);
this.drawer.drawPeaks(peaks, length);
this.fireEvent('redraw', peaks, length);
},
/**
* Internal method.
*/
loadArrayBuffer: function (arraybuffer) {
var my = this;
this.backend.decodeArrayBuffer(arraybuffer, function (data) {
my.loadDecodedBuffer(data);
}, function () {
my.fireEvent('error', 'Error decoding audiobuffer');
});
},
/**
* Directly load an externally decoded AudioBuffer.
*/
loadDecodedBuffer: function (buffer) {
this.empty();
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.empty();
my.loadArrayBuffer(e.target.result);
});
reader.addEventListener('error', function () {
my.fireEvent('error', 'Error reading file');
});
reader.readAsArrayBuffer(blob);
},
/**
* Loads audio and rerenders the waveform.
*/
load: function (url, peaks) {
switch (this.params.backend) {
case 'WebAudio': return this.loadBuffer(url);
case 'AudioElement': return this.loadAudioElement(url, peaks);
}
},
/**
* Loads audio using Web Audio buffer backend.
*/
loadBuffer: function (url) {
this.empty();
// load via XHR and render all at once
return this.downloadArrayBuffer(url, this.loadArrayBuffer.bind(this));
},
loadAudioElement: function (url, peaks) {
this.empty();
this.backend.load(url, peaks, this.container);
this.backend.once('canplay', (function () {
this.drawBuffer();
this.fireEvent('ready');
}).bind(this));
this.backend.once('error', (function (err) {
this.fireEvent('error', err);
}).bind(this));
},
downloadArrayBuffer: function (url, callback) {
var my = this;
var ajax = WaveSurfer.util.ajax({
url: url,
responseType: 'arraybuffer'
});
ajax.on('progress', function (e) {
my.onProgress(e);
});
ajax.on('success', callback);
ajax.on('error', function (e) {
my.fireEvent('error', 'XHR error: ' + e.target.statusText);
});
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;
},
/**
* Display empty waveform.
*/
empty: function () {
if (!this.backend.isPaused()) {
this.stop();
this.backend.disconnectSource();
}
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.unAll();
this.backend.destroy();
this.drawer.destroy();
}
};
/* Observer */
WaveSurfer.Observer = {
on: function (event, fn) {
if (!this.handlers) { this.handlers = {}; }
var handlers = this.handlers[event];
if (!handlers) {
handlers = this.handlers[event] = [];
}
handlers.push(fn);
},
un: function (event, fn) {
if (!this.handlers) { return; }
var handlers = this.handlers[event];
if (handlers) {
if (fn) {
for (var i = handlers.length - 1; i >= 0; i--) {
if (handlers[i] == fn) {
handlers.splice(i, 1);
}
}
} else {
handlers.length = 0;
}
}
},
unAll: function () {
this.handlers = null;
},
once: function (event, handler) {
var my = this;
var fn = function () {
handler();
setTimeout(function () {
my.un(event, fn);
}, 0);
};
this.on(event, fn);
},
fireEvent: function (event) {
if (!this.handlers) { return; }
var handlers = this.handlers[event];
var args = Array.prototype.slice.call(arguments, 1);
handlers && handlers.forEach(function (fn) {
fn.apply(null, args);
});
}
};
/* Common utilities */
WaveSurfer.util = {
extend: function (dest) {
var sources = Array.prototype.slice.call(arguments, 1);
sources.forEach(function (source) {
Object.keys(source).forEach(function (key) {
dest[key] = source[key];
});
});
return dest;
},
getId: function () {
return 'wavesurfer_' + Math.random().toString(32).substring(2);
},
max: function (values, min) {
var max = -Infinity;
for (var i = 0, len = values.length; i < len; i++) {
var val = values[i];
if (min != null) {
val = Math.abs(val - min);
}
if (val > max) { max = val; }
}
return max;
},
ajax: function (options) {
var ajax = Object.create(WaveSurfer.Observer);
var xhr = new XMLHttpRequest();
var fired100 = false;
xhr.open(options.method || 'GET', options.url, true);
xhr.responseType = options.responseType;
xhr.addEventListener('progress', function (e) {
ajax.fireEvent('progress', e);
if (e.lengthComputable && e.loaded == e.total) {
fired100 = true;
}
});
xhr.addEventListener('load', function (e) {
if (!fired100) {
ajax.fireEvent('progress', e);
}
ajax.fireEvent('load', e);
if (200 == xhr.status || 206 == xhr.status) {
ajax.fireEvent('success', xhr.response, e);
} else {
ajax.fireEvent('error', e);
}
});
xhr.addEventListener('error', function (e) {
ajax.fireEvent('error', e);
});
xhr.send();
ajax.xhr = xhr;
return ajax;
}
};
WaveSurfer.util.extend(WaveSurfer, WaveSurfer.Observer);