├── .eslintrc.json ├── .github └── workflows │ └── lint.yml ├── .gitignore ├── LICENSE ├── README.md ├── assets ├── AudioVisualizer.png └── LiveAudioVisualizer.gif ├── index.html ├── package-lock.json ├── package.json ├── src ├── AudioVisualizer │ ├── AudioVisualizer.tsx │ ├── index.ts │ ├── types.ts │ └── utils.ts ├── LiveAudioVisualizer │ ├── LiveAudioVisualizer.tsx │ ├── index.ts │ └── utils.ts ├── index.ts └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "plugin:react/recommended", 8 | "plugin:react-hooks/recommended", 9 | "standard-with-typescript", 10 | "plugin:prettier/recommended" 11 | ], 12 | "overrides": [ 13 | ], 14 | "parserOptions": { 15 | "ecmaVersion": "latest", 16 | "sourceType": "module", 17 | "ecmaFeatures": { 18 | "jsx": true 19 | }, 20 | "project": "tsconfig.json" 21 | }, 22 | "plugins": [ 23 | "react" 24 | ], 25 | "rules": { 26 | "prettier/prettier": [ 27 | "error", 28 | { 29 | // Prettier config 30 | "endOfLine": "lf", 31 | "semi": true, 32 | "singleQuote": false, 33 | "tabWidth": 2, 34 | "trailingComma": "es5" 35 | } 36 | ], 37 | "react/react-in-jsx-scope": "off", 38 | "react-hooks/exhaustive-deps": "off", 39 | "@typescript-eslint/strict-boolean-expressions": "off", 40 | "@typescript-eslint/no-floating-promises": "off" 41 | }, 42 | "ignorePatterns": ["src/main.tsx", "src/vite-env.d.ts"] 43 | } 44 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Run ESLint 2 | on: [pull_request] 3 | 4 | env: 5 | GITHUB_TOKEN: ${{ github.token }} 6 | jobs: 7 | eslint: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - uses: actions/setup-node@v2 12 | with: 13 | node-version: "16.x" 14 | - name: install dependencies 15 | run: npm ci 16 | - uses: reviewdog/action-eslint@v1 17 | with: 18 | reporter: github-pr-review 19 | eslint_flags: "src/" 20 | fail_on_error: "true" 21 | level: "warning" 22 | -------------------------------------------------------------------------------- /.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 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Samhir Tarif 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # **react-audio-visualize** 2 | An audio visualizer for React. Provides separate components to visualize both live audio and audio blobs. 3 | 4 | ## Installation 5 | ```sh 6 | npm install react-audio-visualize 7 | ``` 8 | 9 | ## **AudioVisualizer** Component ([Example](https://stackblitz.com/edit/stackblitz-starters-kjpu5q?file=src%2FApp.tsx)) 10 | 11 | ![screenshot](./assets/AudioVisualizer.png) 12 | 13 | ```js 14 | import React, { useState, useRef } from 'react'; 15 | import { AudioVisualizer } from 'react-audio-visualize'; 16 | 17 | const Visualizer = () => { 18 | const [blob, setBlob] = useState(); 19 | const visualizerRef = useRef(null) 20 | 21 | // set blob somewhere in code 22 | 23 | return ( 24 |
25 | {blob && ( 26 | 35 | )} 36 |
37 | ) 38 | } 39 | 40 | ``` 41 | 42 | | Props | Description | Default | Optional | 43 | | :------------ |:--------------- |:--------------- | :--------------- | 44 | | **`blob`** | Audio blob to visualize | N/A | No | 45 | | **`width`** | Width of the visualizer | N/A | No | 46 | | **`height`** | Height of the visualizer | N/A | No | 47 | | **`barWidth`** | Width of each individual bar in the visualization | `2` | Yes | 48 | | **`gap`** | Gap between each bar in the visualization | `1` | Yes | 49 | | **`backgroundColor`** | BackgroundColor for the visualization | `transparent` | Yes | 50 | | **`barColor`** | Color for the bars that have not yet been played | `"rgb(184, 184, 184)""` | Yes | 51 | | **`barPlayedColor`** | Color for the bars that have been played | `"rgb(160, 198, 255)""` | Yes | 52 | | **`currentTime`** | Current time stamp till which the audio blob has been played. Visualized bars that fall before the current time will have `barPlayerColor`, while that ones that fall after will have `barColor` | N/A | Yes | 53 | | **`style`** | Custom styles that can be passed to the visualization canvas | N/A | Yes | 54 | | **`ref`** | A `ForwardedRef` for the `HTMLCanvasElement` | N/A | Yes | 55 | 56 | --- 57 | 58 | ## **LiveAudioVisualizer** Component ([Example](https://stackblitz.com/edit/stackblitz-starters-kjpu5q?file=src%2FApp.tsx)) 59 | 60 | ![livevisualizergif](./assets/LiveAudioVisualizer.gif) 61 | 62 | ```js 63 | import React, { useState } from 'react'; 64 | import { LiveAudioVisualizer } from 'react-audio-visualize'; 65 | 66 | const Visualizer = () => { 67 | const [mediaRecorder, setMediaRecorder] = useState(); 68 | 69 | // set media recorder somewhere in code 70 | 71 | return ( 72 |
73 | {mediaRecorder && ( 74 | 79 | )} 80 |
81 | ) 82 | } 83 | 84 | ``` 85 | 86 | | Props | Description | Default | Optional | 87 | | :------------ |:--------------- |:--------------- | :--------------- | 88 | | **`mediaRecorder`** | Media recorder who's stream needs to visualized | N/A | No | 89 | | **`width`** | Width of the visualizer | `100%` | Yes | 90 | | **`height`** | Height of the visualizer | `100%` | Yes | 91 | | **`barWidth`** | Width of each individual bar in the visualization | `2` | Yes | 92 | | **`gap`** | Gap between each bar in the visualization | `1` | Yes | 93 | | **`backgroundColor`** | BackgroundColor for the visualization | `transparent` | Yes | 94 | | **`barColor`** | Color for the bars that have not yet been played | `"rgb(160, 198, 255)"` | Yes | 95 | | **`fftSize`** | An unsigned integer, representing the window size of the FFT, given in number of samples. For more [details](https://developer.mozilla.org/en-US/docs/Web/API/AnalyserNode/fftSize) | `1024` | Yes | 96 | | **`maxDecibels`** | A double, representing the maximum decibel value for scaling the FFT analysis data. For more [details](https://developer.mozilla.org/en-US/docs/Web/API/AnalyserNode/maxDecibels) | `-10` | Yes | 97 | | **`minDecibels`** | A double, representing the minimum decibel value for scaling the FFT analysis data, where 0 dB is the loudest possible sound. For more [details](https://developer.mozilla.org/en-US/docs/Web/API/AnalyserNode/minDecibels) | `-90` | Yes | 98 | | **`smoothingTimeConstant`** | A double within the range 0 to 1 (0 meaning no time averaging). For more [details](https://developer.mozilla.org/en-US/docs/Web/API/AnalyserNode/smoothingTimeConstant) | `0.4` | Yes | 99 | 100 | 101 | 102 | -------------------------------------------------------------------------------- /assets/AudioVisualizer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samhirtarif/react-audio-visualize/e284411a8ffb304ef53802caea6f52db171524ef/assets/AudioVisualizer.png -------------------------------------------------------------------------------- /assets/LiveAudioVisualizer.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samhirtarif/react-audio-visualize/e284411a8ffb304ef53802caea6f52db171524ef/assets/LiveAudioVisualizer.gif -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React + TS 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-audio-visualize", 3 | "private": false, 4 | "version": "1.2.0", 5 | "license": "MIT", 6 | "author": "Samhir Tarif", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/samhirtarif/react-audio-visualize.git" 10 | }, 11 | "keywords": [ 12 | "audio", 13 | "visualize", 14 | "visualise", 15 | "visualizer", 16 | "visualiser", 17 | "audio visualiser", 18 | "audio visualizer", 19 | "audio visualize", 20 | "audio visualise", 21 | "audio waveform", 22 | "waveform", 23 | "audio visualization", 24 | "visualization", 25 | "live visualize" 26 | ], 27 | "files": [ 28 | "dist" 29 | ], 30 | "main": "./dist/react-audio-visualize.umd.js", 31 | "module": "./dist/react-audio-visualize.es.js", 32 | "types": "./dist/index.d.ts", 33 | "exports": { 34 | ".": { 35 | "import": "./dist/react-audio-visualize.es.js", 36 | "require": "./dist/react-audio-visualize.umd.js", 37 | "types": "./dist/index.d.ts" 38 | } 39 | }, 40 | "scripts": { 41 | "dev": "vite", 42 | "build": "tsc && vite build", 43 | "preview": "vite preview", 44 | "lint": "eslint src/**/*.ts{,x}", 45 | "lint:fix": "npm run lint -- --fix" 46 | }, 47 | "devDependencies": { 48 | "@types/node": "^20.2.1", 49 | "@types/react": "^18.0.28", 50 | "@types/react-dom": "^18.0.11", 51 | "@typescript-eslint/eslint-plugin": "^5.57.1", 52 | "@typescript-eslint/parser": "^5.57.1", 53 | "@vitejs/plugin-react": "^4.0.0", 54 | "eslint": "^8.38.0", 55 | "eslint-config-prettier": "^8.8.0", 56 | "eslint-config-standard-with-typescript": "^34.0.1", 57 | "eslint-plugin-prettier": "^4.2.1", 58 | "eslint-plugin-react": "^7.32.2", 59 | "eslint-plugin-react-hooks": "^4.6.0", 60 | "eslint-plugin-react-refresh": "^0.3.4", 61 | "path": "^0.12.7", 62 | "prettier": "^2.8.8", 63 | "react": "^18.2.0", 64 | "react-dom": "^18.2.0", 65 | "typescript": "^5.0.2", 66 | "vite": "^4.5.3", 67 | "vite-plugin-dts": "^2.3.0" 68 | }, 69 | "peerDependencies": { 70 | "react": ">=16.2.0", 71 | "react-dom": ">=16.2.0" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/AudioVisualizer/AudioVisualizer.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useRef, 3 | useState, 4 | forwardRef, 5 | type ForwardedRef, 6 | type ForwardRefExoticComponent, 7 | type RefAttributes, 8 | useImperativeHandle, 9 | useEffect, 10 | } from "react"; 11 | import { type dataPoint } from "./types"; 12 | import { calculateBarData, draw } from "./utils"; 13 | 14 | interface Props { 15 | /** 16 | * Audio blob to visualize 17 | */ 18 | blob: Blob; 19 | /** 20 | * Width of the visualizer 21 | */ 22 | width: number; 23 | /** 24 | * Height of the visualizer 25 | */ 26 | height: number; 27 | /** 28 | * Width of each individual bar in the visualization. Default: `2` 29 | */ 30 | barWidth?: number; 31 | /** 32 | * Gap between each bar in the visualization. Default: `1` 33 | */ 34 | gap?: number; 35 | /** 36 | * BackgroundColor for the visualization: Default: `"transparent"` 37 | */ 38 | backgroundColor?: string; 39 | /** 40 | * Color for the bars that have not yet been played: Default: `"rgb(184, 184, 184)""` 41 | */ 42 | barColor?: string; 43 | /** 44 | * Color for the bars that have been played: Default: `"rgb(160, 198, 255)""` 45 | */ 46 | barPlayedColor?: string; 47 | /** 48 | * Current time stamp till which the audio blob has been played. 49 | * Visualized bars that fall before the current time will have `barPlayerColor`, while that ones that fall after will have `barColor` 50 | */ 51 | currentTime?: number; 52 | /** 53 | * Custome styles that can be passed to the visualization canvas 54 | */ 55 | style?: React.CSSProperties; 56 | /** 57 | * A `ForwardedRef` for the `HTMLCanvasElement` 58 | */ 59 | ref?: React.ForwardedRef; 60 | } 61 | 62 | const AudioVisualizer: ForwardRefExoticComponent< 63 | Props & RefAttributes 64 | > = forwardRef( 65 | ( 66 | { 67 | blob, 68 | width, 69 | height, 70 | barWidth = 2, 71 | gap = 1, 72 | currentTime, 73 | style, 74 | backgroundColor = "transparent", 75 | barColor = "rgb(184, 184, 184)", 76 | barPlayedColor = "rgb(160, 198, 255)", 77 | }: Props, 78 | ref?: ForwardedRef 79 | ) => { 80 | const canvasRef = useRef(null); 81 | const [data, setData] = useState([]); 82 | const [duration, setDuration] = useState(0); 83 | 84 | useImperativeHandle( 85 | ref, 86 | () => canvasRef.current, 87 | [] 88 | ); 89 | 90 | useEffect(() => { 91 | const processBlob = async (): Promise => { 92 | if (!canvasRef.current) return; 93 | 94 | if (!blob) { 95 | const barsData = Array.from({ length: 100 }, () => ({ 96 | max: 0, 97 | min: 0, 98 | })); 99 | draw( 100 | barsData, 101 | canvasRef.current, 102 | barWidth, 103 | gap, 104 | backgroundColor, 105 | barColor, 106 | barPlayedColor 107 | ); 108 | return; 109 | } 110 | 111 | const audioBuffer = await blob.arrayBuffer(); 112 | const audioContext = new AudioContext(); 113 | await audioContext.decodeAudioData(audioBuffer, (buffer) => { 114 | if (!canvasRef.current) return; 115 | setDuration(buffer.duration); 116 | const barsData = calculateBarData( 117 | buffer, 118 | height, 119 | width, 120 | barWidth, 121 | gap 122 | ); 123 | setData(barsData); 124 | draw( 125 | barsData, 126 | canvasRef.current, 127 | barWidth, 128 | gap, 129 | backgroundColor, 130 | barColor, 131 | barPlayedColor 132 | ); 133 | }); 134 | }; 135 | 136 | processBlob(); 137 | }, [blob, canvasRef.current]); 138 | 139 | useEffect(() => { 140 | if (!canvasRef.current) return; 141 | 142 | draw( 143 | data, 144 | canvasRef.current, 145 | barWidth, 146 | gap, 147 | backgroundColor, 148 | barColor, 149 | barPlayedColor, 150 | currentTime, 151 | duration 152 | ); 153 | }, [currentTime, duration]); 154 | 155 | return ( 156 | 164 | ); 165 | } 166 | ); 167 | 168 | AudioVisualizer.displayName = "AudioVisualizer"; 169 | 170 | export { AudioVisualizer }; 171 | -------------------------------------------------------------------------------- /src/AudioVisualizer/index.ts: -------------------------------------------------------------------------------- 1 | export { AudioVisualizer } from "./AudioVisualizer"; 2 | -------------------------------------------------------------------------------- /src/AudioVisualizer/types.ts: -------------------------------------------------------------------------------- 1 | export interface dataPoint { 2 | max: number; 3 | min: number; 4 | } 5 | -------------------------------------------------------------------------------- /src/AudioVisualizer/utils.ts: -------------------------------------------------------------------------------- 1 | import { type dataPoint } from "./types"; 2 | 3 | interface CustomCanvasRenderingContext2D extends CanvasRenderingContext2D { 4 | roundRect: ( 5 | x: number, 6 | y: number, 7 | w: number, 8 | h: number, 9 | radius: number 10 | ) => void; 11 | } 12 | 13 | export const calculateBarData = ( 14 | buffer: AudioBuffer, 15 | height: number, 16 | width: number, 17 | barWidth: number, 18 | gap: number 19 | ): dataPoint[] => { 20 | const bufferData = buffer.getChannelData(0); 21 | const units = width / (barWidth + gap); 22 | const step = Math.floor(bufferData.length / units); 23 | const amp = height / 2; 24 | 25 | let data: dataPoint[] = []; 26 | let maxDataPoint = 0; 27 | 28 | for (let i = 0; i < units; i++) { 29 | const mins: number[] = []; 30 | let minCount = 0; 31 | const maxs: number[] = []; 32 | let maxCount = 0; 33 | 34 | for (let j = 0; j < step && i * step + j < buffer.length; j++) { 35 | const datum = bufferData[i * step + j]; 36 | if (datum <= 0) { 37 | mins.push(datum); 38 | minCount++; 39 | } 40 | if (datum > 0) { 41 | maxs.push(datum); 42 | maxCount++; 43 | } 44 | } 45 | const minAvg = mins.reduce((a, c) => a + c, 0) / minCount; 46 | const maxAvg = maxs.reduce((a, c) => a + c, 0) / maxCount; 47 | 48 | const dataPoint = { max: maxAvg, min: minAvg }; 49 | 50 | if (dataPoint.max > maxDataPoint) maxDataPoint = dataPoint.max; 51 | if (Math.abs(dataPoint.min) > maxDataPoint) 52 | maxDataPoint = Math.abs(dataPoint.min); 53 | 54 | data.push(dataPoint); 55 | } 56 | 57 | if (amp * 0.8 > maxDataPoint * amp) { 58 | const adjustmentFactor = (amp * 0.8) / maxDataPoint; 59 | data = data.map((dp) => ({ 60 | max: dp.max * adjustmentFactor, 61 | min: dp.min * adjustmentFactor, 62 | })); 63 | } 64 | 65 | return data; 66 | }; 67 | 68 | export const draw = ( 69 | data: dataPoint[], 70 | canvas: HTMLCanvasElement, 71 | barWidth: number, 72 | gap: number, 73 | backgroundColor: string, 74 | barColor: string, 75 | barPlayedColor?: string, 76 | currentTime: number = 0, 77 | duration: number = 1 78 | ): void => { 79 | const amp = canvas.height / 2; 80 | 81 | const ctx = canvas.getContext("2d") as CustomCanvasRenderingContext2D; 82 | if (!ctx) return; 83 | 84 | ctx.clearRect(0, 0, canvas.width, canvas.height); 85 | 86 | if (backgroundColor !== "transparent") { 87 | ctx.fillStyle = backgroundColor; 88 | ctx.fillRect(0, 0, canvas.width, canvas.height); 89 | } 90 | 91 | const playedPercent = (currentTime || 0) / duration; 92 | 93 | data.forEach((dp, i) => { 94 | const mappingPercent = i / data.length; 95 | const played = playedPercent > mappingPercent; 96 | ctx.fillStyle = played && barPlayedColor ? barPlayedColor : barColor; 97 | 98 | const x = i * (barWidth + gap); 99 | const y = amp + dp.min; 100 | const w = barWidth; 101 | const h = amp + dp.max - y; 102 | 103 | ctx.beginPath(); 104 | if (ctx.roundRect) { 105 | // making sure roundRect is supported by the browser 106 | ctx.roundRect(x, y, w, h, 50); 107 | ctx.fill(); 108 | } else { 109 | // fallback for browsers that do not support roundRect 110 | ctx.fillRect(x, y, w, h); 111 | } 112 | }); 113 | }; 114 | -------------------------------------------------------------------------------- /src/LiveAudioVisualizer/LiveAudioVisualizer.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | type ReactElement, 3 | useCallback, 4 | useEffect, 5 | useRef, 6 | useState, 7 | } from "react"; 8 | import { calculateBarData, draw } from "./utils"; 9 | 10 | export interface Props { 11 | /** 12 | * Media recorder who's stream needs to visualized 13 | */ 14 | mediaRecorder: MediaRecorder; 15 | /** 16 | * Width of the visualization. Default" "100%" 17 | */ 18 | width?: number | string; 19 | /** 20 | * Height of the visualization. Default" "100%" 21 | */ 22 | height?: number | string; 23 | /** 24 | * Width of each individual bar in the visualization. Default: `2` 25 | */ 26 | barWidth?: number; 27 | /** 28 | * Gap between each bar in the visualization. Default `1` 29 | */ 30 | gap?: number; 31 | /** 32 | * BackgroundColor for the visualization: Default `transparent` 33 | */ 34 | backgroundColor?: string; 35 | /** 36 | * Color of the bars drawn in the visualization. Default: `"rgb(160, 198, 255)"` 37 | */ 38 | barColor?: string; 39 | /** 40 | * An unsigned integer, representing the window size of the FFT, given in number of samples. 41 | * A higher value will result in more details in the frequency domain but fewer details in the amplitude domain. 42 | * For more details {@link https://developer.mozilla.org/en-US/docs/Web/API/AnalyserNode/fftSize MDN AnalyserNode: fftSize property} 43 | * Default: `1024` 44 | */ 45 | fftSize?: 46 | | 32 47 | | 64 48 | | 128 49 | | 256 50 | | 512 51 | | 1024 52 | | 2048 53 | | 4096 54 | | 8192 55 | | 16384 56 | | 32768; 57 | /** 58 | * A double, representing the maximum decibel value for scaling the FFT analysis data 59 | * For more details {@link https://developer.mozilla.org/en-US/docs/Web/API/AnalyserNode/maxDecibels MDN AnalyserNode: maxDecibels property} 60 | * Default: `-10` 61 | */ 62 | maxDecibels?: number; 63 | /** 64 | * A double, representing the minimum decibel value for scaling the FFT analysis data, where 0 dB is the loudest possible sound 65 | * For more details {@link https://developer.mozilla.org/en-US/docs/Web/API/AnalyserNode/minDecibels MDN AnalyserNode: minDecibels property} 66 | * Default: `-90` 67 | */ 68 | minDecibels?: number; 69 | /** 70 | * A double within the range 0 to 1 (0 meaning no time averaging). The default value is 0.8. 71 | * If 0 is set, there is no averaging done, whereas a value of 1 means "overlap the previous and current buffer quite a lot while computing the value", 72 | * which essentially smooths the changes across 73 | * For more details {@link https://developer.mozilla.org/en-US/docs/Web/API/AnalyserNode/smoothingTimeConstant MDN AnalyserNode: smoothingTimeConstant property} 74 | * Default: `0.4` 75 | */ 76 | smoothingTimeConstant?: number; 77 | } 78 | 79 | const LiveAudioVisualizer: (props: Props) => ReactElement = ({ 80 | mediaRecorder, 81 | width = "100%", 82 | height = "100%", 83 | barWidth = 2, 84 | gap = 1, 85 | backgroundColor = "transparent", 86 | barColor = "rgb(160, 198, 255)", 87 | fftSize = 1024, 88 | maxDecibels = -10, 89 | minDecibels = -90, 90 | smoothingTimeConstant = 0.4, 91 | }: Props) => { 92 | const [context, setContext] = useState(); 93 | const [audioSource, setAudioSource] = useState(); 94 | const [analyser, setAnalyser] = useState(); 95 | const canvasRef = useRef(null); 96 | 97 | useEffect(() => { 98 | if (!mediaRecorder.stream) return; 99 | 100 | const ctx = new AudioContext(); 101 | const analyserNode = ctx.createAnalyser(); 102 | setAnalyser(analyserNode); 103 | analyserNode.fftSize = fftSize; 104 | analyserNode.minDecibels = minDecibels; 105 | analyserNode.maxDecibels = maxDecibels; 106 | analyserNode.smoothingTimeConstant = smoothingTimeConstant; 107 | const source = ctx.createMediaStreamSource(mediaRecorder.stream); 108 | source.connect(analyserNode); 109 | setContext(ctx); 110 | setAudioSource(source); 111 | 112 | return () => { 113 | source.disconnect(); 114 | analyserNode.disconnect(); 115 | ctx.state !== "closed" && ctx.close(); 116 | }; 117 | }, [mediaRecorder.stream]); 118 | 119 | useEffect(() => { 120 | if (analyser && mediaRecorder.state === "recording") { 121 | report(); 122 | } 123 | }, [analyser, mediaRecorder.state]); 124 | 125 | const report = useCallback(() => { 126 | if (!analyser || !context) return; 127 | 128 | const data = new Uint8Array(analyser?.frequencyBinCount); 129 | 130 | if (mediaRecorder.state === "recording") { 131 | analyser?.getByteFrequencyData(data); 132 | processFrequencyData(data); 133 | requestAnimationFrame(report); 134 | } else if (mediaRecorder.state === "paused") { 135 | processFrequencyData(data); 136 | } else if ( 137 | mediaRecorder.state === "inactive" && 138 | context.state !== "closed" 139 | ) { 140 | context.close(); 141 | } 142 | }, [analyser, context?.state]); 143 | 144 | useEffect(() => { 145 | return () => { 146 | if (context && context.state !== "closed") { 147 | context.close(); 148 | } 149 | audioSource?.disconnect(); 150 | analyser?.disconnect(); 151 | }; 152 | }, []); 153 | 154 | const processFrequencyData = (data: Uint8Array): void => { 155 | if (!canvasRef.current) return; 156 | 157 | const dataPoints = calculateBarData( 158 | data, 159 | canvasRef.current.width, 160 | barWidth, 161 | gap 162 | ); 163 | draw( 164 | dataPoints, 165 | canvasRef.current, 166 | barWidth, 167 | gap, 168 | backgroundColor, 169 | barColor 170 | ); 171 | }; 172 | 173 | return ( 174 | 182 | ); 183 | }; 184 | 185 | export { LiveAudioVisualizer }; 186 | -------------------------------------------------------------------------------- /src/LiveAudioVisualizer/index.ts: -------------------------------------------------------------------------------- 1 | export { LiveAudioVisualizer } from "./LiveAudioVisualizer"; 2 | -------------------------------------------------------------------------------- /src/LiveAudioVisualizer/utils.ts: -------------------------------------------------------------------------------- 1 | interface CustomCanvasRenderingContext2D extends CanvasRenderingContext2D { 2 | roundRect: ( 3 | x: number, 4 | y: number, 5 | w: number, 6 | h: number, 7 | radius: number 8 | ) => void; 9 | } 10 | 11 | export const calculateBarData = ( 12 | frequencyData: Uint8Array, 13 | width: number, 14 | barWidth: number, 15 | gap: number 16 | ): number[] => { 17 | let units = width / (barWidth + gap); 18 | let step = Math.floor(frequencyData.length / units); 19 | 20 | if (units > frequencyData.length) { 21 | units = frequencyData.length; 22 | step = 1; 23 | } 24 | 25 | const data: number[] = []; 26 | 27 | for (let i = 0; i < units; i++) { 28 | let sum = 0; 29 | 30 | for (let j = 0; j < step && i * step + j < frequencyData.length; j++) { 31 | sum += frequencyData[i * step + j]; 32 | } 33 | data.push(sum / step); 34 | } 35 | return data; 36 | }; 37 | 38 | export const draw = ( 39 | data: number[], 40 | canvas: HTMLCanvasElement, 41 | barWidth: number, 42 | gap: number, 43 | backgroundColor: string, 44 | barColor: string 45 | ): void => { 46 | const amp = canvas.height / 2; 47 | 48 | const ctx = canvas.getContext("2d") as CustomCanvasRenderingContext2D; 49 | if (!ctx) return; 50 | 51 | ctx.clearRect(0, 0, canvas.width, canvas.height); 52 | 53 | if (backgroundColor !== "transparent") { 54 | ctx.fillStyle = backgroundColor; 55 | ctx.fillRect(0, 0, canvas.width, canvas.height); 56 | } 57 | 58 | data.forEach((dp, i) => { 59 | ctx.fillStyle = barColor; 60 | 61 | const x = i * (barWidth + gap); 62 | const y = amp - dp / 2; 63 | const w = barWidth; 64 | const h = dp || 1; 65 | 66 | ctx.beginPath(); 67 | if (ctx.roundRect) { 68 | // making sure roundRect is supported by the browser 69 | ctx.roundRect(x, y, w, h, 50); 70 | ctx.fill(); 71 | } else { 72 | // fallback for browsers that do not support roundRect 73 | ctx.fillRect(x, y, w, h); 74 | } 75 | }); 76 | }; 77 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { LiveAudioVisualizer } from "./LiveAudioVisualizer"; 2 | export { AudioVisualizer } from "./AudioVisualizer"; 3 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 5 | "module": "ESNext", 6 | "skipLibCheck": true, 7 | 8 | /* Bundler mode */ 9 | "moduleResolution": "node", 10 | "allowSyntheticDefaultImports": true, 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "noEmit": true, 14 | "jsx": "react-jsx", 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true 21 | }, 22 | "include": ["src"], 23 | "references": [{ "path": "./tsconfig.node.json" }] 24 | } 25 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "node", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import dts from 'vite-plugin-dts'; 3 | import path from "path" 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [ 8 | dts({ 9 | insertTypesEntry: true, 10 | }), 11 | ], 12 | build: { 13 | lib: { 14 | entry: path.resolve(__dirname, 'src/index.ts'), 15 | name: 'AudioVisualize', 16 | fileName: (format) => `react-audio-visualize.${format}.js` 17 | }, 18 | rollupOptions: { 19 | external: ['react', 'react-dom'], 20 | output: { 21 | globals: { 22 | react: 'React', 23 | } 24 | } 25 | }, 26 | }, 27 | }) 28 | 29 | --------------------------------------------------------------------------------