Audio Processing with the Web Audio API

Building an audio graph: sources, nodes, and a visualizer

The audio graph

The Web Audio API models audio processing as a directed graph of connected nodes. Every graph has at least one source and ends at a single destination — usually the user's speakers or headphones, represented by ctx.destination. Between the source and the destination you can insert any number of processing nodes: gain (volume), equalisation, convolution reverb, compression, and more.

AudioContext

The entire API lives inside an AudioContext. You create one instance per page and reuse it. All nodes — sources, processors, and the destination — belong to a specific context and can only be connected within the same context.

One important constraint: browsers suspend the AudioContext by default until a user gesture (a click, tap, or keypress) has occurred. This is a deliberate anti-autoplay policy. You must either create the context inside a click handler, or call ctx.resume() inside one. Attempting to play audio while the context is suspended produces silence.

Typical graph structure

A simple playback-with-volume graph looks like this:

Audio flows left to right. Each arrow represents a .connect() call.
// Create the context (must happen inside a user gesture, or resume() must be called) const ctx = new AudioContext(); // Build the graph const src = ctx.createMediaElementSource(audioElement); const gain = ctx.createGain(); src.connect(gain).connect(ctx.destination); // Start playback await ctx.resume(); audioElement.play();

.connect() returns the destination node, which lets you chain calls: src.connect(gain).connect(ctx.destination) reads naturally left to right. Once nodes are connected, audio flows through them automatically — you do not need to push data manually.

Browser autoplay policy: Chrome, Firefox, and Safari all block AudioContext from running until the user has interacted with the page. If you create the context eagerly (outside a click handler), its state will be "suspended". Call ctx.resume() inside your click handler to unblock it.

Connecting a media element and controlling gain

ctx.createMediaElementSource(element) takes an existing <audio> or <video> element and wraps it as a Web Audio source node. Once you do this, the element's audio output is rerouted through your graph — it no longer goes directly to the speakers until it reaches ctx.destination.

Important: call createMediaElementSource only once

If you call createMediaElementSource() on the same element twice, the browser throws an error: "HTMLMediaElement already connected previously to a different MediaElementSourceNode". Guard against this with a flag so re-clicking your "Start" button is safe.

(function () { var audio = document.getElementById('wa-audio'); var startBtn = document.getElementById('wa-start'); var volSlider = document.getElementById('wa-vol'); var ctx, gain; var connected = false; // guard — createMediaElementSource may only be called once startBtn.addEventListener('click', function () { if (!ctx) { ctx = new AudioContext(); } if (!connected) { var src = ctx.createMediaElementSource(audio); gain = ctx.createGain(); src.connect(gain).connect(ctx.destination); connected = true; } ctx.resume().then(function () { if (audio.paused) { audio.play().catch(function (err) { console.warn('Play prevented:', err.message); }); startBtn.textContent = 'Pause'; } else { audio.pause(); startBtn.textContent = 'Play'; } }); }); audio.addEventListener('ended', function () { startBtn.textContent = 'Play'; }); volSlider.addEventListener('input', function () { if (gain) { gain.gain.value = parseFloat(volSlider.value); } }); }()); <audio id="wa-audio" src="../assets/sample-audio.mp3"></audio> <div class="demo-controls"> <button id="wa-start" class="btn-start">Play</button> <label class="vol-label"> Volume <input type="range" id="wa-vol" min="0" max="1" step="0.01" value="1"> </label> </div>

Live demo

Press Play — the AudioContext is created and the audio graph is assembled on your first click. Drag the volume slider to set gain.gain.value in real time; the native audio volume control never enters the picture.

GainNode vs element.volume: Setting gain.gain.value via the Web Audio API and setting audio.volume directly are independent controls — their effects multiply. If the element volume is 0.5 and the gain node is 0.5, you hear 25% of the original level. When using Web Audio, leave audio.volume at its default (1) and control everything through the graph.

Analyser node and frequency visualizer

An AnalyserNode is a read-only tap — it does not change the audio signal, it just exposes real-time frequency and time-domain data. You insert it anywhere in the graph (typically after the source), then read its data in a requestAnimationFrame loop to drive a canvas visualizer.

Adding the analyser to the graph

Because createMediaElementSource() can only be called once, the analyser must be set up at the same time as the gain node. The analyser taps off the source in parallel with the gain path:

var src = ctx.createMediaElementSource(audio); var gain = ctx.createGain(); var analyser = ctx.createAnalyser(); analyser.fftSize = 256; // number of frequency buckets (power of 2, 32–32768) // Audio path: src → gain → destination (you hear this) src.connect(gain).connect(ctx.destination); // Analysis tap: src → analyser (silent side-chain, read-only) src.connect(analyser);

Drawing frequency bars on a canvas

analyser.getByteFrequencyData(dataArray) fills a Uint8Array with values from 0–255, one per frequency bin. You call it every frame inside a requestAnimationFrame callback and draw the bars yourself.

Always respect prefers-reduced-motion: if the user has requested reduced motion, skip the animation loop and draw a single static snapshot instead.

var canvas = document.getElementById('wa-canvas'); var canvasCtx = canvas.getContext('2d'); var bufferLength = analyser.frequencyBinCount; // = fftSize / 2 var dataArray = new Uint8Array(bufferLength); var reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; function drawFrame() { analyser.getByteFrequencyData(dataArray); canvasCtx.clearRect(0, 0, canvas.width, canvas.height); canvasCtx.fillStyle = '#111'; canvasCtx.fillRect(0, 0, canvas.width, canvas.height); var barWidth = (canvas.width / bufferLength) * 2; var x = 0; for (var i = 0; i < bufferLength; i++) { var barHeight = (dataArray[i] / 255) * canvas.height; // Teal-to-green gradient based on frequency position var green = Math.round(180 + (i / bufferLength) * 50); canvasCtx.fillStyle = 'rgb(15, ' + green + ', 110)'; canvasCtx.fillRect(x, canvas.height - barHeight, barWidth, barHeight); x += barWidth + 1; } if (!reducedMotion) { requestAnimationFrame(drawFrame); } } // Start the loop drawFrame(); // If reducedMotion, drawFrame() runs once and stops — a static snapshot.

Live visualizer demo

This demo uses its own <audio> element, separate from the gain demo above. It has to: createMediaElementSource() can be called only once per element — calling it again on the same element throws an error — so each Web Audio demo needs its own <audio>. Press "Start Visualizer", then play the audio to see the frequency bars update.

prefers-reduced-motion: Some users configure their OS to minimise on-screen movement (vestibular disorders, epilepsy, preference). Calling matchMedia('(prefers-reduced-motion: reduce)') lets your code respect that preference. For a visualizer the right response is to draw a single static snapshot rather than a live animation loop — the information is still there, just not moving.

Key AnalyserNode properties

Property / Method What it does
fftSize FFT window size — power of 2, 32–32768. Larger = more frequency resolution, more CPU.
frequencyBinCount Read-only. Always fftSize / 2. This is the length of your data array.
getByteFrequencyData(array) Fills array with frequency magnitude values 0–255 (unsigned byte).
getFloatFrequencyData(array) Same, but values are in decibels (float). More precise, same concept.
getByteTimeDomainData(array) Fills array with waveform samples — useful for an oscilloscope-style display.
smoothingTimeConstant 0–1. Higher = smoother animation (values decay slowly). Default is 0.8.

When to use the Web Audio API

The Web Audio API is powerful but has a non-trivial learning curve. Before reaching for it, ask whether a simpler tool does the job:

  • Plain playback — use <audio controls>
  • Custom controls — use the JS media element API (Tutorial 06)
  • Volume/seeking onlyaudio.volume and audio.currentTime are enough
  • Visualizer — Web Audio API (this tutorial)
  • Real-time effects — equaliser, reverb, compression — Web Audio API
  • Browser games — spatial audio, synthesized sound effects — Web Audio API
  • Music production apps — DAW-style sequencers — Web Audio API

For a complete, styled frequency visualizer wired up to a custom player interface, see Example 03: Audio Visualizer.

Further reading: The Web Audio API specification is maintained by the W3C Audio Working Group. MDN's Web Audio API guide is the most accessible starting point, with detailed explanations of every node type and several interactive examples.