Skip to content

Custom Renderers

Complete control over waveform visualization with custom rendering implementations

Custom renderers provide complete control over waveform visualization while maintaining compatibility with the library’s core features like caching, events, and option management.

In this guide, we’ll build a complete ConnectedWaveRenderer that creates connected wave segments with alternating up/down patterns, developing it progressively by showing only the changes in each step.

import type { CustomRenderer, RenderCache, WaveformOptions } from "waveform-renderer";
export class ConnectedWaveRenderer implements CustomRenderer {
public render(
ctx: CanvasRenderingContext2D,
cache: RenderCache,
options: Required<WaveformOptions>,
staticPath?: Path2D,
): boolean {
ctx.save();
try {
ctx.clearRect(0, 0, cache.canvasWidth, cache.canvasHeight);
ctx.translate(0, cache.canvasHeight / 2);
this.renderWaveform(ctx, cache, options, options.backgroundColor);
if (options.progress > 0) {
this.renderProgress(ctx, cache, options);
}
} finally {
ctx.restore();
}
return true;
}
private renderWaveform(
ctx: CanvasRenderingContext2D,
cache: RenderCache,
options: Required<WaveformOptions>,
strokeColor: string,
): void {
ctx.strokeStyle = strokeColor;
ctx.lineWidth = options.barWidth;
ctx.lineCap = "round";
ctx.lineJoin = "round";
for (let i = 0; i < cache.totalBars; i++) {
const bar = cache.bars[i];
const x = bar.x;
const height = bar.height / 2;
const isEven = i % 2 === 0;
this.drawLineSegment(ctx, x, height, cache.singleUnitWidth, isEven);
}
}
private drawLineSegment(
ctx: CanvasRenderingContext2D,
x: number,
height: number,
width: number,
isEven: boolean,
): void {
ctx.beginPath();
const y = isEven ? height : -height;
ctx.moveTo(x, 0);
ctx.lineTo(x, y);
ctx.arc(x + width / 2, y, width / 2, Math.PI, 0, isEven);
ctx.lineTo(x + width, 0);
ctx.stroke();
}
private renderProgress(ctx: CanvasRenderingContext2D, cache: RenderCache, options: Required<WaveformOptions>): void {
ctx.save();
const progressWidth = cache.canvasWidth * options.progress;
ctx.beginPath();
ctx.rect(0, -cache.canvasHeight / 2, progressWidth, cache.canvasHeight);
ctx.clip();
this.renderWaveform(ctx, cache, options, options.color);
ctx.restore();
}
}

Step 2: Adding Custom Properties and Position Support

Section titled “Step 2: Adding Custom Properties and Position Support”
import type { CustomRenderer, RenderCache, WaveformOptions, RenderMode } from "waveform-renderer";
interface ConnectedWaveOptions {
startWithUp?: boolean;
}
export class ConnectedWaveRenderer implements CustomRenderer {
private connectedOptions: Required<ConnectedWaveOptions>;
constructor(customOptions: ConnectedWaveOptions = {}) {
this.connectedOptions = {
startWithUp: true,
...customOptions,
};
}
public updateOptions(options: Partial<ConnectedWaveOptions>): void {
this.connectedOptions = { ...this.connectedOptions, ...options };
}
public render(/* ... */): boolean {
ctx.save();
try {
ctx.clearRect(0, 0, cache.canvasWidth, cache.canvasHeight);
ctx.translate(0, cache.canvasHeight / 2);
this.setupCoordinateSystem(ctx, cache, options);
this.renderWaveform(ctx, cache, options, options.backgroundColor);
this.renderWaveform(ctx, cache, options, options.backgroundColor, false);
if (options.progress > 0) {
this.renderProgress(ctx, cache, options);
}
} finally {
ctx.restore();
}
return true;
}
private setupCoordinateSystem(ctx: CanvasRenderingContext2D, cache: RenderCache, options: Required<WaveformOptions>): void {
switch (options.position) {
case "center": ctx.translate(0, cache.canvasHeight / 2); break;
case "top": ctx.translate(0, cache.canvasHeight / 4); break;
case "bottom": ctx.translate(0, (cache.canvasHeight * 3) / 4); break;
}
}
private renderWaveform(
ctx: CanvasRenderingContext2D,
cache: RenderCache,
options: Required<WaveformOptions>,
strokeColor: string
strokeColor: string,
isProgress: boolean = false
): void {
ctx.strokeStyle = strokeColor;
ctx.lineWidth = options.barWidth;
ctx.lineCap = "round";
ctx.lineJoin = "round";
const segmentWidth = cache.singleUnitWidth;
for (let i = 0; i < cache.totalBars; i++) {
const bar = cache.bars[i];
const x = bar.x;
const height = bar.height / 2;
const x = bar.x + (cache.singleUnitWidth - segmentWidth) / 2;
let height = this.calculateHeight(bar.height / 2, cache.canvasHeight, options.position);
const isEven = i % 2 === 0;
const isEven = this.connectedOptions.startWithUp ? i % 2 === 0 : i % 2 === 1;
this.drawLineSegment(ctx, x, height, cache.singleUnitWidth, isEven);
this.drawLineSegment(ctx, x, height, segmentWidth, isEven, options);
}
}
private calculateHeight(barHeight: number, canvasHeight: number, position: RenderMode): number {
switch (position) {
case "top":
case "bottom": return Math.min(barHeight, canvasHeight / 3);
default: return Math.min(barHeight, canvasHeight / 2);
}
}
private drawLineSegment(
ctx: CanvasRenderingContext2D,
x: number,
height: number,
width: number,
isEven: boolean
isEven: boolean,
options: Required<WaveformOptions>
): void { /* unchanged */ }
private renderProgress(/* ... */): void {
ctx.save();
const progressWidth = cache.canvasWidth * options.progress;
ctx.beginPath();
ctx.rect(0, -cache.canvasHeight / 2, progressWidth, cache.canvasHeight);
ctx.clip();
const progressWidth = cache.canvasWidth * options.progress;
ctx.beginPath();
switch (options.position) {
case "center": ctx.rect(0, -cache.canvasHeight / 2, progressWidth, cache.canvasHeight); break;
case "top": ctx.rect(0, -cache.canvasHeight / 4, progressWidth, cache.canvasHeight); break;
case "bottom": ctx.rect(0, (-cache.canvasHeight * 3) / 4, progressWidth, cache.canvasHeight); break;
}
ctx.clip();
this.renderWaveform(ctx, cache, options, options.color);
this.renderWaveform(ctx, cache, options, options.color, true);
ctx.restore();
}
}

Step 3: Adding Border Support and Progress Line

Section titled “Step 3: Adding Border Support and Progress Line”
public render(/* ... */): boolean {
ctx.save();
try {
ctx.clearRect(0, 0, cache.canvasWidth, cache.canvasHeight);
this.setupCoordinateSystem(ctx, cache, options);
this.renderWaveform(ctx, cache, options, options.backgroundColor, false);
this.renderWaveform(ctx, cache, options, options.backgroundColor, false);
if (options.progress > 0) {
this.renderProgress(ctx, cache, options);
}
ctx.restore();
ctx.save();
if (options.progressLine && options.progress > 0) {
this.drawProgressLine(ctx, cache, options);
}
} finally {
ctx.restore();
}
return true;
}
private drawLineSegment(
ctx: CanvasRenderingContext2D,
x: number,
height: number,
width: number,
isEven: boolean,
options: Required<WaveformOptions>
options: Required<WaveformOptions>,
isProgress: boolean
): void {
ctx.beginPath();
const y = isEven ? height : -height;
const y = isEven ? height : -height;
ctx.moveTo(x, 0);
ctx.lineTo(x, y);
ctx.arc(x + width / 2, y, width / 2, Math.PI, 0, isEven);
ctx.lineTo(x + width, 0);
ctx.stroke();
if (options.borderWidth > 0) {
ctx.save();
ctx.strokeStyle = options.borderColor;
ctx.lineWidth = options.borderWidth;
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, y);
ctx.arc(x + width / 2, y, width / 2, Math.PI, 0, isEven);
ctx.lineTo(x + width, 0);
ctx.stroke();
ctx.restore();
}
}
private drawProgressLine(ctx: CanvasRenderingContext2D, cache: RenderCache, options: Required<WaveformOptions>): void {
if (!options.progressLine) return;
const { color, heightPercent, position, style, width } = options.progressLine;
const x = cache.canvasWidth * options.progress;
const lineHeight = cache.canvasHeight * (heightPercent || 1);
ctx.save();
ctx.strokeStyle = color || "#FF0000";
ctx.lineWidth = width || 2;
ctx.lineCap = "round";
let startY: number;
let endY: number;
switch (position || "center") {
case "bottom": startY = cache.canvasHeight; endY = cache.canvasHeight - lineHeight; break;
case "top": startY = 0; endY = lineHeight; break;
default: startY = (cache.canvasHeight - lineHeight) / 2; endY = (cache.canvasHeight + lineHeight) / 2; break;
}
if (style && 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();
}
private renderWaveform(/* ... */): void {
for (let i = 0; i < cache.totalBars; i++) {
this.drawLineSegment(ctx, x, height, segmentWidth, isEven, options);
this.drawLineSegment(ctx, x, height, segmentWidth, isEven, options, isProgress);
}
}
import { WaveformRenderer } from "waveform-renderer";
import { ConnectedWaveRenderer } from "./connected-wave-renderer";
const connectedRenderer = new ConnectedWaveRenderer({ startWithUp: true });
const waveform = new WaveformRenderer(canvas, peaks, {
color: "#2196F3",
backgroundColor: "#E3F2FD",
barWidth: 3,
gap: 10,
borderWidth: 1,
borderColor: "#1565C0",
position: "center",
progressLine: {
color: "#FF4081",
width: 2,
style: "dashed",
heightPercent: 0.8,
},
});
waveform.setCustomRenderer(connectedRenderer);
connectedRenderer.updateOptions({ startWithUp: false });
waveform.setOptions({ position: "top" });
waveform.setOptions({ position: "bottom" });
waveform.setCustomRenderer(undefined);
const waveform = new WaveformRenderer(canvas, peaks, {
debug: true,
});