Exploring the Web Audio API
The Web Audio API is an interface for creating and modulating sound on the web. There are many applications and uses for the API, but in this guide we will mainly be exploring how a simple Audio Visualizer1 can be built from the API.
import React, { useState, useRef, useEffect } from "react";
import { IoIosPlay, IoIosPause } from "react-icons/io";
// Helpers
import getWidth from "../../helpers/getWidth";
// style
import styled, { css } from "styled-components";
import { neuzeitGroLight, fontSize, primary } from "../../styles/fonts";
const Container = styled.div`
display: flex;
flex-direction: column;
align-items: flex-start;
`;
const StyledInput = styled.input`
display: none;
`;
// 100 (default) + 24 (padding) + 2 (border) = 126 width
const ButtonContainer = styled.div`
width: 126px;
overflow: hidden;
transition: width 0.25s;
${(props) =>
props.fileName !== null &&
css`
width: 0px;
`}
`;
const StyledLabel = styled.label`
margin: 16px 0px;
border: 1px solid black;
padding: 6px 12px;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
color: black;
background-color: white;
width: 100px;
min-width: 0px;
white-space: nowrap;
transition: color 0.25s, background-color 0.25s, padding 0.25s;
&:hover {
color: white;
background-color: black;
}
${(props) =>
props.fileName !== null &&
css`
padding: 6px 0px;
`}
`;
const VisualizerControls = styled.div`
display: flex;
align-items: center;
width: 100%;
justify-content: space-between;
`;
const UploadAndTitle = styled.div`
display: flex;
min-width: 0px;
`;
const FileNameContainer = styled.div`
overflow: hidden;
display: flex;
align-items: center;
min-width: 0px;
`;
const FileName = styled.div`
${neuzeitGroLight}
font-size: ${fontSize.sm};
line-height: 20px;
margin-left: 8px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
`;
const ButtonText = styled.div`
${neuzeitGroLight}
font-size: ${fontSize.sm};
line-height: 20px;
`;
const StyledPlayIcon = styled(IoIosPlay)`
color: black;
cursor: pointer;
font-size: 28px;
margin-right: 8px;
&:hover {
color: ${primary};
}
`;
const StyledPauseIcon = styled(IoIosPause)`
color: black;
cursor: pointer;
font-size: 28px;
margin-right: 8px;
&:hover {
color: ${primary};
}
`;
const RATIO = 2.6666;
const calcBarHeight = (barHeight, height) => {
const barHeightPercent = barHeight / 255;
return Math.floor(barHeightPercent * height);
};
let AudioContext;
let isSafari;
if (typeof window !== `undefined`) {
AudioContext = window.AudioContext || window.webkitAudioContext;
isSafari = !!window.webkitAudioContext;
}
const Visualizer = () => {
const [stateWidth, setWidth] = useState(getWidth());
const [fileName, setFileName] = useState(null);
const [showIcon, setShowIcon] = useState(null);
const width = useRef(getWidth());
const audioCtx = useRef(new AudioContext());
const analyser = useRef(audioCtx.current.createAnalyser());
const canvas = useRef(null);
const input = useRef(null);
const canvasCtx = useRef();
const dataArray = useRef();
const source = useRef();
useEffect(() => {
canvasCtx.current = canvas.current.getContext("2d");
const handleResize = () => {
// this is only so the component
// will rerender when the window is resized
setWidth(getWidth());
// need to store this as a ref
width.current = getWidth();
};
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
audioCtx.current.close();
};
}, []);
const handlePlayClick = () => {
audioCtx.current.resume().then(function () {
setShowIcon("pause");
});
};
const handlePauseClick = () => {
audioCtx.current.suspend().then(function () {
setShowIcon("play");
});
};
const handleFiles = () => {
const reader = new FileReader();
source.current = audioCtx.current.createBufferSource();
if (input.current.files.length !== 1) {
alert("You may only select a single file!");
return;
}
if (
input.current.files[0].type !== "audio/mpeg" &&
input.current.files[0].type !== "audio/mp3"
) {
alert("The file selected must be an mp3!");
return;
}
setFileName(input.current.files[0].name);
reader.readAsArrayBuffer(input.current.files[0]);
reader.onloadend = (e) => {
audioCtx.current.decodeAudioData(e.target.result, (decodedData) => {
source.current.buffer = decodedData;
source.current.connect(analyser.current);
analyser.current.connect(audioCtx.current.destination);
setUp();
});
};
};
const setUp = () => {
// audio config
analyser.current.minDecibels = -90;
analyser.current.maxDecibels = -10;
analyser.current.smoothingTimeConstant = 0.85;
analyser.current.fftSize = 256;
// 8-bit unsigned integers
dataArray.current = new Uint8Array(analyser.current.frequencyBinCount);
//begin
setShowIcon(isSafari ? "play" : "pause");
// does not auto play on safari
source.current.start(0);
setInterval(() => {
requestAnimationFrame(draw);
}, 1000 / 40);
};
// use callback?
const draw = () => {
const WIDTH = width.current;
const HEIGHT = width.current / RATIO;
const bufferLength = analyser.current.frequencyBinCount;
analyser.current.getByteFrequencyData(dataArray.current);
canvasCtx.current.clearRect(0, 0, WIDTH, HEIGHT);
const barWidth = (WIDTH - bufferLength) / bufferLength;
let barHeight;
let x = 0;
for (let i = 0; i < bufferLength; i++) {
barHeight = calcBarHeight(dataArray.current[i], HEIGHT);
canvasCtx.current.fillStyle = "rgb(255, 87, 51)";
canvasCtx.current.fillRect(x, HEIGHT - barHeight, barWidth, barHeight);
x += barWidth + 1;
}
};
return (
<Container>
<canvas
ref={canvas}
height={stateWidth / RATIO}
width={stateWidth}
style={{ border: "1px solid black" }}
/>
<VisualizerControls>
<UploadAndTitle>
<ButtonContainer fileName={fileName}>
<StyledLabel for="file-upload" fileName={fileName}>
<ButtonText>Select File</ButtonText>
</StyledLabel>
<StyledInput
type="file"
id="file-upload"
onChange={handleFiles}
ref={input}
/>
</ButtonContainer>
<FileNameContainer>
<FileName>{fileName}</FileName>
</FileNameContainer>
</UploadAndTitle>
{audioCtx.current.state === "running" && showIcon !== null && (
<StyledPauseIcon onClick={handlePauseClick} />
)}
{audioCtx.current.state === "suspended" && showIcon !== null && (
<StyledPlayIcon onClick={handlePlayClick} />
)}
</VisualizerControls>
</Container>
);
};
export default Visualizer;
Building an Audio Visualizer
The visualizer above is comprised of 3 main functions. The first being a function that handles the file uploaded, the result of this function is then passed to our second function which configures our analyzer node and finally the result of that function is given to our draw function which uses the analyzer nodes data to draw to the canvas. There’s also a bit of code that handles the screen resizing but will be ignored.2 Below we’ll go into each function in detail. Feel free to look above or on Github if you'd like to see code snippets within the context of the entire component!
Handling File Uploads
For our visualizer we’ll be using an audio file provided by the user as our input source. To capture the user's file we’ll first place an input tag of type file in our render function.
<StyledInput type="file" id="file-upload" onChange={handleFiles} ref={input} />
Next we’ll need to write a function that will be used to handle the file uploaded by the user. At the top of our function we’ll create two objects. The first being a FileReader, this object is necessary to read the user provided file. We’ll also need to create an AudioBufferSourceNode through the createBufferSource method. The AudioBufferSourceNode will be given the processed file and will serve the origin root of our audio graph.
const reader = new FileReader();
const source = audioCtx.current.createBufferSource();
To access the file provided by the user we’ll use the ref attached to the file input. Before we begin processing the file provided by the user we’ll write a check that will ensure that user has selected only a single file and the file is an mp3. If the file selected passes both checks we can begin processing the file via the readAsArraybuffer method on the FileReader object created above.
if (input.current.files.length !== 1) {
alert("You may only select a single file!");
return;
}
if (
input.current.files[0].type !== "audio/mpeg" &&
input.current.files[0].type !== "audio/mp3"
) {
alert("The file selected must be an mp3!");
return;
}
setFileName(input.current.files[0].name);
reader.readAsArrayBuffer(input.current.files[0]);
The FileReader has an event handler, onloadend which is called upon successful completion of the readAsArraybuffer method. Inside this handler we’re passed the processed ArrayBuffer which can then be passed to the decodeAudioData method on the audioCtx object. This method will resample3 the decoded file to the sample rate set on the audioCtx (default is 44100hz). Once the ArrayBuffer has been decoded we can set the buffer property on our ArrayBufferSourceNode to the now in-memory ArrayBuffer. We’ll connect the output of the source node to the input of the AnalyzerNode and we then connect the output of the AnalyzerNode to the input of AudioContext’s destination (by default: your speakers). After this routing we can now call our setUp function, where we will configure the settings of the AnalyzerNode.
reader.onloadend = (e) => {
audioCtx.current.decodeAudioData(e.target.result, (decodedData) => {
source.current.buffer = decodedData;
source.current.connect(analyser.current);
analyser.current.connect(audioCtx.current.destination);
setUp();
});
};
Analyser Configuration
Inside our setUp function we begin by setting the minDecibels and maxDecibels to -90 and -10, respectively. These properties will set a min and max range of values when we call getByteFrequencyData below. The smoothingTimeConstant property takes a value between 0 and 1. This property determines how the values from the previous buffer and the current buffer will be averaged out, setting the property to 0 will result in no averaging between the buffers, and setting a value of 1 will result in as much averaging as possible. For our example we’ll provide a value of 0.85. Next we set the fftSize to 256, this value must be a power 2 between 32 and 32768.
analyser.current.minDecibels = -90;
analyser.current.maxDecibels = -10;
analyser.current.smoothingTimeConstant = 0.85;
analyser.current.fftSize = 256;
Next we’ll create a new array of unsigned 8-bit integers. The length of this array will be equal to the frequencyBinCount property on our analyser. The frequencyBinCount is equal to half of the fftSize, so for our case 128. The frequencyBinCount is also the number of data points we’ll have available to us for our animation. Finally we’ll call start on our source node.
// 8-bit unsigned integers
dataArray.current = new Uint8Array(analyser.current.frequencyBinCount);
//begin
source.start(0);
Now that we’ve configured our AnalyzerNode we can initialize our draw function. We’ll pass our draw function to requestAnimationFrame. This is an important function for animation, it ensures that the function provided will be called once per screen refresh which will prevent skipped frames and keep our animation looking smooth. If we’d like further control over the speed of our animation we can wrap requestAnimationFrame with setInterval which will allow us to set the frames rendered per second, in our example we’ve set our fps to 40 as 1000 / 40 = 25, meaning setInterval will be called every 25 milliseconds which equates to 40 calls per second.
setInterval(() => {
requestAnimationFrame(draw);
}, 1000 / 40);
Draw
Inside the actual draw function is where we specify what we want drawn on the canvas every render. To begin we’ll get the ref on our canvas element to get the current height and width of our canvas (these dimensions will change if the width of the if altered).
const WIDTH = width.current;
const HEIGHT = width.current / RATIO;
const bufferLength = analyser.current.frequencyBinCount;
We’ll call getByteFrequencyData and pass in our dataArray created above as an argument. This method will fill our array with the current frequency data contained within the analyzer node.
analyser.current.getByteFrequencyData(dataArray.current);
With our data in place, we can begin drawing to the canvas. First we’ll call clearRect which will give us an empty canvas, on first render this is not necessary but for all future renders we’ll need this to clear what was previously drawn to the canvas last frame. To get the width of each bar in the visualizer we first subtract the bufferLength from the width of the canvas, this is needed since there will be 1 pixel of white space to the right of each bar, we then divide the remaining width over the bufferLength. We’ll initialize a barHeight variable, which will hold the height of the bar and an x which will hold the x coordinate needed for the drawRect function used below.
canvasCtx.current.clearRect(0, 0, WIDTH, HEIGHT);
const barWidth = (WIDTH - bufferLength) / bufferLength;
let barHeight;
let x = 0;
With a for loop we’ll iterate from 0 to the bufferLength, each pass through we’ll draw a rectangle. Inside the for loop we first calculate the height of the rectangle. To calculate the height we’ll get the percentage (by dividing over 255, the maximum value that can be returned from getByteFrequencyData) and then apply that percentage to the current height of the canvas.
const calcBarHeight = (barHeight, height) => {
const barHeightPercent = barHeight / 255;
return Math.floor(barHeightPercent * height);
};
Now that we have our height, we can draw our rectangle. We’ll call the fillRect method and provide four parameters: the x coordinate, y coordinate, width, and height, respectively. It’s important to remember that on a canvas the origin is located at the upper left corner. To move right we provide a positive x-value and to move down we provide a positive y-value. To start out our x value is equal to 0, the y-value is equal to the height of the canvas minus the height of our bar, this will move our point down from 0 to the top of the rectangle, which will be drawn left to right, top to bottom. Next we provide the width, which is a constant value and finally the height that we calculated above. At the end of our for-loop we position the x value for the next rectangle by incrementing x by the width of a rectangle plus 1 empty pixel of white space. Once the for-loop has finished we’ll have a complete frame that can be rendered to the canvas!
for (let i = 0; i < bufferLength; i++) {
barHeight = calcBarHeight(dataArray.current[i], HEIGHT);
canvasCtx.current.fillStyle = "rgb(255, 87, 51)";
canvasCtx.current.fillRect(x, HEIGHT - barHeight, barWidth, barHeight);
x += barWidth + 1;
}
Endnotes

[1] The code for this example is mostly taken from Mozilla's audio visualizer example. I've converter their eaxmple into a React component, added a file uploader, and added code to make the width of the canvas dynamic.

[2] The styling at the top of the file will also be ignored as it isn't needed to get the example up and running. If you'd like to learn about the styling I used check out styled components.

[3] If you'd like to learn more about resampling: Audio buffers: frames, samples and channels