FFT signal analysis in your browser in 10 lines
Build a noisy signal, run it through an FFT, read the spectrum, and find the dominant frequencies — every cell runs in a browser tab.
Somewhere inside every noisy measurement — a vibration log, an audio clip, an EEG trace — there are a handful of clean sine waves trying to get out. The fast Fourier transform (FFT) is the tool that finds them, and it is far less mysterious than its reputation suggests.
In this tutorial we'll build a noisy signal from scratch, run it through an FFT, read the resulting spectrum, and locate the dominant frequencies programmatically. The core analysis is ten lines of code, and you can run every cell in a browser tab — no Python environment, no NumPy install, no toolbox license. We'll use Equana, a scientific-computing notebook that runs entirely in the browser, but the DSP ideas transfer directly to NumPy, Julia, or anything else with an fft function.
Step 0: what sampling actually means
A computer never sees a continuous signal. It sees samples: the signal's value measured at regular intervals. Two numbers pin down everything that follows:
- Sampling rate
fs— how many samples per second. We'll use 256 Hz. - Number of samples
n— how long we record. We'll take 256 samples, i.e. exactly one second.
The sampling rate sets a hard ceiling on what you can observe. The Nyquist frequency, fs / 2, is the highest frequency your samples can represent — anything faster aliases, i.e. masquerades as a lower frequency. At 256 Hz we can see tones up to 128 Hz, which is plenty for the two we're about to hide.
Step 1: build a signal worth analyzing
Let's manufacture a test signal we know the answer to: a strong 50 Hz tone (amplitude 1.0), a weaker 120 Hz tone (amplitude 0.5), and a generous layer of Gaussian noise on top.
fs = 256.0 # sampling rate in Hz
n = 256 # number of samples: one full second
t = arange(0, n - 1, 1) / fs # time axis: 0, 1/fs, 2/fs, ...
signal = sin(2*pi*50.0*t) + 0.5 * sin(2*pi*120.0*t) + 0.4 * randn(n)
plot(t, signal)
title("One second of a noisy signal")
xlabel("Time (s)")
Plot it and you'll see… a mess. The 50 Hz component is barely guessable as a texture; the 120 Hz tone is invisible. In the time domain — value versus time — this signal keeps its secrets. That's the problem the Fourier transform solves: it re-expresses the same data in the frequency domain, as a recipe of sine waves.
Step 2: the ten lines
Here is the whole analysis:
fs = 256.0 # sampling rate in Hz
n = 256 # one second of samples
t = arange(0, n - 1, 1) / fs # time axis
signal = sin(2*pi*50.0*t) + 0.5 * sin(2*pi*120.0*t) + 0.4 * randn(n)
spectrum = fft(signal)
mag = zeros(n)
for k in 1:n { mag[k] = abs(spectrum[k]) * 2 / n }
freqs = arange(0, n - 1, 1) * fs / n
plot(freqs[1:128], mag[1:128])
title("Amplitude spectrum")
Run it, and two unmistakable spikes rise out of a low grass of noise: one at 50 Hz with height ≈ 1.0, one at 120 Hz with height ≈ 0.5. The amplitudes we hid in step 1, recovered. Let's unpack the three ideas doing the work.
fft(signal) returns complex numbers. The discrete Fourier transform computes, for each of n frequency "bins", the coefficient
X[k] = Σⱼ x[j] · e^(−2πi · j · k / n)
Each coefficient is complex because it encodes two things at once: how much of that frequency is present (its magnitude) and where the wave starts (its phase). For "what frequencies are in here?", we only need the magnitude — hence abs(spectrum[k]).
The 2 / n scaling recovers true amplitudes. Raw DFT coefficients grow with the number of samples, so we divide by n. The extra factor of 2 exists because a real-valued signal splits its energy between a positive and a matching negative frequency; folding the two halves together doubles each amplitude. With * 2 / n, a sine of amplitude 1.0 shows up as a peak of height 1.0. Skip the scaling and the shape is identical — only the y-axis lies to you.
Bin k corresponds to frequency (k − 1) · fs / n. With n = 256 samples at fs = 256 Hz, the bins land neatly on 0 Hz, 1 Hz, 2 Hz, … — a resolution of exactly 1 Hz. That's what freqs = arange(0, n - 1, 1) * fs / n computes. And we only plot the first half, [1:128]: for a real signal the upper half of the spectrum is a mirror image of the lower half (those "negative frequencies" again), so everything above Nyquist is redundant.
Step 3: find the peaks programmatically
Eyeballing a chart is fine; code is better. A linear scan over the first half of the spectrum finds the dominant bin:
let peak be 1
for k in 2:128 {
if mag[k] > mag[peak] { peak = k }
}
println("dominant frequency: ${freqs[peak]} Hz")
Output:
dominant frequency: 50.0 Hz
Despite noise with a standard deviation nearly half the amplitude of our main tone, the FFT nails it. This is the quiet superpower of Fourier analysis: noise spreads its energy thinly across all bins, while a periodic signal concentrates its energy into one. The signal-to-noise ratio per bin improves the longer you record.
Step 4 (bonus): denoise by round trip
Once you're in the frequency domain, filtering becomes almost embarrassingly simple: zero out the weak bins, keep the strong ones, and transform back with the inverse FFT.
keep = zeros(n)
for k in 1:n {
if abs(spectrum[k]) * 2 / n > 0.3 { keep[k] = 1.0 }
}
y = ifft(spectrum .* keep)
denoised = zeros(n)
for k in 1:n { denoised[k] = real(y[k]) }
plot(t, denoised)
title("Denoised signal")
The result is a clean sum of two sine waves — the noise is gone because no single noise bin survived the threshold. (We take real(...) per element because ifft returns complex values; the imaginary parts are numerically zero, ~1e-17, but the plot wants reals.) This threshold-and-invert move is the seed of real techniques: audio noise gates, image compression, and spectral subtraction all start here.
Where to go from here
Our example was rigged to be friendly: the tones sat exactly on bin centers. Real-world frequencies fall between bins and smear across their neighbors — an effect called spectral leakage, tamed by multiplying the signal with a window function before the FFT. Try changing 50.0 to 50.5 and watch the peak spread; then try weighting the signal with a Hann window (0.5 - 0.5 * cos(2*pi*t*fs/(n-1)) — you can build it in one line) and watch it sharpen. Longer recordings buy you finer frequency resolution; zero-padding buys you a smoother-looking plot. Each of these is a small experiment away.
A note on the tool: every code cell above runs in Equana, the scientific-computing notebook I'm building at quebi GmbH. It executes entirely in your browser — the FFT runs on-device via WebAssembly, and your data never leaves your machine. It has its own language (Julia-inspired, with an optional natural-language syntax layer), built-in plotting, and a debugger. It's currently in alpha and free to try after signing in — if you spot rough edges, that's what the roadmap is for. The DSP above, of course, works anywhere.