UNPKG

waveform-renderer

Version:

High-performance audio waveform visualization library for the web. Create customizable, interactive waveform renderers with TypeScript support and zero dependencies.

380 lines (379 loc) 12.1 kB
var __defProp = Object.defineProperty; var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); const DEFAULT_OPTIONS = { amplitude: 1, backgroundColor: "#CCCCCC", barWidth: 2, borderColor: "#000000", borderRadius: 0, borderWidth: 0, color: "#000000", gap: 1, minPixelRatio: 1, position: "center", progress: 0, progressLine: { color: "#FF0000", heightPercent: 1, position: "center", style: "solid", width: 2 }, smoothing: true }; class EventEmitter { constructor() { __publicField(this, "events", /* @__PURE__ */ new Map()); } off(event, callback) { const callbacks = this.events.get(event); if (callbacks) { const index = callbacks.indexOf(callback); if (index !== -1) { callbacks.splice(index, 1); } if (callbacks.length === 0) { this.events.delete(event); } } } on(event, callback) { if (!this.events.has(event)) { this.events.set(event, []); } const callbacks = this.events.get(event); callbacks.push(callback); } once(event, callback) { const onceCallback = (args) => { this.off(event, onceCallback); callback(args); }; this.on(event, onceCallback); } removeAllListeners(event) { if (event) { this.events.delete(event); } else { this.events.clear(); } } emit(event, args) { const callbacks = this.events.get(event); if (callbacks) { callbacks.forEach((callback) => callback(args)); } } hasListeners(event) { const callbacks = this.events.get(event); return callbacks ? callbacks.length > 0 : false; } } function calculateBarDimensions(peak, canvasHeight, amplitude, position) { const height = peak * canvasHeight * amplitude; switch (position) { case "bottom": return { height, y: canvasHeight - height }; case "top": return { height, y: 0 }; case "center": default: return { height, y: (canvasHeight - height) / 2 }; } } function calculateLineDimensions(lineHeight, canvasHeight, position) { switch (position) { case "bottom": return { endY: canvasHeight, startY: canvasHeight - lineHeight }; case "top": return { endY: lineHeight, startY: 0 }; case "center": default: return { endY: (canvasHeight + lineHeight) / 2, startY: (canvasHeight - lineHeight) / 2 }; } } function drawProgressLine(ctx, x, canvasHeight, options) { const { color, heightPercent, position, style, width } = options; const lineHeight = canvasHeight * heightPercent; ctx.save(); ctx.strokeStyle = color; ctx.lineWidth = width; ctx.lineCap = "round"; const { endY, startY } = calculateLineDimensions(lineHeight, canvasHeight, position); if (style !== "solid") { const [dashSize, gapSize] = style === "dashed" ? [8, 4] : [2, 2]; ctx.setLineDash([dashSize, gapSize]); } ctx.beginPath(); ctx.moveTo(x, startY); ctx.lineTo(x, endY); ctx.stroke(); ctx.restore(); } function resizeCanvas(canvas, devicePixelRatio) { const rect = canvas.getBoundingClientRect(); const width = rect.width * devicePixelRatio; const height = rect.height * devicePixelRatio; if (canvas.width !== width || canvas.height !== height) { canvas.width = width; canvas.height = height; } return { height: rect.height, width: rect.width }; } function setupCanvasContext(ctx, smoothing = true) { ctx.imageSmoothingEnabled = smoothing; if (smoothing) { ctx.imageSmoothingQuality = "high"; } } function getPeaksFromAudioBuffer(audioBuffer, numberOfPeaks) { const channelData = audioBuffer.getChannelData(0); const peaks = new Array(numberOfPeaks); const samplesPerPeak = Math.floor(channelData.length / numberOfPeaks); for (let i = 0; i < numberOfPeaks; i++) { const start = i * samplesPerPeak; const end = start + samplesPerPeak; let max = 0; for (let j = start; j < end; j++) { const absolute = Math.abs(channelData[j]); if (absolute > max) max = absolute; } peaks[i] = max; } return normalizePeaks(peaks); } function normalizePeaks(peaks) { const maxPeak = Math.max(...peaks.map(Math.abs), 1); return peaks.map((peak) => peak / maxPeak); } function normalizeProgress(progress) { return Math.max(0, Math.min(1, progress)); } class WaveformRenderer extends EventEmitter { constructor(canvas, peaks, options = {}) { super(); __publicField(this, "canvas"); __publicField(this, "ctx"); __publicField(this, "devicePixelRatio"); __publicField(this, "frameRequest"); __publicField(this, "isDestroyed", false); __publicField(this, "options"); __publicField(this, "peaks"); __publicField(this, "resizeObserver"); __publicField(this, "handleClick", (event) => { event.preventDefault(); if (this.isDestroyed) return; try { const progress = this.calculateProgressFromEvent(event); this.emit("seek", progress); } catch (e) { this.handleError(e); } }); __publicField(this, "handleError", (e) => { console.error(e); this.emit("error", e instanceof Error ? e : new Error("An unknown error occurred")); }); __publicField(this, "handleResize", () => { if (this.isDestroyed) return; try { const rect = this.canvas.getBoundingClientRect(); this.emit("resize", { height: rect.height, width: rect.width }); this.resizeCanvas(); this.scheduleRender(); } catch (e) { this.handleError(e); } }); __publicField(this, "handleTouch", (event) => { event.preventDefault(); if (this.isDestroyed || !event.changedTouches[0]) return; try { const progress = this.calculateProgressFromTouch(event.changedTouches[0]); this.emit("seek", progress); } catch (e) { this.handleError(e); } }); try { if (!canvas) { throw new Error("Canvas element is required"); } if (!Array.isArray(peaks) || peaks.length === 0) { throw new Error("Peaks array is required and must not be empty"); } this.canvas = canvas; const context = this.canvas.getContext("2d"); if (!context) { throw new Error("Could not get 2D context from canvas"); } this.ctx = context; this.peaks = normalizePeaks(peaks); this.options = { ...DEFAULT_OPTIONS, ...options, progressLine: options.progressLine ? { ...DEFAULT_OPTIONS.progressLine, ...options.progressLine } : null }; this.devicePixelRatio = Math.max(window.devicePixelRatio || 1, this.options.minPixelRatio); this.setupContext(); this.resizeObserver = new ResizeObserver(this.handleResize); this.resizeObserver.observe(this.canvas); this.canvas.addEventListener("click", this.handleClick); this.canvas.addEventListener("touchstart", this.handleTouch); this.resizeCanvas(); this.scheduleRender(); requestAnimationFrame(() => this.emit("ready", void 0)); } catch (e) { this.handleError(e); } } destroy() { if (this.isDestroyed) return; this.emit("destroy", void 0); this.isDestroyed = true; this.resizeObserver.disconnect(); this.canvas.removeEventListener("click", this.handleClick); this.canvas.removeEventListener("touchend", this.handleTouch); if (this.frameRequest) cancelAnimationFrame(this.frameRequest); } setOptions(options) { if (this.isDestroyed) return; this.options = { ...this.options, ...options }; this.setupContext(); this.scheduleRender(); } setPeaks(peaks) { if (this.isDestroyed) return; try { if (!Array.isArray(peaks) || peaks.length === 0) { throw new Error("Peaks array must not be empty"); } this.peaks = normalizePeaks(peaks); this.scheduleRender(); } catch (e) { this.handleError(e); } } setProgress(progress) { if (this.isDestroyed) return; try { const normalizedProgress = normalizeProgress(progress); this.options.progress = normalizedProgress; this.emit("progressChange", normalizedProgress); this.scheduleRender(); } catch (e) { this.handleError(e); } } setProgressLineOptions(options) { if (this.isDestroyed) return; try { if (options) { this.options.progressLine = { ...DEFAULT_OPTIONS.progressLine, ...this.options.progressLine, ...options }; } else { this.options.progressLine = null; } this.scheduleRender(); } catch (e) { this.handleError(e); } } calculateProgressFromEvent(event) { const rect = this.canvas.getBoundingClientRect(); const x = event.clientX - rect.left; return normalizeProgress(x / rect.width); } calculateProgressFromTouch(touch) { const rect = this.canvas.getBoundingClientRect(); const x = touch.clientX - rect.left; return normalizeProgress(x / rect.width); } drawWaveform() { if (this.isDestroyed) return; this.emit("renderStart", void 0); try { const { backgroundColor, color, progress } = this.options; const canvasWidth = this.canvas.width / this.devicePixelRatio; const canvasHeight = this.canvas.height / this.devicePixelRatio; this.ctx.clearRect(0, 0, canvasWidth, canvasHeight); this.drawWaveformWithColor(backgroundColor); if (progress > 0) { this.ctx.save(); const progressWidth = canvasWidth * progress; this.ctx.beginPath(); this.ctx.rect(0, 0, progressWidth, canvasHeight); this.ctx.clip(); this.drawWaveformWithColor(color); this.ctx.restore(); } if (this.options.progressLine && progress > 0) { const x = canvasWidth * progress; drawProgressLine(this.ctx, x, canvasHeight, this.options.progressLine); } this.emit("renderComplete", void 0); } catch (e) { this.handleError(e); } } drawWaveformWithColor(color) { const { amplitude = 1, barWidth, borderColor, borderRadius, borderWidth = 0, gap = 0, position } = this.options; const canvasWidth = this.canvas.width / this.devicePixelRatio; const canvasHeight = this.canvas.height / this.devicePixelRatio; const initialOffset = borderWidth; const availableWidth = canvasWidth - borderWidth * 2 * initialOffset; const singleUnitWidth = barWidth + borderWidth * 2 + gap; const totalBars = Math.floor(availableWidth / singleUnitWidth); const finalTotalBars = Math.max(1, totalBars); const step = this.peaks.length / finalTotalBars; this.ctx.fillStyle = color; this.ctx.strokeStyle = borderColor; this.ctx.lineWidth = borderWidth; for (let i = 0; i < finalTotalBars; i++) { const peakIndex = Math.floor(i * step); const peak = Math.abs(this.peaks[peakIndex] || 0); const x = initialOffset + i * singleUnitWidth; const { height, y } = calculateBarDimensions(peak, canvasHeight, amplitude, position); this.ctx.beginPath(); if (borderRadius > 0) { this.ctx.roundRect(x, y, barWidth, height, borderRadius); } else { this.ctx.rect(x, y, barWidth, height); } this.ctx.fill(); if (borderWidth > 0) { this.ctx.stroke(); } } } resizeCanvas() { resizeCanvas(this.canvas, this.devicePixelRatio); this.ctx.setTransform(1, 0, 0, 1, 0, 0); this.ctx.scale(this.devicePixelRatio, this.devicePixelRatio); this.setupContext(); } scheduleRender() { if (this.frameRequest) cancelAnimationFrame(this.frameRequest); this.frameRequest = requestAnimationFrame(() => this.drawWaveform()); } setupContext() { setupCanvasContext(this.ctx, this.options.smoothing); } } export { WaveformRenderer, getPeaksFromAudioBuffer };