"Give me a spectrum and I will delete whichever frequencies displease me. Give me a stream and I will remember, too late, that the world does not arrive in tidy blocks."
An Overconfident AI Agent
Prerequisites
This section assumes you are comfortable with the discrete Fourier transform as a change of basis and with the idea of a frequency response, both introduced in Chapter 3 and developed in full in Chapter 7. It also builds directly on the time-domain filters of Section 6.2 and the pass-band vocabulary of Section 6.3. If the words "FFT" and "convolution" are not yet friends, read those first; here we make them the same operation.
The Big Picture
Every filter you met so far slides a kernel along the signal, sample by sample, in the time domain. Frequency-domain filtering does the identical job by a different route: transform the whole signal to its spectrum, multiply by a mask that keeps the frequencies you want and zeroes the rest, then transform back. The convolution theorem promises the two routes agree. What changes is the economics and the failure modes. Multiplication is cheap and lets you sculpt an arbitrary response by hand, but blocks of a live stream do not multiply cleanly, and a careless mask rings like a struck bell. This section is about buying the power of spectral editing without paying in artifacts.
Filtering is multiplication in the frequency domain
The engine of this whole section is one identity. Convolving a signal \(x\) with a filter kernel \(h\) in time is exactly the same as multiplying their spectra point by point in frequency. Writing \(\mathcal{F}\) for the Fourier transform,
\[ y = x * h \quad\Longleftrightarrow\quad \mathcal{F}\{y\} = \mathcal{F}\{x\}\cdot \mathcal{F}\{h\}. \]This is the convolution theorem, and it reframes filtering completely. A time-domain filter answers "what weighted average of nearby samples do I output?" The frequency view answers "by how much do I scale each sinusoid the signal is built from?" The scaling factor at each frequency is the filter's frequency response \(H(f) = \mathcal{F}\{h\}\). A filter is nothing more than a volume knob per frequency: turned up near \(1\) where a sinusoid should survive, turned down near \(0\) where it should vanish; a low-pass filter simply keeps the low-frequency dials up and the high-frequency dials down. Because you can draw \(H(f)\) directly and inverse-transform it into a kernel, the frequency domain lets you design the response first and derive the filter second, which is the reverse of hand-tuning taps.
Why does this matter for sensors? Because the interference you fight is usually named in frequency, not in time: power-line hum sits at 50 or 60 Hz, a drone's rotor whine sits at its blade-pass frequency, a structure's resonance sits at its natural mode. When you can point at the offender on a spectrum, deleting it there is the most direct move available, and it costs almost nothing: the FFT turns an \(O(N^2)\) convolution into an \(O(N\log N)\) transform-multiply-transform, which is why long kernels are almost always applied this way in practice.
Key Insight
Time-domain and frequency-domain filtering are not two philosophies; they are two implementations of the same mathematical object. Choose between them on engineering grounds, latency, block size, kernel length, not on principle. A 4001-tap denoiser that would crawl as a direct convolution becomes trivial through the FFT, while a 5-tap smoother is faster left in the time domain. The response \(H(f)\) is the same either way; only the arithmetic changes.
Sculpting a spectral mask, and the ringing it invites
The seductive move is to build a "brick-wall" filter: set \(H(f)=1\) inside the pass-band and \(H(f)=0\) everywhere else, a perfectly rectangular mask. In the spectrum this looks flawless. In time it is a disaster, because the inverse transform of a rectangle is a \(\operatorname{sinc}\) function that oscillates forever. Multiply by a sharp-edged mask and every step or transient in your signal sprouts ripples on both sides of it, an overshoot that does not shrink as you add resolution. This is the Gibbs phenomenon, and it is the single most common way frequency-domain filtering goes wrong: an ECG with a hard 40 Hz cutoff grows phantom wiggles around every QRS spike that a clinician could mistake for pathology.
The cure is to forbid infinitely sharp edges. Replace the vertical wall with a smooth roll-off, a raised-cosine or Gaussian taper that eases \(H(f)\) from \(1\) to \(0\) over a transition band. A smoother mask has a shorter, better-behaved time-domain kernel and rings far less. The price is a gentler cutoff: you cannot both delete a frequency 1 Hz away from one you keep and avoid ringing. That trade, transition sharpness against time-domain artifacts, is the same delay-versus-fidelity tension that Section 6.7 makes central, seen now from the spectral side.
import numpy as np
def fft_lowpass(x, fs, f_cut, width):
"""Low-pass by masking the spectrum with a smooth raised-cosine edge."""
X = np.fft.rfft(x)
f = np.fft.rfftfreq(len(x), d=1.0 / fs)
# Raised-cosine roll-off from 1 to 0 across [f_cut, f_cut + width]:
mask = np.clip((f_cut + width - f) / width, 0.0, 1.0)
mask = 0.5 - 0.5 * np.cos(np.pi * mask) # smooth the ramp
return np.fft.irfft(X * mask, n=len(x))
fs = 1000.0
t = np.arange(0, 2, 1 / fs)
sig = np.sin(2 * np.pi * 5 * t) + 0.6 * np.sin(2 * np.pi * 120 * t)
clean = fft_lowpass(sig, fs, f_cut=20.0, width=8.0) # keep 5 Hz, kill 120 Hz
rfft takes the signal to its spectrum, the raised-cosine mask tapers smoothly from pass to stop across an 8 Hz transition band (avoiding the Gibbs ringing a rectangular mask would cause), and irfft returns to the time domain. Widening width reduces ringing at the cost of a less selective cutoff.Listing 6.4.1 is a working filter in a dozen lines, and it exposes the whole idea: the response you want is literally the array mask, drawn by hand. Try replacing the raised-cosine with a hard mask = (f <= f_cut) and you will see the QRS-style ripples appear around any sharp feature in sig.
Common Misconception
It is tempting to assume that zeroing the bin at, say, 60 Hz removes exactly the 60 Hz hum and nothing else. A real-world tone rarely lands exactly on a bin center, and unless it is perfectly periodic within the analysis window, its energy leaks into neighboring bins, the same finite-window truncation Chapter 7 treats in full under windowing. A one-bin-wide notch leaves an audible residue of leaked hum, while a notch wide enough to catch all the leakage also removes real signal beside it. A longer FFT block narrows each bin and reduces the leakage, but never removes it, and buys that improvement with more latency.
Streaming reality: circular convolution and the block problem
Listing 6.4.1 quietly assumed you own the entire signal at once. Real sensors deliver a never-ending stream, so you must filter in blocks. Here the FFT hides a trap. The multiplication \(X\cdot H\) followed by an inverse transform does not compute the ordinary (linear) convolution; it computes circular convolution, which treats each block as if it wrapped around into a loop. Energy that a real filter would push past the end of a block instead reappears, folded back, at the beginning. The result is wrap-around artifacts: corrupted samples at the block edges that grow worse the longer your kernel.
The fix is a pair of classic block methods, and both work the same trick: give the wrap-around a harmless place to land, then discard that place. A kernel of length \(L\) spills \(L-1\) samples of a true linear convolution past a block's edge; circular convolution folds those \(L-1\) samples back onto the block's start instead. Overlap-add pads each non-overlapping input block with \(L-1\) zeros before transforming, so the fold lands on padding rather than real data, then sums the overlapping tails of consecutive filtered blocks, exactly where the true convolution would have spilled. Overlap-save works the same idea from the other end: it feeds the FFT input blocks that already overlap the previous one by \(L-1\) samples, so the corrupted output samples are always the wrap of data already filtered correctly, and discarding the first \(L-1\) samples of each output block leaves clean interior only. Both recover the exact linear-convolution result at FFT speed; overlap-save suits fixed-latency streaming, since it needs no running accumulator, while overlap-add is simpler off-line. You rarely implement either yourself, but knowing they exist explains why a naive "FFT, mask, inverse-FFT, repeat" pipeline glitches at every block boundary.
In Practice: gearbox vibration on a wind turbine
A condition-monitoring team instruments a wind-turbine gearbox with an accelerometer sampled at 25.6 kHz, hunting for the tell-tale sidebands of a cracking gear tooth (the running thread of Chapter 37). The raw stream is dominated by a huge, uninteresting tone at the gear-mesh frequency and its harmonics, which swamp the weak fault sidebands. Rather than design a bespoke bank of notch filters, the engineers transform each measurement window, zero the FFT bins at the mesh harmonics with narrow smooth notches, and inverse-transform; the fault signature, previously buried 30 dB down, becomes visible. Their first prototype filtered each window independently and produced a spray of false alarms every few seconds, classic block-edge wrap-around; switching to overlap-save made the alarms disappear, because the corrupted edge samples were now discarded instead of scored.
Spectral subtraction: a filter with no time-domain twin
Some denoising is natural only in the frequency domain. Spectral subtraction assumes you can estimate the noise's magnitude spectrum, from a quiet interval, say, and then subtract that magnitude from every subsequent block's spectrum while keeping the block's phase. What survives is the signal energy that stood above the noise floor at each frequency. This is not a fixed \(H(f)\): the amount removed at each frequency adapts to how much noise you measured there, so it can suppress broadband hiss that no single pass-band filter would touch. It is the ancestor of the noise-reduction in every hearing aid and voice call.
The catch is a distinctly spectral artifact. Because the noise estimate fluctuates, subtraction leaves behind isolated surviving bins that warble in and out, an eerie "musical noise" of random tones. Taming it (over-subtraction margins, spectral flooring, smoothing across blocks) is a craft of its own, and a reminder that operating in the frequency domain buys new powers and new ways to fail. Where spikes and outliers are the enemy rather than steady-state noise, the frequency domain is the wrong tool entirely, and Section 6.5 reaches instead for robust, order-statistic filters.
Research Frontier
Hand-tuned spectral subtraction has a learned successor. Modern speech and biosignal denoisers replace the fixed "subtract the estimated noise magnitude" rule with a neural network trained end-to-end to predict a time-frequency mask directly from the noisy spectrogram, using paired clean and noisy examples instead of one quiet-interval noise estimate. A learned mask adapts to noise statistics no hand-tuned rule anticipated and largely avoids musical noise, at the cost of labeled training data and, for on-device use, a model fast enough to run in real time. Chapter 13 picks up this thread with representations built for exactly this kind of learned spectral processing.
The Right Tool
Smooth-masked spectral filtering with correct block handling is a solved problem in SciPy. Instead of hand-rolling overlap-save (roughly 40 lines of zero-padding, block bookkeeping, and edge discards that are easy to misalign), design a response once and let oaconvolve apply it via the FFT with the block stitching handled for you:
from scipy.signal import firwin, oaconvolve
taps = firwin(255, cutoff=20.0, fs=1000.0) # smooth low-pass kernel
y = oaconvolve(sig, taps, mode="same") # FFT-fast overlap-add convolution
firwin designs a windowed kernel with a controlled transition band (no Gibbs ringing), and oaconvolve applies it through the FFT using overlap-add internally, giving the exact linear-convolution result at \(O(N\log N)\) cost. Two lines replace both the manual masking of Listing 6.4.1 and the block-stitching of an overlap-save loop.The library removes the arithmetic, not the judgment. It will not tell you where to place the cutoff, how wide the transition band should be, or whether spectral subtraction is even appropriate for your noise; those choices stay yours.
Exercise
Take Listing 6.4.1 and replace the raised-cosine mask with a hard rectangular one, mask = (f <= 20.0).astype(float). Add a step to the test signal (for example set the second half of sig to a constant). Plot the filtered output near the step for both masks. Measure the peak overshoot as a percentage of the step height for the rectangular mask, then show that widening the raised-cosine transition band monotonically reduces that overshoot. Explain in one sentence why the overshoot does not vanish as you increase the FFT length.
Self-Check
1. The convolution theorem says time-domain convolution equals frequency-domain multiplication. Given that, why would anyone ever filter in the time domain instead of always using the FFT?
2. What is circular convolution, and what visible symptom does it produce if you filter a live stream block by block without overlap-add or overlap-save?
3. Why can spectral subtraction remove broadband noise that a fixed low-pass, high-pass, or notch filter cannot, and what new artifact does it introduce in exchange?
What's Next
In Section 6.5, we confront the noise that frequency-domain filtering handles worst: isolated spikes, dropouts, and outliers that smear their energy across every FFT bin. There the right instinct is not a smooth mask but a robust, order-statistic filter that ignores the outlier entirely, and we will see exactly when linear filtering must step aside for the median.