added audioplayer contrib module

This commit is contained in:
Bachir Soussi Chiadmi
2017-11-25 16:05:32 +01:00
parent ff23c45e43
commit 0942e9a212
174 changed files with 21682 additions and 0 deletions
+357
View File
@@ -0,0 +1,357 @@
import * as util from './util';
/**
* Parent class for renderers
*
* @extends {Observer}
*/
export default class Drawer extends util.Observer {
/**
* @param {HTMLElement} container The container node of the wavesurfer instance
* @param {WavesurferParams} params The wavesurfer initialisation options
*/
constructor(container, params) {
super();
/** @private */
this.container = container;
/**
* @type {WavesurferParams}
* @private
*/
this.params = params;
/**
* The width of the renderer
* @type {number}
*/
this.width = 0;
/**
* The height of the renderer
* @type {number}
*/
this.height = params.height * this.params.pixelRatio;
/** @private */
this.lastPos = 0;
/**
* The `<wave>` element which is added to the container
* @type {HTMLElement}
*/
this.wrapper = null;
}
/**
* Alias of `util.style`
*
* @param {HTMLElement} el The element that the styles will be applied to
* @param {Object} styles The map of propName: attribute, both are used as-is
* @return {HTMLElement} el
*/
style(el, styles) {
return util.style(el, styles);
}
/**
* Create the wrapper `<wave>` element, style it and set up the events for
* interaction
*/
createWrapper() {
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();
}
/**
* Handle click event
*
* @param {Event} e Click event
* @param {?boolean} noPrevent Set to true to not call `e.preventDefault()`
* @return {number} Playback position from 0 to 1
*/
handleEvent(e, noPrevent) {
!noPrevent && e.preventDefault();
const clientX = e.targetTouches ? e.targetTouches[0].clientX : e.clientX;
const bbox = this.wrapper.getBoundingClientRect();
const nominalWidth = this.width;
const parentWidth = this.getWidth();
let progress;
if (!this.params.fillParent && nominalWidth < parentWidth) {
progress = ((clientX - bbox.left) * this.params.pixelRatio / nominalWidth) || 0;
if (progress > 1) {
progress = 1;
}
} else {
progress = ((clientX - bbox.left + this.wrapper.scrollLeft) / this.wrapper.scrollWidth) || 0;
}
return progress;
}
/**
* @private
*/
setupWrapperEvents() {
this.wrapper.addEventListener('click', e => {
const scrollbarHeight = this.wrapper.offsetHeight - this.wrapper.clientHeight;
if (scrollbarHeight != 0) {
// scrollbar is visible. Check if click was on it
const bbox = this.wrapper.getBoundingClientRect();
if (e.clientY >= bbox.bottom - scrollbarHeight) {
// ignore mousedown as it was on the scrollbar
return;
}
}
if (this.params.interact) {
this.fireEvent('click', e, this.handleEvent(e));
}
});
this.wrapper.addEventListener('scroll', e => this.fireEvent('scroll', e));
}
/**
* Draw peaks on the canvas
*
* @param {number[]|number[][]} peaks Can also be an array of arrays for split channel
* rendering
* @param {number} length The width of the area that should be drawn
* @param {number} start The x-offset of the beginning of the area that
* should be rendered
* @param {number} end The x-offset of the end of the area that should be
* rendered
*/
drawPeaks(peaks, length, start, end) {
if (!this.setWidth(length)) {
this.clearWave();
}
this.params.barWidth ?
this.drawBars(peaks, 0, start, end) :
this.drawWave(peaks, 0, start, end);
}
/**
* Scroll to the beginning
*/
resetScroll() {
if (this.wrapper !== null) {
this.wrapper.scrollLeft = 0;
}
}
/**
* Recenter the viewport at a certain percent of the waveform
*
* @param {number} percent Value from 0 to 1 on the waveform
*/
recenter(percent) {
const position = this.wrapper.scrollWidth * percent;
this.recenterOnPosition(position, true);
}
/**
* Recenter the viewport on a position, either scroll there immediately or
* in steps of 5 pixels
*
* @param {number} position X-offset in pixels
* @param {boolean} immediate Set to true to immediately scroll somewhere
*/
recenterOnPosition(position, immediate) {
const scrollLeft = this.wrapper.scrollLeft;
const half = ~~(this.wrapper.clientWidth / 2);
const maxScroll = this.wrapper.scrollWidth - this.wrapper.clientWidth;
let target = position - half;
let offset = target - scrollLeft;
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.
const 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;
}
}
/**
* Get the current scroll position in pixels
*
* @return {number}
*/
getScrollX() {
return Math.round(this.wrapper.scrollLeft * this.params.pixelRatio);
}
/**
* Get the width of the container
*
* @return {number}
*/
getWidth() {
return Math.round(this.container.clientWidth * this.params.pixelRatio);
}
/**
* Set the width of the container
*
* @param {number} width
*/
setWidth(width) {
if (this.width == width) {
return false;
}
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.updateSize();
return true;
}
/**
* Set the height of the container
*
* @param {number} height
*/
setHeight(height) {
if (height == this.height) {
return false;
}
this.height = height;
this.style(this.wrapper, {
height: ~~(this.height / this.params.pixelRatio) + 'px'
});
this.updateSize();
return true;
}
/**
* Called by wavesurfer when progress should be renderered
*
* @param {number} progress From 0 to 1
*/
progress(progress) {
const minPxDelta = 1 / this.params.pixelRatio;
const pos = Math.round(progress * this.width) * minPxDelta;
if (pos < this.lastPos || pos - this.lastPos >= minPxDelta) {
this.lastPos = pos;
if (this.params.scrollParent && this.params.autoCenter) {
const newPos = ~~(this.wrapper.scrollWidth * progress);
this.recenterOnPosition(newPos);
}
this.updateProgress(pos);
}
}
/**
* This is called when wavesurfer is destroyed
*/
destroy() {
this.unAll();
if (this.wrapper) {
if (this.wrapper.parentNode == this.container) {
this.container.removeChild(this.wrapper);
}
this.wrapper = null;
}
}
/* Renderer-specific methods */
/**
* Called when the size of the container changes so the renderer can adjust
*
* @abstract
*/
updateSize() {}
/**
* Draw a waveform with bars
*
* @abstract
* @param {number[]|number[][]} peaks Can also be an array of arrays for split channel
* rendering
* @param {number} channelIndex The index of the current channel. Normally
* should be 0
* @param {number} start The x-offset of the beginning of the area that
* should be rendered
* @param {number} end The x-offset of the end of the area that should be
* rendered
*/
drawBars(peaks, channelIndex, start, end) {}
/**
* Draw a waveform
*
* @abstract
* @param {number[]|number[][]} peaks Can also be an array of arrays for split channel
* rendering
* @param {number} channelIndex The index of the current channel. Normally
* should be 0
* @param {number} start The x-offset of the beginning of the area that
* should be rendered
* @param {number} end The x-offset of the end of the area that should be
* rendered
*/
drawWave(peaks, channelIndex, start, end) {}
/**
* Clear the waveform
*
* @abstract
*/
clearWave() {}
/**
* Render the new progress
*
* @abstract
* @param {number} position X-Offset of progress position in pixels
*/
updateProgress(position) {}
}
@@ -0,0 +1,529 @@
import Drawer from './drawer';
import * as util from './util';
/**
* @typedef {Object} CanvasEntry
* @private
* @property {HTMLElement} wave The wave node
* @property {CanvasRenderingContext2D} waveCtx The canvas rendering context
* @property {?HTMLElement} progress The progress wave node
* @property {?CanvasRenderingContext2D} progressCtx The progress wave canvas
* rendering context
* @property {?number} start Start of the area the canvas should render, between 0 and 1
* @property {?number} end End of the area the canvas should render, between 0 and 1
*/
/**
* MultiCanvas renderer for wavesurfer. Is currently the default and sole built
* in renderer.
*/
export default class MultiCanvas extends Drawer {
/**
* @param {HTMLElement} container The container node of the wavesurfer instance
* @param {WavesurferParams} params The wavesurfer initialisation options
*/
constructor(container, params) {
super(container, params);
/**
* @type {number}
* @private
*/
this.maxCanvasWidth = params.maxCanvasWidth;
/**
* @private
* @type {number}
*/
this.maxCanvasElementWidth = Math.round(params.maxCanvasWidth / params.pixelRatio);
/**
* Whether or not the progress wave is renderered. If the `waveColor`
* and `progressColor` are the same colour it is not.
* @type {boolean}
*/
this.hasProgressCanvas = params.waveColor != params.progressColor;
/**
* @private
* @type {number}
*/
this.halfPixel = 0.5 / params.pixelRatio;
/**
* @private
* @type {Array}
*/
this.canvases = [];
/** @private */
this.progressWave = null;
}
/**
* Initialise the drawer
*/
init() {
this.createWrapper();
this.createElements();
}
/**
* Create the canvas elements and style them
*
* @private
*/
createElements() {
this.progressWave = this.wrapper.appendChild(
this.style(document.createElement('wave'), {
position: 'absolute',
zIndex: 3,
left: 0,
top: 0,
bottom: 0,
overflow: 'hidden',
width: '0',
display: 'none',
boxSizing: 'border-box',
borderRightStyle: 'solid',
borderRightWidth: this.params.cursorWidth + 'px',
borderRightColor: this.params.cursorColor,
pointerEvents: 'none'
})
);
this.addCanvas();
}
/**
* Adjust to the updated size by adding or removing canvases
*/
updateSize() {
const totalWidth = Math.round(this.width / this.params.pixelRatio);
const requiredCanvases = Math.ceil(totalWidth / this.maxCanvasElementWidth);
while (this.canvases.length < requiredCanvases) {
this.addCanvas();
}
while (this.canvases.length > requiredCanvases) {
this.removeCanvas();
}
this.canvases.forEach((entry, i) => {
// Add some overlap to prevent vertical white stripes, keep the width even for simplicity.
let canvasWidth = this.maxCanvasWidth + 2 * Math.ceil(this.params.pixelRatio / 2);
if (i == this.canvases.length - 1) {
canvasWidth = this.width - (this.maxCanvasWidth * (this.canvases.length - 1));
}
this.updateDimensions(entry, canvasWidth, this.height);
this.clearWaveForEntry(entry);
});
}
/**
* Add a canvas to the canvas list
*
* @private
*/
addCanvas() {
const entry = {};
const leftOffset = this.maxCanvasElementWidth * this.canvases.length;
entry.wave = this.wrapper.appendChild(
this.style(document.createElement('canvas'), {
position: 'absolute',
zIndex: 2,
left: leftOffset + 'px',
top: 0,
bottom: 0,
height: '100%',
pointerEvents: 'none'
})
);
entry.waveCtx = entry.wave.getContext('2d');
if (this.hasProgressCanvas) {
entry.progress = this.progressWave.appendChild(
this.style(document.createElement('canvas'), {
position: 'absolute',
left: leftOffset + 'px',
top: 0,
bottom: 0,
height: '100%'
})
);
entry.progressCtx = entry.progress.getContext('2d');
}
this.canvases.push(entry);
}
/**
* Pop one canvas from the list
*
* @private
*/
removeCanvas() {
const lastEntry = this.canvases.pop();
lastEntry.wave.parentElement.removeChild(lastEntry.wave);
if (this.hasProgressCanvas) {
lastEntry.progress.parentElement.removeChild(lastEntry.progress);
}
}
/**
* Update the dimensions of a canvas element
*
* @private
* @param {CanvasEntry} entry
* @param {number} width The new width of the element
* @param {number} height The new height of the element
*/
updateDimensions(entry, width, height) {
const elementWidth = Math.round(width / this.params.pixelRatio);
const totalWidth = Math.round(this.width / this.params.pixelRatio);
// Where the canvas starts and ends in the waveform, represented as a decimal between 0 and 1.
entry.start = (entry.waveCtx.canvas.offsetLeft / totalWidth) || 0;
entry.end = entry.start + elementWidth / totalWidth;
entry.waveCtx.canvas.width = width;
entry.waveCtx.canvas.height = height;
this.style(entry.waveCtx.canvas, { width: elementWidth + 'px'});
this.style(this.progressWave, { display: 'block'});
if (this.hasProgressCanvas) {
entry.progressCtx.canvas.width = width;
entry.progressCtx.canvas.height = height;
this.style(entry.progressCtx.canvas, { width: elementWidth + 'px'});
}
}
/**
* Clear the whole waveform
*/
clearWave() {
this.canvases.forEach(entry => this.clearWaveForEntry(entry));
}
/**
* Clear one canvas
*
* @private
* @param {CanvasEntry} entry
*/
clearWaveForEntry(entry) {
entry.waveCtx.clearRect(0, 0, entry.waveCtx.canvas.width, entry.waveCtx.canvas.height);
if (this.hasProgressCanvas) {
entry.progressCtx.clearRect(0, 0, entry.progressCtx.canvas.width, entry.progressCtx.canvas.height);
}
}
/**
* Draw a waveform with bars
*
* @param {number[]|number[][]} peaks Can also be an array of arrays for split channel
* rendering
* @param {number} channelIndex The index of the current channel. Normally
* should be 0. Must be an integer.
* @param {number} start The x-offset of the beginning of the area that
* should be rendered
* @param {number} end The x-offset of the end of the area that should be
* rendered
*/
drawBars(peaks, channelIndex, start, end) {
return this.prepareDraw(peaks, channelIndex, start, end, ({
absmax,
hasMinVals,
height,
offsetY,
halfH
}) => {
// if drawBars was called within ws.empty we don't pass a start and
// don't want anything to happen
if (start === undefined) {
return;
}
// Skip every other value if there are negatives.
const peakIndexScale = hasMinVals ? 2 : 1;
const length = peaks.length / peakIndexScale;
const bar = this.params.barWidth * this.params.pixelRatio;
const gap = Math.max(this.params.pixelRatio, ~~(bar / 2));
const step = bar + gap;
const scale = length / this.width;
const first = start;
const last = end;
let i;
for (i = first; i < last; i += step) {
const peak = peaks[Math.floor(i * scale * peakIndexScale)] || 0;
const h = Math.round(peak / absmax * halfH);
this.fillRect(i + this.halfPixel, halfH - h + offsetY, bar + this.halfPixel, h * 2);
}
});
}
/**
* Draw a waveform
*
* @param {number[]|number[][]} peaks Can also be an array of arrays for split channel
* rendering
* @param {number} channelIndex The index of the current channel. Normally
* should be 0
* @param {number?} start The x-offset of the beginning of the area that
* should be rendered (If this isn't set only a flat line is rendered)
* @param {number?} end The x-offset of the end of the area that should be
* rendered
*/
drawWave(peaks, channelIndex, start, end) {
return this.prepareDraw(peaks, channelIndex, start, end, ({
absmax,
hasMinVals,
height,
offsetY,
halfH
}) => {
if (!hasMinVals) {
const reflectedPeaks = [];
const len = peaks.length;
let i;
for (i = 0; i < len; i++) {
reflectedPeaks[2 * i] = peaks[i];
reflectedPeaks[2 * i + 1] = -peaks[i];
}
peaks = reflectedPeaks;
}
// if drawWave was called within ws.empty we don't pass a start and
// end and simply want a flat line
if (start !== undefined) {
this.drawLine(peaks, absmax, halfH, offsetY, start, end);
}
// Always draw a median line
this.fillRect(0, halfH + offsetY - this.halfPixel, this.width, this.halfPixel);
});
}
/**
* Tell the canvas entries to render their portion of the waveform
*
* @private
* @param {number[]} peaks Peak data
* @param {number} absmax Maximum peak value (absolute)
* @param {number} halfH Half the height of the waveform
* @param {number} offsetY Offset to the top
* @param {number} start The x-offset of the beginning of the area that
* should be rendered
* @param {number} end The x-offset of the end of the area that
* should be rendered
*/
drawLine(peaks, absmax, halfH, offsetY, start, end) {
this.canvases.forEach(entry => {
this.setFillStyles(entry);
this.drawLineToContext(entry, entry.waveCtx, peaks, absmax, halfH, offsetY, start, end);
this.drawLineToContext(entry, entry.progressCtx, peaks, absmax, halfH, offsetY, start, end);
});
}
/**
* Render the actual waveform line on a canvas
*
* @private
* @param {CanvasEntry} entry
* @param {Canvas2DContextAttributes} ctx Essentially `entry.[wave|progress]Ctx`
* @param {number[]} peaks
* @param {number} absmax Maximum peak value (absolute)
* @param {number} halfH Half the height of the waveform
* @param {number} offsetY Offset to the top
* @param {number} start The x-offset of the beginning of the area that
* should be rendered
* @param {number} end The x-offset of the end of the area that
* should be rendered
*/
drawLineToContext(entry, ctx, peaks, absmax, halfH, offsetY, start, end) {
if (!ctx) { return; }
const length = peaks.length / 2;
const scale = (this.params.fillParent && this.width != length)
? this.width / length
: 1;
const first = Math.round(length * entry.start);
// Use one more peak value to make sure we join peaks at ends -- unless,
// of course, this is the last canvas.
const last = Math.round(length * entry.end) + 1;
if (first > end || last < start) { return; }
const canvasStart = Math.min(first, start);
const canvasEnd = Math.max(last, end);
let i;
let j;
ctx.beginPath();
ctx.moveTo((canvasStart - first) * scale + this.halfPixel, halfH + offsetY);
for (i = canvasStart; i < canvasEnd; i++) {
const peak = peaks[2 * i] || 0;
const h = Math.round(peak / absmax * halfH);
ctx.lineTo((i - first) * scale + this.halfPixel, halfH - h + offsetY);
}
// Draw the bottom edge going backwards, to make a single
// closed hull to fill.
for (j = canvasEnd - 1; j >= canvasStart; j--) {
const peak = peaks[2 * j + 1] || 0;
const h = Math.round(peak / absmax * halfH);
ctx.lineTo((j - first) * scale + this.halfPixel, halfH - h + offsetY);
}
ctx.closePath();
ctx.fill();
}
/**
* Draw a rectangle on the waveform
*
* @param {number} x
* @param {number} y
* @param {number} width
* @param {number} height
*/
fillRect(x, y, width, height) {
const startCanvas = Math.floor(x / this.maxCanvasWidth);
const endCanvas = Math.min(
Math.ceil((x + width) / this.maxCanvasWidth) + 1,
this.canvases.length
);
let i;
for (i = startCanvas; i < endCanvas; i++) {
const entry = this.canvases[i];
const leftOffset = i * this.maxCanvasWidth;
const intersection = {
x1: Math.max(x, i * this.maxCanvasWidth),
y1: y,
x2: Math.min(x + width, i * this.maxCanvasWidth + entry.waveCtx.canvas.width),
y2: y + height
};
if (intersection.x1 < intersection.x2) {
this.setFillStyles(entry);
this.fillRectToContext(entry.waveCtx,
intersection.x1 - leftOffset,
intersection.y1,
intersection.x2 - intersection.x1,
intersection.y2 - intersection.y1);
this.fillRectToContext(entry.progressCtx,
intersection.x1 - leftOffset,
intersection.y1,
intersection.x2 - intersection.x1,
intersection.y2 - intersection.y1);
}
}
}
/**
* Performs preparation tasks and calculations which are shared by drawBars and drawWave
*
* @private
* @param {number[]|number[][]} peaks Can also be an array of arrays for split channel
* rendering
* @param {number} channelIndex The index of the current channel. Normally
* should be 0
* @param {number?} start The x-offset of the beginning of the area that
* should be rendered (If this isn't set only a flat line is rendered)
* @param {number?} end The x-offset of the end of the area that should be
* rendered
* @param {function} fn The render function to call
*/
prepareDraw(peaks, channelIndex, start, end, fn) {
return util.frame(() => {
// Split channels and call this function with the channelIndex set
if (peaks[0] instanceof Array) {
const channels = peaks;
if (this.params.splitChannels) {
this.setHeight(channels.length * this.params.height * this.params.pixelRatio);
channels.forEach((channelPeaks, i) => this.prepareDraw(channelPeaks, i, start, end, fn));
return;
}
peaks = channels[0];
}
// calculate maximum modulation value, either from the barHeight
// parameter or if normalize=true from the largest value in the peak
// set
let absmax = 1 / this.params.barHeight;
if (this.params.normalize) {
const max = util.max(peaks);
const min = util.min(peaks);
absmax = -min > max ? -min : max;
}
// Bar wave draws the bottom only as a reflection of the top,
// so we don't need negative values
const hasMinVals = [].some.call(peaks, val => val < 0);
const height = this.params.height * this.params.pixelRatio;
const offsetY = height * channelIndex || 0;
const halfH = height / 2;
return fn({
absmax: absmax,
hasMinVals: hasMinVals,
height: height,
offsetY: offsetY,
halfH: halfH
});
})();
}
/**
* Draw the actual rectangle on a canvas
*
* @private
* @param {Canvas2DContextAttributes} ctx
* @param {number} x
* @param {number} y
* @param {number} width
* @param {number} height
*/
fillRectToContext(ctx, x, y, width, height) {
if (!ctx) { return; }
ctx.fillRect(x, y, width, height);
}
/**
* Set the fill styles for a certain entry (wave and progress)
*
* @private
* @param {CanvasEntry} entry
*/
setFillStyles(entry) {
entry.waveCtx.fillStyle = this.params.waveColor;
if (this.hasProgressCanvas) {
entry.progressCtx.fillStyle = this.params.progressColor;
}
}
/**
* Return image data of the waveform
*
* @param {string} type='image/png' An optional value of a format type.
* @param {number} quality=0.92 An optional value between 0 and 1.
* @return {string|string[]} images A data URL or an array of data URLs
*/
getImage(type, quality) {
const images = this.canvases.map(entry => entry.wave.toDataURL(type, quality));
return images.length > 1 ? images : images[0];
}
/**
* Render the new progress
*
* @param {number} position X-Offset of progress position in pixels
*/
updateProgress(position) {
this.style(this.progressWave, { width: position + 'px' });
}
}
+221
View File
@@ -0,0 +1,221 @@
import loadScript from 'load-script';
/**
* @typedef {Object} InitParams
* @property {WavesurferParams} [defaults={backend: 'MediaElement,
* mediaControls: true}] The default wavesurfer initialisation parameters
* @property {string|NodeList} containers='wavesurfer' Selector or NodeList of
* elements to attach instances to
* @property {string}
* pluginCdnTemplate='//localhost:8080/dist/plugin/wavesurfer.[name].js' URL
* template for the dynamic loading of plugins
* @property {function} loadPlugin If set overwrites the default ajax function,
* can be used to inject plugins differently.
*/
/**
* The HTML initialisation API is not part of the main library bundle file and
* must be additionally included.
*
* The API attaches wavesurfer instances to all `<wavesurfer>` (can be
* customised), parsing their `data-` attributes to construct an options object
* for initialisation. Among other things it can dynamically load plugin code.
*
* The automatic initialisation can be prevented by setting the
* `window.WS_StopAutoInit` flag to true. The `html-init[.min].js` file exports
* the `Init` class, which can be called manually.
*
* Site-wide defaults can be added by setting `window.WS_InitOptions`.
*
* @example
* <!-- with minimap and timeline plugin -->
* <wavesurfer
* data-url="../media/demo.wav"
* data-plugins="minimap,timeline"
* data-minimap-height="30"
* data-minimap-wave-color="#ddd"
* data-minimap-progress-color="#999"
* data-timeline-font-size="13px"
* data-timeline-container="#timeline"
* >
* </wavesurfer>
* <div id="timeline"></div>
*
* <!-- with regions plugin -->
* <wavesurfer
* data-url="../media/demo.wav"
* data-plugins="regions"
* data-regions-regions='[{"start": 1,"end": 3,"color": "hsla(400, 100%, 30%, 0.5)"}]'
* >
* </wavesurfer>
*/
class Init {
/**
* Instantiate Init class and initialise elements
*
* This is done automatically if `window` is defined and
* `window.WS_StopAutoInit` is not set to true
*
* @param {WaveSurfer} WaveSurfer The WaveSurfer library object
* @param {InitParams} params initialisation options
*/
constructor(WaveSurfer, params = {}) {
if (!WaveSurfer) {
throw new Error('WaveSurfer is not available!');
}
/**
* cache WaveSurfer
* @private
*/
this.WaveSurfer = WaveSurfer;
/**
* build parameters, cache them in _params so minified builds are smaller
* @private
*/
const _params = this.params = WaveSurfer.util.extend({}, {
// wavesurfer parameter defaults so by default the audio player is
// usable with native media element controls
defaults: {
backend: 'MediaElement',
mediaControls: true
},
// containers to instantiate on, can be selector string or NodeList
containers: 'wavesurfer',
// @TODO insert plugin CDN URIs
pluginCdnTemplate: '//localhost:8080/dist/plugin/wavesurfer.[name].js',
// loadPlugin function can be overriden to inject plugin definition
// objects, this default function uses load-script to load a plugin
// and pass it to a callback
loadPlugin(name, cb) {
const src = _params.pluginCdnTemplate.replace('[name]', name);
loadScript(src, { async: false }, (err, plugin) => {
if (err) {
return console.error(`WaveSurfer plugin ${name} not found at ${src}`);
}
cb(window.WaveSurfer[name]);
});
}
}, params);
/**
* The nodes that should have instances attached to them
* @type {NodeList}
*/
this.containers = typeof _params.containers == 'string'
? document.querySelectorAll(_params.containers)
: _params.containers;
/** @private */
this.pluginCache = {};
/**
* An array of wavesurfer instances
* @type {Object[]}
*/
this.instances = [];
this.initAllEls();
}
/**
* Initialise all container elements
*/
initAllEls() {
// iterate over all the container elements
Array.prototype.forEach.call(this.containers, el => {
// load the plugins as an array of plugin names
const plugins = el.dataset.plugins
? el.dataset.plugins.split(',')
: [];
// no plugins to be loaded, just render
if (!plugins.length) {
return this.initEl(el);
}
// … or: iterate over all the plugins
plugins.forEach((name, i) => {
// plugin is not cached already, load it
if (!this.pluginCache[name]) {
this.params.loadPlugin(name, lib => {
this.pluginCache[name] = lib;
// plugins were all loaded, render the element
if (i + 1 === plugins.length) {
this.initEl(el, plugins);
}
});
} else if (i === plugins.length) {
// plugin was cached and this plugin was the last
this.initEl(el, plugins);
}
});
});
}
/**
* Initialise a single container element and add to `this.instances`
*
* @param {HTMLElement} el The container to instantiate wavesurfer to
* @param {PluginDefinition[]} plugins An Array of plugin names to initialise with
* @return {Object} Wavesurfer instance
*/
initEl(el, plugins = []) {
const jsonRegex = /^[[|{]/;
// initialise plugins with the correct options
const initialisedPlugins = plugins.map(plugin => {
const options = {};
// the regex to find this plugin attributes
const attrNameRegex = new RegExp('^' + plugin);
let attrName;
// iterate over all the data attributes and find ones for this
// plugin
for (attrName in el.dataset) {
const regexResult = attrNameRegex.exec(attrName);
if (regexResult) {
const attr = el.dataset[attrName];
// if the string begins with a [ or a { parse it as JSON
const prop = jsonRegex.test(attr) ? JSON.parse(attr) : attr;
// this removes the plugin prefix and changes the first letter
// of the resulting string to lower case to follow the naming
// convention of ws params
const unprefixedOptionName = attrName.slice(plugin.length, plugin.length + 1).toLowerCase()
+ attrName.slice(plugin.length + 1);
options[unprefixedOptionName] = prop;
}
}
return this.pluginCache[plugin].create(options);
});
// build parameter object for this container
const params = this.WaveSurfer.util.extend(
{ container: el },
this.params.defaults,
el.dataset,
{ plugins: initialisedPlugins }
);
// @TODO make nicer
el.style.display = 'block';
// initialise wavesurfer, load audio (with peaks if provided)
const instance = this.WaveSurfer.create(params);
const peaks = params.peaks ? JSON.parse(params.peaks) : undefined;
instance.load(params.url, peaks);
// push this instance into the instances cache
this.instances.push(instance);
return instance;
}
}
// if window object exists and window.WS_StopAutoInit is not true
if (typeof window === 'object' && !window.WS_StopAutoInit) {
// call init when document is ready, apply any custom default settings
// in window.WS_InitOptions
if (document.readyState === 'complete') {
window.WaveSurferInit = new Init(window.WaveSurfer, window.WS_InitOptions);
} else {
window.addEventListener('load', () => {
window.WaveSurferInit = new Init(window.WaveSurfer, window.WS_InitOptions);
});
}
}
// export init for manual usage
export default Init;
+312
View File
@@ -0,0 +1,312 @@
import WebAudio from './webaudio';
import * as util from './util';
/**
* MediaElement backend
*/
export default class MediaElement extends WebAudio {
/**
* Construct the backend
*
* @param {WavesurferParams} params
*/
constructor(params) {
super(params);
/** @private */
this.params = params;
// Dummy media to catch errors
/** @private */
this.media = {
currentTime: 0,
duration: 0,
paused: true,
playbackRate: 1,
play() {},
pause() {}
};
/** @private */
this.mediaType = params.mediaType.toLowerCase();
/** @private */
this.elementPosition = params.elementPosition;
/** @private */
this.peaks = null;
/** @private */
this.playbackRate = 1;
/** @private */
this.buffer = null;
/** @private */
this.onPlayEnd = null;
}
/**
* Initialise the backend, called in `wavesurfer.createBackend()`
*/
init() {
this.setPlaybackRate(this.params.audioRate);
this.createTimer();
}
/**
* Create a timer to provide a more precise `audioprocess` event.
*
* @private
*/
createTimer() {
const onAudioProcess = () => {
if (this.isPaused()) { return; }
this.fireEvent('audioprocess', this.getCurrentTime());
// Call again in the next frame
const requestAnimationFrame = window.requestAnimationFrame || window.webkitRequestAnimationFrame;
requestAnimationFrame(onAudioProcess);
};
this.on('play', onAudioProcess);
}
/**
* Create media element with url as its source,
* and append to container element.
*
* @param {string} url Path to media file
* @param {HTMLElement} container HTML element
* @param {Array} peaks Array of peak data
* @param {string} preload HTML 5 preload attribute value
*/
load(url, container, peaks, preload) {
const media = document.createElement(this.mediaType);
media.controls = this.params.mediaControls;
media.autoplay = this.params.autoplay || false;
media.preload = preload == null ? 'auto' : preload;
media.src = url;
media.style.width = '100%';
const prevMedia = container.querySelector(this.mediaType);
if (prevMedia) {
container.removeChild(prevMedia);
}
container.appendChild(media);
this._load(media, peaks);
}
/**
* Load existing media element.
*
* @param {MediaElement} elt HTML5 Audio or Video element
* @param {Array} peaks Array of peak data
*/
loadElt(elt, peaks) {
elt.controls = this.params.mediaControls;
elt.autoplay = this.params.autoplay || false;
this._load(elt, peaks);
}
/**
* Private method called by both load (from url)
* and loadElt (existing media element).
*
* @param {MediaElement} media HTML5 Audio or Video element
* @param {Array} peaks array of peak data
* @private
*/
_load(media, peaks) {
// load must be called manually on iOS, otherwise peaks won't draw
// until a user interaction triggers load --> 'ready' event
if (typeof media.load == 'function') {
media.load();
}
media.addEventListener('error', () => {
this.fireEvent('error', 'Error loading media element');
});
media.addEventListener('canplay', () => {
this.fireEvent('canplay');
});
media.addEventListener('ended', () => {
this.fireEvent('finish');
});
// Listen to and relay play and pause events to enable
// playback control from the external media element
media.addEventListener('play', () => {
this.fireEvent('play');
});
media.addEventListener('pause', () => {
this.fireEvent('pause');
});
this.media = media;
this.peaks = peaks;
this.onPlayEnd = null;
this.buffer = null;
this.setPlaybackRate(this.playbackRate);
}
/**
* Used by `wavesurfer.isPlaying()` and `wavesurfer.playPause()`
*
* @return {boolean}
*/
isPaused() {
return !this.media || this.media.paused;
}
/**
* Used by `wavesurfer.getDuration()`
*
* @return {number}
*/
getDuration() {
let duration = (this.buffer || this.media).duration;
if (duration >= Infinity) { // streaming audio
duration = this.media.seekable.end(0);
}
return duration;
}
/**
* Returns the current time in seconds relative to the audioclip's
* duration.
*
* @return {number}
*/
getCurrentTime() {
return this.media && this.media.currentTime;
}
/**
* Get the position from 0 to 1
*
* @return {number}
*/
getPlayedPercents() {
return (this.getCurrentTime() / this.getDuration()) || 0;
}
/**
* Get the audio source playback rate.
*
* @return {number}
*/
getPlaybackRate() {
return this.playbackRate || this.media.playbackRate;
}
/**
* Set the audio source playback rate.
*
* @param {number} value
*/
setPlaybackRate(value) {
this.playbackRate = value || 1;
this.media.playbackRate = this.playbackRate;
}
/**
* Used by `wavesurfer.seekTo()`
*
* @param {number} start Position to start at in seconds
*/
seekTo(start) {
if (start != null) {
this.media.currentTime = start;
}
this.clearPlayEnd();
}
/**
* 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.
* @emits MediaElement#play
*/
play(start, end) {
this.seekTo(start);
this.media.play();
end && this.setPlayEnd(end);
}
/**
* Pauses the loaded audio.
*
* @emits MediaElement#pause
*/
pause() {
this.media && this.media.pause();
this.clearPlayEnd();
}
/** @private */
setPlayEnd(end) {
this._onPlayEnd = time => {
if (time >= end) {
this.pause();
this.seekTo(end);
}
};
this.on('audioprocess', this._onPlayEnd);
}
/** @private */
clearPlayEnd() {
if (this._onPlayEnd) {
this.un('audioprocess', this._onPlayEnd);
this._onPlayEnd = null;
}
}
/**
* 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.buffer) {
return super.getPeaks(length, first, last);
}
return this.peaks || [];
}
/**
* Get the current volume
*
* @return {number} value A floating point value between 0 and 1.
*/
getVolume() {
return this.media.volume;
}
/**
* Set the audio volume
*
* @param {number} value A floating point value between 0 and 1.
*/
setVolume(value) {
this.media.volume = value;
}
/**
* This is called when wavesurfer is destroyed
*
*/
destroy() {
this.pause();
this.unAll();
this.media && this.media.parentNode && this.media.parentNode.removeChild(this.media);
this.media = null;
}
}
+116
View File
@@ -0,0 +1,116 @@
/**
* Caches the decoded peaks data to improve rendering speed for lage audio
*
* Is used if the option parameter `partialRender` is set to `true`
*/
export default class PeakCache {
/**
* Instantiate cache
*/
constructor() {
this.clearPeakCache();
}
/**
* Empty the cache
*/
clearPeakCache() {
/**
* Flat array with entries that are always in pairs to mark the
* beginning and end of each subrange. This is a convenience so we can
* iterate over the pairs for easy set difference operations.
* @private
*/
this.peakCacheRanges = [];
/**
* Length of the entire cachable region, used for resetting the cache
* when this changes (zoom events, for instance).
* @private
*/
this.peakCacheLength = -1;
}
/**
* Add a range of peaks to the cache
*
* @param {number} length The length of the range
* @param {number} start The x offset of the start of the range
* @param {number} end The x offset of the end of the range
* @return {number[][]}
*/
addRangeToPeakCache(length, start, end) {
if (length != this.peakCacheLength) {
this.clearPeakCache();
this.peakCacheLength = length;
}
// Return ranges that weren't in the cache before the call.
let uncachedRanges = [];
let i = 0;
// Skip ranges before the current start.
while (i < this.peakCacheRanges.length && this.peakCacheRanges[i] < start) {
i++;
}
// If |i| is even, |start| falls after an existing range. Otherwise,
// |start| falls between an existing range, and the uncached region
// starts when we encounter the next node in |peakCacheRanges| or
// |end|, whichever comes first.
if (i % 2 == 0) {
uncachedRanges.push(start);
}
while (i < this.peakCacheRanges.length && this.peakCacheRanges[i] <= end) {
uncachedRanges.push(this.peakCacheRanges[i]);
i++;
}
// If |i| is even, |end| is after all existing ranges.
if (i % 2 == 0) {
uncachedRanges.push(end);
}
// Filter out the 0-length ranges.
uncachedRanges = uncachedRanges.filter((item, pos, arr) => {
if (pos == 0) {
return item != arr[pos + 1];
} else if (pos == arr.length - 1) {
return item != arr[pos - 1];
}
return item != arr[pos - 1] && item != arr[pos + 1];
});
// Merge the two ranges together, uncachedRanges will either contain
// wholly new points, or duplicates of points in peakCacheRanges. If
// duplicates are detected, remove both and extend the range.
this.peakCacheRanges = this.peakCacheRanges.concat(uncachedRanges);
this.peakCacheRanges = this.peakCacheRanges.sort((a, b) => a - b).filter((item, pos, arr) => {
if (pos == 0) {
return item != arr[pos + 1];
} else if (pos == arr.length - 1) {
return item != arr[pos - 1];
}
return item != arr[pos - 1] && item != arr[pos + 1];
});
// Push the uncached ranges into an array of arrays for ease of
// iteration in the functions that call this.
const uncachedRangePairs = [];
for (i = 0; i < uncachedRanges.length; i += 2) {
uncachedRangePairs.push([uncachedRanges[i], uncachedRanges[i+1]]);
}
return uncachedRangePairs;
}
/**
* For testing
*
* @return {number[][]}
*/
getCacheRanges() {
const peakCacheRangePairs = [];
let i;
for (i = 0; i < this.peakCacheRanges.length; i += 2) {
peakCacheRangePairs.push([this.peakCacheRanges[i], this.peakCacheRanges[i+1]]);
}
return peakCacheRangePairs;
}
}
+137
View File
@@ -0,0 +1,137 @@
/**
* @typedef {Object} CursorPluginParams
* @property {?boolean} deferInit Set to true to stop auto init in `addPlugin()`
*/
/**
* Displays a thin line at the position of the cursor on the waveform.
*
* @implements {PluginClass}
* @extends {Observer}
* @example
* // es6
* import CursorPlugin from 'wavesurfer.cursor.js';
*
* // commonjs
* var CursorPlugin = require('wavesurfer.cursor.js');
*
* // if you are using <script> tags
* var CursorPlugin = window.WaveSurfer.cursor;
*
* // ... initialising wavesurfer with the plugin
* var wavesurfer = WaveSurfer.create({
* // wavesurfer options ...
* plugins: [
* CursorPlugin.create({
* // plugin options ...
* })
* ]
* });
*/
export default class CursorPlugin {
/**
* Cursor plugin definition factory
*
* This function must be used to create a plugin definition which can be
* used by wavesurfer to correctly instantiate the plugin.
*
* @param {CursorPluginParams} params parameters use to initialise the
* plugin
* @return {PluginDefinition} an object representing the plugin
*/
static create(params) {
return {
name: 'cursor',
deferInit: params && params.deferInit ? params.deferInit : false,
params: params,
staticProps: {
enableCursor() {
console.warn('Deprecated enableCursor!');
this.initPlugins('cursor');
}
},
instance: CursorPlugin
};
}
constructor(params, ws) {
this.wavesurfer = ws;
this.style = ws.util.style;
this._onDrawerCreated = () => {
this.drawer = this.wavesurfer.drawer;
this.wrapper = this.wavesurfer.drawer.wrapper;
this._onMousemove = e => this.updateCursorPosition(this.drawer.handleEvent(e));
this.wrapper.addEventListener('mousemove', this._onMousemove);
this._onMouseenter = () => this.showCursor();
this.wrapper.addEventListener('mouseenter', this._onMouseenter);
this._onMouseleave = () => this.hideCursor();
this.wrapper.addEventListener('mouseleave', this._onMouseleave);
this.cursor = this.wrapper.appendChild(
this.style(document.createElement('wave'), {
position: 'absolute',
zIndex: 3,
left: 0,
top: 0,
bottom: 0,
width: '0',
display: 'block',
borderRightStyle: 'solid',
borderRightWidth: 1 + 'px',
borderRightColor: 'black',
opacity: '.25',
pointerEvents: 'none'
})
);
};
}
init() {
// drawer already existed, just call initialisation code
if (this.wavesurfer.drawer) {
this._onDrawerCreated();
}
// the drawer was initialised, call the initialisation code
this.wavesurfer.on('drawer-created', this._onDrawerCreated);
}
destroy() {
this.wavesurfer.un('drawer-created', this._onDrawerCreated);
// if cursor was appended, remove it
if (this.cursor) {
this.cursor.parentNode.removeChild(this.cursor);
}
// if the drawer existed (the cached version referenced in the init code),
// remove the event listeners attached to it
if (this.drawer) {
this.wrapper.removeEventListener('mousemove', this._onMousemove);
this.wrapper.removeEventListener('mouseenter', this._onMouseenter);
this.wrapper.removeEventListener('mouseleave', this._onMouseleave);
}
}
updateCursorPosition(progress) {
const pos = Math.round(this.drawer.width * progress) / this.drawer.params.pixelRatio - 1;
this.style(this.cursor, {
left: `${pos}px`
});
}
showCursor() {
this.style(this.cursor, {
display: 'block'
});
}
hideCursor() {
this.style(this.cursor, {
display: 'none'
});
}
}
+297
View File
@@ -0,0 +1,297 @@
/**
* @typedef {Object} ElanPluginParams
* @property {string|HTMLElement} container CSS selector or HTML element where
* the ELAN information should be renderer.
* @property {string} url The location of ELAN XML data
* @property {?boolean} deferInit Set to true to manually call
* @property {?Object} tiers If set only shows the data tiers with the `TIER_ID`
* in this map.
*/
/**
* Downloads and renders ELAN audio transcription documents alongside the
* waveform.
*
* @implements {PluginClass}
* @extends {Observer}
* @example
* // es6
* import ElanPlugin from 'wavesurfer.elan.js';
*
* // commonjs
* var ElanPlugin = require('wavesurfer.elan.js');
*
* // if you are using <script> tags
* var ElanPlugin = window.WaveSurfer.elan;
*
* // ... initialising wavesurfer with the plugin
* var wavesurfer = WaveSurfer.create({
* // wavesurfer options ...
* plugins: [
* ElanPlugin.create({
* // plugin options ...
* })
* ]
* });
*/
export default class ElanPlugin {
/**
* Elan plugin definition factory
*
* This function must be used to create a plugin definition which can be
* used by wavesurfer to correctly instantiate the plugin.
*
* @param {ElanPluginParams} params parameters use to initialise the plugin
* @return {PluginDefinition} an object representing the plugin
*/
static create(params) {
return {
name: 'elan',
deferInit: params && params.deferInit ? params.deferInit : false,
params: params,
instance: ElanPlugin
};
}
Types = {
ALIGNABLE_ANNOTATION: 'ALIGNABLE_ANNOTATION',
REF_ANNOTATION: 'REF_ANNOTATION'
}
constructor(params, ws) {
this.data = null;
this.params = params;
this.container = 'string' == typeof params.container ?
document.querySelector(params.container) : params.container;
if (!this.container) {
throw Error('No container for ELAN');
}
}
init() {
this.bindClick();
if (this.params.url) {
this.load(this.params.url);
}
}
destroy() {
this.container.removeEventListener('click', this._onClick);
this.container.removeChild(this.table);
}
load(url) {
this.loadXML(url, xml => {
this.data = this.parseElan(xml);
this.render();
this.fireEvent('ready', this.data);
});
}
loadXML(url, callback) {
const xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.responseType = 'document';
xhr.send();
xhr.addEventListener('load', e => {
callback && callback(e.target.responseXML);
});
}
parseElan(xml) {
const _forEach = Array.prototype.forEach;
const _map = Array.prototype.map;
const data = {
media: {},
timeOrder: {},
tiers: [],
annotations: {},
alignableAnnotations: []
};
const header = xml.querySelector('HEADER');
const inMilliseconds = header.getAttribute('TIME_UNITS') == 'milliseconds';
const media = header.querySelector('MEDIA_DESCRIPTOR');
data.media.url = media.getAttribute('MEDIA_URL');
data.media.type = media.getAttribute('MIME_TYPE');
const timeSlots = xml.querySelectorAll('TIME_ORDER TIME_SLOT');
const timeOrder = {};
_forEach.call(timeSlots, slot => {
let value = parseFloat(slot.getAttribute('TIME_VALUE'));
// If in milliseconds, convert to seconds with rounding
if (inMilliseconds) {
value = Math.round(value * 1e2) / 1e5;
}
timeOrder[slot.getAttribute('TIME_SLOT_ID')] = value;
});
data.tiers = _map.call(xml.querySelectorAll('TIER'), tier => ({
id: tier.getAttribute('TIER_ID'),
linguisticTypeRef: tier.getAttribute('LINGUISTIC_TYPE_REF'),
defaultLocale: tier.getAttribute('DEFAULT_LOCALE'),
annotations: _map.call(
tier.querySelectorAll('REF_ANNOTATION, ALIGNABLE_ANNOTATION'), node => {
const annot = {
type: node.nodeName,
id: node.getAttribute('ANNOTATION_ID'),
ref: node.getAttribute('ANNOTATION_REF'),
value: node.querySelector('ANNOTATION_VALUE')
.textContent.trim()
};
if (this.Types.ALIGNABLE_ANNOTATION == annot.type) {
// Add start & end to alignable annotation
annot.start = timeOrder[node.getAttribute('TIME_SLOT_REF1')];
annot.end = timeOrder[node.getAttribute('TIME_SLOT_REF2')];
// Add to the list of alignable annotations
data.alignableAnnotations.push(annot);
}
// Additionally, put into the flat map of all annotations
data.annotations[annot.id] = annot;
return annot;
}
)
}));
// Create JavaScript references between annotations
data.tiers.forEach(tier => {
tier.annotations.forEach(annot => {
if (null != annot.ref) {
annot.reference = data.annotations[annot.ref];
}
});
});
// Sort alignable annotations by start & end
data.alignableAnnotations.sort((a, b) => {
let d = a.start - b.start;
if (d == 0) {
d = b.end - a.end;
}
return d;
});
data.length = data.alignableAnnotations.length;
return data;
}
render() {
// apply tiers filter
let tiers = this.data.tiers;
if (this.params.tiers) {
tiers = tiers.filter(tier => tier.id in this.params.tiers);
}
// denormalize references to alignable annotations
const backRefs = {};
let indeces = {};
tiers.forEach((tier, index) => {
tier.annotations.forEach(annot => {
if (annot.reference && annot.reference.type == this.Types.ALIGNABLE_ANNOTATION) {
if (!(annot.reference.id in backRefs)) {
backRefs[annot.ref] = {};
}
backRefs[annot.ref][index] = annot;
indeces[index] = true;
}
});
});
indeces = Object.keys(indeces).sort();
this.renderedAlignable = this.data.alignableAnnotations.filter(alignable => backRefs[alignable.id]);
// table
const table = this.table = document.createElement('table');
table.className = 'wavesurfer-annotations';
// head
const thead = document.createElement('thead');
const headRow = document.createElement('tr');
thead.appendChild(headRow);
table.appendChild(thead);
const th = document.createElement('th');
th.textContent = 'Time';
th.className = 'wavesurfer-time';
headRow.appendChild(th);
indeces.forEach(index => {
const tier = tiers[index];
const th = document.createElement('th');
th.className = 'wavesurfer-tier-' + tier.id;
th.textContent = tier.id;
th.style.width = this.params.tiers[tier.id];
headRow.appendChild(th);
});
// body
const tbody = document.createElement('tbody');
table.appendChild(tbody);
this.renderedAlignable.forEach(alignable => {
const row = document.createElement('tr');
row.id = 'wavesurfer-alignable-' + alignable.id;
tbody.appendChild(row);
const td = document.createElement('td');
td.className = 'wavesurfer-time';
td.textContent = alignable.start.toFixed(1) + '' +
alignable.end.toFixed(1);
row.appendChild(td);
const backRef = backRefs[alignable.id];
indeces.forEach(index => {
const tier = tiers[index];
const td = document.createElement('td');
const annotation = backRef[index];
if (annotation) {
td.id = 'wavesurfer-annotation-' + annotation.id;
td.dataset.ref = alignable.id;
td.dataset.start = alignable.start;
td.dataset.end = alignable.end;
td.textContent = annotation.value;
}
td.className = 'wavesurfer-tier-' + tier.id;
row.appendChild(td);
});
});
this.container.innerHTML = '';
this.container.appendChild(table);
}
bindClick() {
this._onClick = e => {
const ref = e.target.dataset.ref;
if (null != ref) {
const annot = this.data.annotations[ref];
if (annot) {
this.fireEvent('select', annot.start, annot.end);
}
}
};
this.container.addEventListener('click', this._onClick);
}
getRenderedAnnotation(time) {
let result;
this.renderedAlignable.some(annotation => {
if (annotation.start <= time && annotation.end >= time) {
result = annotation;
return true;
}
return false;
});
return result;
}
getAnnotationNode(annotation) {
return document.getElementById(
'wavesurfer-alignable-' + annotation.id
);
}
}
@@ -0,0 +1,350 @@
/**
* @typedef {Object} MicrophonePluginParams
* @property {MediaStreamConstraints} constraints The constraints parameter is a
* MediaStreamConstaints object with two members: video and audio, describing
* the media types requested. Either or both must be specified.
* @property {number} bufferSize=4096 The buffer size in units of sample-frames.
* If specified, the bufferSize must be one of the following values: `256`,
* `512`, `1024`, `2048`, `4096`, `8192`, `16384`
* @property {number} numberOfInputChannels=1 Integer specifying the number of
* channels for this node's input. Values of up to 32 are supported.
* @property {?boolean} deferInit Set to true to manually call
* `initPlugin('microphone')`
*/
/**
* Visualise microphone input in a wavesurfer instance.
*
* @implements {PluginClass}
* @extends {Observer}
* @example
* // es6
* import MicrophonePlugin from 'wavesurfer.microphone.js';
*
* // commonjs
* var MicrophonePlugin = require('wavesurfer.microphone.js');
*
* // if you are using <script> tags
* var MicrophonePlugin = window.WaveSurfer.microphone;
*
* // ... initialising wavesurfer with the plugin
* var wavesurfer = WaveSurfer.create({
* // wavesurfer options ...
* plugins: [
* MicrophonePlugin.create({
* // plugin options ...
* })
* ]
* });
*/
export default class MicrophonePlugin {
/**
* Microphone plugin definition factory
*
* This function must be used to create a plugin definition which can be
* used by wavesurfer to correctly instantiate the plugin.
*
* @param {MicrophonePluginParams} params parameters use to initialise the plugin
* @return {PluginDefinition} an object representing the plugin
*/
static create(params) {
return {
name: 'microphone',
deferInit: params && params.deferInit ? params.deferInit : false,
params: params,
instance: MicrophonePlugin
};
}
constructor(params, ws) {
this.params = params;
this.wavesurfer = ws;
this.active = false;
this.paused = false;
this.reloadBufferFunction = e => this.reloadBuffer(e);
// cross-browser getUserMedia
const promisifiedOldGUM = (constraints, successCallback, errorCallback) => {
// get ahold of getUserMedia, if present
const getUserMedia = (navigator.getUserMedia ||
navigator.webkitGetUserMedia ||
navigator.mozGetUserMedia ||
navigator.msGetUserMedia
);
// Some browsers just don't implement it - return a rejected
// promise with an error to keep a consistent interface
if (!getUserMedia) {
return Promise.reject(
new Error('getUserMedia is not implemented in this browser')
);
}
// otherwise, wrap the call to the old navigator.getUserMedia with
// a Promise
return new Promise((successCallback, errorCallback) => {
getUserMedia.call(navigator, constraints, successCallback, errorCallback);
});
};
// Older browsers might not implement mediaDevices at all, so we set an
// empty object first
if (navigator.mediaDevices === undefined) {
navigator.mediaDevices = {};
}
// Some browsers partially implement mediaDevices. We can't just assign
// an object with getUserMedia as it would overwrite existing
// properties. Here, we will just add the getUserMedia property if it's
// missing.
if (navigator.mediaDevices.getUserMedia === undefined) {
navigator.mediaDevices.getUserMedia = promisifiedOldGUM;
}
this.constraints = this.params.constraints || {
video: false,
audio: true
};
this.bufferSize = this.params.bufferSize || 4096;
this.numberOfInputChannels = this.params.numberOfInputChannels || 1;
this.numberOfOutputChannels = this.params.numberOfOutputChannels || 1;
this._onBackendCreated = () => {
// wavesurfer's AudioContext where we'll route the mic signal to
this.micContext = this.wavesurfer.backend.getAudioContext();
};
}
init() {
this.wavesurfer.on('backend-created', this._onBackendCreated);
if (this.wavesurfer.backend) {
this._onBackendCreated();
}
}
/**
* Destroy the microphone plugin.
*/
destroy() {
// make sure the buffer is not redrawn during
// cleanup and demolition of this plugin.
this.paused = true;
this.wavesurfer.un('backend-created', this._onBackendCreated);
this.stop();
}
/**
* Allow user to select audio input device, eg. microphone, and
* start the visualization.
*/
start() {
navigator.mediaDevices.getUserMedia(this.constraints)
.then((data) => this.gotStream(data))
.catch((data) => this.deviceError(data));
}
/**
* Pause/resume visualization.
*/
togglePlay() {
if (!this.active) {
// start it first
this.start();
} else {
// toggle paused
this.paused = !this.paused;
if (this.paused) {
this.pause();
} else {
this.play();
}
}
}
/**
* Play visualization.
*/
play() {
this.paused = false;
this.connect();
}
/**
* Pause visualization.
*/
pause() {
this.paused = true;
// disconnect sources so they can be used elsewhere
// (eg. during audio playback)
this.disconnect();
}
/**
* Stop the device stream and remove any remaining waveform drawing from
* the wavesurfer canvas.
*/
stop() {
if (this.active) {
// stop visualization and device
this.stopDevice();
// empty last frame
this.wavesurfer.empty();
}
}
/**
* Stop the device and the visualization.
*/
stopDevice() {
this.active = false;
// stop visualization
this.disconnect();
// stop stream from device
if (this.stream) {
const result = this.detectBrowser();
// MediaStream.stop is deprecated since:
// - Firefox 44 (https://www.fxsitecompat.com/en-US/docs/2015/mediastream-stop-has-been-deprecated/)
// - Chrome 45 (https://developers.google.com/web/updates/2015/07/mediastream-deprecations)
if ((result.browser === 'chrome' && result.version >= 45) ||
(result.browser === 'firefox' && result.version >= 44) ||
(result.browser === 'edge')) {
if (this.stream.getTracks) { // note that this should not be a call
this.stream.getTracks().forEach(stream => stream.stop());
return;
}
}
this.stream.stop();
}
}
/**
* Connect the media sources that feed the visualization.
*/
connect() {
if (this.stream !== undefined) {
// Create an AudioNode from the stream.
this.mediaStreamSource = this.micContext.createMediaStreamSource(this.stream);
this.levelChecker = this.micContext.createScriptProcessor(
this.bufferSize,
this.numberOfInputChannels,
this.numberOfOutputChannels
);
this.mediaStreamSource.connect(this.levelChecker);
this.levelChecker.connect(this.micContext.destination);
this.levelChecker.onaudioprocess = this.reloadBufferFunction;
}
}
/**
* Disconnect the media sources that feed the visualization.
*/
disconnect() {
if (this.mediaStreamSource !== undefined) {
this.mediaStreamSource.disconnect();
}
if (this.levelChecker !== undefined) {
this.levelChecker.disconnect();
this.levelChecker.onaudioprocess = undefined;
}
}
/**
* Redraw the waveform.
*/
reloadBuffer(event) {
if (!this.paused) {
this.wavesurfer.empty();
this.wavesurfer.loadDecodedBuffer(event.inputBuffer);
}
}
/**
* Audio input device is ready.
*
* @param {LocalMediaStream} stream The microphone's media stream.
*/
gotStream(stream) {
this.stream = stream;
this.active = true;
// start visualization
this.play();
// notify listeners
this.fireEvent('deviceReady', stream);
}
/**
* Device error callback.
*/
deviceError(code) {
// notify listeners
this.fireEvent('deviceError', code);
}
/**
* Extract browser version out of the provided user agent string.
* @param {!string} uastring userAgent string.
* @param {!string} expr Regular expression used as match criteria.
* @param {!number} pos position in the version string to be returned.
* @return {!number} browser version.
*/
extractVersion(uastring, expr, pos) {
const match = uastring.match(expr);
return match && match.length >= pos && parseInt(match[pos], 10);
}
/**
* Browser detector.
* @return {object} result containing browser, version and minVersion
* properties.
*/
detectBrowser() {
// Returned result object.
const result = {};
result.browser = null;
result.version = null;
result.minVersion = null;
// Non supported browser.
if (typeof window === 'undefined' || !window.navigator) {
result.browser = 'Not a supported browser.';
return result;
}
// Firefox.
if (navigator.mozGetUserMedia) {
result.browser = 'firefox';
result.version = this.extractVersion(navigator.userAgent, /Firefox\/([0-9]+)\./, 1);
result.minVersion = 31;
return result;
}
// Chrome/Chromium/Webview.
if (navigator.webkitGetUserMedia && window.webkitRTCPeerConnection) {
result.browser = 'chrome';
result.version = this.extractVersion(navigator.userAgent, /Chrom(e|ium)\/([0-9]+)\./, 2);
result.minVersion = 38;
return result;
}
// Edge.
if (navigator.mediaDevices && navigator.userAgent.match(/Edge\/(\d+).(\d+)$/)) {
result.browser = 'edge';
result.version = this.extractVersion(navigator.userAgent, /Edge\/(\d+).(\d+)$/, 2);
result.minVersion = 10547;
return result;
}
// Non supported browser default.
result.browser = 'Not a supported browser.';
return result;
}
}
+326
View File
@@ -0,0 +1,326 @@
/**
* @typedef {Object} MinimapPluginParams
* @desc Extends the `WavesurferParams` wavesurfer was initialised with
* @property {?string|HTMLElement} container CSS selector or HTML element where
* the ELAN information should be renderer. By default it is simply appended
* after the waveform.
* @property {?boolean} deferInit Set to true to manually call
* `initPlugin('minimap')`
*/
/**
* Renders a smaller version waveform as a minimap of the main waveform.
*
* @implements {PluginClass}
* @extends {Observer}
* @example
* // es6
* import MinimapPlugin from 'wavesurfer.minimap.js';
*
* // commonjs
* var MinimapPlugin = require('wavesurfer.minimap.js');
*
* // if you are using <script> tags
* var MinimapPlugin = window.WaveSurfer.minimap;
*
* // ... initialising wavesurfer with the plugin
* var wavesurfer = WaveSurfer.create({
* // wavesurfer options ...
* plugins: [
* MinimapPlugin.create({
* // plugin options ...
* })
* ]
* });
*/
export default class MinimapPlugin {
/**
* Minimap plugin definition factory
*
* This function must be used to create a plugin definition which can be
* used by wavesurfer to correctly instantiate the plugin.
*
* @param {MinimapPluginParams} params parameters use to initialise the plugin
* @return {PluginDefinition} an object representing the plugin
*/
static create(params) {
return {
name: 'minimap',
deferInit: params && params.deferInit ? params.deferInit : false,
params: params,
staticProps: {
initMinimap(customConfig) {
console.warn('Deprecated initMinimap!');
params = customConfig;
this.initPlugins('minimap');
}
},
instance: MinimapPlugin
};
}
constructor(params, ws) {
this.params = ws.util.extend(
{}, ws.params, {
showRegions: false,
showOverview: false,
overviewBorderColor: 'green',
overviewBorderSize: 2,
// the container should be different
container: false,
height: Math.max(Math.round(ws.params.height / 4), 20)
}, params, {
scrollParent: false,
fillParent: true
}
);
// if container is a selector, get the element
if (typeof params.container === 'string') {
const el = document.querySelector(params.container);
if (!el) {
console.warn(`Wavesurfer minimap container ${params.container} was not found! The minimap will be automatically appended below the waveform.`);
}
this.params.container = el;
}
// if no container is specified add a new element and insert it
if (!params.container) {
this.params.container = ws.util.style(document.createElement('minimap'), {
display: 'block'
});
}
this.drawer = new (ws.Drawer)(this.params.container, this.params);
this.wavesurfer = ws;
this.util = ws.util;
/**
* Minimap needs to register to ready and waveform-ready events to
* work with MediaElement, the time when ready is called is different
* (peaks can not be got)
*
* @type {string}
* @see https://github.com/katspaugh/wavesurfer.js/issues/736
*/
this.renderEvent = ws.params.backend === 'MediaElement' ? 'waveform-ready' : 'ready';
this.overviewRegion = null;
this.drawer.createWrapper();
this.createElements();
let isInitialised = false;
// ws ready event listener
this._onShouldRender = () => {
// only bind the events in the first run
if (!isInitialised) {
this.bindWavesurferEvents();
this.bindMinimapEvents();
isInitialised = true;
}
// if there is no such element, append it to the container (below
// the waveform)
if (!document.body.contains(this.params.container)) {
ws.container.insertBefore(this.params.container, null);
}
if (this.wavesurfer.regions && this.params.showRegions) {
this.regions();
}
this.render();
};
this._onAudioprocess = currentTime => {
this.drawer.progress(this.wavesurfer.backend.getPlayedPercents());
};
// ws seek event listener
this._onSeek = () => this.drawer.progress(ws.backend.getPlayedPercents());
// event listeners for the overview region
this._onScroll = e => {
if (!this.draggingOverview) {
this.moveOverviewRegion(e.target.scrollLeft / this.ratio);
}
};
this._onMouseover = e => {
if (this.draggingOverview) {
this.draggingOverview = false;
}
};
let prevWidth = 0;
this._onResize = ws.util.debounce(() => {
if (prevWidth != this.drawer.wrapper.clientWidth) {
prevWidth = this.drawer.wrapper.clientWidth;
this.render();
this.drawer.progress(this.wavesurfer.backend.getPlayedPercents());
}
});
}
init() {
if (this.wavesurfer.isReady) {
this._onShouldRender();
}
this.wavesurfer.on(this.renderEvent, this._onShouldRender);
}
destroy() {
window.removeEventListener('resize', this._onResize, true);
window.removeEventListener('orientationchange', this._onResize, true);
this.wavesurfer.drawer.wrapper.removeEventListener('mouseover', this._onMouseover);
this.wavesurfer.un(this.renderEvent, this._onShouldRender);
this.wavesurfer.un('seek', this._onSeek);
this.wavesurfer.un('scroll', this._onScroll);
this.wavesurfer.un('audioprocess', this._onAudioprocess);
this.drawer.destroy();
this.overviewRegion = null;
this.unAll();
}
regions() {
this.regions = {};
this.wavesurfer.on('region-created', region => {
this.regions[region.id] = region;
this.renderRegions();
});
this.wavesurfer.on('region-updated', region => {
this.regions[region.id] = region;
this.renderRegions();
});
this.wavesurfer.on('region-removed', region => {
delete this.regions[region.id];
this.renderRegions();
});
}
renderRegions() {
const regionElements = this.drawer.wrapper.querySelectorAll('region');
let i;
for (i = 0; i < regionElements.length; ++i) {
this.drawer.wrapper.removeChild(regionElements[i]);
}
Object.keys(this.regions).forEach(id => {
const region = this.regions[id];
const width = (this.drawer.width * ((region.end - region.start) / this.wavesurfer.getDuration()));
const left = (this.drawer.width * (region.start / this.wavesurfer.getDuration()));
const regionElement = this.util.style(document.createElement('region'), {
height: 'inherit',
backgroundColor: region.color,
width: width + 'px',
left: left + 'px',
display: 'block',
position: 'absolute'
});
regionElement.classList.add(id);
this.drawer.wrapper.appendChild(regionElement);
});
}
createElements() {
this.drawer.createElements();
if (this.params.showOverview) {
this.overviewRegion = this.util.style(document.createElement('overview'), {
height: (this.drawer.wrapper.offsetHeight - (this.params.overviewBorderSize * 2)) + 'px',
width: '0px',
display: 'block',
position: 'absolute',
cursor: 'move',
border: this.params.overviewBorderSize + 'px solid ' + this.params.overviewBorderColor,
zIndex: 2,
opacity: this.params.overviewOpacity
});
this.drawer.wrapper.appendChild(this.overviewRegion);
}
}
bindWavesurferEvents() {
window.addEventListener('resize', this._onResize, true);
window.addEventListener('orientationchange', this._onResize, true);
this.wavesurfer.on('audioprocess', this._onAudioprocess);
this.wavesurfer.on('seek', this._onSeek);
if (this.params.showOverview) {
this.wavesurfer.on('scroll', this._onScroll);
this.wavesurfer.drawer.wrapper.addEventListener('mouseover', this._onMouseover);
}
}
bindMinimapEvents() {
const positionMouseDown = {
clientX: 0,
clientY: 0
};
let relativePositionX = 0;
let seek = true;
// the following event listeners will be destroyed by using
// this.unAll() and nullifying the DOM node references after
// removing them
this.on('click', (e, position) => {
if (seek) {
this.progress(position);
this.wavesurfer.seekAndCenter(position);
} else {
seek = true;
}
});
if (this.params.showOverview) {
this.overviewRegion.addEventListener('mousedown', event => {
this.draggingOverview = true;
relativePositionX = event.layerX;
positionMouseDown.clientX = event.clientX;
positionMouseDown.clientY = event.clientY;
});
this.drawer.wrapper.addEventListener('mousemove', event => {
if (this.draggingOverview) {
this.moveOverviewRegion(event.clientX - this.drawer.container.getBoundingClientRect().left - relativePositionX);
}
});
this.drawer.wrapper.addEventListener('mouseup', event => {
if (positionMouseDown.clientX - event.clientX === 0 && positionMouseDown.clientX - event.clientX === 0) {
seek = true;
this.draggingOverview = false;
} else if (this.draggingOverview) {
seek = false;
this.draggingOverview = false;
}
});
}
}
render() {
const len = this.drawer.getWidth();
const peaks = this.wavesurfer.backend.getPeaks(len, 0, len);
this.drawer.drawPeaks(peaks, len, 0, len);
this.drawer.progress(this.wavesurfer.backend.getPlayedPercents());
if (this.params.showOverview) {
//get proportional width of overview region considering the respective
//width of the drawers
this.ratio = this.wavesurfer.drawer.width / this.drawer.width;
this.waveShowedWidth = this.wavesurfer.drawer.width / this.ratio;
this.waveWidth = this.wavesurfer.drawer.width;
this.overviewWidth = (this.drawer.width / this.ratio);
this.overviewPosition = 0;
this.moveOverviewRegion(this.wavesurfer.drawer.wrapper.scrollLeft / this.ratio);
this.overviewRegion.style.width = (this.overviewWidth - (this.params.overviewBorderSize * 2)) + 'px';
}
}
moveOverviewRegion(pixels) {
if (pixels < 0) {
this.overviewPosition = 0;
} else if (pixels + this.overviewWidth < this.drawer.width) {
this.overviewPosition = pixels;
} else {
this.overviewPosition = (this.drawer.width - this.overviewWidth);
}
this.overviewRegion.style.left = this.overviewPosition + 'px';
if (this.draggingOverview) {
this.wavesurfer.drawer.wrapper.scrollLeft = this.overviewPosition * this.ratio;
}
}
}
+622
View File
@@ -0,0 +1,622 @@
/**
* (Single) Region plugin class
*
* Must be turned into an observer before instantiating. This is done in
* RegionsPlugin (main plugin class)
*
* @extends {Observer}
*/
class Region {
constructor(params, ws) {
this.wavesurfer = ws;
this.wrapper = ws.drawer.wrapper;
this.style = ws.util.style;
this.id = params.id == null ? ws.util.getId() : params.id;
this.start = Number(params.start) || 0;
this.end = params.end == null ?
// small marker-like region
this.start + (4 / this.wrapper.scrollWidth) * this.wavesurfer.getDuration() :
Number(params.end);
this.resize = params.resize === undefined ? true : Boolean(params.resize);
this.drag = params.drag === undefined ? true : Boolean(params.drag);
this.loop = Boolean(params.loop);
this.color = params.color || 'rgba(0, 0, 0, 0.1)';
this.data = params.data || {};
this.attributes = params.attributes || {};
this.maxLength = params.maxLength;
this.minLength = params.minLength;
this.bindInOut();
this.render();
this.onZoom = this.updateRender.bind(this);
this.wavesurfer.on('zoom', this.onZoom);
this.wavesurfer.fireEvent('region-created', this);
}
/* Update region params. */
update(params) {
if (null != params.start) {
this.start = Number(params.start);
}
if (null != params.end) {
this.end = Number(params.end);
}
if (null != params.loop) {
this.loop = Boolean(params.loop);
}
if (null != params.color) {
this.color = params.color;
}
if (null != params.data) {
this.data = params.data;
}
if (null != params.resize) {
this.resize = Boolean(params.resize);
}
if (null != params.drag) {
this.drag = Boolean(params.drag);
}
if (null != params.maxLength) {
this.maxLength = Number(params.maxLength);
}
if (null != params.minLength) {
this.minLength = Number(params.minLength);
}
if (null != params.attributes) {
this.attributes = params.attributes;
}
this.updateRender();
this.fireEvent('update');
this.wavesurfer.fireEvent('region-updated', this);
}
/* Remove a single region. */
remove() {
if (this.element) {
this.wrapper.removeChild(this.element);
this.element = null;
this.fireEvent('remove');
this.wavesurfer.un('zoom', this.onZoom);
this.wavesurfer.fireEvent('region-removed', this);
}
}
/* Play the audio region. */
play() {
this.wavesurfer.play(this.start, this.end);
this.fireEvent('play');
this.wavesurfer.fireEvent('region-play', this);
}
/* Play the region in loop. */
playLoop() {
this.play();
this.once('out', () => this.playLoop());
}
/* Render a region as a DOM element. */
render() {
const regionEl = document.createElement('region');
regionEl.className = 'wavesurfer-region';
regionEl.title = this.formatTime(this.start, this.end);
regionEl.setAttribute('data-id', this.id);
for (const attrname in this.attributes) {
regionEl.setAttribute('data-region-' + attrname, this.attributes[attrname]);
}
const width = this.wrapper.scrollWidth;
this.style(regionEl, {
position: 'absolute',
zIndex: 2,
height: '100%',
top: '0px'
});
/* Resize handles */
if (this.resize) {
const handleLeft = regionEl.appendChild(document.createElement('handle'));
const handleRight = regionEl.appendChild(document.createElement('handle'));
handleLeft.className = 'wavesurfer-handle wavesurfer-handle-start';
handleRight.className = 'wavesurfer-handle wavesurfer-handle-end';
const css = {
cursor: 'col-resize',
position: 'absolute',
left: '0px',
top: '0px',
width: '1%',
maxWidth: '4px',
height: '100%'
};
this.style(handleLeft, css);
this.style(handleRight, css);
this.style(handleRight, {
left: '100%'
});
}
this.element = this.wrapper.appendChild(regionEl);
this.updateRender();
this.bindEvents(regionEl);
}
formatTime(start, end) {
return (start == end ? [start] : [start, end]).map(time => [
Math.floor((time % 3600) / 60), // minutes
('00' + Math.floor(time % 60)).slice(-2) // seconds
].join(':')).join('-');
}
getWidth() {
return this.wavesurfer.drawer.width / this.wavesurfer.params.pixelRatio;
}
/* Update element's position, width, color. */
updateRender() {
const dur = this.wavesurfer.getDuration();
const width = this.getWidth();
if (this.start < 0) {
this.start = 0;
this.end = this.end - this.start;
}
if (this.end > dur) {
this.end = dur;
this.start = dur - (this.end - this.start);
}
if (this.minLength != null) {
this.end = Math.max(this.start + this.minLength, this.end);
}
if (this.maxLength != null) {
this.end = Math.min(this.start + this.maxLength, this.end);
}
if (this.element != null) {
// Calculate the left and width values of the region such that
// no gaps appear between regions.
const left = Math.round(this.start / dur * width);
const regionWidth =
Math.round(this.end / dur * width) - left;
this.style(this.element, {
left: left + 'px',
width: regionWidth + 'px',
backgroundColor: this.color,
cursor: this.drag ? 'move' : 'default'
});
for (const attrname in this.attributes) {
this.element.setAttribute('data-region-' + attrname, this.attributes[attrname]);
}
this.element.title = this.formatTime(this.start, this.end);
}
}
/* Bind audio events. */
bindInOut() {
this.firedIn = false;
this.firedOut = false;
const onProcess = time => {
if (!this.firedOut && this.firedIn && (this.start >= Math.round(time * 100) / 100 || this.end <= Math.round(time * 100) / 100)) {
this.firedOut = true;
this.firedIn = false;
this.fireEvent('out');
this.wavesurfer.fireEvent('region-out', this);
}
if (!this.firedIn && this.start <= time && this.end > time) {
this.firedIn = true;
this.firedOut = false;
this.fireEvent('in');
this.wavesurfer.fireEvent('region-in', this);
}
};
this.wavesurfer.backend.on('audioprocess', onProcess);
this.on('remove', () => {
this.wavesurfer.backend.un('audioprocess', onProcess);
});
/* Loop playback. */
this.on('out', () => {
if (this.loop) {
this.wavesurfer.play(this.start);
}
});
}
/* Bind DOM events. */
bindEvents() {
this.element.addEventListener('mouseenter', e => {
this.fireEvent('mouseenter', e);
this.wavesurfer.fireEvent('region-mouseenter', this, e);
});
this.element.addEventListener('mouseleave', e => {
this.fireEvent('mouseleave', e);
this.wavesurfer.fireEvent('region-mouseleave', this, e);
});
this.element.addEventListener('click', e => {
e.preventDefault();
this.fireEvent('click', e);
this.wavesurfer.fireEvent('region-click', this, e);
});
this.element.addEventListener('dblclick', e => {
e.stopPropagation();
e.preventDefault();
this.fireEvent('dblclick', e);
this.wavesurfer.fireEvent('region-dblclick', this, e);
});
/* Drag or resize on mousemove. */
(this.drag || this.resize) && (() => {
const duration = this.wavesurfer.getDuration();
let startTime;
let touchId;
let drag;
let resize;
const onDown = e => {
if (e.touches && e.touches.length > 1) { return; }
touchId = e.targetTouches ? e.targetTouches[0].identifier : null;
e.stopPropagation();
startTime = this.wavesurfer.drawer.handleEvent(e, true) * duration;
if (e.target.tagName.toLowerCase() == 'handle') {
if (e.target.classList.contains('wavesurfer-handle-start')) {
resize = 'start';
} else {
resize = 'end';
}
} else {
drag = true;
resize = false;
}
};
const onUp = e => {
if (e.touches && e.touches.length > 1) { return; }
if (drag || resize) {
drag = false;
resize = false;
this.fireEvent('update-end', e);
this.wavesurfer.fireEvent('region-update-end', this, e);
}
};
const onMove = e => {
if (e.touches && e.touches.length > 1) { return; }
if (e.targetTouches && e.targetTouches[0].identifier != touchId) { return; }
if (drag || resize) {
const time = this.wavesurfer.drawer.handleEvent(e) * duration;
const delta = time - startTime;
startTime = time;
// Drag
if (this.drag && drag) {
this.onDrag(delta);
}
// Resize
if (this.resize && resize) {
this.onResize(delta, resize);
}
}
};
this.element.addEventListener('mousedown', onDown);
this.element.addEventListener('touchstart', onDown);
this.wrapper.addEventListener('mousemove', onMove);
this.wrapper.addEventListener('touchmove', onMove);
document.body.addEventListener('mouseup', onUp);
document.body.addEventListener('touchend', onUp);
this.on('remove', () => {
document.body.removeEventListener('mouseup', onUp);
document.body.removeEventListener('touchend', onUp);
this.wrapper.removeEventListener('mousemove', onMove);
this.wrapper.removeEventListener('touchmove', onMove);
});
this.wavesurfer.on('destroy', () => {
document.body.removeEventListener('mouseup', onUp);
document.body.removeEventListener('touchend', onUp);
});
})();
}
onDrag(delta) {
const maxEnd = this.wavesurfer.getDuration();
if ((this.end + delta) > maxEnd || (this.start + delta) < 0) {
return;
}
this.update({
start: this.start + delta,
end: this.end + delta
});
}
onResize(delta, direction) {
if (direction == 'start') {
this.update({
start: Math.min(this.start + delta, this.end),
end: Math.max(this.start + delta, this.end)
});
} else {
this.update({
start: Math.min(this.end + delta, this.start),
end: Math.max(this.end + delta, this.start)
});
}
}
}
/**
* @typedef {Object} RegionsPluginParams
* @property {?boolean} dragSelection Enable creating regions by dragging wih
* the mouse
* @property {?RegionParams[]} regions Regions that should be added upon
* initialisation
* @property {number} slop=2 The sensitivity of the mouse dragging
* @property {?boolean} deferInit Set to true to manually call
* `initPlugin('regions')`
*/
/**
* @typedef {Object} RegionParams
* @desc The parameters used to describe a region.
* @example wavesurfer.addRegion(regionParams);
* @property {string} id=→random The id of the region
* @property {number} start=0 The start position of the region (in seconds).
* @property {number} end=0 The end position of the region (in seconds).
* @property {?boolean} loop Whether to loop the region when played back.
* @property {boolean} drag=true Allow/dissallow dragging the region.
* @property {boolean} resize=true Allow/dissallow resizing the region.
* @property {string} [color='rgba(0, 0, 0, 0.1)'] HTML color code.
*/
/**
* Regions are visual overlays on waveform that can be used to play and loop
* portions of audio. Regions can be dragged and resized.
*
* Visual customization is possible via CSS (using the selectors
* `.wavesurfer-region` and `.wavesurfer-handle`).
*
* @implements {PluginClass}
* @extends {Observer}
*
* @example
* // es6
* import RegionsPlugin from 'wavesurfer.regions.js';
*
* // commonjs
* var RegionsPlugin = require('wavesurfer.regions.js');
*
* // if you are using <script> tags
* var RegionsPlugin = window.WaveSurfer.regions;
*
* // ... initialising wavesurfer with the plugin
* var wavesurfer = WaveSurfer.create({
* // wavesurfer options ...
* plugins: [
* RegionsPlugin.create({
* // plugin options ...
* })
* ]
* });
*/
export default class RegionsPlugin {
/**
* Regions plugin definition factory
*
* This function must be used to create a plugin definition which can be
* used by wavesurfer to correctly instantiate the plugin.
*
* @param {RegionsPluginParams} params parameters use to initialise the plugin
* @return {PluginDefinition} an object representing the plugin
*/
static create(params) {
return {
name: 'regions',
deferInit: params && params.deferInit ? params.deferInit : false,
params: params,
staticProps: {
initRegions() {
console.warn('Deprecated initRegions! Use wavesurfer.initPlugins("regions") instead!');
this.initPlugin('regions');
},
addRegion(options) {
if (!this.initialisedPluginList.regions) {
this.initPlugin('regions');
}
return this.regions.add(options);
},
clearRegions() {
this.regions && this.regions.clear();
},
enableDragSelection(options) {
if (!this.initialisedPluginList.regions) {
this.initPlugin('regions');
}
this.regions.enableDragSelection(options);
},
disableDragSelection() {
this.regions.disableDragSelection();
}
},
instance: RegionsPlugin
};
}
constructor(params, ws) {
this.params = params;
this.wavesurfer = ws;
this.util = ws.util;
// turn the plugin instance into an observer
const observerPrototypeKeys = Object.getOwnPropertyNames(this.util.Observer.prototype);
observerPrototypeKeys.forEach(key => {
Region.prototype[key] = this.util.Observer.prototype[key];
});
this.wavesurfer.Region = Region;
// Id-based hash of regions.
this.list = {};
this._onReady = () => {
this.wrapper = this.wavesurfer.drawer.wrapper;
if (this.params.regions) {
this.params.regions.forEach(region => {
this.add(region);
});
}
if (this.params.dragSelection) {
this.enableDragSelection(this.params);
}
};
}
init() {
// Check if ws is ready
if (this.wavesurfer.isReady) {
this._onReady();
}
this.wavesurfer.on('ready', this._onReady);
}
destroy() {
this.wavesurfer.un('ready', this._onReady);
this.disableDragSelection();
this.clear();
}
/* Add a region. */
add(params) {
const region = new this.wavesurfer.Region(params, this.wavesurfer);
this.list[region.id] = region;
region.on('remove', () => {
delete this.list[region.id];
});
return region;
}
/* Remove all regions. */
clear() {
Object.keys(this.list).forEach(id => {
this.list[id].remove();
});
}
enableDragSelection(params) {
const slop = params.slop || 2;
let drag;
let start;
let region;
let touchId;
let pxMove = 0;
const eventDown = e => {
if (e.touches && e.touches.length > 1) { return; }
touchId = e.targetTouches ? e.targetTouches[0].identifier : null;
drag = true;
start = this.wavesurfer.drawer.handleEvent(e, true);
region = null;
};
this.wrapper.addEventListener('mousedown', eventDown);
this.wrapper.addEventListener('touchstart', eventDown);
this.on('disable-drag-selection', () => {
this.wrapper.removeEventListener('touchstart', eventDown);
this.wrapper.removeEventListener('mousedown', eventDown);
});
const eventUp = e => {
if (e.touches && e.touches.length > 1) { return; }
drag = false;
pxMove = 0;
if (region) {
region.fireEvent('update-end', e);
this.wavesurfer.fireEvent('region-update-end', region, e);
}
region = null;
};
this.wrapper.addEventListener('mouseup', eventUp);
this.wrapper.addEventListener('touchend', eventUp);
this.on('disable-drag-selection', () => {
this.wrapper.removeEventListener('touchend', eventUp);
this.wrapper.removeEventListener('mouseup', eventUp);
});
const eventMove = e => {
if (!drag) { return; }
if (++pxMove <= slop) { return; }
if (e.touches && e.touches.length > 1) { return; }
if (e.targetTouches && e.targetTouches[0].identifier != touchId) { return; }
if (!region) {
region = this.add(params || {});
}
const duration = this.wavesurfer.getDuration();
const end = this.wavesurfer.drawer.handleEvent(e);
region.update({
start: Math.min(end * duration, start * duration),
end: Math.max(end * duration, start * duration)
});
};
this.wrapper.addEventListener('mousemove', eventMove);
this.wrapper.addEventListener('touchmove', eventMove);
this.on('disable-drag-selection', () => {
this.wrapper.removeEventListener('touchmove', eventMove);
this.wrapper.removeEventListener('mousemove', eventMove);
});
}
disableDragSelection() {
this.fireEvent('disable-drag-selection');
}
/* Get current region
* The smallest region that contains the current time.
* If several such regions exist, we take the first.
* Return null if none exist. */
getCurrentRegion() {
const time = this.wavesurfer.getCurrentTime();
let min = null;
Object.keys(this.list).forEach(id => {
const cur = this.list[id];
if (cur.start <= time && cur.end >= time) {
if (!min || ((cur.end - cur.start) < (min.end - min.start))) {
min = cur;
}
}
});
return min;
}
}
@@ -0,0 +1,562 @@
/**
* Calculate FFT - Based on https://github.com/corbanbrook/dsp.js
*/
/* eslint-disable complexity, no-redeclare, no-var, one-var */
const FFT = function(bufferSize, sampleRate, windowFunc, alpha) {
this.bufferSize = bufferSize;
this.sampleRate = sampleRate;
this.bandwidth = 2 / bufferSize * sampleRate / 2;
this.sinTable = new Float32Array(bufferSize);
this.cosTable = new Float32Array(bufferSize);
this.windowValues = new Float32Array(bufferSize);
this.reverseTable = new Uint32Array(bufferSize);
this.peakBand = 0;
this.peak = 0;
switch (windowFunc) {
case 'bartlett' :
for (var i = 0; i<bufferSize; i++) {
this.windowValues[i] = 2 / (bufferSize - 1) * ((bufferSize - 1) / 2 - Math.abs(i - (bufferSize - 1) / 2));
}
break;
case 'bartlettHann' :
for (var i = 0; i<bufferSize; i++) {
this.windowValues[i] = 0.62 - 0.48 * Math.abs(i / (bufferSize - 1) - 0.5) - 0.38 * Math.cos(Math.PI * 2 * i / (bufferSize - 1));
}
break;
case 'blackman' :
alpha = alpha || 0.16;
for (var i = 0; i<bufferSize; i++) {
this.windowValues[i] = (1 - alpha)/2 - 0.5 * Math.cos(Math.PI * 2 * i / (bufferSize - 1)) + alpha/2 * Math.cos(4 * Math.PI * i / (bufferSize - 1));
}
break;
case 'cosine' :
for (var i = 0; i<bufferSize; i++) {
this.windowValues[i] = Math.cos(Math.PI * i / (bufferSize - 1) - Math.PI / 2);
}
break;
case 'gauss' :
alpha = alpha || 0.25;
for (var i = 0; i<bufferSize; i++) {
this.windowValues[i] = Math.pow(Math.E, -0.5 * Math.pow((i - (bufferSize - 1) / 2) / (alpha * (bufferSize - 1) / 2), 2));
}
break;
case 'hamming' :
for (var i = 0; i<bufferSize; i++) {
this.windowValues[i] = 0.54 - 0.46 * Math.cos(Math.PI * 2 * i / (bufferSize - 1));
}
break;
case 'hann' :
case undefined :
for (var i = 0; i<bufferSize; i++) {
this.windowValues[i] = 0.5 * (1 - Math.cos(Math.PI * 2 * i / (bufferSize - 1)));
}
break;
case 'lanczoz' :
for (var i = 0; i<bufferSize; i++) {
this.windowValues[i] = Math.sin(Math.PI * (2 * i / (bufferSize - 1) - 1)) / (Math.PI * (2 * i / (bufferSize - 1) - 1));
}
break;
case 'rectangular' :
for (var i = 0; i<bufferSize; i++) {
this.windowValues[i] = 1;
}
break;
case 'triangular' :
for (var i = 0; i<bufferSize; i++) {
this.windowValues[i] = 2 / bufferSize * (bufferSize / 2 - Math.abs(i - (bufferSize - 1) / 2));
}
break;
default:
throw Error('No such window function \'' + windowFunc + '\'');
}
var limit = 1;
var bit = bufferSize >> 1;
var i;
while (limit < bufferSize) {
for (i = 0; i < limit; i++) {
this.reverseTable[i + limit] = this.reverseTable[i] + bit;
}
limit = limit << 1;
bit = bit >> 1;
}
for (i = 0; i < bufferSize; i++) {
this.sinTable[i] = Math.sin(-Math.PI/i);
this.cosTable[i] = Math.cos(-Math.PI/i);
}
this.calculateSpectrum = function(buffer) {
// Locally scope variables for speed up
var bufferSize = this.bufferSize,
cosTable = this.cosTable,
sinTable = this.sinTable,
reverseTable = this.reverseTable,
real = new Float32Array(bufferSize),
imag = new Float32Array(bufferSize),
bSi = 2 / this.bufferSize,
sqrt = Math.sqrt,
rval,
ival,
mag,
spectrum = new Float32Array(bufferSize / 2);
var k = Math.floor(Math.log(bufferSize) / Math.LN2);
if (Math.pow(2, k) !== bufferSize) {
throw 'Invalid buffer size, must be a power of 2.';
}
if (bufferSize !== buffer.length) {
throw 'Supplied buffer is not the same size as defined FFT. FFT Size: ' + bufferSize + ' Buffer Size: ' + buffer.length;
}
var halfSize = 1,
phaseShiftStepReal,
phaseShiftStepImag,
currentPhaseShiftReal,
currentPhaseShiftImag,
off,
tr,
ti,
tmpReal;
for (var i = 0; i < bufferSize; i++) {
real[i] = buffer[reverseTable[i]] * this.windowValues[reverseTable[i]];
imag[i] = 0;
}
while (halfSize < bufferSize) {
phaseShiftStepReal = cosTable[halfSize];
phaseShiftStepImag = sinTable[halfSize];
currentPhaseShiftReal = 1;
currentPhaseShiftImag = 0;
for (var fftStep = 0; fftStep < halfSize; fftStep++) {
var i = fftStep;
while (i < bufferSize) {
off = i + halfSize;
tr = (currentPhaseShiftReal * real[off]) - (currentPhaseShiftImag * imag[off]);
ti = (currentPhaseShiftReal * imag[off]) + (currentPhaseShiftImag * real[off]);
real[off] = real[i] - tr;
imag[off] = imag[i] - ti;
real[i] += tr;
imag[i] += ti;
i += halfSize << 1;
}
tmpReal = currentPhaseShiftReal;
currentPhaseShiftReal = (tmpReal * phaseShiftStepReal) - (currentPhaseShiftImag * phaseShiftStepImag);
currentPhaseShiftImag = (tmpReal * phaseShiftStepImag) + (currentPhaseShiftImag * phaseShiftStepReal);
}
halfSize = halfSize << 1;
}
for (var i = 0, N = bufferSize / 2; i < N; i++) {
rval = real[i];
ival = imag[i];
mag = bSi * sqrt(rval * rval + ival * ival);
if (mag > this.peak) {
this.peakBand = i;
this.peak = mag;
}
spectrum[i] = mag;
}
return spectrum;
};
};
/* eslint-enable complexity, no-redeclare, no-var, one-var */
/**
* @typedef {Object} SpectrogramPluginParams
* @property {string|HTMLElement} container Selector of element or element in
* which to render
* @property {number} fftSamples=512 number of samples to fetch to FFT. Must be
* a pwer of 2.
* @property {number} noverlap Size of the overlapping window. Must be <
* fftSamples. Auto deduced from canvas size by default.
* @property {string} windowFunc='hann' The window function to be used. One of
* these: `'bartlett'`, `'bartlettHann'`, `'blackman'`, `'cosine'`, `'gauss'`,
* `'hamming'`, `'hann'`, `'lanczoz'`, `'rectangular'`, `'triangular'`
* @property {?number} alpha Some window functions have this extra value.
* (Between 0 and 1)
* @property {number} pixelRatio=wavesurfer.params.pixelRatio to control the
* size of the spectrogram in relation with its canvas. 1 = Draw on the whole
* canvas. 2 = Draw on a quarter (1/2 the length and 1/2 the width)
* @property {?boolean} deferInit Set to true to manually call
* `initPlugin('spectrogram')`
*/
/**
* Render a spectrogram visualisation of the audio.
*
* @implements {PluginClass}
* @extends {Observer}
* @example
* // es6
* import SpectrogramPlugin from 'wavesurfer.spectrogram.js';
*
* // commonjs
* var SpectrogramPlugin = require('wavesurfer.spectrogram.js');
*
* // if you are using <script> tags
* var SpectrogramPlugin = window.WaveSurfer.spectrogram;
*
* // ... initialising wavesurfer with the plugin
* var wavesurfer = WaveSurfer.create({
* // wavesurfer options ...
* plugins: [
* SpectrogramPlugin.create({
* // plugin options ...
* })
* ]
* });
*/
export default class SpectrogramPlugin {
/**
* Spectrogram plugin definition factory
*
* This function must be used to create a plugin definition which can be
* used by wavesurfer to correctly instantiate the plugin.
*
* @param {SpectrogramPluginParams} params parameters use to initialise the plugin
* @return {PluginDefinition} an object representing the plugin
*/
static create(params) {
return {
name: 'spectrogram',
deferInit: params && params.deferInit ? params.deferInit : false,
params: params,
staticProps: {
FFT: FFT
},
instance: SpectrogramPlugin
};
}
constructor(params, ws) {
this.params = params;
this.wavesurfer = ws;
this.util = ws.util;
this.frequenciesDataUrl = params.frequenciesDataUrl;
this._onScroll = e => {
this.updateScroll(e);
};
this._onReady = () => {
const drawer = this.drawer = ws.drawer;
this.container = 'string' == typeof params.container ?
document.querySelector(params.container) : params.container;
if (!this.container) {
throw Error('No container for WaveSurfer spectrogram');
}
this.width = drawer.width;
this.pixelRatio = this.params.pixelRatio || ws.params.pixelRatio;
this.fftSamples = this.params.fftSamples || ws.params.fftSamples || 512;
this.height = this.fftSamples / 2;
this.noverlap = params.noverlap;
this.windowFunc = params.windowFunc;
this.alpha = params.alpha;
this.createWrapper();
this.createCanvas();
this.render();
drawer.wrapper.addEventListener('scroll', this._onScroll);
ws.on('redraw', () => this.render());
};
}
init() {
// Check if ws is ready
if (this.wavesurfer.isReady) {
this._onReady();
}
this.wavesurfer.on('ready', this._onReady);
}
destroy() {
this.unAll();
this.wavesurfer.un('ready', this._onReady);
this.drawer.wrapper.removeEventListener('scroll', this._onScroll);
this.wavesurfer = null;
this.util = null;
this.params = null;
if (this.wrapper) {
this.wrapper.parentNode.removeChild(this.wrapper);
this.wrapper = null;
}
}
createWrapper() {
const prevSpectrogram = this.container.querySelector('spectrogram');
if (prevSpectrogram) {
this.container.removeChild(prevSpectrogram);
}
const wsParams = this.wavesurfer.params;
this.wrapper = document.createElement('spectrogram');
// if labels are active
if (this.params.labels) {
const labelsEl = this.labelsEl = document.createElement('canvas');
labelsEl.classList.add('spec-labels');
this.drawer.style(labelsEl, {
left: 0,
position: 'absolute',
zIndex: 9,
height: `${this.height / this.pixelRatio}px`,
width: `${55 / this.pixelRatio}px`
});
this.wrapper.appendChild(labelsEl);
// can be customized in next version
this.loadLabels('rgba(68,68,68,0.5)', '12px', '10px', '', '#fff', '#f7f7f7', 'center', '#specLabels');
}
this.drawer.style(this.wrapper, {
display: 'block',
position: 'relative',
userSelect: 'none',
webkitUserSelect: 'none',
height: this.height + 'px'
});
if (wsParams.fillParent || wsParams.scrollParent) {
this.drawer.style(this.wrapper, {
width: '100%',
overflowX: 'hidden',
overflowY: 'hidden'
});
}
this.container.appendChild(this.wrapper);
this.wrapper.addEventListener('click', e => {
e.preventDefault();
const relX = 'offsetX' in e ? e.offsetX : e.layerX;
this.fireEvent('click', (relX / this.scrollWidth) || 0);
});
}
createCanvas() {
const canvas = this.canvas = this.wrapper.appendChild(
document.createElement('canvas')
);
this.spectrCc = canvas.getContext('2d');
this.util.style(canvas, {
position: 'absolute',
zIndex: 4
});
}
render() {
this.updateCanvasStyle();
if (this.frequenciesDataUrl) {
this.loadFrequenciesData(this.frequenciesDataUrl);
} else {
this.getFrequencies(this.drawSpectrogram);
}
}
updateCanvasStyle() {
const width = Math.round(this.width / this.pixelRatio) + 'px';
this.canvas.width = this.width;
this.canvas.height = this.height;
this.canvas.style.width = width;
}
drawSpectrogram(frequenciesData, my) {
const spectrCc = my.spectrCc;
const length = my.wavesurfer.backend.getDuration();
const height = my.height;
const pixels = my.resample(frequenciesData);
const heightFactor = my.buffer ? 2 / my.buffer.numberOfChannels : 1;
let i;
let j;
for (i = 0; i < pixels.length; i++) {
for (j = 0; j < pixels[i].length; j++) {
const colorValue = 255 - pixels[i][j];
my.spectrCc.fillStyle = 'rgb(' + colorValue + ', ' + colorValue + ', ' + colorValue + ')';
my.spectrCc.fillRect(i, height - j * heightFactor, 1, heightFactor);
}
}
}
getFrequencies(callback) {
const fftSamples = this.fftSamples;
const buffer = this.buffer = this.wavesurfer.backend.buffer;
const channelOne = buffer.getChannelData(0);
const bufferLength = buffer.length;
const sampleRate = buffer.sampleRate;
const frequencies = [];
if (!buffer) {
this.fireEvent('error', 'Web Audio buffer is not available');
return;
}
let noverlap = this.noverlap;
if (!noverlap) {
const uniqueSamplesPerPx = buffer.length / this.canvas.width;
noverlap = Math.max(0, Math.round(fftSamples - uniqueSamplesPerPx));
}
const fft = new FFT(fftSamples, sampleRate, this.windowFunc, this.alpha);
const maxSlicesCount = Math.floor(bufferLength / (fftSamples - noverlap));
let currentOffset = 0;
while (currentOffset + fftSamples < channelOne.length) {
const segment = channelOne.slice(currentOffset, currentOffset + fftSamples);
const spectrum = fft.calculateSpectrum(segment);
const array = new Uint8Array(fftSamples / 2);
let j;
for (j = 0; j < fftSamples / 2; j++) {
array[j] = Math.max(-255, Math.log10(spectrum[j]) * 45);
}
frequencies.push(array);
currentOffset += (fftSamples - noverlap);
}
callback(frequencies, this);
}
loadFrequenciesData(url) {
const ajax = this.util.ajax({ url: url });
ajax.on('success', data => this.drawSpectrogram(JSON.parse(data), this));
ajax.on('error', e => this.fireEvent('error', 'XHR error: ' + e.target.statusText));
return ajax;
}
freqType(freq) {
return (freq >= 1000 ? (freq / 1000).toFixed(1) : Math.round(freq));
}
unitType(freq) {
return (freq >= 1000 ? 'KHz' : 'Hz');
}
loadLabels(bgFill, fontSizeFreq, fontSizeUnit, fontType, textColorFreq, textColorUnit, textAlign, container) {
const frequenciesHeight = this.height;
bgFill = bgFill || 'rgba(68,68,68,0)';
fontSizeFreq = fontSizeFreq || '12px';
fontSizeUnit = fontSizeUnit || '10px';
fontType = fontType || 'Helvetica';
textColorFreq = textColorFreq || '#fff';
textColorUnit = textColorUnit || '#fff';
textAlign = textAlign || 'center';
container = container || '#specLabels';
const getMaxY = frequenciesHeight || 512;
const labelIndex = 5 * (getMaxY / 256);
const freqStart = 0;
const step = ((this.wavesurfer.backend.ac.sampleRate / 2) - freqStart) / labelIndex;
const ctx = this.labelsEl.getContext('2d');
this.labelsEl.height = this.height;
this.labelsEl.width = 55;
ctx.fillStyle = bgFill;
ctx.fillRect(0, 0, 55, getMaxY);
ctx.fill();
let i;
for (i = 0; i <= labelIndex; i++) {
ctx.textAlign = textAlign;
ctx.textBaseline = 'middle';
const freq = freqStart + (step * i);
const index = Math.round(freq / (this.sampleRate / 2) * this.fftSamples);
const label = this.freqType(freq);
const units = this.unitType(freq);
const x = 16;
const yLabelOffset = 2;
if (i == 0) {
ctx.fillStyle = textColorUnit;
ctx.font = fontSizeUnit + ' ' + fontType;
ctx.fillText(units, x + 24, getMaxY + i - 10);
ctx.fillStyle = textColorFreq;
ctx.font = fontSizeFreq + ' ' + fontType;
ctx.fillText(label, x, getMaxY + i - 10);
} else {
ctx.fillStyle = textColorUnit;
ctx.font = fontSizeUnit + ' ' + fontType;
ctx.fillText(units, x + 24, getMaxY - i * 50 + yLabelOffset);
ctx.fillStyle = textColorFreq;
ctx.font = fontSizeFreq + ' ' + fontType;
ctx.fillText(label, x, getMaxY - i * 50 + yLabelOffset);
}
}
}
updateScroll(e) {
if (this.wrapper) {
this.wrapper.scrollLeft = e.target.scrollLeft;
}
}
resample(oldMatrix) {
const columnsNumber = this.width;
const newMatrix = [];
const oldPiece = 1 / oldMatrix.length;
const newPiece = 1 / columnsNumber;
let i;
for (i = 0; i < columnsNumber; i++) {
const column = new Array(oldMatrix[0].length);
let j;
for (j = 0; j < oldMatrix.length; j++) {
const oldStart = j * oldPiece;
const oldEnd = oldStart + oldPiece;
const newStart = i * newPiece;
const newEnd = newStart + newPiece;
const overlap = (oldEnd <= newStart || newEnd <= oldStart) ?
0 :
Math.min(Math.max(oldEnd, newStart), Math.max(newEnd, oldStart)) -
Math.max(Math.min(oldEnd, newStart), Math.min(newEnd, oldStart));
let k;
/* eslint-disable max-depth */
if (overlap > 0) {
for (k = 0; k < oldMatrix[0].length; k++) {
if (column[k] == null) {
column[k] = 0;
}
column[k] += (overlap / newPiece) * oldMatrix[j][k];
}
}
/* eslint-enable max-depth */
}
const intColumn = new Uint8Array(oldMatrix[0].length);
let m;
for (m = 0; m < oldMatrix[0].length; m++) {
intColumn[m] = column[m];
}
newMatrix.push(intColumn);
}
return newMatrix;
}
}
+358
View File
@@ -0,0 +1,358 @@
/**
* @typedef {Object} TimelinePluginParams
* @desc Extends the `WavesurferParams` wavesurfer was initialised with
* @property {!string|HTMLElement} container CSS selector or HTML element where
* the timeline should be drawn. This is the only required parameter.
* @property {number} notchPercentHeight=90 Height of notches in percent
* @property {string} primaryColor='#000' The colour of the main notches
* @property {string} secondaryColor='#c0c0c0' The colour of the secondary
* notches
* @property {string} primaryFontColor='#000' The colour of the labels next to
* the main notches
* @property {string} secondaryFontColor='#000' The colour of the labels next to
* the secondary notches
* @property {string} fontFamily='Arial'
* @property {number} fontSize=10 Font size of labels in pixels
* @property {function} formatTimeCallback=→00:00
* @property {?boolean} deferInit Set to true to manually call
* `initPlugin('timeline')`
*/
/**
* Adds a timeline to the waveform.
*
* @implements {PluginClass}
* @extends {Observer}
* @example
* // es6
* import TimelinePlugin from 'wavesurfer.timeline.js';
*
* // commonjs
* var TimelinePlugin = require('wavesurfer.timeline.js');
*
* // if you are using <script> tags
* var TimelinePlugin = window.WaveSurfer.timeline;
*
* // ... initialising wavesurfer with the plugin
* var wavesurfer = WaveSurfer.create({
* // wavesurfer options ...
* plugins: [
* TimelinePlugin.create({
* // plugin options ...
* })
* ]
* });
*/
export default class TimelinePlugin {
/**
* Timeline plugin definition factory
*
* This function must be used to create a plugin definition which can be
* used by wavesurfer to correctly instantiate the plugin.
*
* @param {TimelinePluginParams} params parameters use to initialise the plugin
* @return {PluginDefinition} an object representing the plugin
*/
static create(params) {
return {
name: 'timeline',
deferInit: params && params.deferInit ? params.deferInit : false,
params: params,
instance: TimelinePlugin
};
}
constructor(params, ws) {
this.container = 'string' == typeof params.container
? document.querySelector(params.container)
: params.container;
if (!this.container) {
throw new Error('No container for wavesurfer timeline');
}
this.wavesurfer = ws;
this.util = ws.util;
this.params = this.util.extend({}, {
height: 20,
notchPercentHeight: 90,
primaryColor: '#000',
secondaryColor: '#c0c0c0',
primaryFontColor: '#000',
secondaryFontColor: '#000',
fontFamily: 'Arial',
fontSize: 10,
formatTimeCallback(seconds) {
if (seconds / 60 > 1) {
// calculate minutes and seconds from seconds count
const minutes = parseInt(seconds / 60, 10);
seconds = parseInt(seconds % 60, 10);
// fill up seconds with zeroes
seconds = (seconds < 10) ? '0' + seconds : seconds;
return `${minutes}:${seconds}`;
}
return Math.round(seconds * 1000) / 1000;
},
timeInterval(pxPerSec) {
if (pxPerSec >= 25) {
return 1;
} else if (pxPerSec * 5 >= 25) {
return 5;
} else if (pxPerSec * 15 >= 25) {
return 15;
}
return Math.ceil(0.5 / pxPerSec) * 60;
},
primaryLabelInterval(pxPerSec) {
if (pxPerSec >= 25) {
return 10;
} else if (pxPerSec * 5 >= 25) {
return 6;
} else if (pxPerSec * 15 >= 25) {
return 4;
}
return 4;
},
secondaryLabelInterval(pxPerSec) {
if (pxPerSec >= 25) {
return 5;
} else if (pxPerSec * 5 >= 25) {
return 2;
} else if (pxPerSec * 15 >= 25) {
return 2;
}
return 2;
}
}, params);
this.canvases = [];
this._onZoom = () => this.render();
this._onScroll = () => {
if (this.wrapper && this.drawer.wrapper) {
this.wrapper.scrollLeft = this.drawer.wrapper.scrollLeft;
}
};
this._onRedraw = () => this.render();
this._onReady = () => {
this.drawer = ws.drawer;
this.pixelRatio = ws.drawer.params.pixelRatio;
this.maxCanvasWidth = ws.drawer.maxCanvasWidth || ws.drawer.width;
this.maxCanvasElementWidth = ws.drawer.maxCanvasElementWidth || Math.round(this.maxCanvasWidth / this.pixelRatio);
this.createWrapper();
this.render();
ws.drawer.wrapper.addEventListener('scroll', this._onScroll);
ws.on('redraw', this._onRedraw);
ws.on('zoom', this._onZoom);
};
}
init() {
this.wavesurfer.on('ready', this._onReady);
// Check if ws is ready
if (this.wavesurfer.isReady) {
this._onReady();
}
}
destroy() {
this.unAll();
this.wavesurfer.un('redraw', this._onRedraw);
this.wavesurfer.un('zoom', this._onZoom);
this.wavesurfer.un('ready', this._onReady);
this.wavesurfer.drawer.wrapper.removeEventListener('scroll', this._onScroll);
if (this.wrapper && this.wrapper.parentNode) {
this.wrapper.parentNode.removeChild(this.wrapper);
this.wrapper = null;
}
}
createWrapper() {
const wsParams = this.wavesurfer.params;
this.wrapper = this.container.appendChild(
document.createElement('timeline')
);
this.util.style(this.wrapper, {
display: 'block',
position: 'relative',
userSelect: 'none',
webkitUserSelect: 'none',
height: `${this.params.height}px`
});
if (wsParams.fillParent || wsParams.scrollParent) {
this.util.style(this.wrapper, {
width: '100%',
overflowX: 'hidden',
overflowY: 'hidden'
});
}
this._onClick = e => {
e.preventDefault();
const relX = 'offsetX' in e ? e.offsetX : e.layerX;
this.fireEvent('click', (relX / this.wrapper.scrollWidth) || 0);
};
this.wrapper.addEventListener('click', this._onClick);
}
removeOldCanvases() {
while (this.canvases.length > 0) {
const canvas = this.canvases.pop();
canvas.parentElement.removeChild(canvas);
}
}
createCanvases() {
this.removeOldCanvases();
const totalWidth = Math.round(this.drawer.wrapper.scrollWidth);
const requiredCanvases = Math.ceil(totalWidth / this.maxCanvasElementWidth);
let i;
for (i = 0; i < requiredCanvases; i++) {
const canvas = this.wrapper.appendChild(document.createElement('canvas'));
this.canvases.push(canvas);
this.util.style(canvas, {
position: 'absolute',
zIndex: 4
});
}
}
render() {
this.createCanvases();
this.updateCanvasStyle();
this.drawTimeCanvases();
}
updateCanvasStyle() {
const requiredCanvases = this.canvases.length;
let i;
for (i = 0; i < requiredCanvases; i++) {
const canvas = this.canvases[i];
let canvasWidth = this.maxCanvasElementWidth;
if (i === requiredCanvases - 1) {
canvasWidth = this.drawer.wrapper.scrollWidth - (this.maxCanvasElementWidth * (requiredCanvases - 1));
}
canvas.width = canvasWidth * this.pixelRatio;
canvas.height = this.params.height * this.pixelRatio;
this.util.style(canvas, {
width: `${canvasWidth}px`,
height: `${this.params.height}px`,
left: `${i * this.maxCanvasElementWidth}px`
});
}
}
drawTimeCanvases() {
const backend = this.wavesurfer.backend;
const wsParams = this.wavesurfer.params;
const duration = this.wavesurfer.backend.getDuration();
const totalSeconds = parseInt(duration, 10) + 1;
const width = wsParams.fillParent && !wsParams.scrollParent
? this.drawer.getWidth()
: this.drawer.wrapper.scrollWidth * wsParams.pixelRatio;
const pixelsPerSecond = width / duration;
const formatTime = this.params.formatTimeCallback;
// if parameter is function, call the function with
// pixelsPerSecond, otherwise simply take the value as-is
const intervalFnOrVal = option => (typeof option === 'function' ? option(pixelsPerSecond) : option);
const timeInterval = intervalFnOrVal(this.params.timeInterval);
const primaryLabelInterval = intervalFnOrVal(this.params.primaryLabelInterval);
const secondaryLabelInterval = intervalFnOrVal(this.params.secondaryLabelInterval);
let curPixel = 0;
let curSeconds = 0;
if (duration <= 0) {
return;
}
const height1 = this.params.height - 4;
const height2 = (this.params.height * (this.params.notchPercentHeight / 100)) - 4;
const fontSize = this.params.fontSize * wsParams.pixelRatio;
let i;
for (i = 0; i < totalSeconds / timeInterval; i++) {
if (i % primaryLabelInterval == 0) {
this.setFillStyles(this.params.primaryColor);
this.fillRect(curPixel, 0, 1, height1);
this.setFonts(`${fontSize}px ${this.params.fontFamily}`);
this.setFillStyles(this.params.primaryFontColor);
this.fillText(formatTime(curSeconds), curPixel + 5, height1);
} else if (i % secondaryLabelInterval == 0) {
this.setFillStyles(this.params.secondaryColor);
this.fillRect(curPixel, 0, 1, height1);
this.setFonts(`${fontSize}px ${this.params.fontFamily}`);
this.setFillStyles(this.params.secondaryFontColor);
this.fillText(formatTime(curSeconds), curPixel + 5, height1);
} else {
this.setFillStyles(this.params.secondaryColor);
this.fillRect(curPixel, 0, 1, height2);
}
curSeconds += timeInterval;
curPixel += pixelsPerSecond * timeInterval;
}
}
setFillStyles(fillStyle) {
this.canvases.forEach(canvas => {
canvas.getContext('2d').fillStyle = fillStyle;
});
}
setFonts(font) {
this.canvases.forEach(canvas => {
canvas.getContext('2d').font = font;
});
}
fillRect(x, y, width, height) {
this.canvases.forEach((canvas, i) => {
const leftOffset = i * this.maxCanvasWidth;
const intersection = {
x1: Math.max(x, i * this.maxCanvasWidth),
y1: y,
x2: Math.min(x + width, i * this.maxCanvasWidth + canvas.width),
y2: y + height
};
if (intersection.x1 < intersection.x2) {
canvas.getContext('2d').fillRect(
intersection.x1 - leftOffset,
intersection.y1,
intersection.x2 - intersection.x1,
intersection.y2 - intersection.y1
);
}
});
}
fillText(text, x, y) {
let textWidth;
let xOffset = 0;
let i;
for (i in this.canvases) {
const context = this.canvases[i].getContext('2d');
const canvasWidth = context.canvas.width;
if (xOffset > x + textWidth) {
break;
}
if (xOffset + canvasWidth > x) {
textWidth = context.measureText(text).width;
context.fillText(text, x - xOffset, y);
}
xOffset += canvasWidth;
}
}
}
+37
View File
@@ -0,0 +1,37 @@
import Observer from './observer';
/**
* Perform an ajax request
*
* @param {Options} options Description
*
* @returns {Object} Observer instance
*/
export default function ajax (options) {
const instance = new Observer();
const xhr = new XMLHttpRequest();
let fired100 = false;
xhr.open(options.method || 'GET', options.url, true);
xhr.responseType = options.responseType || 'json';
xhr.addEventListener('progress', e => {
instance.fireEvent('progress', e);
if (e.lengthComputable && e.loaded == e.total) {
fired100 = true;
}
});
xhr.addEventListener('load', e => {
if (!fired100) {
instance.fireEvent('progress', e);
}
instance.fireEvent('load', e);
if (200 == xhr.status || 206 == xhr.status) {
instance.fireEvent('success', xhr.response, e);
} else {
instance.fireEvent('error', e);
}
});
xhr.addEventListener('error', e => instance.fireEvent('error', e));
xhr.send();
instance.xhr = xhr;
return instance;
}
+16
View File
@@ -0,0 +1,16 @@
/**
* Extend an object shallowly with others
*
* @param {Object} dest The target object
* @param {Object[]} sources The objects to use for extending
*
* @return {Object} Merged object
*/
export default function extend (dest, ...sources) {
sources.forEach(source => {
Object.keys(source).forEach(key => {
dest[key] = source[key];
});
});
return dest;
}
+13
View File
@@ -0,0 +1,13 @@
import reqAnimationFrame from './request-animation-frame';
/**
* Create a function which will be called at the next requestAnimationFrame
* cycle
*
* @param {function} func The function to call
*
* @return {func} The function wrapped within a requestAnimationFrame
*/
export default function frame (func) {
return (...args) => reqAnimationFrame(() => func(...args));
}
+8
View File
@@ -0,0 +1,8 @@
/**
* Get a random prefixed ID
*
* @returns {String} Random ID
*/
export default function getId () {
return 'wavesurfer_' + Math.random().toString(32).substring(2);
}
+10
View File
@@ -0,0 +1,10 @@
export { default as ajax } from './ajax';
export { default as getId } from './get-id';
export { default as max } from './max';
export { default as min } from './min';
export { default as Observer } from './observer';
export { default as extend } from './extend';
export { default as style } from './style';
export { default as requestAnimationFrame } from './request-animation-frame';
export { default as frame } from './frame';
export { default as debounce } from 'debounce';
+15
View File
@@ -0,0 +1,15 @@
/**
* Get the largest value
*
* @param {Array} values Array of numbers
* @returns {Number} Largest number found
*/
export default function max (values) {
let largest = -Infinity;
Object.keys(values).forEach(i => {
if (values[i] > largest) {
largest = values[i];
}
});
return largest;
}
+15
View File
@@ -0,0 +1,15 @@
/**
* Get the smallest value
*
* @param {Array} values Array of numbers
* @returns {Number} Smallest number found
*/
export default function min (values) {
let smallest = Number(Infinity);
Object.keys(values).forEach(i => {
if (values[i] < smallest) {
smallest = values[i];
}
});
return smallest;
}
+112
View File
@@ -0,0 +1,112 @@
/**
* @typedef {Object} ListenerDescriptor
* @property {string} name The name of the event
* @property {function} callback The callback
* @property {function} un The function to call to remove the listener
*/
/**
* Observer class
*/
export default class Observer {
/**
* Instantiate Observer
*/
constructor() {
/**
* @private
* @todo Initialise the handlers here already and remove the conditional
* assignment in `on()`
*/
this.handlers = null;
}
/**
* Attach a handler function for an event.
*
* @param {string} event Name of the event to listen to
* @param {function} fn The callback to trigger when the event is fired
* @return {ListenerDescriptor}
*/
on(event, fn) {
if (!this.handlers) { this.handlers = {}; }
let handlers = this.handlers[event];
if (!handlers) {
handlers = this.handlers[event] = [];
}
handlers.push(fn);
// Return an event descriptor
return {
name: event,
callback: fn,
un: (e, fn) => this.un(e, fn)
};
}
/**
* Remove an event handler.
*
* @param {string} event Name of the event the listener that should be
* removed listens to
* @param {function} fn The callback that should be removed
*/
un(event, fn) {
if (!this.handlers) { return; }
const handlers = this.handlers[event];
let i;
if (handlers) {
if (fn) {
for (i = handlers.length - 1; i >= 0; i--) {
if (handlers[i] == fn) {
handlers.splice(i, 1);
}
}
} else {
handlers.length = 0;
}
}
}
/**
* Remove all event handlers.
*/
unAll() {
this.handlers = null;
}
/**
* Attach a handler to an event. The handler is executed at most once per
* event type.
*
* @param {string} event The event to listen to
* @param {function} handler The callback that is only to be called once
* @return {ListenerDescriptor}
*/
once(event, handler) {
const fn = (...args) => {
/* eslint-disable no-invalid-this */
handler.apply(this, args);
/* eslint-enable no-invalid-this */
setTimeout(() => {
this.un(event, fn);
}, 0);
};
return this.on(event, fn);
}
/**
* Manually fire an event
*
* @param {string} event The event to fire manually
* @param {...any} args The arguments with which to call the listeners
*/
fireEvent(event, ...args) {
if (!this.handlers) { return; }
const handlers = this.handlers[event];
handlers && handlers.forEach(fn => {
fn(...args);
});
}
}
@@ -0,0 +1,14 @@
/**
* Returns the requestAnimationFrame function for the browser, or a shim with
* setTimeout if none is found
*
* @return {function}
*/
export default (
window.requestAnimationFrame ||
window.webkitRequestAnimationFrame ||
window.mozRequestAnimationFrame ||
window.oRequestAnimationFrame ||
window.msRequestAnimationFrame ||
((callback, element) => setTimeout(callback, 1000 / 60))
).bind(window);
+16
View File
@@ -0,0 +1,16 @@
/**
* Apply a map of styles to an element
*
* @param {HTMLElement} el The element that the styles will be applied to
* @param {Object} styles The map of propName: attribute, both are used as-is
*
* @return {HTMLElement} el
*/
export default function style (el, styles) {
Object.keys(styles).forEach(prop => {
if (el.style[prop] !== styles[prop]) {
el.style[prop] = styles[prop];
}
});
return el;
}
File diff suppressed because it is too large Load Diff
+629
View File
@@ -0,0 +1,629 @@
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();
}
}
}