├── .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 | Waveform Renderer 5 | 6 | 7 | [![npm version](https://img.shields.io/npm/v/waveform-renderer)](#) 8 | [![license](https://img.shields.io/npm/l/waveform-renderer)](#) 9 | [![build status](https://img.shields.io/github/workflow/status/maximux13/waveform-renderer/CI)](#) 10 | [![downloads](https://img.shields.io/npm/dm/waveform-renderer)](#) 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 | Waveform Renderer 35 |

36 |

37 | 38 |
42 |
43 | 44 |
45 | 0:00 46 | -0:00 47 |
48 |
49 | 50 |
51 | 60 | 94 | 103 |
104 |
105 | 106 |

107 | Upload an audio file to render its waveform. Supported formats: .mp3, .wav, 108 |

109 | 110 |
111 |

112 | 117 | 129 | 130 | View on GitHub 135 | 136 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | View on npm 162 |

163 | 164 |

Made by @maximux13

165 |
166 |
167 | 168 |
169 |
170 |

Waveform Renderer

171 |

172 | npm version 173 | license 174 | build status 179 | downloads 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 | 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 |
248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 |
OptionTypeDefaultDescription
amplitudenumber1Amplitude multiplier for the waveform
backgroundColorstring"#CCCCCC"Background color of the waveform
barWidthnumber2Width of each bar in pixels
borderColorstring"#000000"Border color of the bars
borderRadiusnumber0Border radius of the bars in pixels
borderWidthnumber0Border width of the bars in pixels
colorstring"#000000"Color of the waveform bars
gapnumber1Gap between bars in pixels
minPixelRationumber1Minimum pixel ratio for rendering
position"bottom" | "center" | "top""center"Vertical positioning of the waveform
progressnumber0Initial progress (0-1)
smoothingbooleantrueWhether to apply smoothing to the rendering
progressLineProgressLineOptionsnullProgress line options
338 |
339 |

ProgressLineOptions

340 |
341 | 342 | 343 | 344 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 |
OptionTypeDefaultDescription
colorstring"#FF0000"Color of the progress line
heightPercentnumber1Height of the line as percentage of total height
position"bottom" | "center" | "top""center"Vertical position of the line
style"solid" | "dashed" | "dotted""solid"Style of the progress line
widthnumber2Width of the line in pixels
383 |
384 |

🎯 Events

385 |

The waveform renderer emits the following events:

386 |
387 | 388 | 389 | 390 | 391 | 392 | 393 | 394 | 395 | 396 | 397 | 398 | 399 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 419 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | 427 | 428 | 429 | 430 | 431 | 432 | 433 | 434 | 435 | 436 | 437 |
EventPayloadDescription
renderStartvoidEmitted when rendering begins
renderCompletevoidEmitted when rendering is complete
seeknumberProgress value between 0-1 when user seeks
errorErrorError object when an error occurs
destroyvoidEmitted when the instance is destroyed
readyvoidEmitted when the waveform is ready
resize{ width: number; height: number }New dimensions when canvas is resized
progressChangenumberNew progress value between 0-1
438 |
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 | 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 | 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 | 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 | --------------------------------------------------------------------------------