Build a Software-Defined Radio in 100 Lines
A radio is no longer hardware. Once you sample the antenna fast enough, every classic RF block — mixer, filter, demodulator — becomes a few lines of arithmetic on a stream of complex numbers. The antenna is the last analog component.
There is no radio inside a radio — and once you see why, it feels like cheating. Open a 30-dollar USB dongle that pulls in FM stations, aircraft transponders, and weather satellites, and you will not find a tuned circuit for each band, a mixer chip per service, or a stack of analog filters. You will find an antenna, one fast analog-to-digital converter, and a USB cable. Everything a textbook calls a "radio" — the mixer that shifts a station down to where you can hear it, the filter that rejects the neighbours, the demodulator that recovers the voice — has dissolved into arithmetic. The mixer is a multiply. The filter is a for loop. The demodulator is an absolute value. Once the samples are flowing, a radio is software, and a short program at that.
The trap is the word "radio" itself, which still smells of soldering irons and ferrite cores. That intuition was correct for a century. It stopped being correct the moment converters got fast enough to digitise the antenna directly: the signal stops being a voltage on a wire and becomes a stream of numbers in memory, and numbers do not care whether you process them with a tuned circuit or a numpy array. This post is the capstone of the electrical-engineering labs on this site. Every block we build, you have already dragged around with a slider — the spectrum that finds the station, the complex sample stream that carries it, the digital filter that isolates it, the envelope that turns it back into audio. Software-defined radio (SDR) is that same math, assembled into one pipeline.
The antenna is the last analog part
Walk the signal path of an old superheterodyne receiver and almost every box is a lump of analog hardware: the RF amplifier, the local oscillator, the mixer, the intermediate-frequency filter, the detector, the audio stage. Each one is a circuit you can hold, chained into a receiver.
An SDR keeps exactly one of those boxes. The antenna catches the electromagnetic field and turns it into a voltage — genuinely physical, genuinely analog, and no amount of software replaces it. Right behind it sits the analog-to-digital converter (ADC), the border crossing. On the antenna side you have volts; on the far side, a list of samples arriving at a known rate , the sample rate. Everything past the ADC is a for loop.
import numpy as np
from rtlsdr import RtlSdr
sdr = RtlSdr()
sdr.sample_rate = 2.4e6 # 2.4 million complex samples per second
sdr.center_freq = 100.1e6 # park the tuner at 100.1 MHz
sdr.gain = 'auto'
iq = sdr.read_samples(256 * 1024) # one capture: a numpy array of complex128
sdr.close()That is the only hardware-specific code in the whole project — four lines of setup and one read_samples. After it returns, you hold a numpy array of complex numbers and the dongle has done its job. Call it 6 lines on the running tally; the rest of this post never touches the device again, only iq.
The sample rate is the whole bandwidth you own
When you set sample_rate = 2.4e6, you are not choosing a station — you are choosing how wide a slice of spectrum to capture at once. Because the samples are complex, a rate of lets you represent a band of width around the tuner's centre frequency (real samples would give you only — the second reason radios work in IQ). So 2.4 MS/s (megasamples per second) hands you a 2.4 MHz-wide window in a single array. The FFT from the opening lab is how you see what is inside it; picking a station is then a software question: which peak in the 2.4 MHz do you want?
IQ: why radios speak in complex numbers
Notice the word complex a few lines up. The dongle did not hand you real voltages — it handed you complex numbers, and that is not a quirk of numpy but the native language of every modern radio. A signal carries two facts at each instant: amplitude and phase . One real number per sample cannot hold both; two can. Split the signal into a component in phase with a reference oscillator (, for in-phase) and one a quarter-cycle behind it (, for quadrature), and the pair pins down both at once.
The clean way to write that pair is as a single complex number — equation 1, the representation everything else rests on.
Read it both ways. On the left, and are the two voltages the receiver measures. On the right, the magnitude is the instantaneous amplitude — the envelope — and the angle is the instantaneous phase. AM lives in the magnitude; FM and PM live in the angle. Once your signal is a stream of complex numbers, "demodulate" becomes "take the part of the complex number you care about" — which is why radios bother with the complex plane at all.
Mixing = multiply by a complex exponential
You captured 2.4 MHz of spectrum and found your station as a peak off-centre — say its carrier sits 300 kHz above where the tuner is parked. To process it, you want it at zero: slide the whole spectrum down so the carrier lands at DC, the centre of the plot. In analog hardware that slide is a mixer, a nonlinear device fed by a local oscillator. In software it is a multiplication, and a one-liner at that.
Multiplying the sample stream by a complex exponential rotates every sample's phase at a steady rate, and a steady phase rotation is a frequency shift. This is equation 2, the digital downconversion.
Choose to be the offset of your station from the tuner centre (300 kHz here), and after the multiply the station's carrier sits at 0 Hz; everything else in the captured band slides down with it. In code it is exactly as short as the equation promises.
fs = 2.4e6
f_offset = 300e3 # station is 300 kHz above tuner centre
n = np.arange(len(iq))
lo = np.exp(-1j * 2 * np.pi * f_offset * n / fs) # the local oscillator
baseband = iq * lo # the mixer: one elementwise multiplyThat is the mixer. Three working lines, and the only reason it needs three is that you build the oscillator vector first. The local oscillator that took a quartz crystal and a phase-locked loop in hardware is np.exp of a ramp. Running tally: about 9 lines, and the station is centred at zero.
Why the minus sign, and why complex
The exponent is negative because you are shifting down — to cancel a carrier at you rotate at . And it must be a complex exponential, not a cosine: a real cosine is , so multiplying by it makes two copies of your spectrum, one shifted up and one down, which then overlap and interfere. The complex exponential carries only the single term, so it slides the spectrum cleanly one way with no mirror image. That is the payoff of working in IQ: a one-sided spectrum you can shift without folding it onto itself.
Filtering = a for loop
Your station is centred at zero, but it is not alone — the adjacent channels you slid down with it still sit a few hundred kHz to either side, and they will bleed into your audio if you let them. You need a channel-select filter: keep everything within the station's bandwidth, reject everything outside it. In hardware this was a carefully tuned LC or crystal filter, a physical object with a physical resonance. In software it is a weighted running sum, the most ordinary loop you will write all year.
A finite-impulse-response (FIR) filter slides a short window of coefficients along the sample stream and, for each output sample, computes a weighted sum of the recent inputs. That is equation 3 — a convolution, the workhorse of DSP.
The coefficients are where the design lives. Choose them for a low-pass shape and the loop passes slow variations (your centred channel) while cancelling fast ones (the neighbours). A windowed-sinc design computes a good set in one call, and the convolution itself is one more.
from scipy.signal import firwin
channel_bw = 200e3 # keep ±100 kHz around DC
taps = firwin(numtaps=64, cutoff=channel_bw/2, fs=fs)
filtered = np.convolve(baseband, taps, mode='same') # equation 3, vectorisedTwo lines: design the taps, run the convolution. np.convolve is equation 3 with the loop hidden inside a C kernel — for each output it multiplies 64 coefficients by 64 recent samples and adds them up. Running tally: about 11 lines, and the neighbouring channels are gone.
Demodulation = take the envelope
The station is centred and isolated. One step remains: turn the complex stream back into something a speaker can play. For an AM (amplitude-modulation) signal — broadcast medium wave, but also aircraft voice in the 118–137 MHz airband — the audio is encoded in the carrier's amplitude, how big the wave is moment to moment. Equation 1 already told you the amplitude of a complex sample is its magnitude. So AM demodulation is the magnitude of the complex baseband, equation 4, as short as it sounds.
Because the signal is already complex baseband, you do not need the analog trick of a diode and a capacitor to trace the envelope — it is sitting right there as the magnitude, and np.abs reads it off directly.
from scipy.signal import decimate
envelope = np.abs(filtered) # equation 4: the AM envelope
audio = envelope - np.mean(envelope) # drop the DC carrier offset
audio = decimate(audio, int(fs / 48e3)) # 2.4 MS/s down to 48 kHz audio
audio /= np.max(np.abs(audio)) # normalise to ±1 for the sound cardTake the magnitude, subtract the carrier's DC term, drop the rate to something a sound card wants, normalise. Four lines, and filtered has become playable audio. Running tally: about 15 lines of real signal processing on top of the 6-line capture — comfortably under 100 once you add the file plumbing and a sounddevice.play(audio, 48000) at the end.
Put it together: 100 lines, a dongle, a voice out of the noise
Stack the pieces and the whole receiver fits on one screen. Capture, downconvert, filter, demodulate — four functions, each a near-transcription of an equation you have now seen in a lab.
import numpy as np
from rtlsdr import RtlSdr
from scipy.signal import firwin, decimate
import sounddevice as sd
def capture(center, fs=2.4e6, n=1 << 20):
sdr = RtlSdr()
sdr.sample_rate, sdr.center_freq, sdr.gain = fs, center, 'auto'
iq = sdr.read_samples(n)
sdr.close()
return iq, fs
def downconvert(iq, f_offset, fs): # equation 2
n = np.arange(len(iq))
return iq * np.exp(-1j * 2 * np.pi * f_offset * n / fs)
def channel_filter(x, fs, bw=200e3, taps=64): # equation 3
h = firwin(taps, bw / 2, fs=fs)
return np.convolve(x, h, mode='same')
def am_demod(x, fs, audio_fs=48e3): # equation 4
env = np.abs(x) - np.mean(np.abs(x))
audio = decimate(env, int(fs / audio_fs))
return audio / np.max(np.abs(audio))
iq, fs = capture(center=120.0e6) # an airband voice channel (AM, ~118–137 MHz)
bb = downconvert(iq, f_offset=0, fs=fs)
chan = channel_filter(bb, fs)
audio = am_demod(chan, fs)
sd.play(audio, 48000)Count it: roughly 30 lines with the imports, signatures, and the whitespace that makes it readable. The arithmetic — the part that is the radio — is four operations: a multiply, a convolution, a magnitude, a rate change. A voice comes out of the speaker, and not one line of it tuned a circuit. The dongle delivered an array; numpy did the rest.
- Analog components
- 1
- the antenna — everything past the ADC is software
- Lines of DSP
- ~15
- downconvert, filter, demod, on top of a 6-line capture
- Captured bandwidth
- 2.4 MHz
- one array, the whole window set by the sample rate
Every classic RF block has a one-line software twin: the mixer is a multiply, the filter is a for-loop, the demodulator is an absolute value.
What each lab was really teaching
The four labs here were not illustrations of a radio — they were the radio, one stage each. The FFT spectrum is how an SDR finds a station. The constellation plot is the IQ stream it computes on. The Bode plot is the channel-select filter's frequency response. The AM modulation lab is the envelope you reverse to get audio. You have been operating the four stages of a software radio with sliders this whole time; this post only connected the wires.
The same DSP is in every phone in your pocket
Once a radio is a for loop, the loop runs everywhere. The pipeline you just wrote — downconvert, filter, demodulate on a complex baseband stream — is the skeleton of the baseband processor in every phone, every Wi-Fi chip, every Bluetooth earbud. The differences are in the demodulator (your phone tracks the angle of equation 1 for digital phase modulation, where you tracked the magnitude for AM) and in the volume of arithmetic, not the architecture. The constellation lab's 16-QAM is not a toy; it is how your phone packs bits onto the carrier, and the receiver decides each noisy dot's intended point with the same geometry you watched smear under falling SNR.
The framework that made this style of radio mainstream is GNU Radio, where you wire these same blocks — a "source", a "multiply by exponential", a "low-pass filter", an "AM demod" — into a flow graph instead of a Python script. Each block is a tidied, optimised version of the four functions above. And the operation underneath all of it, the FFT, turns the opening lab's spectrum from an chore into an routine fast enough to run in real time on a laptop. Without a fast FFT there is no real-time SDR; the whole edifice rests on that algorithm.
Where the software radio still meets physics
Software does not repeal physics. The antenna's size still sets which bands you can hear, and the tuner's range sets the rest — a stock RTL-SDR covers roughly 24 MHz to 1.7 GHz, so the broadcast AM band below it needs an upconverter or a direct-sampling mod. The ADC's bit depth still sets your dynamic range: a strong local station can saturate the converter and bury a weak one no numpy can recover. And aliasing is unforgiving — anything outside your sampled bandwidth folds back in as a phantom, so a real receiver keeps one analog low-pass filter ahead of the ADC. The radio is software, but the front end — antenna, gain, anti-alias filter, converter — is an engineering problem you cannot code your way out of.
The deeper lesson outlasts radio. Any time you can sample a physical quantity fast and accurately enough, the analog processing that used to be a rack of equipment collapses into arithmetic on a stream of numbers. Software-defined radio is the cleanest example because the math is so old and so exact — Fourier in 1822, Nyquist in 1928, Shannon's information theory in 1948 — but the move is general. Digitise early, process in software, and the hardware shrinks to the one component that genuinely touches the physical world. For a radio, that is the antenna. Everything after it is a for loop.
Reading further
- Lyons, Understanding Digital Signal Processing, 3rd ed., chapter 8 — the canonical bridge from DSP theory to real receivers; chapter 8 on quadrature signals and complex down-conversion is the textbook home of equations 1 and 2.
- Ossmann, Software Defined Radio with HackRF (free video course) — Michael Ossmann's hands-on GNU Radio course; it builds IQ, mixing, and filtering intuition by driving real hardware block by block.
- About RTL-SDR (the cheap-dongle origin story) — the practical entry point: a 30-dollar dongle plus open-source software, and the discovery that DVB-T tuners could be repurposed as wideband receivers.
- Cooley & Tukey, An Algorithm for the Machine Calculation of Complex Fourier Series (Math. Comp. 19, no. 90, 1965, pp. 297–301) — the five-page note that makes the opening lab's FFT fast enough to run in real time; without it, software radio stays a thought experiment.
Try it in the lab
All effects →Bode Plotter
engineeringFrequency response of 1st–4th order filters — LP/HP/BP/Notch with animated pole movement.
dspfiltersFFT Spectrum
engineeringReal-time frequency analyzer with log-scale bins — sine, square, sawtooth, chirp waveforms.
dspspectrumAM Modulation
engineeringCarrier, message, modulated signal, and spectrum — envelope, sidebands, overmodulation.
communicationsmodulationrf
More from the blog
AM, FM, QAM: A Tour of the Modulation Zoo
Every modulation scheme is the same act — painting information onto a carrier — and they differ only in which property of the carrier you paint on. Plotted as a constellation, AM is a line, FM is a circle, and QAM is a grid.
Every Wire Is an RLC Circuit: Why Your Digital Signal Rings
There is no such thing as a digital signal at the physical layer. The clean trapezoid you draw is a fiction; every trace is a distributed RLC network, and the ringing and reflections are the lumped RLC step response playing out at picosecond timescales.
PLL Design from First Principles
A phase-locked loop is a control system with a phase detector instead of a summing junction. The intuition you can build with the lab above is more durable than the textbook derivations.