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
JavaScript
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
};