added audioplayer contrib module
This commit is contained in:
@@ -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' });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
Executable
+312
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
Executable
+1259
File diff suppressed because it is too large
Load Diff
Executable
+629
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user