├── .github
├── logo-dark.svg
├── renovate.json
└── workflows
│ └── test.yml
├── .gitignore
├── .lintstagedrc
├── .npmignore
├── .prettierignore
├── .prettierrc
├── LICENSE.md
├── README.md
├── dts-bundle-generator.config.ts
├── eslint.config.js
├── package.json
├── pnpm-lock.yaml
├── src
├── constants
│ ├── default.ts
│ └── index.ts
├── events
│ ├── EventEmitter.ts
│ └── index.ts
├── index.ts
├── renderer.ts
├── types
│ └── index.ts
├── utils
│ ├── canvas.ts
│ ├── index.ts
│ └── peaks.ts
└── vite-env.d.ts
├── test
└── .gitkeep
├── tsconfig.json
├── vite.config.ts
└── webpage
├── demo
├── controls.ts
└── player.ts
├── index.css
├── index.html
└── public
├── audio.mp3
├── favicon.svg
└── logo-light.svg
/.github/logo-dark.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.github/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3 | "extends": [
4 | "config:best-practices",
5 | ":pinOnlyDevDependencies",
6 | ":ignoreModulesAndTests",
7 | ":preserveSemverRanges",
8 | "group:all",
9 | "schedule:weekly",
10 | ":maintainLockFilesMonthly"
11 | ],
12 | "labels": ["dependencies", "renovate"],
13 | "lockFileMaintenance": {
14 | "commitMessageAction": "Update",
15 | "extends": [
16 | "group:all"
17 | ]
18 | },
19 | "separateMajorMinor": false,
20 | "autoApprove": true,
21 | "automerge": true,
22 | "pruneBranchAfterAutomerge": true,
23 | "automergeType": "branch"
24 | }
25 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: TypeScript CI
2 |
3 | on:
4 | push:
5 | branches:
6 | - '**'
7 | pull_request:
8 | types: [opened, synchronize, reopened]
9 |
10 | jobs:
11 | validate:
12 | runs-on: ubuntu-latest
13 |
14 | strategy:
15 | matrix:
16 | node-version: [18.x, 20.x, 22.x]
17 |
18 | steps:
19 | - uses: actions/checkout@v4
20 |
21 | - name: Use Node.js ${{ matrix.node-version }}
22 | uses: actions/setup-node@v4
23 | with:
24 | node-version: ${{ matrix.node-version }}
25 |
26 | - name: Install pnpm
27 | uses: pnpm/action-setup@v2
28 | with:
29 | version: 9
30 |
31 | - name: Get pnpm store directory
32 | shell: bash
33 | run: |
34 | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
35 |
36 | - uses: actions/cache@v3
37 | name: Setup pnpm cache
38 | with:
39 | path: ${{ env.STORE_PATH }}
40 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
41 | restore-keys: |
42 | ${{ runner.os }}-pnpm-store-
43 |
44 | - name: Install dependencies
45 | run: pnpm install --frozen-lockfile
46 |
47 | - name: Check TypeScript types
48 | run: pnpm run type-check
49 |
50 | - name: Run ESLint
51 | run: pnpm run lint
52 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | build
12 | dist
13 | out
14 | dist-ssr
15 | *.local
16 | coverage
17 |
18 | # Editor directories and files
19 | .vscode/*
20 | .history/*
21 | !.vscode/extensions.json
22 | .idea
23 | .DS_Store
24 | *.suo
25 | *.ntvs*
26 | *.njsproj
27 | *.sln
28 | *.sw?
29 |
--------------------------------------------------------------------------------
/.lintstagedrc:
--------------------------------------------------------------------------------
1 | {
2 | "./**/*.{ts,html,json}": "npm run format"
3 | }
4 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | coverage
2 | test
3 | node_modules
4 | *.log
5 | .lintstagedrc
6 | .prettierignore
7 | .prettierrc
8 | favicon.ico
9 | vite.config.js
10 | dts-bundle-generator.config.json
11 | tsconfig.json
12 | index.html
13 | .vscode
14 | .github
15 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | .history
2 | .husky
3 | .vscode
4 | coverage
5 | dist
6 | node_modules
7 | .github
8 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 120,
3 | "tabWidth": 4,
4 | "singleQuote": false,
5 | "trailingComma": "es5",
6 | "arrowParens": "avoid",
7 | "bracketSpacing": true,
8 | "useTabs": false,
9 | "endOfLine": "auto",
10 | "singleAttributePerLine": false,
11 | "bracketSameLine": false,
12 | "jsxSingleQuote": false,
13 | "quoteProps": "as-needed",
14 | "semi": true
15 | }
16 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Andres Alarcon
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6 |
7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8 |
9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
10 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Waveform Renderer
2 |
3 |
4 |
5 |
6 |
7 | [](#)
8 | [](#)
9 | [](#)
10 | [](#)
11 |
12 | A lightweight and customizable TypeScript library for rendering audio waveforms on HTML canvas. Create beautiful, interactive audio visualizations with ease.
13 |
14 | ## 📝 Table of Contents
15 |
16 | - [Features](#features)
17 | - [Installation](#installation)
18 | - [Quick Start](#quick-start)
19 | - [API](#api)
20 | - [Configuration Options](#configuration-options)
21 | - [Events](#events)
22 | - [Exports](#exports)
23 | - [Methods](#methods)
24 | - [Examples](#examples)
25 | - [Browser Support](#browser-support)
26 | - [Motivation](#motivation)
27 | - [Contributing](#contributing)
28 | - [License](#license)
29 | - [Acknowledgements](#acknowledgements)
30 |
31 | ## ✨ Features
32 |
33 | - 🎨 Highly customizable appearance
34 | - ⚡ Performant canvas-based rendering
35 | - 📱 Responsive and touch-friendly
36 | - 🔄 Real-time progress updates
37 | - 🎯 Interactive seeking
38 | - 💪 Written in TypeScript with full type support
39 | - 📏 Resolution independent with HiDPI/Retina support
40 |
41 | ## 🚀 Installation
42 |
43 | ```bash
44 | npm install waveform-renderer
45 | # or
46 | yarn add waveform-renderer
47 | ```
48 |
49 | ## 📖 Quick Start
50 |
51 | ```typescript
52 | import { WaveformRenderer } from 'waveform-renderer';
53 |
54 | // Get your canvas element
55 | const canvas = document.getElementById('waveform') as HTMLCanvasElement;
56 |
57 | // Prepare your audio peaks data
58 | const peaks = [...]; // Array of numbers
59 |
60 | // Create waveform instance
61 | const waveform = new WaveformRenderer(canvas, peaks, {
62 | color: '#2196F3',
63 | backgroundColor: '#E3F2FD',
64 | progressLine: {
65 | color: '#1565C0'
66 | }
67 | });
68 |
69 | // Listen to events
70 | waveform.on('seek', (progress) => {
71 | console.log(`Seeked to ${progress * 100}%`);
72 | });
73 | ```
74 |
75 | ## 🛠 API
76 |
77 | ### Configuration Options
78 |
79 | #### WaveformOptions
80 |
81 | | Option | Type | Default | Description |
82 | | ----------------- | ------------------------------- | ----------- | ------------------------------------------- |
83 | | `amplitude` | `number` | `1` | Amplitude multiplier for the waveform |
84 | | `backgroundColor` | `string` | `"#CCCCCC"` | Background color of the waveform |
85 | | `barWidth` | `number` | `2` | Width of each bar in pixels |
86 | | `borderColor` | `string` | `"#000000"` | Border color of the bars |
87 | | `borderRadius` | `number` | `0` | Border radius of the bars in pixels |
88 | | `borderWidth` | `number` | `0` | Border width of the bars in pixels |
89 | | `color` | `string` | `"#000000"` | Color of the waveform bars |
90 | | `gap` | `number` | `1` | Gap between bars in pixels |
91 | | `minPixelRatio` | `number` | `1` | Minimum pixel ratio for rendering |
92 | | `position` | `"bottom" \| "center" \| "top"` | `"center"` | Vertical positioning of the waveform |
93 | | `progress` | `number` | `0` | Initial progress (0-1) |
94 | | `smoothing` | `boolean` | `true` | Whether to apply smoothing to the rendering |
95 | | `progressLine` | `ProgressLineOptions` | `null` | Progress line options |
96 |
97 | #### ProgressLineOptions
98 |
99 | | Option | Type | Default | Description |
100 | | --------------- | --------------------------------- | ----------- | ------------------------------------------------ |
101 | | `color` | `string` | `"#FF0000"` | Color of the progress line |
102 | | `heightPercent` | `number` | `1` | Height of the line as percentage of total height |
103 | | `position` | `"bottom" \| "center" \| "top"` | `"center"` | Vertical position of the line |
104 | | `style` | `"solid" \| "dashed" \| "dotted"` | `"solid"` | Style of the progress line |
105 | | `width` | `number` | `2` | Width of the line in pixels |
106 |
107 | ### 🎯 Events
108 |
109 | The waveform renderer emits the following events:
110 |
111 | | Event | Payload | Description |
112 | | ---------------- | ----------------------------------- | ------------------------------------------ |
113 | | `renderStart` | `void` | Emitted when rendering begins |
114 | | `renderComplete` | `void` | Emitted when rendering is complete |
115 | | `seek` | `number` | Progress value between 0-1 when user seeks |
116 | | `error` | `Error` | Error object when an error occurs |
117 | | `destroy` | `void` | Emitted when the instance is destroyed |
118 | | `ready` | `void` | Emitted when the waveform is ready |
119 | | `resize` | `{ width: number; height: number }` | New dimensions when canvas is resized |
120 | | `progressChange` | `number` | New progress value between 0-1 |
121 |
122 | Example of type-safe event handling:
123 |
124 | ```typescript
125 | waveform.on("resize", ({ width, height }) => {
126 | console.log(`Canvas resized to ${width}x${height}`);
127 | });
128 |
129 | waveform.on("seek", progress => {
130 | // progress is a number between 0-1
131 | audioElement.currentTime = audioElement.duration * progress;
132 | });
133 | ```
134 |
135 | ## 📦 Exports
136 |
137 | The library provides the following exports:
138 |
139 | ### Main Component
140 |
141 | ```typescript
142 | import { WaveformRenderer } from "waveform-renderer";
143 | ```
144 |
145 | ### Utility Functions
146 |
147 | ```typescript
148 | import { getPeaksFromAudioBuffer } from "waveform-renderer";
149 | ```
150 |
151 | This utility helps you calculate peaks from an AudioBuffer, useful when you need to generate waveform data from raw audio.
152 |
153 | ### TypeScript Types
154 |
155 | ```typescript
156 | import type { WaveformOptions, ProgressLineOptions, WaveformEvents, RenderMode } from "waveform-renderer";
157 | ```
158 |
159 | Example of using the utility function:
160 |
161 | ```typescript
162 | // Get an AudioBuffer from your audio source
163 | const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
164 |
165 | // Calculate peaks
166 | const peaks = getPeaksFromAudioBuffer(audioBuffer);
167 |
168 | // Create waveform with calculated peaks
169 | const waveform = new WaveformRenderer(canvas, peaks, options);
170 | ```
171 |
172 | ### 🔧 Methods
173 |
174 | #### Constructor
175 |
176 | ```typescript
177 | constructor(
178 | canvas: HTMLCanvasElement,
179 | peaks: number[],
180 | options?: Partial
181 | )
182 | ```
183 |
184 | #### Instance Methods
185 |
186 | - `setOptions(options: Partial)`: Updates the waveform options
187 | - `setPeaks(peaks: number[])`: Updates the waveform peaks data
188 | - `setProgress(progress: number)`: Updates the current progress (0-1)
189 | - `destroy()`: Cleans up and removes the instance
190 |
191 | ## 💡 Examples
192 |
193 | ### Custom Styling
194 |
195 | ```typescript
196 | const waveform = new WaveformRenderer(canvas, peaks, {
197 | color: "#2196F3",
198 | backgroundColor: "#E3F2FD",
199 | barWidth: 3,
200 | gap: 2,
201 | borderRadius: 2,
202 | progressLine: {
203 | color: "#1565C0",
204 | style: "dashed",
205 | width: 2,
206 | },
207 | });
208 | ```
209 |
210 | ### Event Handling
211 |
212 | ```typescript
213 | const waveform = new WaveformRenderer(canvas, peaks);
214 |
215 | waveform.on("ready", () => {
216 | console.log("Waveform is ready!");
217 | });
218 |
219 | waveform.on("seek", progress => {
220 | audioElement.currentTime = audioElement.duration * progress;
221 | });
222 |
223 | // Cleanup
224 | waveform.off("seek", seekHandler);
225 | // or remove all listeners
226 | waveform.removeAllListeners();
227 | ```
228 |
229 | ## 🌐 Browser Support
230 |
231 | The library works in all modern browsers that support Canvas and ES6.
232 |
233 | ## 💡 Motivation
234 |
235 | While [wavesurfer.js](https://wavesurfer.xyz/) is an excellent library, we needed a more focused solution. Waveform Renderer was created to be a lightweight alternative that concentrates solely on waveform visualization, eliminating additional features like playback, regions, or spectrograms. This results in:
236 |
237 | - 🎯 Focused scope: just waveform rendering
238 | - 📦 Smaller bundle size
239 | - 💪 TypeScript-first development
240 | - ⚡ Optimized performance for waveform rendering
241 |
242 | Choose Waveform Renderer when you need efficient waveform visualization without the overhead of a full-featured audio library.
243 |
244 | ## 🤝 Contributing
245 |
246 | Contributions are welcome! Please feel free to submit a Pull Request.
247 |
248 | ## 📄 License
249 |
250 | MIT License
251 |
252 | ## 🙏 Acknowledgements
253 |
254 | - Inspired by [wavesurfer.js](https://wavesurfer.xyz/)
255 | - Co-created with the help of [Claude](https://www.anthropic.com/index/introducing-claude)
256 |
--------------------------------------------------------------------------------
/dts-bundle-generator.config.ts:
--------------------------------------------------------------------------------
1 | const config = {
2 | entries: [
3 | {
4 | filePath: "./src/index.ts",
5 | noCheck: false,
6 | outFile: "./dist/waveform-renderer.d.ts",
7 | },
8 | ],
9 | };
10 |
11 | module.exports = config;
12 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import pluginJs from "@eslint/js";
2 | import perfectionist from "eslint-plugin-perfectionist";
3 | import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended";
4 | import globals from "globals";
5 | import tseslint from "typescript-eslint";
6 |
7 | /** @type {import('eslint').Linter.Config[]} */
8 | export default [
9 | { files: ["**/*.{js,mjs,cjs,ts}"] },
10 | { languageOptions: { globals: globals.browser } },
11 | pluginJs.configs.recommended,
12 | ...tseslint.configs.recommended,
13 | eslintPluginPrettierRecommended,
14 | perfectionist.configs["recommended-natural"],
15 | {
16 | rules: {
17 | "perfectionist/sort-imports": ["error"],
18 | },
19 | },
20 | ];
21 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "waveform-renderer",
3 | "description": "High-performance audio waveform visualization library for the web. Create customizable, interactive waveform renderers with TypeScript support and zero dependencies.",
4 | "version": "1.0.0-beta.1",
5 | "author": {
6 | "name": "Andres Alarcon",
7 | "email": "work@andrez.co"
8 | },
9 | "repository": {
10 | "type": "git",
11 | "url": "git+https://github.com/maximux13/waveform-renderer.git"
12 | },
13 | "main": "./dist/waveform-renderer.cjs",
14 | "module": "./dist/waveform-renderer.esm.js",
15 | "devDependencies": {
16 | "@eslint/js": "^9.16.0",
17 | "@tailwindcss/typography": "^0.5.15",
18 | "@tailwindcss/vite": "4.0.0-beta.6",
19 | "@types/jsdom": "^21.1.6",
20 | "@types/node": "^22.10.1",
21 | "@vercel/analytics": "^1.4.1",
22 | "@vitest/coverage-v8": "^2.1.8",
23 | "dts-bundle-generator": "^9.2.4",
24 | "eslint": "^9.16.0",
25 | "eslint-config-prettier": "^9.1.0",
26 | "eslint-plugin-perfectionist": "^4.2.0",
27 | "eslint-plugin-prettier": "^5.2.1",
28 | "globals": "^15.13.0",
29 | "jsdom": "^25.0.1",
30 | "lint-staged": "^15.2.0",
31 | "np": "^10.0.6",
32 | "prettier": "^3.4.2",
33 | "tailwindcss": "4.0.0-beta.6",
34 | "terser": "^5.31.1",
35 | "ts-node": "^10.9.2",
36 | "tweakpane": "^4.0.5",
37 | "typescript": "^5.3.3",
38 | "typescript-eslint": "^8.18.0",
39 | "upgradeps": "^2.0.6",
40 | "vite": "^6.0.3",
41 | "vitest": "^2.1.8"
42 | },
43 | "exports": {
44 | ".": {
45 | "require": "./dist/waveform-renderer.cjs",
46 | "import": "./dist/waveform-renderer.esm.js",
47 | "types": "./dist/waveform-renderer.d.ts"
48 | },
49 | "./dist/": {
50 | "import": "./dist/",
51 | "require": "./dist/",
52 | "types": "./dist/"
53 | }
54 | },
55 | "bugs": {
56 | "url": "https://github.com/maximux13/waveform-renderer/issues"
57 | },
58 | "files": [
59 | "dist",
60 | "README.md",
61 | "LICENSE.md",
62 | "CHANGELOG.md",
63 | "src",
64 | "package.json"
65 | ],
66 | "homepage": "https://github.com/maximux13/waveform-renderer#readme",
67 | "jsdelivr": "./dist/waveform-renderer.iife.js",
68 | "keywords": [
69 | "waveform",
70 | "audio",
71 | "canvas",
72 | "visualization",
73 | "audio-visualization",
74 | "audio-waveform",
75 | "typescript",
76 | "web-audio",
77 | "html5-canvas",
78 | "audio-player",
79 | "sound-visualization",
80 | "wave",
81 | "peaks",
82 | "renderer",
83 | "audio-renderer",
84 | "interactive",
85 | "audio-visualization-library",
86 | "waveform-renderer",
87 | "canvas-renderer"
88 | ],
89 | "license": "MIT",
90 | "private": false,
91 | "scripts": {
92 | "dev": "vite --host",
93 | "build": "npm run build:lib && npm run build:web",
94 | "build:lib": "vite build --mode lib",
95 | "build:web": "vite build --mode webpage",
96 | "postbuild:lib": "dts-bundle-generator --config ./dts-bundle-generator.config.ts",
97 | "test": "vitest",
98 | "type-check": "tsc --noEmit",
99 | "lint": "eslint ./src",
100 | "test:coverage": "vitest --coverage",
101 | "format": "eslint --fix",
102 | "upgrade": "upgradeps",
103 | "release": "npm run build:lib && np"
104 | },
105 | "type": "module",
106 | "types": "./dist/waveform-renderer.d.ts",
107 | "typesVersions": {
108 | "*": {
109 | "*": [
110 | "./dist/waveform-renderer.d.ts"
111 | ]
112 | }
113 | },
114 | "typings": "./dist/waveform-renderer.d.ts"
115 | }
116 |
--------------------------------------------------------------------------------
/src/constants/default.ts:
--------------------------------------------------------------------------------
1 | import type { WaveformOptions } from "@/types";
2 |
3 | export const DEFAULT_OPTIONS: Required = {
4 | amplitude: 1,
5 | backgroundColor: "#CCCCCC",
6 | barWidth: 2,
7 | borderColor: "#000000",
8 | borderRadius: 0,
9 | borderWidth: 0,
10 | color: "#000000",
11 | gap: 1,
12 | minPixelRatio: 1,
13 | position: "center",
14 | progress: 0,
15 | progressLine: {
16 | color: "#FF0000",
17 | heightPercent: 1,
18 | position: "center",
19 | style: "solid",
20 | width: 2,
21 | },
22 | smoothing: true,
23 | };
24 |
--------------------------------------------------------------------------------
/src/constants/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./default";
2 |
--------------------------------------------------------------------------------
/src/events/EventEmitter.ts:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
2 | export class EventEmitter> {
3 | private events = new Map void>>();
4 |
5 | public off(event: E, callback: (args: Events[E]) => void): void {
6 | const callbacks = this.events.get(event);
7 | if (callbacks) {
8 | const index = callbacks.indexOf(callback as (args: Events[keyof Events]) => void);
9 | if (index !== -1) {
10 | callbacks.splice(index, 1);
11 | }
12 | if (callbacks.length === 0) {
13 | this.events.delete(event);
14 | }
15 | }
16 | }
17 |
18 | public on(event: E, callback: (args: Events[E]) => void): void {
19 | if (!this.events.has(event)) {
20 | this.events.set(event, []);
21 | }
22 | const callbacks = this.events.get(event)!;
23 | callbacks.push(callback as (args: Events[keyof Events]) => void);
24 | }
25 |
26 | public once(event: E, callback: (args: Events[E]) => void): void {
27 | const onceCallback = ((args: Events[E]) => {
28 | this.off(event, onceCallback);
29 | callback(args);
30 | }) as (args: Events[keyof Events]) => void;
31 |
32 | this.on(event, onceCallback);
33 | }
34 |
35 | public removeAllListeners(event?: E): void {
36 | if (event) {
37 | this.events.delete(event);
38 | } else {
39 | this.events.clear();
40 | }
41 | }
42 |
43 | protected emit(event: E, args: Events[E]): void {
44 | const callbacks = this.events.get(event);
45 | if (callbacks) {
46 | (callbacks as Array<(args: Events[E]) => void>).forEach(callback => callback(args));
47 | }
48 | }
49 |
50 | protected hasListeners(event: E): boolean {
51 | const callbacks = this.events.get(event);
52 | return callbacks ? callbacks.length > 0 : false;
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/src/events/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./EventEmitter";
2 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export { default as WaveformRenderer } from "@/renderer";
2 | export type { ProgressLineOptions, RenderMode, WaveformEvents, WaveformOptions } from "@/types";
3 |
4 | export { getPeaksFromAudioBuffer } from "@/utils/peaks";
5 |
--------------------------------------------------------------------------------
/src/renderer.ts:
--------------------------------------------------------------------------------
1 | import type { ProgressLineOptions, WaveformEvents, WaveformOptions } from "@/types";
2 |
3 | import { DEFAULT_OPTIONS } from "@/constants";
4 | import { EventEmitter } from "@/events";
5 | import {
6 | calculateBarDimensions,
7 | drawProgressLine,
8 | normalizePeaks,
9 | normalizeProgress,
10 | resizeCanvas,
11 | setupCanvasContext,
12 | } from "@/utils";
13 |
14 | export default class WaveformRenderer extends EventEmitter {
15 | private readonly canvas!: HTMLCanvasElement;
16 | private readonly ctx!: CanvasRenderingContext2D;
17 | private devicePixelRatio!: number;
18 |
19 | private frameRequest?: number;
20 | private isDestroyed: boolean = false;
21 | private options!: Required;
22 | private peaks!: number[];
23 | private readonly resizeObserver!: ResizeObserver;
24 |
25 | constructor(canvas: HTMLCanvasElement, peaks: number[], options: Partial = {}) {
26 | super();
27 |
28 | try {
29 | if (!canvas) {
30 | throw new Error("Canvas element is required");
31 | }
32 |
33 | if (!Array.isArray(peaks) || peaks.length === 0) {
34 | throw new Error("Peaks array is required and must not be empty");
35 | }
36 |
37 | this.canvas = canvas;
38 | const context = this.canvas.getContext("2d");
39 |
40 | if (!context) {
41 | throw new Error("Could not get 2D context from canvas");
42 | }
43 |
44 | this.ctx = context;
45 | this.peaks = normalizePeaks(peaks);
46 | this.options = {
47 | ...DEFAULT_OPTIONS,
48 | ...options,
49 | progressLine: options.progressLine
50 | ? {
51 | ...DEFAULT_OPTIONS.progressLine,
52 | ...options.progressLine,
53 | }
54 | : null,
55 | };
56 |
57 | this.devicePixelRatio = Math.max(window.devicePixelRatio || 1, this.options.minPixelRatio);
58 |
59 | this.setupContext();
60 |
61 | this.resizeObserver = new ResizeObserver(this.handleResize);
62 | this.resizeObserver.observe(this.canvas);
63 |
64 | this.canvas.addEventListener("click", this.handleClick);
65 | this.canvas.addEventListener("touchstart", this.handleTouch);
66 |
67 | this.resizeCanvas();
68 | this.scheduleRender();
69 |
70 | requestAnimationFrame(() => this.emit("ready", undefined));
71 | } catch (e) {
72 | this.handleError(e);
73 | }
74 | }
75 |
76 | public destroy(): void {
77 | if (this.isDestroyed) return;
78 |
79 | this.emit("destroy", undefined);
80 | this.isDestroyed = true;
81 | this.resizeObserver.disconnect();
82 | this.canvas.removeEventListener("click", this.handleClick);
83 | this.canvas.removeEventListener("touchend", this.handleTouch);
84 |
85 | if (this.frameRequest) cancelAnimationFrame(this.frameRequest);
86 | }
87 |
88 | public setOptions(options: Partial): void {
89 | if (this.isDestroyed) return;
90 |
91 | this.options = {
92 | ...this.options,
93 | ...options,
94 | };
95 |
96 | this.setupContext();
97 | this.scheduleRender();
98 | }
99 |
100 | public setPeaks(peaks: number[]): void {
101 | if (this.isDestroyed) return;
102 |
103 | try {
104 | if (!Array.isArray(peaks) || peaks.length === 0) {
105 | throw new Error("Peaks array must not be empty");
106 | }
107 |
108 | this.peaks = normalizePeaks(peaks);
109 | this.scheduleRender();
110 | } catch (e) {
111 | this.handleError(e);
112 | }
113 | }
114 |
115 | public setProgress(progress: number): void {
116 | if (this.isDestroyed) return;
117 |
118 | try {
119 | const normalizedProgress = normalizeProgress(progress);
120 | this.options.progress = normalizedProgress;
121 | this.emit("progressChange", normalizedProgress);
122 | this.scheduleRender();
123 | } catch (e) {
124 | this.handleError(e);
125 | }
126 | }
127 |
128 | public setProgressLineOptions(options: null | Partial): void {
129 | if (this.isDestroyed) return;
130 |
131 | try {
132 | if (options) {
133 | this.options.progressLine = {
134 | ...DEFAULT_OPTIONS.progressLine,
135 | ...this.options.progressLine,
136 | ...options,
137 | };
138 | } else {
139 | this.options.progressLine = null;
140 | }
141 |
142 | this.scheduleRender();
143 | } catch (e) {
144 | this.handleError(e);
145 | }
146 | }
147 |
148 | private calculateProgressFromEvent(event: MouseEvent): number {
149 | const rect = this.canvas.getBoundingClientRect();
150 | const x = event.clientX - rect.left;
151 | return normalizeProgress(x / rect.width);
152 | }
153 |
154 | private calculateProgressFromTouch(touch: Touch): number {
155 | const rect = this.canvas.getBoundingClientRect();
156 | const x = touch.clientX - rect.left;
157 | return normalizeProgress(x / rect.width);
158 | }
159 |
160 | private drawWaveform(): void {
161 | if (this.isDestroyed) return;
162 |
163 | this.emit("renderStart", undefined);
164 |
165 | try {
166 | const { backgroundColor, color, progress } = this.options;
167 | const canvasWidth = this.canvas.width / this.devicePixelRatio;
168 | const canvasHeight = this.canvas.height / this.devicePixelRatio;
169 |
170 | this.ctx.clearRect(0, 0, canvasWidth, canvasHeight);
171 |
172 | this.drawWaveformWithColor(backgroundColor);
173 |
174 | if (progress > 0) {
175 | this.ctx.save();
176 | const progressWidth = canvasWidth * progress;
177 | this.ctx.beginPath();
178 | this.ctx.rect(0, 0, progressWidth, canvasHeight);
179 | this.ctx.clip();
180 | this.drawWaveformWithColor(color);
181 | this.ctx.restore();
182 | }
183 |
184 | if (this.options.progressLine && progress > 0) {
185 | const x = canvasWidth * progress;
186 | drawProgressLine(this.ctx, x, canvasHeight, this.options.progressLine as Required);
187 | }
188 |
189 | this.emit("renderComplete", undefined);
190 | } catch (e) {
191 | this.handleError(e);
192 | }
193 | }
194 |
195 | private drawWaveformWithColor(color: string): void {
196 | const { amplitude = 1, barWidth, borderColor, borderRadius, borderWidth = 0, gap = 0, position } = this.options;
197 |
198 | const canvasWidth = this.canvas.width / this.devicePixelRatio;
199 | const canvasHeight = this.canvas.height / this.devicePixelRatio;
200 |
201 | const initialOffset = borderWidth;
202 | const availableWidth = canvasWidth - borderWidth * 2 * initialOffset;
203 | const singleUnitWidth = barWidth + borderWidth * 2 + gap;
204 | const totalBars = Math.floor(availableWidth / singleUnitWidth);
205 | const finalTotalBars = Math.max(1, totalBars);
206 |
207 | const step = this.peaks.length / finalTotalBars;
208 |
209 | this.ctx.fillStyle = color;
210 | this.ctx.strokeStyle = borderColor;
211 | this.ctx.lineWidth = borderWidth;
212 |
213 | for (let i = 0; i < finalTotalBars; i++) {
214 | const peakIndex = Math.floor(i * step);
215 | const peak = Math.abs(this.peaks[peakIndex] || 0);
216 |
217 | const x = initialOffset + i * singleUnitWidth;
218 | const { height, y } = calculateBarDimensions(peak, canvasHeight, amplitude, position);
219 |
220 | this.ctx.beginPath();
221 |
222 | if (borderRadius > 0) {
223 | this.ctx.roundRect(x, y, barWidth, height, borderRadius);
224 | } else {
225 | this.ctx.rect(x, y, barWidth, height);
226 | }
227 |
228 | this.ctx.fill();
229 |
230 | if (borderWidth > 0) {
231 | this.ctx.stroke();
232 | }
233 | }
234 | }
235 |
236 | private handleClick = (event: MouseEvent): void => {
237 | event.preventDefault();
238 | if (this.isDestroyed) return;
239 |
240 | try {
241 | const progress = this.calculateProgressFromEvent(event);
242 | this.emit("seek", progress);
243 | } catch (e) {
244 | this.handleError(e);
245 | }
246 | };
247 |
248 | private handleError = (e: unknown): void => {
249 | console.error(e);
250 | this.emit("error", e instanceof Error ? e : new Error("An unknown error occurred"));
251 | };
252 |
253 | private handleResize = (): void => {
254 | if (this.isDestroyed) return;
255 |
256 | try {
257 | const rect = this.canvas.getBoundingClientRect();
258 | this.emit("resize", {
259 | height: rect.height,
260 | width: rect.width,
261 | });
262 |
263 | this.resizeCanvas();
264 | this.scheduleRender();
265 | } catch (e) {
266 | this.handleError(e);
267 | }
268 | };
269 |
270 | private handleTouch = (event: TouchEvent): void => {
271 | event.preventDefault();
272 | if (this.isDestroyed || !event.changedTouches[0]) return;
273 |
274 | try {
275 | const progress = this.calculateProgressFromTouch(event.changedTouches[0]);
276 | this.emit("seek", progress);
277 | } catch (e) {
278 | this.handleError(e);
279 | }
280 | };
281 |
282 | private resizeCanvas(): void {
283 | resizeCanvas(this.canvas, this.devicePixelRatio);
284 | this.ctx.setTransform(1, 0, 0, 1, 0, 0);
285 | this.ctx.scale(this.devicePixelRatio, this.devicePixelRatio);
286 | this.setupContext();
287 | }
288 |
289 | private scheduleRender(): void {
290 | if (this.frameRequest) cancelAnimationFrame(this.frameRequest);
291 | this.frameRequest = requestAnimationFrame(() => this.drawWaveform());
292 | }
293 |
294 | private setupContext(): void {
295 | setupCanvasContext(this.ctx, this.options.smoothing);
296 | }
297 | }
298 |
--------------------------------------------------------------------------------
/src/types/index.ts:
--------------------------------------------------------------------------------
1 | export interface ProgressLineOptions {
2 | color?: string;
3 | heightPercent?: number;
4 | position?: RenderMode;
5 | style?: "dashed" | "dotted" | "solid";
6 | width?: number;
7 | }
8 |
9 | export type RenderMode = "bottom" | "center" | "top";
10 |
11 | export interface WaveformEvents {
12 | destroy: void;
13 | error: Error;
14 | progressChange: number;
15 | ready: void;
16 | renderComplete: void;
17 | renderStart: void;
18 | resize: { height: number; width: number };
19 | seek: number;
20 | }
21 |
22 | export interface WaveformOptions {
23 | amplitude?: number;
24 | backgroundColor?: string;
25 | barWidth?: number;
26 | borderColor?: string;
27 | borderRadius?: number;
28 | borderWidth?: number;
29 | color?: string;
30 | gap?: number;
31 | minPixelRatio?: number;
32 | position?: RenderMode;
33 | progress?: number;
34 | progressLine?: null | ProgressLineOptions;
35 | smoothing?: boolean;
36 | }
37 |
--------------------------------------------------------------------------------
/src/utils/canvas.ts:
--------------------------------------------------------------------------------
1 | import type { ProgressLineOptions, RenderMode } from "../types";
2 |
3 | /**
4 | * Calculates the vertical position and height for a bar based on the render mode
5 | */
6 | export function calculateBarDimensions(
7 | peak: number,
8 | canvasHeight: number,
9 | amplitude: number,
10 | position: RenderMode
11 | ): { height: number; y: number } {
12 | const height = peak * canvasHeight * amplitude;
13 |
14 | switch (position) {
15 | case "bottom":
16 | return { height, y: canvasHeight - height };
17 | case "top":
18 | return { height, y: 0 };
19 | case "center":
20 | default:
21 | return { height, y: (canvasHeight - height) / 2 };
22 | }
23 | }
24 |
25 | /**
26 | * Calculates the vertical position for a progress line based on the render mode
27 | */
28 | export function calculateLineDimensions(
29 | lineHeight: number,
30 | canvasHeight: number,
31 | position: RenderMode
32 | ): { endY: number; startY: number } {
33 | switch (position) {
34 | case "bottom":
35 | return { endY: canvasHeight, startY: canvasHeight - lineHeight };
36 | case "top":
37 | return { endY: lineHeight, startY: 0 };
38 | case "center":
39 | default:
40 | return { endY: (canvasHeight + lineHeight) / 2, startY: (canvasHeight - lineHeight) / 2 };
41 | }
42 | }
43 |
44 | /**
45 | * Draws a progress line on the canvas
46 | */
47 | export function drawProgressLine(
48 | ctx: CanvasRenderingContext2D,
49 | x: number,
50 | canvasHeight: number,
51 | options: Required
52 | ): void {
53 | const { color, heightPercent, position, style, width } = options;
54 | const lineHeight = canvasHeight * heightPercent;
55 |
56 | ctx.save();
57 | ctx.strokeStyle = color;
58 | ctx.lineWidth = width;
59 | ctx.lineCap = "round";
60 |
61 | const { endY, startY } = calculateLineDimensions(lineHeight, canvasHeight, position);
62 |
63 | if (style !== "solid") {
64 | const [dashSize, gapSize] = style === "dashed" ? [8, 4] : [2, 2];
65 | ctx.setLineDash([dashSize, gapSize]);
66 | }
67 |
68 | ctx.beginPath();
69 | ctx.moveTo(x, startY);
70 | ctx.lineTo(x, endY);
71 | ctx.stroke();
72 | ctx.restore();
73 | }
74 |
75 | /**
76 | * Resizes the canvas accounting for device pixel ratio
77 | */
78 | export function resizeCanvas(canvas: HTMLCanvasElement, devicePixelRatio: number): { height: number; width: number } {
79 | const rect = canvas.getBoundingClientRect();
80 | const width = rect.width * devicePixelRatio;
81 | const height = rect.height * devicePixelRatio;
82 |
83 | if (canvas.width !== width || canvas.height !== height) {
84 | canvas.width = width;
85 | canvas.height = height;
86 | }
87 |
88 | return { height: rect.height, width: rect.width };
89 | }
90 |
91 | /**
92 | * Configures the canvas context with the specified settings
93 | */
94 | export function setupCanvasContext(ctx: CanvasRenderingContext2D, smoothing: boolean = true): void {
95 | ctx.imageSmoothingEnabled = smoothing;
96 | if (smoothing) {
97 | ctx.imageSmoothingQuality = "high";
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./canvas";
2 | export * from "./peaks";
3 |
--------------------------------------------------------------------------------
/src/utils/peaks.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Calculates peaks from an AudioBuffer
3 | */
4 | export function getPeaksFromAudioBuffer(audioBuffer: AudioBuffer, numberOfPeaks: number): number[] {
5 | const channelData = audioBuffer.getChannelData(0);
6 | const peaks: number[] = new Array(numberOfPeaks);
7 | const samplesPerPeak = Math.floor(channelData.length / numberOfPeaks);
8 |
9 | for (let i = 0; i < numberOfPeaks; i++) {
10 | const start = i * samplesPerPeak;
11 | const end = start + samplesPerPeak;
12 | let max = 0;
13 |
14 | for (let j = start; j < end; j++) {
15 | const absolute = Math.abs(channelData[j]);
16 | if (absolute > max) max = absolute;
17 | }
18 |
19 | peaks[i] = max;
20 | }
21 |
22 | return normalizePeaks(peaks);
23 | }
24 |
25 | /**
26 | * Normalizes an array of peak values to a range of -1 to 1
27 | */
28 | export function normalizePeaks(peaks: number[]): number[] {
29 | const maxPeak = Math.max(...peaks.map(Math.abs), 1);
30 | return peaks.map(peak => peak / maxPeak);
31 | }
32 |
33 | /**
34 | * Ensures progress value is between 0 and 1
35 | */
36 | export function normalizeProgress(progress: number): number {
37 | return Math.max(0, Math.min(1, progress));
38 | }
39 |
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/test/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maximux13/waveform-renderer/2949538a298a69858782178a20de005de40d4311/test/.gitkeep
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "rootDir": ".",
4 | "paths": {
5 | "@/*": ["./src/*"],
6 | "@@/*": ["./*"]
7 | },
8 | "target": "ESNext",
9 | "useDefineForClassFields": true,
10 | "module": "ESNext",
11 | "lib": ["ESNext", "DOM"],
12 | "moduleResolution": "Node",
13 | "strict": true,
14 | "sourceMap": true,
15 | "resolveJsonModule": true,
16 | "esModuleInterop": true,
17 | "noEmit": true,
18 | "noUnusedLocals": true,
19 | "noUnusedParameters": true,
20 | "noImplicitReturns": true,
21 | "forceConsistentCasingInFileNames": true,
22 | "types": ["vite/client", "node"],
23 | "declaration": true,
24 | "declarationDir": "dist",
25 | "listEmittedFiles": true
26 | },
27 | "include": ["src", "dist/index.d.ts"],
28 | "exclude": ["**/*.test.ts", "node_modules", "test/**", ".history/**"]
29 | }
30 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | import tailwindcss from "@tailwindcss/vite";
4 | import path from "path";
5 | import { defineConfig } from "vite";
6 |
7 | import packageJson from "./package.json";
8 |
9 | const getPackageName = () => {
10 | return packageJson.name;
11 | };
12 |
13 | const getPackageNameCamelCase = () => {
14 | try {
15 | return getPackageName().replace(/-./g, char => char[1].toUpperCase());
16 | } catch {
17 | throw new Error("Name property in package.json is missing.");
18 | }
19 | };
20 |
21 | const fileName = {
22 | cjs: `${getPackageName()}.cjs`,
23 | es: `${getPackageName()}.esm.js`,
24 | iife: `${getPackageName()}.iife.js`,
25 | };
26 |
27 | const formats = Object.keys(fileName) as Array;
28 |
29 | export default defineConfig(({ mode }) => {
30 | const baseConfig = {
31 | base: "./",
32 | resolve: {
33 | alias: [
34 | { find: "@", replacement: path.resolve(__dirname, "src") },
35 | { find: "@@", replacement: path.resolve(__dirname) },
36 | ],
37 | },
38 | };
39 |
40 | if (mode === "webpage") {
41 | return {
42 | ...baseConfig,
43 | build: {
44 | emptyOutDir: true,
45 | minify: "esbuild",
46 | outDir: "../out",
47 | publicDir: path.resolve(__dirname, "webpage/public"),
48 | rollupOptions: {
49 | input: path.resolve(__dirname, "webpage/index.html"),
50 | },
51 | },
52 | plugins: [tailwindcss()],
53 | publicDir: path.resolve(__dirname, "webpage/public"),
54 | root: path.resolve(__dirname, "webpage"),
55 | server: {
56 | open: "/index.html",
57 | },
58 | };
59 | }
60 |
61 | return {
62 | ...baseConfig,
63 | build: {
64 | emptyOutDir: true,
65 | lib: {
66 | entry: path.resolve(__dirname, "src/index.ts"),
67 | fileName: format => fileName[format],
68 | formats,
69 | name: getPackageNameCamelCase(),
70 | },
71 | minify: "terser",
72 | outDir: "./dist",
73 | terserOptions: {
74 | keep_classnames: true,
75 | keep_fnames: true,
76 | },
77 | },
78 | test: {
79 | environment: "jsdom",
80 | globals: true,
81 | },
82 | };
83 | });
84 |
--------------------------------------------------------------------------------
/webpage/demo/controls.ts:
--------------------------------------------------------------------------------
1 | import { Pane } from "tweakpane";
2 |
3 | import type WaveformRenderer from "../../src/renderer";
4 | import type { WaveformOptions } from "../../src/types";
5 |
6 | const positions = { bottom: "bottom", center: "center", top: "top" };
7 | const styles = { dashed: "dashed", dotted: "dotted", solid: "solid" };
8 |
9 | export const buildControls = (waveform: WaveformRenderer, config: WaveformOptions) => {
10 | const pane = new Pane({
11 | container: document.querySelector("#controls") as HTMLElement,
12 | expanded: true,
13 | title: "Parameters",
14 | });
15 |
16 | const f1 = pane.addFolder({
17 | title: "Waveform",
18 | });
19 |
20 | const f2 = pane.addFolder({
21 | title: "Peaks",
22 | });
23 |
24 | const f3 = pane.addFolder({
25 | expanded: false,
26 | title: "Progress Line",
27 | });
28 |
29 | f1.addBinding(config, "amplitude", { max: 1, min: 0, step: 0.1 }).on("change", ev => {
30 | waveform.setOptions({ amplitude: ev.value });
31 | });
32 |
33 | f1.addBinding(config, "color").on("change", ev => {
34 | waveform.setOptions({ color: ev.value });
35 | });
36 |
37 | f1.addBinding(config, "backgroundColor").on("change", ev => {
38 | waveform.setOptions({ backgroundColor: ev.value });
39 | });
40 |
41 | f1.addBinding(config, "progress", { interval: 1000, max: 1, min: 0, step: 0.01 }).on("change", ev => {
42 | waveform.setProgress(ev.value);
43 | });
44 |
45 | f1.addBinding(config, "position", { options: positions }).on("change", ev => {
46 | return waveform.setOptions({ position: ev.value });
47 | });
48 |
49 | f2.addBinding(config, "barWidth", { max: 10, min: 1, step: 1 }).on("change", ev => {
50 | waveform.setOptions({ barWidth: ev.value });
51 | });
52 |
53 | f2.addBinding(config, "gap", { max: 10, min: 0, step: 1 }).on("change", ev => {
54 | waveform.setOptions({ gap: ev.value });
55 | });
56 |
57 | f2.addBinding(config, "borderColor").on("change", ev => {
58 | waveform.setOptions({ borderColor: ev.value });
59 | });
60 |
61 | f2.addBinding(config, "borderRadius", { max: 10, min: 0, step: 0.1 }).on("change", ev => {
62 | waveform.setOptions({ borderRadius: ev.value });
63 | });
64 |
65 | f2.addBinding(config, "borderWidth", { max: 10, min: 0, step: 0.1 }).on("change", ev => {
66 | waveform.setOptions({ borderWidth: ev.value });
67 | });
68 |
69 | f3.addBinding(config.progressLine, "color").on("change", ev => {
70 | waveform.setProgressLineOptions({ color: ev.value });
71 | });
72 |
73 | f3.addBinding(config.progressLine, "heightPercent", { max: 1, min: 0, step: 0.1 }).on("change", ev => {
74 | waveform.setProgressLineOptions({ heightPercent: ev.value });
75 | });
76 |
77 | f3.addBinding(config.progressLine, "position", { options: positions }).on("change", ev => {
78 | waveform.setProgressLineOptions({ position: ev.value });
79 | });
80 |
81 | f3.addBinding(config.progressLine, "style", { options: styles }).on("change", ev => {
82 | waveform.setProgressLineOptions({ style: ev.value });
83 | });
84 |
85 | f3.addBinding(config.progressLine, "width", { max: 10, min: 1, step: 0.1 }).on("change", ev => {
86 | waveform.setProgressLineOptions({ width: ev.value });
87 | });
88 | };
89 |
--------------------------------------------------------------------------------
/webpage/demo/player.ts:
--------------------------------------------------------------------------------
1 | import { getPeaksFromAudioBuffer, type WaveformOptions, WaveformRenderer } from "../../src/";
2 | import { DEFAULT_OPTIONS } from "../../src/constants/default";
3 | import { buildControls } from "./controls";
4 |
5 | interface PlayerConfig {
6 | canvas: HTMLCanvasElement;
7 | dropzone: HTMLElement;
8 | waveformOptions?: Partial;
9 | }
10 |
11 | interface PlayerUI {
12 | button: HTMLElement;
13 | duration: HTMLElement;
14 | pauseIcon: HTMLElement;
15 | playIcon: HTMLElement;
16 | remaining: HTMLElement;
17 | }
18 |
19 | export class Player {
20 | private readonly allowedTypes: readonly string[];
21 | private audio: HTMLAudioElement;
22 | private readonly audioContext: AudioContext;
23 | private peaks: number[];
24 | private readonly UI: PlayerUI;
25 | private waveform: null | WaveformRenderer;
26 |
27 | constructor(private readonly config: PlayerConfig) {
28 | this.audio = new Audio();
29 | this.audio.src = "/audio.mp3";
30 | this.waveform = null;
31 | this.peaks = [];
32 | this.allowedTypes = ["audio/*"] as const;
33 | this.audioContext = new AudioContext();
34 |
35 | this.UI = this.initializeUI();
36 |
37 | this.initializeAudio();
38 | this.setupDropzone();
39 | }
40 |
41 | public getDuration(): string {
42 | return this.formatTime(this.audio.duration);
43 | }
44 |
45 | public getRemainingTime(): string {
46 | return `-${this.formatTime(this.audio.duration - this.audio.currentTime)}`;
47 | }
48 |
49 | private formatTime(timeInSeconds: number): string {
50 | return new Date(timeInSeconds * 1000).toISOString().substr(14, 5);
51 | }
52 |
53 | private async getAudioBuffer(): Promise {
54 | const response = await fetch(this.audio.src);
55 | const arrayBuffer = await response.arrayBuffer();
56 | return await this.audioContext.decodeAudioData(arrayBuffer);
57 | }
58 |
59 | private handleDragOver = (event: DragEvent): void => {
60 | event.preventDefault();
61 | };
62 |
63 | private handleDrop = (event: DragEvent): void => {
64 | event.preventDefault();
65 | const file = event.dataTransfer?.files[0];
66 |
67 | if (file && this.isValidAudioFile(file)) {
68 | this.loadNewAudioFile(file);
69 | }
70 | };
71 |
72 | private handlePlayPause = (): void => {
73 | if (this.audio.paused) {
74 | this.audio.play();
75 | } else {
76 | this.audio.pause();
77 | }
78 | };
79 |
80 | private handleSeek = (progress: number): void => {
81 | this.audio.currentTime = progress * this.audio.duration;
82 | if (this.audio.paused) this.audio.play();
83 | };
84 |
85 | private handleTimeUpdate = (): void => {
86 | const progress = this.audio.currentTime / this.audio.duration;
87 | this.waveform?.setProgress(progress);
88 | this.UI.remaining.textContent = this.getRemainingTime();
89 | };
90 |
91 | private async initializeAudio(): Promise {
92 | this.audio.addEventListener("loadeddata", async () => {
93 | const audioBuffer = await this.getAudioBuffer();
94 | this.peaks = getPeaksFromAudioBuffer(audioBuffer, 1200);
95 |
96 | this.initializeWaveform();
97 | this.setupAudioEventListeners();
98 | this.UI.duration.textContent = this.getDuration();
99 | this.UI.remaining.textContent = this.getRemainingTime();
100 | });
101 | }
102 |
103 | private initializeUI(): PlayerUI {
104 | return {
105 | button: document.querySelector("#play")!,
106 | duration: document.querySelector("#duration")!,
107 | pauseIcon: document.querySelector("[data-icon=pause]")!,
108 | playIcon: document.querySelector("[data-icon=play]")!,
109 | remaining: document.querySelector("#remaining")!,
110 | };
111 | }
112 |
113 | private initializeWaveform(): void {
114 | if (this.waveform) {
115 | this.waveform.setPeaks(this.peaks);
116 | } else {
117 | this.waveform = new WaveformRenderer(
118 | this.config.canvas,
119 | this.peaks,
120 | this.config.waveformOptions ?? DEFAULT_OPTIONS
121 | );
122 | this.setupWaveformListeners();
123 | this.setControls();
124 | }
125 | }
126 |
127 | private isValidAudioFile(file: File): boolean {
128 | const isValid = this.allowedTypes.some(type => {
129 | if (type.endsWith("/*")) return file.type.startsWith(type.slice(0, -2));
130 | return file.type === type;
131 | });
132 |
133 | if (!isValid) {
134 | alert("Invalid file type, please upload an audio file");
135 | }
136 |
137 | return isValid;
138 | }
139 |
140 | private loadNewAudioFile(file: File): void {
141 | this.audio.pause();
142 | this.audio = new Audio(URL.createObjectURL(file));
143 | this.waveform?.setProgress(0);
144 | this.initializeAudio();
145 | }
146 |
147 | private setControls(): void {
148 | if (!this.waveform) return;
149 | buildControls(this.waveform, this.config.waveformOptions ?? DEFAULT_OPTIONS);
150 | }
151 |
152 | private setupAudioEventListeners(): void {
153 | this.audio.addEventListener("play", () => this.updatePlayPauseUI(true));
154 | this.audio.addEventListener("pause", () => this.updatePlayPauseUI(false));
155 | this.audio.addEventListener("timeupdate", () => this.handleTimeUpdate());
156 | this.audio.addEventListener("ended", () => (this.audio.currentTime = 0));
157 | }
158 |
159 | private setupDropzone(): void {
160 | const { dropzone } = this.config;
161 |
162 | dropzone.addEventListener("dragover", this.handleDragOver);
163 | dropzone.addEventListener("dragenter", this.handleDragOver);
164 | dropzone.addEventListener("drop", this.handleDrop);
165 | }
166 |
167 | private setupWaveformListeners(): void {
168 | if (!this.waveform) return;
169 |
170 | this.UI.button.addEventListener("click", this.handlePlayPause);
171 | this.waveform.on("seek", this.handleSeek);
172 | }
173 |
174 | private updatePlayPauseUI(isPlaying: boolean): void {
175 | this.UI.playIcon.classList.toggle("hidden", isPlaying);
176 | this.UI.pauseIcon.classList.toggle("hidden", !isPlaying);
177 | }
178 | }
179 |
--------------------------------------------------------------------------------
/webpage/index.css:
--------------------------------------------------------------------------------
1 | @import "tailwindcss";
2 |
3 | @plugin "@tailwindcss/typography";
4 |
--------------------------------------------------------------------------------
/webpage/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Waveform Renderer
8 |
9 |
10 |
14 |
15 |
16 |
17 |
21 |
22 |
23 |
24 |
28 |
29 |
30 |
31 | Waveform Renderer
32 |
33 |
34 |
35 |
36 |
37 |
38 |
42 |
43 |
44 |
45 | 0:00
46 | -0:00
47 |
48 |
49 |
50 |
51 |
54 |
55 |
58 |
59 |
60 |
64 | Play
65 |
66 |
73 |
78 |
79 |
80 |
87 |
92 |
93 |
94 |
97 |
98 |
101 |
102 |
103 |
104 |
105 |
106 |
107 | Upload an audio file to render its waveform. Supported formats: .mp3
, .wav
,
108 |
109 |
110 |
166 |
167 |
168 |
169 |
170 |
Waveform Renderer
171 |
172 |
173 |
174 |
179 |
180 |
181 |
182 | A lightweight and customizable TypeScript library for rendering audio waveforms on HTML canvas.
183 | Create beautiful, interactive audio visualizations with ease.
184 |
185 |
📝 Table of Contents
186 |
206 |
✨ Features
207 |
208 | 🎨 Highly customizable appearance
209 | ⚡ Performant canvas-based rendering
210 | 📱 Responsive and touch-friendly
211 | 🔄 Real-time progress updates
212 | 🎯 Interactive seeking
213 | 💪 Written in TypeScript with full type support
214 | 📏 Resolution independent with HiDPI/Retina support
215 |
216 |
🚀 Installation
217 |
npm install waveform-renderer
218 | # or
219 | yarn add waveform-renderer
220 |
221 |
📖 Quick Start
222 |
import { WaveformRenderer } from 'waveform-renderer';
223 |
224 | // Get your canvas element
225 | const canvas = document.getElementById('waveform') as HTMLCanvasElement;
226 |
227 | // Prepare your audio peaks data
228 | const peaks = [...]; // Array of numbers
229 |
230 | // Create waveform instance
231 | const waveform = new WaveformRenderer(canvas, peaks, {
232 | color: '#2196F3',
233 | backgroundColor: '#E3F2FD',
234 | progressLine: {
235 | color: '#1565C0'
236 | }
237 | });
238 |
239 | // Listen to events
240 | waveform.on('seek', (progress) => {
241 | console.log(`Seeked to ${progress * 100}%`);
242 | });
243 |
244 |
🛠 API
245 |
Configuration Options
246 |
WaveformOptions
247 |
339 |
ProgressLineOptions
340 |
384 |
🎯 Events
385 |
The waveform renderer emits the following events:
386 |
439 |
Example of type-safe event handling:
440 |
waveform.on("resize", ({ width, height }) => {
441 | console.log(`Canvas resized to ${width}x${height}`);
442 | });
443 |
444 | waveform.on("seek", progress => {
445 | // progress is a number between 0-1
446 | audioElement.currentTime = audioElement.duration * progress;
447 | });
448 |
449 |
📦 Exports
450 |
The library provides the following exports:
451 |
Main Component
452 |
import { WaveformRenderer } from "waveform-renderer";
453 |
454 |
Utility Functions
455 |
import { getPeaksFromAudioBuffer } from "waveform-renderer";
456 |
457 |
458 | This utility helps you calculate peaks from an AudioBuffer, useful when you need to generate
459 | waveform data from raw audio.
460 |
461 |
TypeScript Types
462 |
import type { WaveformOptions, ProgressLineOptions, WaveformEvents, RenderMode } from "waveform-renderer";
463 |
464 |
Example of using the utility function:
465 |
// Get an AudioBuffer from your audio source
466 | const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
467 |
468 | // Calculate peaks
469 | const peaks = getPeaksFromAudioBuffer(audioBuffer);
470 |
471 | // Create waveform with calculated peaks
472 | const waveform = new WaveformRenderer(canvas, peaks, options);
473 |
474 |
🔧 Methods
475 |
Constructor
476 |
constructor(
477 | canvas: HTMLCanvasElement,
478 | peaks: number[],
479 | options?: Partial<WaveformOptions>
480 | )
481 |
482 |
Instance Methods
483 |
484 |
485 | setOptions(options: Partial<WaveformOptions>)
: Updates the waveform options
486 |
487 | setPeaks(peaks: number[])
: Updates the waveform peaks data
488 | setProgress(progress: number)
: Updates the current progress (0-1)
489 | destroy()
: Cleans up and removes the instance
490 |
491 |
💡 Examples
492 |
Custom Styling
493 |
const waveform = new WaveformRenderer(canvas, peaks, {
494 | color: "#2196F3",
495 | backgroundColor: "#E3F2FD",
496 | barWidth: 3,
497 | gap: 2,
498 | borderRadius: 2,
499 | progressLine: {
500 | color: "#1565C0",
501 | style: "dashed",
502 | width: 2,
503 | },
504 | });
505 |
506 |
Event Handling
507 |
const waveform = new WaveformRenderer(canvas, peaks);
508 |
509 | waveform.on("ready", () => {
510 | console.log("Waveform is ready!");
511 | });
512 |
513 | waveform.on("seek", progress => {
514 | audioElement.currentTime = audioElement.duration * progress;
515 | });
516 |
517 | // Cleanup
518 | waveform.off("seek", seekHandler);
519 | // or remove all listeners
520 | waveform.removeAllListeners();
521 |
522 |
🌐 Browser Support
523 |
The library works in all modern browsers that support Canvas and ES6.
524 |
💡 Motivation
525 |
526 | While wavesurfer.js is an excellent library, we needed a more
527 | focused solution. Waveform Renderer was created to be a lightweight alternative that concentrates
528 | solely on waveform visualization, eliminating additional features like playback, regions, or
529 | spectrograms. This results in:
530 |
531 |
532 | 🎯 Focused scope: just waveform rendering
533 | 📦 Smaller bundle size
534 | 💪 TypeScript-first development
535 | ⚡ Optimized performance for waveform rendering
536 |
537 |
538 | Choose Waveform Renderer when you need efficient waveform visualization without the overhead of a
539 | full-featured audio library.
540 |
541 |
🤝 Contributing
542 |
Contributions are welcome! Please feel free to submit a Pull Request.
543 |
📄 License
544 |
MIT License
545 |
🙏 Acknowledgements
546 |
547 | Inspired by wavesurfer.js
548 |
549 | Co-created with the help of
550 | Claude
551 |
552 |
553 |
554 |
555 |
556 |
612 |
613 |
614 |
615 |
616 |
624 |
625 |
626 |
--------------------------------------------------------------------------------
/webpage/public/audio.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maximux13/waveform-renderer/2949538a298a69858782178a20de005de40d4311/webpage/public/audio.mp3
--------------------------------------------------------------------------------
/webpage/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/webpage/public/logo-light.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------