UNPKG

waveform-renderer

Version:

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

836 lines (825 loc) 24.9 kB
var WaveformRenderer = (function(exports) { //#region src/constants/default.ts 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, debug: false, smoothing: true, progressLine: { color: "#FF0000", heightPercent: 1, position: "center", style: "solid", width: 2 } }; //#endregion //#region src/events/EventEmitter.ts var EventEmitter = class { 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; } }; //#endregion //#region src/utils/canvas.ts /** * Calculates the vertical position and height for a bar based on the render mode */ 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 }; } } /** * Calculates the vertical position for a progress line based on the render mode */ 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 }; } } /** * Draws a progress line on the canvas */ 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(); } /** * Resizes the canvas accounting for device pixel ratio */ 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 }; } /** * Configures the canvas context with the specified settings */ function setupCanvasContext(ctx, smoothing = true) { ctx.imageSmoothingEnabled = smoothing; if (smoothing) ctx.imageSmoothingQuality = "high"; } //#endregion //#region src/utils/peaks.ts /** * Calculates peaks from an AudioBuffer */ function getPeaksFromAudioBuffer(audioBuffer, numberOfPeaks) { const channelData = audioBuffer.getChannelData(0); const peaks = Array.from({ length: 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); } /** * Normalizes an array of peak values to a range of -1 to 1 */ function normalizePeaks(peaks) { let maxPeak = 1; for (let i = 0; i < peaks.length; i++) { const peak = Math.abs(peaks[i]); if (peak > maxPeak) maxPeak = peak; } for (let i = 0; i < peaks.length; i++) peaks[i] = peaks[i] / maxPeak; return peaks; } /** * Ensures progress value is between 0 and 1 */ function normalizeProgress(progress) { return Math.max(0, Math.min(1, progress)); } //#endregion //#region src/cache-manager.ts var CacheManager = class { cache = null; invalidate() { if (this.cache) this.cache.staticWaveformPath = void 0; } clear() { this.cache = null; } getCache(canvas, devicePixelRatio, peaks, options) { const currentCanvasWidth = canvas.width / devicePixelRatio; const currentCanvasHeight = canvas.height / devicePixelRatio; const currentOptionsHash = this.createOptionsHash(options); const currentPeaksHash = this.createPeaksHash(peaks); if (this.isCacheValid(currentCanvasWidth, currentCanvasHeight, currentOptionsHash, currentPeaksHash)) return this.cache; this.cache = this.buildCache(currentCanvasWidth, currentCanvasHeight, peaks, options, currentOptionsHash, currentPeaksHash); return this.cache; } createStaticPath(cache, borderRadius) { if (cache.staticWaveformPath) return cache.staticWaveformPath; const path = new Path2D(); for (const bar of cache.bars) if (borderRadius > 0 && typeof path.roundRect === "function") path.roundRect(bar.x, bar.y, bar.width, bar.height, borderRadius); else path.rect(bar.x, bar.y, bar.width, bar.height); cache.staticWaveformPath = path; return path; } isValid() { return this.cache !== null; } isCacheValid(canvasWidth, canvasHeight, optionsHash, peaksHash) { return this.cache !== null && this.cache.canvasWidth === canvasWidth && this.cache.canvasHeight === canvasHeight && this.cache.lastOptionsHash === optionsHash && this.cache.lastPeaksHash === peaksHash; } buildCache(canvasWidth, canvasHeight, peaks, options, optionsHash, peaksHash) { const { amplitude = 1, barWidth, borderWidth = 0, gap = 0, position } = options; const initialOffset = borderWidth; const availableWidth = canvasWidth - borderWidth * 2 * initialOffset; const singleUnitWidth = barWidth + borderWidth * 2 + gap; const totalBars = Math.max(1, Math.floor(availableWidth / singleUnitWidth)); const step = peaks.length / totalBars; const bars = Array.from({ length: totalBars }); for (let i = 0; i < totalBars; i++) { const peakIndex = Math.floor(i * step); const peakValue = Math.abs(peaks[peakIndex] || 0); const x = initialOffset + i * singleUnitWidth; const { height, y } = calculateBarDimensions(peakValue, canvasHeight, amplitude, position); bars[i] = { x, y, width: barWidth, height, peakValue }; } return { canvasWidth, canvasHeight, totalBars, step, singleUnitWidth, bars, lastOptionsHash: optionsHash, lastPeaksHash: peaksHash }; } createOptionsHash(options) { const { amplitude, barWidth, borderWidth, gap, position, borderRadius } = options; return `${amplitude}-${barWidth}-${borderWidth}-${gap}-${position}-${borderRadius}`; } createPeaksHash(peaks) { return `${peaks.length}-${peaks[0] || 0}-${peaks[peaks.length - 1] || 0}`; } }; //#endregion //#region src/debug-system.ts var DebugSystem = class { enabled = false; debugInfo; renderTimes = []; lastFrameTime = 0; constructor() { this.debugInfo = this.createInitialDebugInfo(); } enable() { this.enabled = true; this.log("Debug mode enabled"); } disable() { this.enabled = false; } isEnabled() { return this.enabled; } log(message, data) { if (!this.enabled) return; const timestamp = performance.now().toFixed(2); const prefix = `[WaveformRenderer Debug ${timestamp}ms]`; if (data) console.log(prefix, message, data); else console.log(prefix, message); } updateRenderMetrics(renderTime) { if (!this.enabled) return; this.debugInfo.performance.totalRenders++; this.debugInfo.performance.lastRenderTime = renderTime; this.renderTimes.push(renderTime); if (this.renderTimes.length > 60) this.renderTimes.shift(); this.debugInfo.performance.averageRenderTime = this.renderTimes.reduce((sum, time) => sum + time, 0) / this.renderTimes.length; const now = performance.now(); if (this.lastFrameTime > 0) { const deltaTime = now - this.lastFrameTime; this.debugInfo.performance.fps = Math.round(1e3 / deltaTime); } this.lastFrameTime = now; } updateCacheMetrics(buildTime) { if (!this.enabled) return; this.debugInfo.performance.cacheBuilds++; this.debugInfo.performance.lastCacheBuildTime = buildTime; } updateState(canvas, peaksCount, barsRendered, cacheValid, dirtyFlags) { if (!this.enabled) return; const rect = canvas.getBoundingClientRect(); this.debugInfo.state = { canvasSize: { width: rect.width, height: rect.height }, peaksCount, barsRendered, cacheValid, dirtyFlags: { ...dirtyFlags } }; } incrementSeeks() { this.debugInfo.events.totalSeeks++; } incrementResizes() { this.debugInfo.events.totalResizes++; } incrementErrors() { this.debugInfo.events.totalErrors++; } getInfo() { return JSON.parse(JSON.stringify(this.debugInfo)); } reset() { this.debugInfo.performance.totalRenders = 0; this.debugInfo.performance.averageRenderTime = 0; this.debugInfo.performance.cacheBuilds = 0; this.debugInfo.events.totalSeeks = 0; this.debugInfo.events.totalResizes = 0; this.debugInfo.events.totalErrors = 0; this.renderTimes = []; this.log("Debug counters reset"); } logPerformanceSummary() { if (!this.enabled || this.debugInfo.performance.totalRenders % 60 !== 0) return; this.log("Performance summary", { totalRenders: this.debugInfo.performance.totalRenders, averageRenderTime: this.debugInfo.performance.averageRenderTime.toFixed(2) + "ms", fps: this.debugInfo.performance.fps, cacheBuilds: this.debugInfo.performance.cacheBuilds }); } createInitialDebugInfo() { return { performance: { lastRenderTime: 0, averageRenderTime: 0, totalRenders: 0, fps: 0, cacheBuilds: 0, lastCacheBuildTime: 0 }, state: { canvasSize: { width: 0, height: 0 }, peaksCount: 0, barsRendered: 0, cacheValid: false, dirtyFlags: { peaks: true, options: true, size: true, progress: true } }, events: { totalSeeks: 0, totalResizes: 0, totalErrors: 0 } }; } }; //#endregion //#region src/event-handler.ts var EventHandlerManager = class { canvas; resizeObserver; callbacks; resizeTimeout; resizeDebounceDelay = 150; constructor(canvas, callbacks) { this.canvas = canvas; this.callbacks = callbacks; this.resizeObserver = new ResizeObserver(this.handleResize); this.resizeObserver.observe(this.canvas); this.attachEventListeners(); } destroy() { this.detachEventListeners(); this.resizeObserver.disconnect(); if (this.resizeTimeout) clearTimeout(this.resizeTimeout); } attachEventListeners() { this.canvas.addEventListener("click", this.handleClick); this.canvas.addEventListener("touchstart", this.handleTouch); } detachEventListeners() { this.canvas.removeEventListener("click", this.handleClick); this.canvas.removeEventListener("touchstart", this.handleTouch); } handleClick = (event) => { event.preventDefault(); try { const progress = this.calculateProgressFromEvent(event); this.callbacks.onSeek(progress); } catch (e) { this.handleError(e); } }; handleTouch = (event) => { event.preventDefault(); if (!event.changedTouches[0]) return; try { const progress = this.calculateProgressFromTouch(event.changedTouches[0]); this.callbacks.onSeek(progress); } catch (e) { this.handleError(e); } }; handleResize = () => { if (this.resizeTimeout) clearTimeout(this.resizeTimeout); this.resizeTimeout = window.setTimeout(() => { try { const rect = this.canvas.getBoundingClientRect(); this.callbacks.onResize({ height: rect.height, width: rect.width }); } catch (e) { this.handleError(e); } }, this.resizeDebounceDelay); }; 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); } handleError(e) { const error = e instanceof Error ? e : new Error("An unknown error occurred"); this.callbacks.onError(error); } }; //#endregion //#region src/rendering-engine.ts var RenderingEngine = class { ctx; callbacks; customRenderer; hooks = {}; constructor(ctx, callbacks) { this.ctx = ctx; this.callbacks = callbacks; } setCustomRenderer(renderer) { this.customRenderer = renderer; } setHooks(hooks) { this.hooks = { ...hooks }; } clearHooks() { this.hooks = {}; } render(cache, options, staticPath) { this.callbacks.onRenderStart(); try { this.hooks.beforeRender?.(this.ctx, cache, options); if (this.customRenderer?.render(this.ctx, cache, options, staticPath)) { this.callbacks.onRenderComplete(); return; } const { backgroundColor, color, progress } = options; this.ctx.clearRect(0, 0, cache.canvasWidth, cache.canvasHeight); if (this.shouldUseFallbackRendering(options, staticPath)) this.renderWithFallback(cache, backgroundColor, options); else this.renderWithPath(staticPath, backgroundColor, options); this.hooks.afterBackground?.(this.ctx, cache, options); if (progress > 0) { if (this.shouldUseFallbackRendering(options, staticPath)) this.renderProgressWithFallback(cache, color, progress, options); else this.renderProgressWithPath(staticPath, color, progress, cache.canvasWidth, options); this.hooks.afterProgress?.(this.ctx, cache, options, progress); } if (options.progressLine && progress > 0) { const x = cache.canvasWidth * progress; drawProgressLine(this.ctx, x, cache.canvasHeight, options.progressLine); } this.hooks.afterComplete?.(this.ctx, cache, options); this.callbacks.onRenderComplete(); } catch (error) { throw error; } } shouldUseFallbackRendering(options, staticPath) { return options.borderRadius > 0 && (!staticPath || typeof Path2D.prototype.roundRect !== "function"); } renderWithPath(path, backgroundColor, options) { this.ctx.fillStyle = backgroundColor; this.ctx.fill(path); if (options.borderWidth > 0) { this.ctx.strokeStyle = options.borderColor; this.ctx.lineWidth = options.borderWidth; this.ctx.stroke(path); } } renderProgressWithPath(path, color, progress, canvasWidth, options) { this.ctx.save(); const progressWidth = canvasWidth * progress; this.ctx.beginPath(); this.ctx.rect(0, 0, progressWidth, canvasWidth); this.ctx.clip(); this.ctx.fillStyle = color; this.ctx.fill(path); if (options.borderWidth > 0) { this.ctx.strokeStyle = options.borderColor; this.ctx.stroke(path); } this.ctx.restore(); } renderWithFallback(cache, color, options) { this.renderBarsWithFallback(cache.bars, color, options); } renderProgressWithFallback(cache, color, progress, options) { this.ctx.save(); const progressWidth = cache.canvasWidth * progress; this.ctx.beginPath(); this.ctx.rect(0, 0, progressWidth, cache.canvasHeight); this.ctx.clip(); this.renderBarsWithFallback(cache.bars, color, options); this.ctx.restore(); } renderBarsWithFallback(bars, color, options) { const { borderColor, borderRadius, borderWidth = 0 } = options; this.ctx.fillStyle = color; if (borderWidth > 0) { this.ctx.strokeStyle = borderColor; this.ctx.lineWidth = borderWidth; } this.ctx.beginPath(); for (const bar of bars) if (borderRadius > 0 && typeof this.ctx.roundRect === "function") this.ctx.roundRect(bar.x, bar.y, bar.width, bar.height, borderRadius); else this.ctx.rect(bar.x, bar.y, bar.width, bar.height); this.ctx.fill(); if (borderWidth > 0) this.ctx.stroke(); } }; //#endregion //#region src/renderer.ts var WaveformRenderer = class extends EventEmitter { canvas; ctx; devicePixelRatio; cacheManager; debugSystem; eventHandler; renderingEngine; isDestroyed = false; options; peaks = []; dirtyFlags = { peaks: true, options: true, size: true, progress: true }; frameRequest; lastRenderTime = 0; minRenderInterval = 16; constructor(canvas, peaks, options = {}) { super(); const startTime = performance.now(); try { this.validateInputs(canvas, peaks); this.canvas = canvas; this.ctx = this.getCanvasContext(canvas); this.peaks = normalizePeaks(peaks); this.options = this.mergeOptions(options); this.devicePixelRatio = Math.max(window.devicePixelRatio || 1, this.options.minPixelRatio); this.cacheManager = new CacheManager(); this.debugSystem = new DebugSystem(); this.renderingEngine = new RenderingEngine(this.ctx, { onRenderStart: () => this.emit("renderStart", void 0), onRenderComplete: () => this.emit("renderComplete", void 0) }); this.eventHandler = new EventHandlerManager(this.canvas, { onSeek: (progress) => this.handleSeek(progress), onResize: (dimensions) => this.handleResize(dimensions), onError: (error) => this.handleError(error) }); if (this.options.debug) this.debugSystem.enable(); this.setupCanvas(); this.scheduleRender(); const initTime = performance.now() - startTime; this.debugSystem.log(`Initialized in ${initTime.toFixed(2)}ms`); requestAnimationFrame(() => this.emit("ready", void 0)); } catch (e) { this.handleError(e); } } destroy() { if (this.isDestroyed) return; this.debugSystem.log("Destroying renderer"); this.emit("destroy", void 0); this.isDestroyed = true; this.eventHandler.destroy(); this.cancelPendingRender(); this.cacheManager.clear(); } setOptions(options) { if (this.isDestroyed) return; const startTime = performance.now(); const oldOptions = this.options; this.options = this.mergeOptions(options, this.options); if (options.debug !== void 0) if (options.debug) this.debugSystem.enable(); else this.debugSystem.disable(); this.updateDirtyFlags(oldOptions, this.options); this.setupContext(); this.scheduleRender(); const setOptionsTime = performance.now() - startTime; this.debugSystem.log(`setOptions completed in ${setOptionsTime.toFixed(2)}ms`); } setPeaks(peaks) { if (this.isDestroyed) return; const startTime = performance.now(); try { if (!Array.isArray(peaks) || peaks.length === 0) throw new Error("Peaks array must not be empty"); this.peaks = normalizePeaks([...peaks]); this.dirtyFlags.peaks = true; this.cacheManager.invalidate(); this.scheduleRender(); const setPeaksTime = performance.now() - startTime; this.debugSystem.log(`setPeaks completed in ${setPeaksTime.toFixed(2)}ms, ${peaks.length} peaks`); } catch (e) { this.handleError(e); } } setProgress(progress) { if (this.isDestroyed) return; try { const normalizedProgress = normalizeProgress(progress); if (Math.abs(this.options.progress - normalizedProgress) < .001) return; this.options.progress = normalizedProgress; this.dirtyFlags.progress = true; this.emit("progressChange", normalizedProgress); this.scheduleRender(); this.debugSystem.log(`Progress set to ${(normalizedProgress * 100).toFixed(1)}%`); } 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.dirtyFlags.options = true; this.scheduleRender(); this.debugSystem.log(`Progress line options ${options ? "updated" : "disabled"}`); } catch (e) { this.handleError(e); } } setDebug(enabled) { if (enabled) this.debugSystem.enable(); else this.debugSystem.disable(); this.options.debug = enabled; } getDebugInfo() { return this.debugSystem.getInfo(); } resetDebugCounters() { this.debugSystem.reset(); } setCustomRenderer(renderer) { this.renderingEngine.setCustomRenderer(renderer); this.debugSystem.log(`Custom renderer ${renderer ? "set" : "cleared"}`); } setRenderHooks(hooks) { this.renderingEngine.setHooks(hooks); this.debugSystem.log("Render hooks configured"); } clearRenderHooks() { this.renderingEngine.clearHooks(); this.debugSystem.log("Render hooks cleared"); } validateInputs(canvas, peaks) { 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"); } getCanvasContext(canvas) { const context = canvas.getContext("2d"); if (!context) throw new Error("Could not get 2D context from canvas"); return context; } mergeOptions(newOptions, baseOptions) { const base = baseOptions || DEFAULT_OPTIONS; return { ...base, ...newOptions, progressLine: newOptions.progressLine !== void 0 ? newOptions.progressLine ? { ...DEFAULT_OPTIONS.progressLine, ...base.progressLine, ...newOptions.progressLine } : null : base.progressLine }; } updateDirtyFlags(oldOptions, newOptions) { const layoutKeys = [ "amplitude", "backgroundColor", "barWidth", "borderColor", "borderRadius", "borderWidth", "color", "gap", "position" ]; const hasLayoutChanges = layoutKeys.some((key) => oldOptions[key] !== newOptions[key]); if (hasLayoutChanges) { this.dirtyFlags.options = true; this.cacheManager.invalidate(); this.debugSystem.log("Layout-affecting options changed, invalidating cache"); } const progressChanged = oldOptions.progress !== newOptions.progress; if (progressChanged && !hasLayoutChanges) { this.dirtyFlags.progress = true; this.debugSystem.log("Progress changed"); } } setupCanvas() { resizeCanvas(this.canvas, this.devicePixelRatio); this.ctx.setTransform(1, 0, 0, 1, 0, 0); this.ctx.scale(this.devicePixelRatio, this.devicePixelRatio); this.setupContext(); } setupContext() { setupCanvasContext(this.ctx, this.options.smoothing); } scheduleRender() { if (this.frameRequest) return; this.frameRequest = requestAnimationFrame(() => { this.frameRequest = void 0; this.render(); }); } cancelPendingRender() { if (this.frameRequest) { cancelAnimationFrame(this.frameRequest); this.frameRequest = void 0; } } render() { if (this.isDestroyed) return; const now = performance.now(); if (now - this.lastRenderTime < this.minRenderInterval) { this.scheduleRender(); return; } this.lastRenderTime = now; const renderStartTime = performance.now(); try { const cache = this.cacheManager.getCache(this.canvas, this.devicePixelRatio, this.peaks, this.options); const staticPath = this.cacheManager.createStaticPath(cache, this.options.borderRadius); this.renderingEngine.render(cache, this.options, staticPath); this.dirtyFlags = { peaks: false, options: false, size: false, progress: false }; const renderTime = performance.now() - renderStartTime; this.debugSystem.updateRenderMetrics(renderTime); this.debugSystem.updateState(this.canvas, this.peaks.length, cache.totalBars, this.cacheManager.isValid(), this.dirtyFlags); this.debugSystem.logPerformanceSummary(); } catch (e) { this.handleError(e); } } handleSeek(progress) { this.debugSystem.incrementSeeks(); this.debugSystem.log(`Seek to ${(progress * 100).toFixed(1)}%`); this.emit("seek", progress); } handleResize(dimensions) { this.debugSystem.incrementResizes(); this.debugSystem.log(`Canvas resized to ${dimensions.width}x${dimensions.height}`); this.emit("resize", dimensions); this.dirtyFlags.size = true; this.cacheManager.invalidate(); this.setupCanvas(); this.scheduleRender(); } handleError(e) { this.debugSystem.incrementErrors(); const error = e instanceof Error ? e : new Error("An unknown error occurred"); this.debugSystem.log("Error occurred", error.message); console.error("WaveformRenderer error:", e); this.emit("error", error); } }; //#endregion exports.DEFAULT_OPTIONS = DEFAULT_OPTIONS; exports.WaveformRenderer = WaveformRenderer; exports.getPeaksFromAudioBuffer = getPeaksFromAudioBuffer; return exports; })({});