UNPKG

waveform-renderer

Version:

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

298 lines (236 loc) 9.46 kB
import type { ProgressLineOptions, WaveformEvents, WaveformOptions } from "@/types"; import { DEFAULT_OPTIONS } from "@/constants"; import { EventEmitter } from "@/events"; import { calculateBarDimensions, drawProgressLine, normalizePeaks, normalizeProgress, resizeCanvas, setupCanvasContext, } from "@/utils"; export default class WaveformRenderer extends EventEmitter<WaveformEvents> { private readonly canvas!: HTMLCanvasElement; private readonly ctx!: CanvasRenderingContext2D; private devicePixelRatio!: number; private frameRequest?: number; private isDestroyed: boolean = false; private options!: Required<WaveformOptions>; private peaks!: number[]; private readonly resizeObserver!: ResizeObserver; constructor(canvas: HTMLCanvasElement, peaks: number[], options: Partial<WaveformOptions> = {}) { super(); 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", undefined)); } catch (e) { this.handleError(e); } } public destroy(): void { if (this.isDestroyed) return; this.emit("destroy", undefined); this.isDestroyed = true; this.resizeObserver.disconnect(); this.canvas.removeEventListener("click", this.handleClick); this.canvas.removeEventListener("touchend", this.handleTouch); if (this.frameRequest) cancelAnimationFrame(this.frameRequest); } public setOptions(options: Partial<WaveformOptions>): void { if (this.isDestroyed) return; this.options = { ...this.options, ...options, }; this.setupContext(); this.scheduleRender(); } public setPeaks(peaks: number[]): void { 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); } } public setProgress(progress: number): void { if (this.isDestroyed) return; try { const normalizedProgress = normalizeProgress(progress); this.options.progress = normalizedProgress; this.emit("progressChange", normalizedProgress); this.scheduleRender(); } catch (e) { this.handleError(e); } } public setProgressLineOptions(options: null | Partial<ProgressLineOptions>): void { 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); } } private calculateProgressFromEvent(event: MouseEvent): number { const rect = this.canvas.getBoundingClientRect(); const x = event.clientX - rect.left; return normalizeProgress(x / rect.width); } private calculateProgressFromTouch(touch: Touch): number { const rect = this.canvas.getBoundingClientRect(); const x = touch.clientX - rect.left; return normalizeProgress(x / rect.width); } private drawWaveform(): void { if (this.isDestroyed) return; this.emit("renderStart", undefined); 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 as Required<ProgressLineOptions>); } this.emit("renderComplete", undefined); } catch (e) { this.handleError(e); } } private drawWaveformWithColor(color: string): void { 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(); } } } private handleClick = (event: MouseEvent): void => { event.preventDefault(); if (this.isDestroyed) return; try { const progress = this.calculateProgressFromEvent(event); this.emit("seek", progress); } catch (e) { this.handleError(e); } }; private handleError = (e: unknown): void => { console.error(e); this.emit("error", e instanceof Error ? e : new Error("An unknown error occurred")); }; private handleResize = (): void => { 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); } }; private handleTouch = (event: TouchEvent): void => { 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); } }; private resizeCanvas(): void { resizeCanvas(this.canvas, this.devicePixelRatio); this.ctx.setTransform(1, 0, 0, 1, 0, 0); this.ctx.scale(this.devicePixelRatio, this.devicePixelRatio); this.setupContext(); } private scheduleRender(): void { if (this.frameRequest) cancelAnimationFrame(this.frameRequest); this.frameRequest = requestAnimationFrame(() => this.drawWaveform()); } private setupContext(): void { setupCanvasContext(this.ctx, this.options.smoothing); } }