Analysis and the FFT: seeing the frequency domain
An AnalyserNode sits in the graph as a silent tap rather than a processor, letting ordinary main-thread JavaScript read the recent signal back out as a waveform or, via the Fast Fourier Transform, as a spectrum — which is the entire mechanism behind every music visualizer you've ever seen.
Analysis and the FFT: seeing the frequency domain
Every lesson so far in this module has talked about "shaping frequency content" as something you do without ever seeing it — a lowpass filter removes highs, a notch cuts a narrow band, but you've had to take the effect on faith. AnalyserNode is the node that closes that loop: it lets you pull the signal's actual shape back out of the graph, either as a raw waveform or as a full frequency spectrum, and read it from ordinary JavaScript on the main thread. This is the node behind every bouncing-bar music visualizer, and it's also where the audio graph and the canvas render loop meet.
A pass-through tap, not a processor
The first thing to get right about AnalyserNode is what it doesn't do: it doesn't change the sound. Connect it into a chain and audio flows through it completely unchanged — it's a tap, not a filter or an effect. What it does instead is continuously keep a rolling window of the recent signal in an internal buffer, which your JavaScript can ask to read at any moment.
const context = new AudioContext();
const analyser = context.createAnalyser();
analyser.fftSize = 2048;
source.connect(analyser);
analyser.connect(context.destination); // signal still reaches the speakers unchangedBecause it's just a tap, you can drop an analyser into any point of any chain from earlier lessons — after a source, after a filter, after a whole effects chain — and read exactly what the signal looks like at that point, without touching what listeners actually hear.
Two views of the same signal
AnalyserNode exposes the buffered signal two different ways, and the difference between them is the core idea of this lesson.
Time domain is the raw waveform: amplitude plotted against time, exactly like an oscilloscope. getByteTimeDomainData (or the float-precision getFloatTimeDomainData) fills a typed array with one amplitude value per sample, in order, straight off the wire.
const waveform = new Uint8Array(analyser.fftSize);
analyser.getByteTimeDomainData(waveform);
// waveform[i] is roughly the amplitude at sample i, centered around 128Frequency domain is the spectrum: energy plotted against frequency instead of against time. getByteFrequencyData (or getFloatFrequencyData) fills a typed array where each entry is the signal's energy in one narrow slice of the frequency range — a bass-heavy sound lights up the low entries, a bright cymbal hit lights up the high ones, regardless of when in the buffered window each frequency occurred.
const spectrum = new Uint8Array(analyser.frequencyBinCount);
analyser.getByteFrequencyData(spectrum);
// spectrum[i] is the energy near a particular frequency, spectrum[0] lowestThe FFT: how a block of samples becomes a spectrum
Getting from "a block of amplitude-over-time samples" to "energy per frequency" is exactly the computation a filter's design leans on implicitly and a visualizer needs explicitly, and the algorithm that does it is the Fast Fourier Transform. It rests on a real result from signal processing — Fourier's theorem — that any signal, no matter how complex, can be decomposed into a sum of simple sine waves at different frequencies, amplitudes, and phases. The FFT is just an efficient algorithm for computing that decomposition: feed it a block of time-domain samples, and it hands back the amplitude of each frequency component that, added together, would reconstruct that block.
Those frequency components come out as discrete bins — the output isn't a continuous curve, it's a fixed number of buckets, each one representing the energy near a particular frequency. spectrum[0] is the lowest bin, spectrum[spectrum.length - 1] is the highest, and everything your ear hears as "bass," "mids," and "highs" is really just which region of that bin array lights up.
fftSize and the time/frequency tradeoff
fftSize sets how many time-domain samples go into each FFT computation, and it must be a power of two (32 up to 32768, defaulting to 2048). Two things follow directly from it:
- The number of frequency bins you get back is always
fftSize / 2, exposed asanalyser.frequencyBinCount. - A bigger
fftSizemeans the FFT is looking at a longer window of time per computation, which lets it distinguish frequencies that are closer together (finer frequency resolution) — but that longer window also means each spectrum you read is now an average over more elapsed time, so fast, percussive changes get smeared (coarser time resolution). A smallerfftSizeis the opposite trade: coarser frequency detail, but a spectrum that updates responsively enough to track a fast transient.
Two more properties tune the readout without touching that fundamental tradeoff: smoothingTimeConstant (0 to 1) blends each new frequency-domain reading with the previous one, which is why most visualizers look like they ease between bars rather than jittering frame to frame; minDecibels/maxDecibels set the dB range that gets mapped onto the 0–255 byte output, effectively controlling the readout's contrast.
How a visualizer actually works
Put the pieces together and a music visualizer is just this: an analyser tapped into the graph, and a requestAnimationFrame loop on the main thread that, every frame, pulls the current frequency (or time-domain) data into a reusable typed array and draws it.
const analyser = context.createAnalyser();
analyser.fftSize = 256;
source.connect(analyser);
analyser.connect(context.destination);
const bufferLength = analyser.frequencyBinCount;
const data = new Uint8Array(bufferLength); // allocated once, reused every frame
const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d");
const barWidth = canvas.width / bufferLength;
function draw() {
analyser.getByteFrequencyData(data);
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let i = 0; i < bufferLength; i++) {
const barHeight = (data[i] / 255) * canvas.height;
ctx.fillRect(i * barWidth, canvas.height - barHeight, barWidth - 1, barHeight);
}
requestAnimationFrame(draw);
}
draw();That loop is exactly the canvas render loop: clear, read fresh state, redraw the whole scene, every frame, inside the 16.6 ms frame budget. The one reused Uint8Array matters for the same reason offscreen caching mattered there — allocating a fresh array every frame is needless garbage-collector pressure inside a loop that has to keep up sixty times a second.
Why is it safe to do all this reading from the unreliable main thread, when lesson 1 spent its whole warning on how the main thread is too jittery to produce audio reliably? Because reading an analyser isn't producing anything — you're pulling a snapshot of a buffer the audio thread already filled in, on its own schedule, well before your requestAnimationFrame callback ever asked for it. If your visualizer's frame is a little late, or skips a frame under load, the sound itself never glitches — the audio thread keeps rendering samples exactly on time regardless of whether anyone's watching. The render thread's real-time guarantee (lesson 1) and the main thread's best-effort animation loop (the animation module) can coexist peacefully here precisely because the analyser only reads; it never writes anything the audio thread depends on.
Where this goes next
Every node covered so far — sources, gain, filters, delay, convolution, and now the analyser — has been a built-in node the audio thread evaluates natively, fast and glitch-free, because you never had to write the per-sample math yourself. AudioWorklet: your own DSP on the audio thread is the capstone: it's what happens when the built-ins genuinely aren't enough and you need to write that per-sample math yourself, on the audio thread, inside its real-time deadline.
Go deeper
- MDN — AnalyserNode — The full property and method list (fftSize, frequencyBinCount, smoothingTimeConstant, the getData variants) this lesson builds its visualizer from.
- MDN — AnalyserNode.getByteFrequencyData() — The exact byte-mapping behavior (minDecibels/maxDecibels onto 0-255) used in the visualizer code above.
- W3C spec — AnalyserNode — The authoritative definition of the FFT block processing and time/frequency-domain data this lesson describes at the mechanism level.
Check yourself
Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.
- Why is AnalyserNode described as a pass-through tap rather than a processing node, and what does that let you do that connecting it elsewhere in the chain wouldn't?
- Explain the difference between what getByteTimeDomainData and getByteFrequencyData each return.
- What does the FFT actually compute, and what theorem justifies treating any signal as a sum of sine waves?
- How does fftSize determine frequencyBinCount, and what tradeoff does raising or lowering fftSize force you to make?
- Walk through the visualizer's per-frame loop and identify which parts run on the audio thread versus the main thread.
- Why is it safe to read analyser data from a requestAnimationFrame loop that might skip frames, when lesson 1 warned the main thread is too unreliable to produce audio?
- What does smoothingTimeConstant change about the visual output, and why doesn't it affect the underlying frequency resolution set by fftSize?