wavesurfer.js
Version:
Interactive navigable audio visualization using Web Audio and Canvas
763 lines (683 loc) • 22.6 kB
JavaScript
import * as util from './util';
// using constants to prevent someone writing the string wrong
const PLAYING = 'playing';
const PAUSED = 'paused';
const FINISHED = 'finished';
/**
* WebAudio backend
*
* @extends {util.Observer}
*/
export default class WebAudio extends util.Observer {
/** audioContext: allows to process audio with WebAudio API */
audioContext = null;
/** @private */
stateBehaviors = {
[PLAYING]: {
init() {
this.addOnAudioProcess();
},
getPlayedPercents() {
const duration = this.getDuration();
return this.getCurrentTime() / duration || 0;
},
getCurrentTime() {
return this.startPosition + this.getPlayedTime();
}
},
[PAUSED]: {
init() {
},
getPlayedPercents() {
const duration = this.getDuration();
return this.getCurrentTime() / duration || 0;
},
getCurrentTime() {
return this.startPosition;
}
},
[FINISHED]: {
init() {
this.fireEvent('finish');
},
getPlayedPercents() {
return 1;
},
getCurrentTime() {
return this.getDuration();
}
}
};
/**
* Does the browser support this backend
*
* @return {boolean} Whether or not this browser supports this backend
*/
supportsWebAudio() {
return !!(window.AudioContext || window.webkitAudioContext);
}
/**
* Get the audio context used by this backend or create one
*
* @return {AudioContext} Existing audio context, or creates a new one
*/
getAudioContext() {
if (!window.WaveSurferAudioContext) {
window.WaveSurferAudioContext = new (window.AudioContext ||
window.webkitAudioContext)();
}
return window.WaveSurferAudioContext;
}
/**
* Get the offline audio context used by this backend or create one
*
* @param {number} sampleRate The sample rate to use
* @return {OfflineAudioContext} Existing offline audio context, or creates
* a new one
*/
getOfflineAudioContext(sampleRate) {
if (!window.WaveSurferOfflineAudioContext) {
window.WaveSurferOfflineAudioContext = new (window.OfflineAudioContext ||
window.webkitOfflineAudioContext)(1, 2, sampleRate);
}
return window.WaveSurferOfflineAudioContext;
}
/**
* Construct the backend
*
* @param {WavesurferParams} params Wavesurfer parameters
*/
constructor(params) {
super();
/** @private */
this.params = params;
/** ac: Audio Context instance */
this.ac =
params.audioContext ||
(this.supportsWebAudio() ? this.getAudioContext() : {});
/**@private */
this.lastPlay = this.ac.currentTime;
/** @private */
this.startPosition = 0;
/** @private */
this.scheduledPause = null;
/** @private */
this.states = {
[PLAYING]: Object.create(this.stateBehaviors[PLAYING]),
[PAUSED]: Object.create(this.stateBehaviors[PAUSED]),
[FINISHED]: Object.create(this.stateBehaviors[FINISHED])
};
/** @private */
this.buffer = null;
/** @private */
this.filters = [];
/** gainNode: allows to control audio volume */
this.gainNode = null;
/** @private */
this.mergedPeaks = null;
/** @private */
this.offlineAc = null;
/** @private */
this.peaks = null;
/** @private */
this.playbackRate = 1;
/** analyser: provides audio analysis information */
this.analyser = null;
/** scriptNode: allows processing audio */
this.scriptNode = null;
/** @private */
this.source = null;
/** @private */
this.splitPeaks = [];
/** @private */
this.state = null;
/** @private */
this.explicitDuration = params.duration;
/** @private */
this.sinkStreamDestination = null;
/** @private */
this.sinkAudioElement = null;
/**
* Boolean indicating if the backend was destroyed.
*/
this.destroyed = false;
}
/**
* Initialise the backend, called in `wavesurfer.createBackend()`
*/
init() {
this.createVolumeNode();
this.createScriptNode();
this.createAnalyserNode();
this.setState(PAUSED);
this.setPlaybackRate(this.params.audioRate);
this.setLength(0);
}
/** @private */
disconnectFilters() {
if (this.filters) {
this.filters.forEach(filter => {
filter && filter.disconnect();
});
this.filters = null;
// Reconnect direct path
this.analyser.connect(this.gainNode);
}
}
/**
* @private
*
* @param {string} state The new state
*/
setState(state) {
if (this.state !== this.states[state]) {
this.state = this.states[state];
this.state.init.call(this);
}
}
/**
* Unpacked `setFilters()`
*
* @param {...AudioNode} filters One or more filters to set
*/
setFilter(...filters) {
this.setFilters(filters);
}
/**
* Insert custom Web Audio nodes into the graph
*
* @param {AudioNode[]} filters Packed filters array
* @example
* const lowpass = wavesurfer.backend.ac.createBiquadFilter();
* wavesurfer.backend.setFilter(lowpass);
*/
setFilters(filters) {
// Remove existing filters
this.disconnectFilters();
// Insert filters if filter array not empty
if (filters && filters.length) {
this.filters = filters;
// Disconnect direct path before inserting filters
this.analyser.disconnect();
// Connect each filter in turn
filters
.reduce((prev, curr) => {
prev.connect(curr);
return curr;
}, this.analyser)
.connect(this.gainNode);
}
}
/** Create ScriptProcessorNode to process audio */
createScriptNode() {
if (this.params.audioScriptProcessor) {
this.scriptNode = this.params.audioScriptProcessor;
this.scriptNode.connect(this.ac.destination);
}
}
/** @private */
addOnAudioProcess() {
const loop = () => {
const time = this.getCurrentTime();
if (time >= this.getDuration() && this.state !== this.states[FINISHED]) {
this.setState(FINISHED);
this.fireEvent('pause');
} else if (time >= this.scheduledPause && this.state !== this.states[PAUSED]) {
this.pause();
} else if (this.state === this.states[PLAYING]) {
this.fireEvent('audioprocess', time);
util.frame(loop)();
}
};
loop();
}
/** Create analyser node to perform audio analysis */
createAnalyserNode() {
this.analyser = this.ac.createAnalyser();
this.analyser.connect(this.gainNode);
}
/**
* Create the gain node needed to control the playback volume.
*
*/
createVolumeNode() {
// 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 sink id for the media player
*
* @param {string} deviceId String value representing audio device id.
* @returns {Promise} A Promise that resolves to `undefined` when there
* are no errors.
*/
setSinkId(deviceId) {
if (deviceId) {
/**
* The webaudio API doesn't currently support setting the device
* output. Here we create an HTMLAudioElement, connect the
* webaudio stream to that element and setSinkId there.
*/
if (!this.sinkAudioElement) {
this.sinkAudioElement = new window.Audio();
// autoplay is necessary since we're not invoking .play()
this.sinkAudioElement.autoplay = true;
}
if (!this.sinkAudioElement.setSinkId) {
return Promise.reject(
new Error('setSinkId is not supported in your browser')
);
}
if (!this.sinkStreamDestination) {
this.sinkStreamDestination = this.ac.createMediaStreamDestination();
}
this.gainNode.disconnect();
this.gainNode.connect(this.sinkStreamDestination);
this.sinkAudioElement.srcObject = this.sinkStreamDestination.stream;
return this.sinkAudioElement.setSinkId(deviceId);
} else {
return Promise.reject(new Error('Invalid deviceId: ' + deviceId));
}
}
/**
* Set the audio volume
*
* @param {number} value A floating point value between 0 and 1.
*/
setVolume(value) {
this.gainNode.gain.setValueAtTime(value, this.ac.currentTime);
}
/**
* Get the current volume
*
* @return {number} value A floating point value between 0 and 1.
*/
getVolume() {
return this.gainNode.gain.value;
}
/**
* Decode an array buffer and pass data to a callback
*
* @private
* @param {ArrayBuffer} arraybuffer The array buffer to decode
* @param {function} callback The function to call on complete.
* @param {function} errback The function to call on error.
*/
decodeArrayBuffer(arraybuffer, callback, errback) {
if (!this.offlineAc) {
this.offlineAc = this.getOfflineAudioContext(
this.ac && this.ac.sampleRate ? this.ac.sampleRate : 44100
);
}
if ('webkitAudioContext' in window) {
// Safari: no support for Promise-based decodeAudioData enabled
// Enable it in Safari using the Experimental Features > Modern WebAudio API option
this.offlineAc.decodeAudioData(
arraybuffer,
data => callback(data),
errback
);
} else {
this.offlineAc.decodeAudioData(arraybuffer).then(
(data) => callback(data)
).catch(
(err) => errback(err)
);
}
}
/**
* Set pre-decoded peaks
*
* @param {number[]|Number.<Array[]>} peaks Peaks data
* @param {?number} duration Explicit duration
*/
setPeaks(peaks, duration) {
if (duration != null) {
this.explicitDuration = duration;
}
this.peaks = peaks;
}
/**
* Set the rendered length (different from the length of the audio)
*
* @param {number} length The rendered length
*/
setLength(length) {
// No resize, we can preserve the cached peaks.
if (this.mergedPeaks && length == 2 * this.mergedPeaks.length - 1 + 2) {
return;
}
this.splitPeaks = [];
this.mergedPeaks = [];
// Set the last element of the sparse array so the peak arrays are
// appropriately sized for other calculations.
const channels = this.buffer ? this.buffer.numberOfChannels : 1;
let c;
for (c = 0; c < channels; c++) {
this.splitPeaks[c] = [];
this.splitPeaks[c][2 * (length - 1)] = 0;
this.splitPeaks[c][2 * (length - 1) + 1] = 0;
}
this.mergedPeaks[2 * (length - 1)] = 0;
this.mergedPeaks[2 * (length - 1) + 1] = 0;
}
/**
* Compute the max and min value of the waveform when broken into <length> subranges.
*
* @param {number} length How many subranges to break the waveform into.
* @param {number} first First sample in the required range.
* @param {number} last Last sample in the required range.
* @return {number[]|Number.<Array[]>} Array of 2*<length> peaks or array of arrays of
* peaks consisting of (max, min) values for each subrange.
*/
getPeaks(length, first, last) {
if (this.peaks) {
return this.peaks;
}
if (!this.buffer) {
return [];
}
first = first || 0;
last = last || length - 1;
this.setLength(length);
if (!this.buffer) {
return this.params.splitChannels
? this.splitPeaks
: this.mergedPeaks;
}
/**
* The following snippet fixes a buffering data issue on the Safari
* browser which returned undefined It creates the missing buffer based
* on 1 channel, 4096 samples and the sampleRate from the current
* webaudio context 4096 samples seemed to be the best fit for rendering
* will review this code once a stable version of Safari TP is out
*/
if (!this.buffer.length) {
const newBuffer = this.createBuffer(1, 4096, this.sampleRate);
this.buffer = newBuffer.buffer;
}
const sampleSize = this.buffer.length / length;
const sampleStep = ~~(sampleSize / 10) || 1;
const channels = this.buffer.numberOfChannels;
let c;
for (c = 0; c < channels; c++) {
const peaks = this.splitPeaks[c];
const chan = this.buffer.getChannelData(c);
let i;
for (i = first; i <= last; i++) {
const start = ~~(i * sampleSize);
const end = ~~(start + sampleSize);
/**
* Initialize the max and min to the first sample of this
* subrange, so that even if the samples are entirely
* on one side of zero, we still return the true max and
* min values in the subrange.
*/
let min = chan[start];
let max = min;
let j;
for (j = start; j < end; j += sampleStep) {
const value = chan[j];
if (value > max) {
max = value;
}
if (value < min) {
min = value;
}
}
peaks[2 * i] = max;
peaks[2 * i + 1] = min;
if (c == 0 || max > this.mergedPeaks[2 * i]) {
this.mergedPeaks[2 * i] = max;
}
if (c == 0 || min < this.mergedPeaks[2 * i + 1]) {
this.mergedPeaks[2 * i + 1] = min;
}
}
}
return this.params.splitChannels ? this.splitPeaks : this.mergedPeaks;
}
/**
* Get the position from 0 to 1
*
* @return {number} Position
*/
getPlayedPercents() {
return this.state.getPlayedPercents.call(this);
}
/** @private */
disconnectSource() {
if (this.source) {
this.source.disconnect();
}
}
/**
* Destroy all references with WebAudio, disconnecting audio nodes and closing Audio Context
*/
destroyWebAudio() {
this.disconnectFilters();
this.disconnectSource();
this.gainNode.disconnect();
this.scriptNode && this.scriptNode.disconnect();
this.analyser.disconnect();
// close the audioContext if closeAudioContext option is set to true
if (this.params.closeAudioContext) {
// check if browser supports AudioContext.close()
if (
typeof this.ac.close === 'function' &&
this.ac.state != 'closed'
) {
this.ac.close();
}
// clear the reference to the audiocontext
this.ac = null;
// clear the actual audiocontext, either passed as param or the
// global singleton
if (!this.params.audioContext) {
window.WaveSurferAudioContext = null;
} else {
this.params.audioContext = null;
}
// clear the offlineAudioContext
window.WaveSurferOfflineAudioContext = null;
}
// disconnect resources used by setSinkId
if (this.sinkStreamDestination) {
this.sinkAudioElement.pause();
this.sinkAudioElement.srcObject = null;
this.sinkStreamDestination.disconnect();
this.sinkStreamDestination = null;
}
}
/**
* This is called when wavesurfer is destroyed
*/
destroy() {
if (!this.isPaused()) {
this.pause();
}
this.unAll();
this.buffer = null;
this.destroyed = true;
this.destroyWebAudio();
}
/**
* Loaded a decoded audio buffer
*
* @param {Object} buffer Decoded audio buffer to load
*/
load(buffer) {
this.startPosition = 0;
this.lastPlay = this.ac.currentTime;
this.buffer = buffer;
this.createSource();
}
/** @private */
createSource() {
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.setPlaybackRate(this.playbackRate);
this.source.buffer = this.buffer;
this.source.connect(this.analyser);
}
/**
* @private
*
* some browsers require an explicit call to #resume before they will play back audio
*/
resumeAudioContext() {
if (this.ac.state == 'suspended') {
this.ac.resume && this.ac.resume();
}
}
/**
* Used by `wavesurfer.isPlaying()` and `wavesurfer.playPause()`
*
* @return {boolean} Whether or not this backend is currently paused
*/
isPaused() {
return this.state !== this.states[PLAYING];
}
/**
* Used by `wavesurfer.getDuration()`
*
* @return {number} Duration of loaded buffer
*/
getDuration() {
if (this.explicitDuration) {
return this.explicitDuration;
}
if (!this.buffer) {
return 0;
}
return this.buffer.duration;
}
/**
* Used by `wavesurfer.seekTo()`
*
* @param {number} start Position to start at in seconds
* @param {number} end Position to end at in seconds
* @return {{start: number, end: number}} Object containing start and end
* positions
*/
seekTo(start, end) {
if (!this.buffer) {
return;
}
this.scheduledPause = null;
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[FINISHED]) {
this.setState(PAUSED);
}
return {
start: start,
end: end
};
}
/**
* Get the playback position in seconds
*
* @return {number} The playback position in seconds
*/
getPlayedTime() {
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(start, end) {
if (!this.buffer) {
return;
}
// need to re-create source on each playback
this.createSource();
const adjustedTime = this.seekTo(start, end);
start = adjustedTime.start;
end = adjustedTime.end;
this.scheduledPause = end;
this.source.start(0, start);
this.resumeAudioContext();
this.setState(PLAYING);
this.fireEvent('play');
}
/**
* Pauses the loaded audio.
*/
pause() {
this.scheduledPause = null;
this.startPosition += this.getPlayedTime();
try {
this.source && this.source.stop(0);
} catch (err) {
// Calling stop can throw the following 2 errors:
// - RangeError (The value specified for when is negative.)
// - InvalidStateNode (The node has not been started by calling start().)
// We can safely ignore both errors, because:
// - The range is surely correct
// - The node might not have been started yet, in which case we just want to carry on without causing any trouble.
}
this.setState(PAUSED);
this.fireEvent('pause');
}
/**
* Returns the current time in seconds relative to the audio-clip's
* duration.
*
* @return {number} The current time in seconds
*/
getCurrentTime() {
return this.state.getCurrentTime.call(this);
}
/**
* Returns the current playback rate. (0=no playback, 1=normal playback)
*
* @return {number} The current playback rate
*/
getPlaybackRate() {
return this.playbackRate;
}
/**
* Set the audio source playback rate.
*
* @param {number} value The playback rate to use
*/
setPlaybackRate(value) {
this.playbackRate = value || 1;
this.source && this.source.playbackRate.setValueAtTime(
this.playbackRate,
this.ac.currentTime
);
}
/**
* Set a point in seconds for playback to stop at.
*
* @param {number} end Position to end at
* @version 3.3.0
*/
setPlayEnd(end) {
this.scheduledPause = end;
}
}