wavesurfer.js
Version:
Interactive navigable audio visualization using Web Audio and Canvas
848 lines (749 loc) • 29.1 kB
JavaScript
/**
* (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.util = ws.util;
this.style = this.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._onRedraw = () => this.updateRender();
this.scroll = params.scroll !== false && ws.params.scrollParent;
this.scrollSpeed = params.scrollSpeed || 1;
this.scrollThreshold = params.scrollThreshold || 10;
this.bindInOut();
this.render();
this.wavesurfer.on('zoom', this._onRedraw);
this.wavesurfer.on('redraw', this._onRedraw);
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._onRedraw);
this.wavesurfer.un('redraw', this._onRedraw);
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 container = this.wavesurfer.drawer.container;
const duration = this.wavesurfer.getDuration();
const scrollSpeed = this.scrollSpeed;
const scrollThreshold = this.scrollThreshold;
let startTime;
let touchId;
let drag;
let maxScroll;
let resize;
let updated = false;
let scrollDirection;
let wrapperRect;
// Scroll when the user is dragging within the threshold
const edgeScroll = e => {
if (!scrollDirection || (!drag && !resize)) {
return;
}
// Update scroll position
let scrollLeft =
this.wrapper.scrollLeft + scrollSpeed * scrollDirection;
this.wrapper.scrollLeft = scrollLeft = Math.min(
maxScroll,
Math.max(0, scrollLeft)
);
// Update time
const time =
this.wavesurfer.drawer.handleEvent(e) * duration;
const delta = time - startTime;
startTime = time;
// Continue dragging or resizing
drag ? this.onDrag(delta) : this.onResize(delta, resize);
// Repeat
window.requestAnimationFrame(() => {
edgeScroll(e);
});
};
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;
// Store for scroll calculations
maxScroll =
this.wrapper.scrollWidth - this.wrapper.clientWidth;
wrapperRect = this.wrapper.getBoundingClientRect();
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;
scrollDirection = null;
resize = false;
}
if (updated) {
updated = false;
this.util.preventClick();
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 oldTime = startTime;
const time =
this.wavesurfer.drawer.handleEvent(e) * duration;
const delta = time - startTime;
startTime = time;
// Drag
if (this.drag && drag) {
updated = updated || !!delta;
this.onDrag(delta);
}
// Resize
if (this.resize && resize) {
updated = updated || !!delta;
this.onResize(delta, resize);
}
if (
this.scroll &&
container.clientWidth < this.wrapper.scrollWidth
) {
if (drag) {
// The threshold is not between the mouse and the container edge
// but is between the region and the container edge
const regionRect = this.element.getBoundingClientRect();
let x = regionRect.left - wrapperRect.left;
// Check direction
if (time < oldTime && x >= 0) {
scrollDirection = -1;
} else if (
time > oldTime &&
x + regionRect.width <= wrapperRect.right
) {
scrollDirection = 1;
}
// Check that we are still beyond the threshold
if (
(scrollDirection === -1 &&
x > scrollThreshold) ||
(scrollDirection === 1 &&
x + regionRect.width <
wrapperRect.right - scrollThreshold)
) {
scrollDirection = null;
}
} else {
// Mouse based threshold
let x = e.clientX - wrapperRect.left;
// Check direction
if (x <= scrollThreshold) {
scrollDirection = -1;
} else if (
x >=
wrapperRect.right - scrollThreshold
) {
scrollDirection = 1;
} else {
scrollDirection = null;
}
}
scrollDirection && edgeScroll(e);
}
}
};
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;
const container = this.wavesurfer.drawer.container;
const scroll =
params.scroll !== false && this.wavesurfer.params.scrollParent;
const scrollSpeed = params.scrollSpeed || 1;
const scrollThreshold = params.scrollThreshold || 10;
let drag;
let duration = this.wavesurfer.getDuration();
let maxScroll;
let start;
let region;
let touchId;
let pxMove = 0;
let scrollDirection;
let wrapperRect;
// Scroll when the user is dragging within the threshold
const edgeScroll = e => {
if (!region || !scrollDirection) {
return;
}
// Update scroll position
let scrollLeft =
this.wrapper.scrollLeft + scrollSpeed * scrollDirection;
this.wrapper.scrollLeft = scrollLeft = Math.min(
maxScroll,
Math.max(0, scrollLeft)
);
// Update range
const end = this.wavesurfer.drawer.handleEvent(e);
region.update({
start: Math.min(end * duration, start * duration),
end: Math.max(end * duration, start * duration)
});
// Check that there is more to scroll and repeat
if (scrollLeft < maxScroll && scrollLeft > 0) {
window.requestAnimationFrame(() => {
edgeScroll(e);
});
}
};
const eventDown = e => {
if (e.touches && e.touches.length > 1) {
return;
}
duration = this.wavesurfer.getDuration();
touchId = e.targetTouches ? e.targetTouches[0].identifier : null;
// Store for scroll calculations
maxScroll = this.wrapper.scrollWidth - this.wrapper.clientWidth;
wrapperRect = this.wrapper.getBoundingClientRect();
drag = true;
start = this.wavesurfer.drawer.handleEvent(e, true);
region = null;
scrollDirection = 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;
scrollDirection = null;
if (region) {
this.util.preventClick();
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);
document.body.addEventListener('mouseup', eventUp);
document.body.addEventListener('touchend', eventUp);
this.on('disable-drag-selection', () => {
document.body.removeEventListener('mouseup', eventUp);
document.body.removeEventListener('touchend', eventUp);
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 end = this.wavesurfer.drawer.handleEvent(e);
region.update({
start: Math.min(end * duration, start * duration),
end: Math.max(end * duration, start * duration)
});
// If scrolling is enabled
if (scroll && container.clientWidth < this.wrapper.scrollWidth) {
// Check threshold based on mouse
const x = e.clientX - wrapperRect.left;
if (x <= scrollThreshold) {
scrollDirection = -1;
} else if (x >= wrapperRect.right - scrollThreshold) {
scrollDirection = 1;
} else {
scrollDirection = null;
}
scrollDirection && edgeScroll(e);
}
};
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;
}
}