UNPKG

wavesurfer.js

Version:
665 lines (663 loc) 27.9 kB
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var __rest = (this && this.__rest) || function (s, e) { var t = {}; for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) t[p] = s[p]; if (s != null && typeof Object.getOwnPropertySymbols === "function") for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) { if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) t[p[i]] = s[p[i]]; } return t; }; import { makeDraggable } from './draggable.js'; import EventEmitter from './event-emitter.js'; class Renderer extends EventEmitter { constructor(options, audioElement) { super(); this.timeouts = []; this.isScrollable = false; this.audioData = null; this.resizeObserver = null; this.lastContainerWidth = 0; this.isDragging = false; this.subscriptions = []; this.unsubscribeOnScroll = []; this.subscriptions = []; this.options = options; const parent = this.parentFromOptionsContainer(options.container); this.parent = parent; const [div, shadow] = this.initHtml(); parent.appendChild(div); this.container = div; this.scrollContainer = shadow.querySelector('.scroll'); this.wrapper = shadow.querySelector('.wrapper'); this.canvasWrapper = shadow.querySelector('.canvases'); this.progressWrapper = shadow.querySelector('.progress'); this.cursor = shadow.querySelector('.cursor'); if (audioElement) { shadow.appendChild(audioElement); } this.initEvents(); } parentFromOptionsContainer(container) { let parent; if (typeof container === 'string') { parent = document.querySelector(container); } else if (container instanceof HTMLElement) { parent = container; } if (!parent) { throw new Error('Container not found'); } return parent; } initEvents() { const getClickPosition = (e) => { const rect = this.wrapper.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; const relativeX = x / rect.width; const relativeY = y / rect.height; return [relativeX, relativeY]; }; // Add a click listener this.wrapper.addEventListener('click', (e) => { const [x, y] = getClickPosition(e); this.emit('click', x, y); }); // Add a double click listener this.wrapper.addEventListener('dblclick', (e) => { const [x, y] = getClickPosition(e); this.emit('dblclick', x, y); }); // Drag if (this.options.dragToSeek === true || typeof this.options.dragToSeek === 'object') { this.initDrag(); } // Add a scroll listener this.scrollContainer.addEventListener('scroll', () => { const { scrollLeft, scrollWidth, clientWidth } = this.scrollContainer; const startX = scrollLeft / scrollWidth; const endX = (scrollLeft + clientWidth) / scrollWidth; this.emit('scroll', startX, endX, scrollLeft, scrollLeft + clientWidth); }); // Re-render the waveform on container resize if (typeof ResizeObserver === 'function') { const delay = this.createDelay(100); this.resizeObserver = new ResizeObserver(() => { delay() .then(() => this.onContainerResize()) .catch(() => undefined); }); this.resizeObserver.observe(this.scrollContainer); } } onContainerResize() { const width = this.parent.clientWidth; if (width === this.lastContainerWidth && this.options.height !== 'auto') return; this.lastContainerWidth = width; this.reRender(); } initDrag() { this.subscriptions.push(makeDraggable(this.wrapper, // On drag (_, __, x) => { this.emit('drag', Math.max(0, Math.min(1, x / this.wrapper.getBoundingClientRect().width))); }, // On start drag (x) => { this.isDragging = true; this.emit('dragstart', Math.max(0, Math.min(1, x / this.wrapper.getBoundingClientRect().width))); }, // On end drag (x) => { this.isDragging = false; this.emit('dragend', Math.max(0, Math.min(1, x / this.wrapper.getBoundingClientRect().width))); })); } getHeight(optionsHeight, optionsSplitChannel) { var _a; const defaultHeight = 128; const numberOfChannels = ((_a = this.audioData) === null || _a === void 0 ? void 0 : _a.numberOfChannels) || 1; if (optionsHeight == null) return defaultHeight; if (!isNaN(Number(optionsHeight))) return Number(optionsHeight); if (optionsHeight === 'auto') { const height = this.parent.clientHeight || defaultHeight; if (optionsSplitChannel === null || optionsSplitChannel === void 0 ? void 0 : optionsSplitChannel.every((channel) => !channel.overlay)) return height / numberOfChannels; return height; } return defaultHeight; } initHtml() { const div = document.createElement('div'); const shadow = div.attachShadow({ mode: 'open' }); const cspNonce = this.options.cspNonce && typeof this.options.cspNonce === 'string' ? this.options.cspNonce.replace(/"/g, '') : ''; shadow.innerHTML = ` <style${cspNonce ? ` nonce="${cspNonce}"` : ''}> :host { user-select: none; min-width: 1px; } :host audio { display: block; width: 100%; } :host .scroll { overflow-x: auto; overflow-y: hidden; width: 100%; position: relative; } :host .noScrollbar { scrollbar-color: transparent; scrollbar-width: none; } :host .noScrollbar::-webkit-scrollbar { display: none; -webkit-appearance: none; } :host .wrapper { position: relative; overflow: visible; z-index: 2; } :host .canvases { min-height: ${this.getHeight(this.options.height, this.options.splitChannels)}px; } :host .canvases > div { position: relative; } :host canvas { display: block; position: absolute; top: 0; image-rendering: pixelated; } :host .progress { pointer-events: none; position: absolute; z-index: 2; top: 0; left: 0; width: 0; height: 100%; overflow: hidden; } :host .progress > div { position: relative; } :host .cursor { pointer-events: none; position: absolute; z-index: 5; top: 0; left: 0; height: 100%; border-radius: 2px; } </style> <div class="scroll" part="scroll"> <div class="wrapper" part="wrapper"> <div class="canvases" part="canvases"></div> <div class="progress" part="progress"></div> <div class="cursor" part="cursor"></div> </div> </div> `; return [div, shadow]; } /** Wavesurfer itself calls this method. Do not call it manually. */ setOptions(options) { if (this.options.container !== options.container) { const newParent = this.parentFromOptionsContainer(options.container); newParent.appendChild(this.container); this.parent = newParent; } if (options.dragToSeek === true || typeof this.options.dragToSeek === 'object') { this.initDrag(); } this.options = options; // Re-render the waveform this.reRender(); } getWrapper() { return this.wrapper; } getWidth() { return this.scrollContainer.clientWidth; } getScroll() { return this.scrollContainer.scrollLeft; } setScroll(pixels) { this.scrollContainer.scrollLeft = pixels; } setScrollPercentage(percent) { const { scrollWidth } = this.scrollContainer; const scrollStart = scrollWidth * percent; this.setScroll(scrollStart); } destroy() { var _a, _b; this.subscriptions.forEach((unsubscribe) => unsubscribe()); this.container.remove(); (_a = this.resizeObserver) === null || _a === void 0 ? void 0 : _a.disconnect(); (_b = this.unsubscribeOnScroll) === null || _b === void 0 ? void 0 : _b.forEach((unsubscribe) => unsubscribe()); this.unsubscribeOnScroll = []; } createDelay(delayMs = 10) { let timeout; let reject; const onClear = () => { if (timeout) clearTimeout(timeout); if (reject) reject(); }; this.timeouts.push(onClear); return () => { return new Promise((resolveFn, rejectFn) => { onClear(); reject = rejectFn; timeout = setTimeout(() => { timeout = undefined; reject = undefined; resolveFn(); }, delayMs); }); }; } // Convert array of color values to linear gradient convertColorValues(color) { if (!Array.isArray(color)) return color || ''; if (color.length < 2) return color[0] || ''; const canvasElement = document.createElement('canvas'); const ctx = canvasElement.getContext('2d'); const gradientHeight = canvasElement.height * (window.devicePixelRatio || 1); const gradient = ctx.createLinearGradient(0, 0, 0, gradientHeight); const colorStopPercentage = 1 / (color.length - 1); color.forEach((color, index) => { const offset = index * colorStopPercentage; gradient.addColorStop(offset, color); }); return gradient; } getPixelRatio() { return Math.max(1, window.devicePixelRatio || 1); } renderBarWaveform(channelData, options, ctx, vScale) { const topChannel = channelData[0]; const bottomChannel = channelData[1] || channelData[0]; const length = topChannel.length; const { width, height } = ctx.canvas; const halfHeight = height / 2; const pixelRatio = this.getPixelRatio(); const barWidth = options.barWidth ? options.barWidth * pixelRatio : 1; const barGap = options.barGap ? options.barGap * pixelRatio : options.barWidth ? barWidth / 2 : 0; const barRadius = options.barRadius || 0; const barIndexScale = width / (barWidth + barGap) / length; const rectFn = barRadius && 'roundRect' in ctx ? 'roundRect' : 'rect'; ctx.beginPath(); let prevX = 0; let maxTop = 0; let maxBottom = 0; for (let i = 0; i <= length; i++) { const x = Math.round(i * barIndexScale); if (x > prevX) { const topBarHeight = Math.round(maxTop * halfHeight * vScale); const bottomBarHeight = Math.round(maxBottom * halfHeight * vScale); const barHeight = topBarHeight + bottomBarHeight || 1; // Vertical alignment let y = halfHeight - topBarHeight; if (options.barAlign === 'top') { y = 0; } else if (options.barAlign === 'bottom') { y = height - barHeight; } ctx[rectFn](prevX * (barWidth + barGap), y, barWidth, barHeight, barRadius); prevX = x; maxTop = 0; maxBottom = 0; } const magnitudeTop = Math.abs(topChannel[i] || 0); const magnitudeBottom = Math.abs(bottomChannel[i] || 0); if (magnitudeTop > maxTop) maxTop = magnitudeTop; if (magnitudeBottom > maxBottom) maxBottom = magnitudeBottom; } ctx.fill(); ctx.closePath(); } renderLineWaveform(channelData, _options, ctx, vScale) { const drawChannel = (index) => { const channel = channelData[index] || channelData[0]; const length = channel.length; const { height } = ctx.canvas; const halfHeight = height / 2; const hScale = ctx.canvas.width / length; ctx.moveTo(0, halfHeight); let prevX = 0; let max = 0; for (let i = 0; i <= length; i++) { const x = Math.round(i * hScale); if (x > prevX) { const h = Math.round(max * halfHeight * vScale) || 1; const y = halfHeight + h * (index === 0 ? -1 : 1); ctx.lineTo(prevX, y); prevX = x; max = 0; } const value = Math.abs(channel[i] || 0); if (value > max) max = value; } ctx.lineTo(prevX, halfHeight); }; ctx.beginPath(); drawChannel(0); drawChannel(1); ctx.fill(); ctx.closePath(); } renderWaveform(channelData, options, ctx) { ctx.fillStyle = this.convertColorValues(options.waveColor); // Custom rendering function if (options.renderFunction) { options.renderFunction(channelData, ctx); return; } // Vertical scaling let vScale = options.barHeight || 1; if (options.normalize) { const max = Array.from(channelData[0]).reduce((max, value) => Math.max(max, Math.abs(value)), 0); vScale = max ? 1 / max : 1; } // Render waveform as bars if (options.barWidth || options.barGap || options.barAlign) { this.renderBarWaveform(channelData, options, ctx, vScale); return; } // Render waveform as a polyline this.renderLineWaveform(channelData, options, ctx, vScale); } renderSingleCanvas(data, options, width, height, offset, canvasContainer, progressContainer) { const pixelRatio = this.getPixelRatio(); const canvas = document.createElement('canvas'); canvas.width = Math.round(width * pixelRatio); canvas.height = Math.round(height * pixelRatio); canvas.style.width = `${width}px`; canvas.style.height = `${height}px`; canvas.style.left = `${Math.round(offset)}px`; canvasContainer.appendChild(canvas); const ctx = canvas.getContext('2d'); this.renderWaveform(data, options, ctx); // Draw a progress canvas if (canvas.width > 0 && canvas.height > 0) { const progressCanvas = canvas.cloneNode(); const progressCtx = progressCanvas.getContext('2d'); progressCtx.drawImage(canvas, 0, 0); // Set the composition method to draw only where the waveform is drawn progressCtx.globalCompositeOperation = 'source-in'; progressCtx.fillStyle = this.convertColorValues(options.progressColor); // This rectangle acts as a mask thanks to the composition method progressCtx.fillRect(0, 0, canvas.width, canvas.height); progressContainer.appendChild(progressCanvas); } } renderMultiCanvas(channelData, options, width, height, canvasContainer, progressContainer) { const pixelRatio = this.getPixelRatio(); const { clientWidth } = this.scrollContainer; const totalWidth = width / pixelRatio; let singleCanvasWidth = Math.min(Renderer.MAX_CANVAS_WIDTH, clientWidth, totalWidth); let drawnIndexes = {}; // Nothing to render if (singleCanvasWidth === 0) return; // Adjust width to avoid gaps between canvases when using bars if (options.barWidth || options.barGap) { const barWidth = options.barWidth || 0.5; const barGap = options.barGap || barWidth / 2; const totalBarWidth = barWidth + barGap; if (singleCanvasWidth % totalBarWidth !== 0) { singleCanvasWidth = Math.floor(singleCanvasWidth / totalBarWidth) * totalBarWidth; } } // Draw a single canvas const draw = (index) => { if (index < 0 || index >= numCanvases) return; if (drawnIndexes[index]) return; drawnIndexes[index] = true; const offset = index * singleCanvasWidth; const clampedWidth = Math.min(totalWidth - offset, singleCanvasWidth); if (clampedWidth <= 0) return; const data = channelData.map((channel) => { const start = Math.floor((offset / totalWidth) * channel.length); const end = Math.floor(((offset + clampedWidth) / totalWidth) * channel.length); return channel.slice(start, end); }); this.renderSingleCanvas(data, options, clampedWidth, height, offset, canvasContainer, progressContainer); }; // Clear canvases to avoid too many DOM nodes const clearCanvases = () => { if (Object.keys(drawnIndexes).length > Renderer.MAX_NODES) { canvasContainer.innerHTML = ''; progressContainer.innerHTML = ''; drawnIndexes = {}; } }; // Calculate how many canvases to render const numCanvases = Math.ceil(totalWidth / singleCanvasWidth); // Render all canvases if the waveform doesn't scroll if (!this.isScrollable) { for (let i = 0; i < numCanvases; i++) { draw(i); } return; } // Lazy rendering const viewPosition = this.scrollContainer.scrollLeft / totalWidth; const startCanvas = Math.floor(viewPosition * numCanvases); // Draw the canvases in the viewport first draw(startCanvas - 1); draw(startCanvas); draw(startCanvas + 1); // Subscribe to the scroll event to draw additional canvases if (numCanvases > 1) { const unsubscribe = this.on('scroll', () => { const { scrollLeft } = this.scrollContainer; const canvasIndex = Math.floor((scrollLeft / totalWidth) * numCanvases); clearCanvases(); draw(canvasIndex - 1); draw(canvasIndex); draw(canvasIndex + 1); }); this.unsubscribeOnScroll.push(unsubscribe); } } renderChannel(channelData, _a, width, channelIndex) { var { overlay } = _a, options = __rest(_a, ["overlay"]); // A container for canvases const canvasContainer = document.createElement('div'); const height = this.getHeight(options.height, options.splitChannels); canvasContainer.style.height = `${height}px`; if (overlay && channelIndex > 0) { canvasContainer.style.marginTop = `-${height}px`; } this.canvasWrapper.style.minHeight = `${height}px`; this.canvasWrapper.appendChild(canvasContainer); // A container for progress canvases const progressContainer = canvasContainer.cloneNode(); this.progressWrapper.appendChild(progressContainer); // Render the waveform this.renderMultiCanvas(channelData, options, width, height, canvasContainer, progressContainer); } render(audioData) { return __awaiter(this, void 0, void 0, function* () { var _a; // Clear previous timeouts this.timeouts.forEach((clear) => clear()); this.timeouts = []; // Clear the canvases this.canvasWrapper.innerHTML = ''; this.progressWrapper.innerHTML = ''; // Width if (this.options.width != null) { this.scrollContainer.style.width = typeof this.options.width === 'number' ? `${this.options.width}px` : this.options.width; } // Determine the width of the waveform const pixelRatio = this.getPixelRatio(); const parentWidth = this.scrollContainer.clientWidth; const scrollWidth = Math.ceil(audioData.duration * (this.options.minPxPerSec || 0)); // Whether the container should scroll this.isScrollable = scrollWidth > parentWidth; const useParentWidth = this.options.fillParent && !this.isScrollable; // Width of the waveform in pixels const width = (useParentWidth ? parentWidth : scrollWidth) * pixelRatio; // Set the width of the wrapper this.wrapper.style.width = useParentWidth ? '100%' : `${scrollWidth}px`; // Set additional styles this.scrollContainer.style.overflowX = this.isScrollable ? 'auto' : 'hidden'; this.scrollContainer.classList.toggle('noScrollbar', !!this.options.hideScrollbar); this.cursor.style.backgroundColor = `${this.options.cursorColor || this.options.progressColor}`; this.cursor.style.width = `${this.options.cursorWidth}px`; this.audioData = audioData; this.emit('render'); // Render the waveform if (this.options.splitChannels) { // Render a waveform for each channel for (let i = 0; i < audioData.numberOfChannels; i++) { const options = Object.assign(Object.assign({}, this.options), (_a = this.options.splitChannels) === null || _a === void 0 ? void 0 : _a[i]); this.renderChannel([audioData.getChannelData(i)], options, width, i); } } else { // Render a single waveform for the first two channels (left and right) const channels = [audioData.getChannelData(0)]; if (audioData.numberOfChannels > 1) channels.push(audioData.getChannelData(1)); this.renderChannel(channels, this.options, width, 0); } // Must be emitted asynchronously for backward compatibility Promise.resolve().then(() => this.emit('rendered')); }); } reRender() { this.unsubscribeOnScroll.forEach((unsubscribe) => unsubscribe()); this.unsubscribeOnScroll = []; // Return if the waveform has not been rendered yet if (!this.audioData) return; // Remember the current cursor position const { scrollWidth } = this.scrollContainer; const { right: before } = this.progressWrapper.getBoundingClientRect(); // Re-render the waveform this.render(this.audioData); // Adjust the scroll position so that the cursor stays in the same place if (this.isScrollable && scrollWidth !== this.scrollContainer.scrollWidth) { const { right: after } = this.progressWrapper.getBoundingClientRect(); let delta = after - before; // to limit compounding floating-point drift // we need to round to the half px furthest from 0 delta *= 2; delta = delta < 0 ? Math.floor(delta) : Math.ceil(delta); delta /= 2; this.scrollContainer.scrollLeft += delta; } } zoom(minPxPerSec) { this.options.minPxPerSec = minPxPerSec; this.reRender(); } scrollIntoView(progress, isPlaying = false) { const { scrollLeft, scrollWidth, clientWidth } = this.scrollContainer; const progressWidth = progress * scrollWidth; const startEdge = scrollLeft; const endEdge = scrollLeft + clientWidth; const middle = clientWidth / 2; if (this.isDragging) { // Scroll when dragging close to the edge of the viewport const minGap = 30; if (progressWidth + minGap > endEdge) { this.scrollContainer.scrollLeft += minGap; } else if (progressWidth - minGap < startEdge) { this.scrollContainer.scrollLeft -= minGap; } } else { if (progressWidth < startEdge || progressWidth > endEdge) { this.scrollContainer.scrollLeft = progressWidth - (this.options.autoCenter ? middle : 0); } // Keep the cursor centered when playing const center = progressWidth - scrollLeft - middle; if (isPlaying && this.options.autoCenter && center > 0) { this.scrollContainer.scrollLeft += Math.min(center, 10); } } // Emit the scroll event { const newScroll = this.scrollContainer.scrollLeft; const startX = newScroll / scrollWidth; const endX = (newScroll + clientWidth) / scrollWidth; this.emit('scroll', startX, endX, newScroll, newScroll + clientWidth); } } renderProgress(progress, isPlaying) { if (isNaN(progress)) return; const percents = progress * 100; this.canvasWrapper.style.clipPath = `polygon(${percents}% 0, 100% 0, 100% 100%, ${percents}% 100%)`; this.progressWrapper.style.width = `${percents}%`; this.cursor.style.left = `${percents}%`; this.cursor.style.transform = `translateX(-${Math.round(percents) === 100 ? this.options.cursorWidth : 0}px)`; if (this.isScrollable && this.options.autoScroll) { this.scrollIntoView(progress, isPlaying); } } exportImage(format, quality, type) { return __awaiter(this, void 0, void 0, function* () { const canvases = this.canvasWrapper.querySelectorAll('canvas'); if (!canvases.length) { throw new Error('No waveform data'); } // Data URLs if (type === 'dataURL') { const images = Array.from(canvases).map((canvas) => canvas.toDataURL(format, quality)); return Promise.resolve(images); } // Blobs return Promise.all(Array.from(canvases).map((canvas) => { return new Promise((resolve, reject) => { canvas.toBlob((blob) => { blob ? resolve(blob) : reject(new Error('Could not export image')); }, format, quality); }); })); }); } } Renderer.MAX_CANVAS_WIDTH = 8000; Renderer.MAX_NODES = 10; export default Renderer;