UNPKG

wavesurfer.js

Version:

Interactive navigable audio visualization using Web Audio and Canvas

1,769 lines (1,516 loc) 72.8 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); 'use strict'; WaveSurfer.WebAudio = { scriptBufferSize: 256, fftSize: 128, PLAYING_STATE: 0, PAUSED_STATE: 1, FINISHED_STATE: 2, getAudioContext: function () { if (!(window.AudioContext || window.webkitAudioContext)) { throw new Error("Your browser doesn't support Web Audio"); } if (!WaveSurfer.WebAudio.audioContext) { WaveSurfer.WebAudio.audioContext = new ( window.AudioContext || window.webkitAudioContext ); } return WaveSurfer.WebAudio.audioContext; }, init: function (params) { this.params = params; this.ac = params.audioContext || this.getAudioContext(); this.lastPlay = this.ac.currentTime; this.startPosition = 0; this.states = [ Object.create(WaveSurfer.WebAudio.state.playing), Object.create(WaveSurfer.WebAudio.state.paused), Object.create(WaveSurfer.WebAudio.state.finished) ]; this.setState(this.PAUSED_STATE); this.createVolumeNode(); this.createScriptNode(); this.createAnalyserNode(); this.setPlaybackRate(this.params.audioRate); }, disconnectFilters: function () { if (this.filters) { this.filters.forEach(function (filter) { filter && filter.disconnect(); }); this.filters = null; } }, setState: function (state) { if (this.state !== this.states[state]) { this.state = this.states[state]; this.state.init.call(this); } }, // Unpacked filters setFilter: function () { this.setFilters([].slice.call(arguments)); }, /** * @param {Array} filters Packed ilters array */ setFilters: function (filters) { this.disconnectFilters(); if (filters && filters.length) { this.filters = filters; // Connect each filter in turn filters.reduce(function (prev, curr) { prev.connect(curr); return curr; }, this.analyser).connect(this.gainNode); } else { this.analyser.connect(this.gainNode); } }, createScriptNode: function () { var my = this; var bufferSize = this.scriptBufferSize; if (this.ac.createScriptProcessor) { this.scriptNode = this.ac.createScriptProcessor(bufferSize); } else { this.scriptNode = this.ac.createJavaScriptNode(bufferSize); } this.scriptNode.connect(this.ac.destination); this.scriptNode.onaudioprocess = function () { var time = my.getCurrentTime(); if (my.state === my.states[my.PLAYING_STATE]) { my.fireEvent('audioprocess', time); } if (my.buffer && time > my.getDuration()) { my.setState(my.FINISHED_STATE); } }; }, createAnalyserNode: function () { this.analyser = this.ac.createAnalyser(); this.analyser.fftSize = this.fftSize; this.analyserData = new Uint8Array(this.analyser.frequencyBinCount); this.analyser.connect(this.gainNode); }, /** * Create the gain node needed to control the playback volume. */ createVolumeNode: function () { // Create gain node using the AudioContext if (this.ac.createGain) { this.gainNode = this.ac.createGain(); } else { this.gainNode = this.ac.createGainNode(); } // Add the gain node to the graph this.gainNode.connect(this.ac.destination); }, /** * Set the gain to a new value. * * @param {Number} newGain The new gain, a floating point value * between 0 and 1. 0 being no gain and 1 being maximum gain. */ setVolume: function (newGain) { this.gainNode.gain.value = newGain; }, /** * Get the current gain. * * @returns {Number} The current gain, a floating point value * between 0 and 1. 0 being no gain and 1 being maximum gain. */ getVolume: function () { return this.gainNode.gain.value; }, decodeArrayBuffer: function (arraybuffer, callback, errback) { var my = this; this.ac.decodeAudioData(arraybuffer, function (data) { my.buffer = data; callback(data); }, errback); }, /** * @returns {Float32Array} Array of peaks. */ getPeaks: function (length) { var buffer = this.buffer; var sampleSize = buffer.length / length; var sampleStep = ~~(sampleSize / 10) || 1; var channels = buffer.numberOfChannels; var peaks = new Float32Array(length); for (var c = 0; c < channels; c++) { var chan = buffer.getChannelData(c); for (var i = 0; i < length; i++) { var start = ~~(i * sampleSize); var end = ~~(start + sampleSize); var max = 0; for (var j = start; j < end; j += sampleStep) { var value = chan[j]; if (value > max) { max = value; // faster than Math.abs } else if (-value > max) { max = -value; } } if (c == 0 || max > peaks[i]) { peaks[i] = max; } } } return peaks; }, getPlayedPercents: function () { return this.state.getPlayedPercents.call(this); }, disconnectSource: function () { if (this.source) { this.source.disconnect(); } }, /** * Returns the real-time waveform data. * * @return {Uint8Array} The frequency data. * Values range from 0 to 255. */ waveform: function () { this.analyser.getByteTimeDomainData(this.analyserData); return this.analyserData; }, destroy: function () { this.pause(); this.unAll(); this.buffer = null; this.disconnectFilters(); this.disconnectSource(); this.gainNode.disconnect(); this.scriptNode.disconnect(); this.analyser.disconnect(); }, load: function (buffer) { this.startPosition = 0; this.lastPlay = this.ac.currentTime; this.buffer = buffer; this.createSource(); }, createSource: function () { this.disconnectSource(); this.source = this.ac.createBufferSource(); //adjust for old browsers. this.source.start = this.source.start || this.source.noteGrainOn; this.source.stop = this.source.stop || this.source.noteOff; this.source.playbackRate.value = this.playbackRate; this.source.buffer = this.buffer; this.source.connect(this.analyser); }, isPaused: function () { return this.state !== this.states[this.PLAYING_STATE]; }, getDuration: function () { return this.buffer.duration || 0; }, seekTo: function (start, end) { if (start == null) { start = this.getCurrentTime(); if (start >= this.getDuration()) { start = 0; } } if (end == null) { end = this.getDuration(); } this.startPosition = start; this.lastPlay = this.ac.currentTime; if (this.state === this.states[this.FINISHED_STATE]) { this.setState(this.PAUSED_STATE); } return { start: start, end: end }; }, getPlayedTime: function () { return (this.ac.currentTime - this.lastPlay) * this.playbackRate; }, /** * Plays the loaded audio region. * * @param {Number} start Start offset in seconds, * relative to the beginning of a clip. * @param {Number} end When to stop * relative to the beginning of a clip. */ play: function (start, end) { // need to re-create source on each playback this.createSource(); var adjustedTime = this.seekTo(start, end); start = adjustedTime.start; end = adjustedTime.end; this.source.start(0, start, end - start); this.setState(this.PLAYING_STATE); }, /** * Pauses the loaded audio. */ pause: function () { this.startPosition += this.getPlayedTime(); this.source && this.source.stop(0); this.setState(this.PAUSED_STATE); }, /** * Returns the current time in seconds relative to the audioclip's duration. */ getCurrentTime: function () { return this.state.getCurrentTime.call(this); }, /** * Set the audio source playback rate. */ setPlaybackRate: function (value) { value = value || 1; if (this.isPaused()) { this.playbackRate = value; } else { this.pause(); this.playbackRate = value; this.play(); } } }; WaveSurfer.WebAudio.state = {}; WaveSurfer.WebAudio.state.playing = { init: function () { }, getPlayedPercents: function () { var duration = this.getDuration(); return (this.getCurrentTime() / duration) || 0; }, getCurrentTime: function () { return this.startPosition + this.getPlayedTime(); } }; WaveSurfer.WebAudio.state.paused = { init: function () { }, getPlayedPercents: function () { var duration = this.getDuration(); return (this.getCurrentTime() / duration) || 0; }, getCurrentTime: function () { return this.startPosition; } }; WaveSurfer.WebAudio.state.finished = { init: function () { this.fireEvent('finish'); }, getPlayedPercents: function () { return 1; }, getCurrentTime: function () { return this.getDuration(); } }; WaveSurfer.util.extend(WaveSurfer.WebAudio, WaveSurfer.Observer); 'use strict'; WaveSurfer.AudioElement = Object.create(WaveSurfer.WebAudio); WaveSurfer.util.extend(WaveSurfer.AudioElement, { init: function (params) { this.params = params; // Dummy media to catch errors this.media = { currentTime: 0, duration: 0, paused: true, playbackRate: 1, play: function () {}, pause: function () {} }; }, load: function (url, peaks, container) { var my = this; var media = document.createElement('audio'); media.controls = false; media.autoplay = false; media.src = url; media.addEventListener('error', function () { my.fireEvent('error', 'Error loading media element'); }); media.addEventListener('canplay', function () { my.fireEvent('canplay'); }); media.addEventListener('ended', function () { my.fireEvent('finish'); }); media.addEventListener('timeupdate', function () { my.fireEvent('audioprocess', my.getCurrentTime()); }); var prevMedia = container.querySelector('audio'); if (prevMedia) { container.removeChild(prevMedia); } container.appendChild(media); this.media = media; this.peaks = peaks; this.setPlaybackRate(this.playbackRate); }, isPaused: function () { return this.media.paused; }, getDuration: function () { var duration = this.media.duration; if (duration >= Infinity) { // streaming audio duration = this.media.seekable.end(); } return duration; }, getCurrentTime: function () { return this.media.currentTime; }, getPlayedPercents: function () { return (this.getCurrentTime() / this.getDuration()) || 0; }, /** * Set the audio source playback rate. */ setPlaybackRate: function (value) { this.playbackRate = value || 1; this.media.playbackRate = this.playbackRate; }, seekTo: function (start) { if (start != null) { this.media.currentTime = start; } }, /** * Plays the loaded audio region. * * @param {Number} start Start offset in seconds, * relative to the beginning of a clip. */ play: function (start) { this.seekTo(start); this.media.play(); }, /** * Pauses the loaded audio. */ pause: function () { this.media.pause(); }, getPeaks: function (length) { return this.peaks || []; }, getVolume: function () { return this.media.volume; }, setVolume: function (val) { this.media.volume = val; }, destroy: function () { this.pause(); this.unAll(); this.media.parentNode && this.media.parentNode.removeChild(this.media); this.media = null; } }); 'use strict'; WaveSurfer.Drawer = { init: function (container, params) { this.container = container; this.params = params; this.width = 0; this.height = params.height * this.params.pixelRatio; this.lastPos = 0; this.createWrapper(); this.createElements(); }, createWrapper: function () { this.wrapper = this.container.appendChild( document.createElement('wave') ); this.style(this.wrapper, { display: 'block', position: 'relative', userSelect: 'none', webkitUserSelect: 'none', height: this.params.height + 'px' }); if (this.params.fillParent || this.params.scrollParent) { this.style(this.wrapper, { width: '100%', overflowX: this.params.hideScrollbar ? 'hidden' : 'auto', overflowY: 'hidden' }); } this.setupWrapperEvents(); }, handleEvent: function (e) { e.preventDefault(); var bbox = this.wrapper.getBoundingClientRect(); return ((e.clientX - bbox.left + this.wrapper.scrollLeft) / this.wrapper.scrollWidth) || 0; }, setupWrapperEvents: function () { var my = this; this.wrapper.addEventListener('click', function (e) { var scrollbarHeight = my.wrapper.offsetHeight - my.wrapper.clientHeight; if (scrollbarHeight != 0) { // scrollbar is visible. Check if click was on it var bbox = my.wrapper.getBoundingClientRect(); if (e.clientY >= bbox.bottom - scrollbarHeight) { // ignore mousedown as it was on the scrollbar return; } } if (my.params.interact) { my.fireEvent('click', e, my.handleEvent(e)); } }); }, drawPeaks: function (peaks, length) { this.resetScroll(); this.setWidth(length); if (this.params.normalize) { var max = WaveSurfer.util.max(peaks); } else { max = 1; } this.drawWave(peaks, max); }, style: function (el, styles) { Object.keys(styles).forEach(function (prop) { if (el.style[prop] != styles[prop]) { el.style[prop] = styles[prop]; } }); return el; }, resetScroll: function () { this.wrapper.scrollLeft = 0; }, recenter: function (percent) { var position = this.wrapper.scrollWidth * percent; this.recenterOnPosition(position, true); }, recenterOnPosition: function (position, immediate) { var scrollLeft = this.wrapper.scrollLeft; var half = ~~(this.wrapper.clientWidth / 2); var target = position - half; var offset = target - scrollLeft; var maxScroll = this.wrapper.scrollWidth - this.wrapper.clientWidth; if (maxScroll == 0) { // no need to continue if scrollbar is not there return; } // if the cursor is currently visible... if (!immediate && -half <= offset && offset < half) { // we'll limit the "re-center" rate. var rate = 5; offset = Math.max(-rate, Math.min(rate, offset)); target = scrollLeft + offset; } // limit target to valid range (0 to maxScroll) target = Math.max(0, Math.min(maxScroll, target)); // no use attempting to scroll if we're not moving if (target != scrollLeft) { this.wrapper.scrollLeft = target; } }, getWidth: function () { return Math.round(this.container.clientWidth * this.params.pixelRatio); }, setWidth: function (width) { if (width == this.width) { return; } this.width = width; if (this.params.fillParent || this.params.scrollParent) { this.style(this.wrapper, { width: '' }); } else { this.style(this.wrapper, { width: ~~(this.width / this.params.pixelRatio) + 'px' }); } this.updateWidth(); }, progress: function (progress) { var minPxDelta = 1 / this.params.pixelRatio; var pos = Math.round(progress * this.width) * minPxDelta; if (pos < this.lastPos || pos - this.lastPos >= minPxDelta) { this.lastPos = pos; if (this.params.scrollParent) { var newPos = ~~(this.wrapper.scrollWidth * progress); this.recenterOnPosition(newPos); } this.updateProgress(progress); } }, destroy: function () { this.unAll(); this.container.removeChild(this.wrapper); this.wrapper = null; }, /* Renderer-specific methods */ createElements: function () {}, updateWidth: function () {}, drawWave: function (peaks, max) {}, clearWave: function () {}, updateProgress: function (position) {} }; WaveSurfer.util.extend(WaveSurfer.Drawer, WaveSurfer.Observer); 'use strict'; WaveSurfer.Drawer.Canvas = Object.create(WaveSurfer.Drawer); WaveSurfer.util.extend(WaveSurfer.Drawer.Canvas, { createElements: function () { var waveCanvas = this.wrapper.appendChild( this.style(document.createElement('canvas'), { position: 'absolute', zIndex: 1 }) ); this.progressWave = this.wrapper.appendChild( this.style(document.createElement('wave'), { position: 'absolute', zIndex: 2, overflow: 'hidden', width: '0', height: this.params.height + 'px', borderRightStyle: 'solid', borderRightWidth: this.params.cursorWidth + 'px', borderRightColor: this.params.cursorColor }) ); var progressCanvas = this.progressWave.appendChild( document.createElement('canvas') ); this.waveCc = waveCanvas.getContext('2d'); this.progressCc = progressCanvas.getContext('2d'); }, updateWidth: function () { var width = Math.round(this.width / this.params.pixelRatio); [ this.waveCc, this.progressCc ].forEach(function (cc) { cc.canvas.width = this.width; cc.canvas.height = this.height; this.style(cc.canvas, { width: width + 'px'}); }, this); this.clearWave(); }, clearWave: function () { this.waveCc.clearRect(0, 0, this.width, this.height); this.progressCc.clearRect(0, 0, this.width, this.height); }, drawWave: function (peaks, max) { // A half-pixel offset makes lines crisp var $ = 0.5 / this.params.pixelRatio; this.waveCc.fillStyle = this.params.waveColor; this.progressCc.fillStyle = this.params.progressColor; var halfH = this.height / 2; var coef = halfH / max; var length = peaks.length; var scale = 1; if (this.params.fillParent && this.width != length) { scale = this.width / peaks.length; } this.waveCc.beginPath(); this.waveCc.moveTo($, halfH); this.progressCc.beginPath(); this.progressCc.moveTo($, halfH); for (var i = 0; i < length; i++) { var h = Math.round(peaks[i] * coef); this.waveCc.lineTo(i * scale + $, halfH + h); this.progressCc.lineTo(i * scale + $, halfH + h); } this.waveCc.lineTo(this.width + $, halfH); this.progressCc.lineTo(this.width + $, halfH); this.waveCc.moveTo($, halfH); this.progressCc.moveTo($, halfH); for (var i = 0; i < length; i++) { var h = Math.round(peaks[i] * coef); this.waveCc.lineTo(i * scale + $, halfH - h); this.progressCc.lineTo(i * scale + $, halfH - h); } this.waveCc.lineTo(this.width + $, halfH); this.waveCc.fill(); this.progressCc.lineTo(this.width + $, halfH); this.progressCc.fill(); // Always draw a median line this.waveCc.fillRect(0, halfH - $, this.width, $); }, updateProgress: function (progress) { var pos = Math.round( this.width * progress ) / this.params.pixelRatio; this.style(this.progressWave, { width: pos + 'px' }); } }); module.exports = WaveSurfer; /** * 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 =