UNPKG

wavesurfer.js

Version:

Interactive navigable audio visualization using Web Audio and Canvas

479 lines (429 loc) 17.4 kB
/** * @since 4.0.0 This class has been split * * @typedef {Object} RegionsPluginParams * @property {?boolean} dragSelection Enable creating regions by dragging with * the mouse * @property {?boolean} contentEditable=false Allow/disallow editing content of the region * @property {?boolean} removeButton=false adds remove region button * @property {?RegionParams[]} regions Regions that should be added upon * initialisation * @property {number} slop=2 The sensitivity of the mouse dragging * @property {?number} snapToGridInterval Snap the regions to a grid of the specified multiples in seconds * @property {?number} snapToGridOffset Shift the snap-to-grid by the specified seconds. May also be negative. * @property {?boolean} deferInit Set to true to manually call * @property {number} maxRegions Maximum number of regions that may be created by the user at one time. * `initPlugin('regions')` * @property {function} formatTimeCallback Allows custom formating for region tooltip. * @property {?number} edgeScrollWidth='5% from container edges' Optional width for edgeScroll to start */ /** * @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/disallow dragging the region. * @property {boolean} resize=true Allow/disallow resizing the region. * @property {string} [color='rgba(0, 0, 0, 0.1)'] HTML color code. * @property {?number} channelIdx Select channel to draw the region on (if there are multiple channel waveforms). * @property {?object} handleStyle A set of CSS properties used to style the left and right handle. * @property {?boolean} preventContextMenu=false Determines whether the context menu is prevented from being opened. * @property {boolean} showTooltip=true Enable/disable tooltip displaying start and end times when hovering over region. */ import {Region} from "./region.js"; /** * 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: { 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, getRegionSnapToGridValue: value => { return this.getRegionSnapToGridValue(value, params); } }; this.maxRegions = params.maxRegions; this.regionsMinLength = params.regionsMinLength || null; // 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; // By default, scroll the container if the user drags a region // within 5% (based on its initial size) of its edge const scrollWidthProportion = 0.05; this._onBackendCreated = () => { this.wrapper = this.wavesurfer.drawer.wrapper; this.orientation = this.wavesurfer.drawer.orientation; this.defaultEdgeScrollWidth = this.wrapper.clientWidth * scrollWidthProportion; if (this.params.regions) { this.params.regions.forEach(region => { this.add(region); }); } }; // Id-based hash of regions this.list = {}; this._onReady = () => { this.wrapper = this.wavesurfer.drawer.wrapper; this.vertical = this.wavesurfer.drawer.params.vertical; if (this.params.dragSelection) { this.enableDragSelection(this.params); } Object.keys(this.list).forEach(id => { this.list[id].updateRender(); }); }; } init() { // Check if ws is ready if (this.wavesurfer.isReady) { this._onBackendCreated(); this._onReady(); } else { this.wavesurfer.once('ready', this._onReady); this.wavesurfer.once('backend-created', this._onBackendCreated); } } destroy() { this.wavesurfer.un('ready', this._onReady); this.wavesurfer.un('backend-created', this._onBackendCreated); // Disabling `region-removed' because destroying the plugin calls // the Region.remove() method that is also used to remove regions based // on user input. This can cause confusion since teardown is not a // user event, but would emit `region-removed` as if it was. this.wavesurfer.setDisabledEventEmissions(['region-removed']); this.disableDragSelection(); this.clear(); } /** * check to see if adding a new region would exceed maxRegions * @return {boolean} whether we should proceed and create a region * @private */ wouldExceedMaxRegions() { return ( this.maxRegions && Object.keys(this.list).length >= this.maxRegions ); } /** * Add a region * * @param {object} params Region parameters * @return {Region} The created region */ add(params) { if (this.wouldExceedMaxRegions()) { return null; } params = { edgeScrollWidth: this.params.edgeScrollWidth || this.defaultEdgeScrollWidth, contentEditable: this.params.contentEditable, removeButton: this.params.removeButton, ...params }; // Take formatTimeCallback from plugin params if not already set if (!params.formatTimeCallback && this.params.formatTimeCallback) { params = {...params, formatTimeCallback: this.params.formatTimeCallback}; } if (!params.minLength && this.regionsMinLength) { params = {...params, minLength: this.regionsMinLength}; } const region = new this.wavesurfer.Region(params, this.util, 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) { this.disableDragSelection(); 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.util.withOrientation( this.wrapper.getBoundingClientRect(), this.vertical ); // set the region channel index based on the clicked area if (this.wavesurfer.params.splitChannels && this.wavesurfer.params.splitChannelsOptions.splitDragSelection) { const y = (e.touches ? e.touches[0].clientY : e.clientY) - wrapperRect.top; const channelCount = this.wavesurfer.backend.buffer != null ? this.wavesurfer.backend.buffer.numberOfChannels : 1; const channelHeight = this.wrapper.clientHeight / channelCount; const channelIdx = Math.floor(y / channelHeight); params.channelIdx = channelIdx; const channelColors = this.wavesurfer.params.splitChannelsOptions.channelColors[channelIdx]; if (channelColors && channelColors.dragColor) { params.color = channelColors.dragColor; } } 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('mouseleave', eventUp); 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); this.wrapper.removeEventListener('mouseleave', eventUp); }); const eventMove = event => { if (!drag) { return; } if (++pxMove <= slop) { return; } if (event.touches && event.touches.length > 1) { return; } if (event.targetTouches && event.targetTouches[0].identifier != touchId) { return; } // auto-create a region during mouse drag, unless region-count would exceed "maxRegions" if (!region) { region = this.add(params || {}); if (!region) { return; } } const end = this.wavesurfer.drawer.handleEvent(event); const startUpdate = this.wavesurfer.regions.util.getRegionSnapToGridValue( start * duration ); const endUpdate = this.wavesurfer.regions.util.getRegionSnapToGridValue( end * duration ); region.update({ start: Math.min(endUpdate, startUpdate), end: Math.max(endUpdate, startUpdate) }); let orientedEvent = this.util.withOrientation(event, this.vertical); // If scrolling is enabled if (scroll && container.clientWidth < this.wrapper.scrollWidth) { // Check threshold based on mouse const x = orientedEvent.clientX - wrapperRect.left; if (x <= scrollThreshold) { scrollDirection = -1; } else if (x >= wrapperRect.right - scrollThreshold) { scrollDirection = 1; } else { scrollDirection = null; } scrollDirection && edgeScroll(event); } }; 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); }); this.wavesurfer.on('region-created', region => { if (this.regionsMinLength) { region.minLength = this.regionsMinLength; } }); } disableDragSelection() { this.fireEvent('disable-drag-selection'); } /** * Get current region * * The smallest region that contains the current time. If several such * regions exist, take the first. Return `null` if none exist. * * @returns {Region} The current region */ 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; } /** * Match the value to the grid, if required * * If the regions plugin params have a snapToGridInterval set, return the * value matching the nearest grid interval. If no snapToGridInterval is set, * the passed value will be returned without modification. * * @param {number} value the value to snap to the grid, if needed * @param {Object} params the regions plugin params * @returns {number} value */ getRegionSnapToGridValue(value, params) { if (params.snapToGridInterval) { // the regions should snap to a grid const offset = params.snapToGridOffset || 0; return ( Math.round((value - offset) / params.snapToGridInterval) * params.snapToGridInterval + offset ); } // no snap-to-grid return value; } }