Creating an audio waveform from your microphone input

I've recently started creating an online audio editor. One of the features I wanted to implement was to create a waveform for each track in the editor. This gives a nice overview of the content of each track.

While recording a new track, it would be cool to visually see the the waveform you're recording so I decided to generate a waveform in realtime while recording a new audio track.

Below I will go through the basics of how you can create such a waveform from your audio input device.

To get ahold of the microphone audio in a web browser, we're going to use getUserMedia. This will either resolve into an audio stream, or reject with an error.

navigator.mediaDevices.getUserMedia({  
    audio: true
}).then(stream => {
    // Handle the incoming audio stream
}, error => {
    // Something went wrong, or the browser does not support getUserMedia
});

Note: we're using navigator.mediaDevices.getUserMedia because navigator.getUserMedia has been deprecated in favor of the mediaDevices method. This currently only works in Chrome and Firefox.

Once we've got an audio stream, we can create a MedaiStreamSourceNode. This is an audio node which we can use in our web-audio chain of nodes.

We are also going to create an AnalyserNode to analyse our incomming audio, and a ScriptProcessorNode to process the data of the audio stream.

Note: we're using a ScriptProcessorNode which has been deprecated, and will be replaced by audio workers in the future. At the moment, no browser supports these audio workers, so we'll stick to the ScriptProcessorNode for now.

Once all audio nodes are created, we can wire them all up.

...
.then(stream => {
    // Handle the incoming audio stream
    const bars = [] // We'll use this later
    const audioContext = new AudioContext();
    const input = audioContext.createMediaStreamSource(stream);
    const analyser = audioContext.createAnalyser();
    const scriptProcessor = audioContext.createScriptProcessor();

    // Some analyser setup
    analyser.smoothingTimeConstant = 0.3;
    analyser.fftSize = 1024;

    input.connect(analyser);
    analyser.connect(scriptProcessor);
    scriptProcessor.connect(audioContext.destination);
}
...

Now our incoming audio will go from the input MediaStreamSourceNode to the AnalyserNode to the ScriptProcessorNode. We'll also connect our scriptProcessor to the destination of our audioContext. There seems to be some strange behavior in Chrome where the scriptProcessor does not receive any input when it is not connected to the audioContext's destination.

Tip: the firefox developer tools have a very nice web-audio inspector. This tab allows you to visualize and inspect your audio chain.

Now our setup is complete, and we can start processing our incoming audio.

We start by adding an event handler to the scriptProcessor's onaudioprocess event.

scriptProcessor.onaudioprocess = processInput;  

Every time the scriptProcessor processes audio, it is going to run the processInput function, so let's create that function next.

const processInput = audioProcessingEvent => {  
    const tempArray = new Uint8Array(analyser.frequencyBinCount);

    analyser.getByteFrequencyData(tempArray);
    bars.push(getAverageVolume(tempArray));

    // We'll create this later
    renderBars(bars);
}

const getAverageVolume = array => {  
    const length = array.length;
    let values = 0;
    let i = 0;

    for (; i < length; i++) {
        values += array[i];
    }

    return values / length;
}

The processInput function is going to create an Uint8Array from the analyser's frequencyBinCount. and pass this array to the analyser's getByteFrequencyData method.

After this, we are able to use that array to calculate the average volume of the audio stream at that moment.

For this we will use a second function getAverageVolume which will simply return the average of all values in that array.

We'll save this average in the bars array we created earlier. This array is going to hold all values we want to draw as a bar in the waveform.

At the end of our processInput function we call the renderBars function which will draw the waveform onto a canvas element.

Here's how this function looks like.

const renderBars = () => {  
    const canvas = document.querySelector('canvas');
    const canvasContext = canvas.getContext('2d');
    const width = canvas.offsetWidth;
    const height = canvas.offsetHeight;
    const halfHeight = height / 2;
    const barWidth = 2;
    const barGutter = 2;
    const barColor = "#49F1D5";

    // Set the size of the canvas context to the size of the canvas element
    canvasContext.canvas.width = width;
    canvasContext.canvas.height = height;

    let drawing = false;

    if (!drawing) {
        drawing = true;

        window.requestAnimationFrame(() => {
            canvasContext.clearRect(0, 0, width, height);

            bars.forEach((bar, index) => {
                canvasContext.fillStyle = barColor;

                // Top part of the bar
                canvasContext.fillRect((index * (barWidth + barGutter)), (halfHeight - (halfHeight * (bar / 100))), barWidth, (halfHeight * (bar / 100)));

                // Bottom part of the bar
                canvasContext.fillRect((index * (barWidth + barGutter)), halfHeight, barWidth, (halfHeight * (bar / 100)));
            });

            drawing = false;
        });
    }
}

Tip: We use requestAnimationFrame, this will prevent the browser painting too much. It will ask the browser when a new frame starts and executes it's callback. The scriptProcessor's onaudioprocess event happens more than than the browser can paint the bars. By using requestAnimationFrame we'll postpone the painting until the browser re-renders the page again (roughly 60 times a second).

The first thing renderBars will do is clear all previously drawn bars from the canvas. We do this by using canvasContext.clearRect and passing the width and the height of the canvas.

Next it is going set the fillStyle to the color we configured and draw a bar for each of the values in the bars array. The middle of the canvas will be the value 0, and we'll draw the same bar above and below the middle of the canvas.

We can draw the bars by using canvas' canvasContext .fillRect method which accepts a starting x and y position and a width and height. We can calculate based on the index of our bar, and the height based on the value of our bar. I've added a bit of extra logic so you can define the width of a bar and the space between bars but it all boils down to this:

Top part of the bar:

  • x: index
  • y: half the canvas' height minus the space above the bar
  • width: width of the bar
  • height: The value of the bar

Bottom part of the bar:

  • x: index
  • y: half the canvas' height
  • width: width of the bar
  • height: The value of the bar

The full code can be found in this Codepen, I've tried to annotate the javascript so it's clear what's happening.

See the Pen Draw a waveform from your microphone's audio by Sam (@Sambego) on CodePen.

These were the basics of how you can draw a waveform of input audio. There is still room for improvement, but that is beyond the scope of this blog post.

Here's a little more extended demo.

See the Pen Draw a waveform of the microphone by Sam (@Sambego) on CodePen.

If you have any question or want to give your opinion on this article, feel free to do so in the comments.

Sam Bellen
Sam Bellen

I'm a software engineer at @madewithlove. I like playing around with the web-audio-api, and all things front-end related.