UNPKG

wavesurfer.js

Version:

Interactive navigable audio visualization using Web Audio and Canvas

512 lines (449 loc) 14.6 kB
/** * 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);