630 lines
17 KiB
JavaScript
Executable File
630 lines
17 KiB
JavaScript
Executable File
import * as util from './util';
|
|
|
|
// using consts to prevent someone writing the string wrong
|
|
const PLAYING = 'playing';
|
|
const PAUSED = 'paused';
|
|
const FINISHED = 'finished';
|
|
|
|
/**
|
|
* WebAudio backend
|
|
*
|
|
* @extends {Observer}
|
|
*/
|
|
export default class WebAudio extends util.Observer {
|
|
/** @private */
|
|
static scriptBufferSize = 256
|
|
/** @private */
|
|
audioContext = null
|
|
/** @private */
|
|
offlineAudioContext = 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() {
|
|
this.removeOnAudioProcess();
|
|
},
|
|
getPlayedPercents() {
|
|
const duration = this.getDuration();
|
|
return (this.getCurrentTime() / duration) || 0;
|
|
},
|
|
getCurrentTime() {
|
|
return this.startPosition;
|
|
}
|
|
},
|
|
[FINISHED]: {
|
|
init() {
|
|
this.removeOnAudioProcess();
|
|
this.fireEvent('finish');
|
|
},
|
|
getPlayedPercents() {
|
|
return 1;
|
|
},
|
|
getCurrentTime() {
|
|
return this.getDuration();
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Does the browser support this backend
|
|
*
|
|
* @return {boolean}
|
|
*/
|
|
supportsWebAudio() {
|
|
return !!(window.AudioContext || window.webkitAudioContext);
|
|
}
|
|
|
|
/**
|
|
* Get the audio context used by this backend or create one
|
|
*
|
|
* @return {AudioContext}
|
|
*/
|
|
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
|
|
* @return {OfflineAudioContext}
|
|
*/
|
|
getOfflineAudioContext(sampleRate) {
|
|
if (!window.WaveSurferOfflineAudioContext) {
|
|
window.WaveSurferOfflineAudioContext = new (
|
|
window.OfflineAudioContext || window.webkitOfflineAudioContext
|
|
)(1, 2, sampleRate);
|
|
}
|
|
return window.WaveSurferOfflineAudioContext;
|
|
}
|
|
|
|
/**
|
|
* Construct the backend
|
|
*
|
|
* @param {WavesurferParams} params
|
|
*/
|
|
constructor(params) {
|
|
super();
|
|
/** @private */
|
|
this.params = params;
|
|
/** @private */
|
|
this.ac = params.audioContext || 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.analyser = null;
|
|
/** @private */
|
|
this.buffer = null;
|
|
/** @private */
|
|
this.filters = [];
|
|
/** @private */
|
|
this.gainNode = null;
|
|
/** @private */
|
|
this.mergedPeaks = null;
|
|
/** @private */
|
|
this.offlineAc = null;
|
|
/** @private */
|
|
this.peaks = null;
|
|
/** @private */
|
|
this.playbackRate = 1;
|
|
/** @private */
|
|
this.analyser = null;
|
|
/** @private */
|
|
this.scriptNode = null;
|
|
/** @private */
|
|
this.source = null;
|
|
/** @private */
|
|
this.splitPeaks = [];
|
|
/** @private */
|
|
this.state = null;
|
|
}
|
|
|
|
/**
|
|
* 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 */
|
|
setState(state) {
|
|
if (this.state !== this.states[state]) {
|
|
this.state = this.states[state];
|
|
this.state.init.call(this);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Unpacked `setFilters()`
|
|
*
|
|
* @param {...AudioNode} filters
|
|
*/
|
|
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);
|
|
}
|
|
|
|
}
|
|
|
|
/** @private */
|
|
createScriptNode() {
|
|
if (this.ac.createScriptProcessor) {
|
|
this.scriptNode = this.ac.createScriptProcessor(WebAudio.scriptBufferSize);
|
|
} else {
|
|
this.scriptNode = this.ac.createJavaScriptNode(WebAudio.scriptBufferSize);
|
|
}
|
|
|
|
this.scriptNode.connect(this.ac.destination);
|
|
}
|
|
|
|
/** @private */
|
|
addOnAudioProcess() {
|
|
this.scriptNode.onaudioprocess = () => {
|
|
const time = this.getCurrentTime();
|
|
|
|
if (time >= this.getDuration()) {
|
|
this.setState(FINISHED);
|
|
this.fireEvent('pause');
|
|
} else if (time >= this.scheduledPause) {
|
|
this.pause();
|
|
} else if (this.state === this.states[PLAYING]) {
|
|
this.fireEvent('audioprocess', time);
|
|
}
|
|
};
|
|
}
|
|
|
|
/** @private */
|
|
removeOnAudioProcess() {
|
|
this.scriptNode.onaudioprocess = null;
|
|
}
|
|
|
|
/** @private */
|
|
createAnalyserNode() {
|
|
this.analyser = this.ac.createAnalyser();
|
|
this.analyser.connect(this.gainNode);
|
|
}
|
|
|
|
/**
|
|
* Create the gain node needed to control the playback volume.
|
|
*
|
|
* @private
|
|
*/
|
|
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 audio volume
|
|
*
|
|
* @param {number} value A floating point value between 0 and 1.
|
|
*/
|
|
setVolume(value) {
|
|
this.gainNode.gain.value = value;
|
|
}
|
|
|
|
/**
|
|
* Get the current volume
|
|
*
|
|
* @return {number} value A floating point value between 0 and 1.
|
|
*/
|
|
getVolume() {
|
|
return this.gainNode.gain.value;
|
|
}
|
|
|
|
/** @private */
|
|
decodeArrayBuffer(arraybuffer, callback, errback) {
|
|
if (!this.offlineAc) {
|
|
this.offlineAc = this.getOfflineAudioContext(this.ac ? this.ac.sampleRate : 44100);
|
|
}
|
|
this.offlineAc.decodeAudioData(arraybuffer, data => callback(data), errback);
|
|
}
|
|
|
|
/**
|
|
* Set pre-decoded peaks
|
|
*
|
|
* @param {Array} peaks
|
|
*/
|
|
setPeaks(peaks) {
|
|
this.peaks = peaks;
|
|
}
|
|
|
|
/**
|
|
* Set the rendered length (different from the length of the audio).
|
|
*
|
|
* @param {number} 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 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; }
|
|
|
|
first = first || 0;
|
|
last = last || length - 1;
|
|
|
|
this.setLength(length);
|
|
|
|
/**
|
|
* 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);
|
|
let min = 0;
|
|
let max = 0;
|
|
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}
|
|
*/
|
|
getPlayedPercents() {
|
|
return this.state.getPlayedPercents.call(this);
|
|
}
|
|
|
|
/** @private */
|
|
disconnectSource() {
|
|
if (this.source) {
|
|
this.source.disconnect();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* This is called when wavesurfer is destroyed
|
|
*/
|
|
destroy() {
|
|
if (!this.isPaused()) {
|
|
this.pause();
|
|
}
|
|
this.unAll();
|
|
this.buffer = null;
|
|
this.disconnectFilters();
|
|
this.disconnectSource();
|
|
this.gainNode.disconnect();
|
|
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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Loaded a decoded audio buffer
|
|
*
|
|
* @param {Object} buffer
|
|
*/
|
|
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.source.playbackRate.value = this.playbackRate;
|
|
this.source.buffer = this.buffer;
|
|
this.source.connect(this.analyser);
|
|
}
|
|
|
|
/**
|
|
* Used by `wavesurfer.isPlaying()` and `wavesurfer.playPause()`
|
|
*
|
|
* @return {boolean}
|
|
*/
|
|
isPaused() {
|
|
return this.state !== this.states[PLAYING];
|
|
}
|
|
|
|
/**
|
|
* Used by `wavesurfer.getDuration()`
|
|
*
|
|
* @return {number}
|
|
*/
|
|
getDuration() {
|
|
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}}
|
|
*/
|
|
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}
|
|
*/
|
|
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, end - start);
|
|
|
|
if (this.ac.state == 'suspended') {
|
|
this.ac.resume && this.ac.resume();
|
|
}
|
|
|
|
this.setState(PLAYING);
|
|
|
|
this.fireEvent('play');
|
|
}
|
|
|
|
/**
|
|
* Pauses the loaded audio.
|
|
*/
|
|
pause() {
|
|
this.scheduledPause = null;
|
|
|
|
this.startPosition += this.getPlayedTime();
|
|
this.source && this.source.stop(0);
|
|
|
|
this.setState(PAUSED);
|
|
|
|
this.fireEvent('pause');
|
|
}
|
|
|
|
/**
|
|
* Returns the current time in seconds relative to the audioclip's
|
|
* duration.
|
|
*
|
|
* @return {number}
|
|
*/
|
|
getCurrentTime() {
|
|
return this.state.getCurrentTime.call(this);
|
|
}
|
|
|
|
/**
|
|
* Returns the current playback rate. (0=no playback, 1=normal playback)
|
|
*
|
|
* @return {number}
|
|
*/
|
|
getPlaybackRate() {
|
|
return this.playbackRate;
|
|
}
|
|
|
|
/**
|
|
* Set the audio source playback rate.
|
|
*
|
|
* @param {number} value
|
|
*/
|
|
setPlaybackRate(value) {
|
|
value = value || 1;
|
|
if (this.isPaused()) {
|
|
this.playbackRate = value;
|
|
} else {
|
|
this.pause();
|
|
this.playbackRate = value;
|
|
this.play();
|
|
}
|
|
}
|
|
}
|