Beginner · Lesson 2 of 10

Complex baseband and IQ samples

Why radios represent signals as I and Q, how mixing moves a signal to and from the carrier, what sampling rate a complex signal needs, and what a negative frequency is

Beginner40 minUpdated 15 Nov 2026Free until 31 Jan 2027

You will learn

  • Write a passband signal in terms of its I and Q components and its complex baseband equivalent
  • Generate complex tones at positive and negative frequencies in numpy and identify them in an FFT
  • Up-convert a complex baseband signal to a real carrier and recover it by mixing down, filtering and decimating, with the residual error below −40 dB
  • State the sample rate a complex baseband signal needs and predict where an out-of-band tone aliases

Before this: What is a radio link

Every software-defined radio hands you a stream of complex numbers. Every DSP textbook writes signals as complex exponentials. This lesson explains why. It shows what the real and imaginary parts of those numbers mean physically, how a mixer moves a signal between the carrier and zero frequency, and what sample rate the result needs. By the end you will have built the up-converter and down-converter from lesson 01 in numpy and checked that the round trip works.

The code uses numpy only. Each block runs on its own in a second or two.

Why one real signal needs two numbers

A radio wave is a real voltage. At any moment it has one value. So why do radios describe it with two?

Look at what a modulated carrier is. Near the carrier frequency fc, any narrowband signal can be written as a cosine whose amplitude and phase change slowly:

x(t) = A(t) cos(2π fc t + φ(t))

The information is in A(t) and φ(t). Expanding the cosine of a sum gives two carriers 90° apart, each with its own slowly varying amplitude:

x(t) = I(t) cos(2π fc t) − Q(t) sin(2π fc t)

with  I(t) = A(t) cos φ(t)   (in-phase)
      Q(t) = A(t) sin φ(t)   (quadrature)

Cosine and sine at the same frequency are orthogonal. Over many carrier cycles their product averages to zero, so a receiver can separate I and Q again. Two independent real signals therefore share one carrier and one slice of spectrum. A single real message modulated onto the cosine alone would occupy the same spectrum and carry half as much.

Pack the two into one complex signal, s(t) = I(t) + jQ(t). Then the passband signal has a compact form:

x(t) = Re{ s(t) · exp(j 2π fc t) }

The complex signal s(t) is the complex baseband equivalent of x(t). It contains everything about the transmission except the carrier frequency itself. Digital processing works on s(t), sampled as IQ samples, because it changes slowly. A signal 20 MHz wide at 3.5 GHz needs tens of millions of samples per second at baseband, not billions.

Negative frequencies

A real signal has a symmetric spectrum. A real cosine at 100 kHz has equal components at +100 kHz and −100 kHz, because cos(ωt) = (exp(jωt) + exp(−jωt)) / 2. The negative half carries no new information. A complex signal has no such constraint. The complex tone exp(j2π·100 kHz·t) has a single spectral line at +100 kHz. Its I part is a cosine and its Q part is a sine, so the phasor rotates anticlockwise. A tone at −100 kHz rotates clockwise.

At baseband, a negative frequency has a plain physical meaning. It is a component below the carrier. A complex baseband tone at −260 kHz, mixed up to a 3 MHz carrier, appears on the air at 2.74 MHz. Because the baseband spectrum need not be symmetric, the upper and lower sides of the carrier can carry different information.

The code below builds a complex baseband signal at 1 MS/s with two tones: +100 kHz at amplitude 1 and −260 kHz at amplitude 0.5. It finds the strongest FFT bins of the complex signal and then of its real part alone.

import numpy as np

fs = 1e6                       # complex sample rate, Hz
n = np.arange(4000)            # 4 ms of samples
t = n / fs

# Two complex tones: one above the centre frequency, one below it
s = 1.0 * np.exp(2j * np.pi * 100e3 * t) + 0.5 * np.exp(-2j * np.pi * 260e3 * t)
s = s.astype(np.complex64)
print(f"dtype {s.dtype}, {s.size} samples at {fs / 1e6:.0f} MS/s")
print(f"first sample: I = {s[0].real:.3f}, Q = {s[0].imag:.3f}")

def peak_frequencies(x: np.ndarray, fs: float, count: int) -> np.ndarray:
    """Frequencies of the strongest FFT bins, sorted."""
    spec = np.fft.fftshift(np.abs(np.fft.fft(x)))
    freqs = np.fft.fftshift(np.fft.fftfreq(x.size, d=1 / fs))
    return np.sort(freqs[np.argsort(spec)[-count:]])

print("complex signal, peaks at (kHz):", peak_frequencies(s, fs, 2) / 1e3)
print("real part only, peaks at (kHz):", peak_frequencies(s.real, fs, 4) / 1e3)
dtype complex64, 4000 samples at 1 MS/s
first sample: I = 1.500, Q = 0.000
complex signal, peaks at (kHz): [-260.  100.]
real part only, peaks at (kHz): [-260. -100.  100.  260.]

The complex signal shows exactly two lines, at −260 kHz and +100 kHz. Throw away Q and the real part shows four. Each tone has acquired a mirror image, and you can no longer tell which side of the carrier the energy came from. That is why a receiver needs both I and Q. The samples are numpy.complex64: two 32-bit floats per sample. That is the same layout as GNU Radio’s gr_complex type, which is a std::complex<float>.

Mixing up and down

Lesson 01 showed a mixer in each radio. In maths, a mixer is a multiplier. The up-converter forms I cos(2π fc t) − Q sin(2π fc t), which is the formula above. The down-converter multiplies the received real signal by a cosine and by a negative sine from the same local oscillator, then low-pass filters each product.

RF inx(t)××LO at fc2 cos(2π fc t)−2 sin(2π fc t)low-passlow-passADCADCI[n]Q[n]s[n] = I[n] + jQ[n]
A quadrature down-converter. The real RF signal x(t) is split and multiplied by 2 cos(2π fc t) and −2 sin(2π fc t) from one local oscillator. Low-pass filters remove the components near twice the carrier frequency. Two ADCs sample the outputs as I[n] and Q[n], which software treats as one complex sample s[n].

Why does this work? Multiply x(t) by 2 exp(−j2π fc t), which is the cosine branch plus j times the negative-sine branch. Using the compact form of x(t), the product is s(t) plus a second term, the complex conjugate of s(t) shifted down to −2fc. The low-pass filter removes the second term and leaves s(t). The factor of 2 restores the original amplitude.

The next block simulates the analogue world at 16 MS/s, puts the two-tone signal onto a 3 MHz carrier, and then runs the down-converter. The filter is a 129-tap windowed-sinc FIR with its cut-off near 1 MHz. After filtering, every 16th sample is kept, which brings the rate back to 1 MS/s.

import numpy as np

fs_rf = 16e6                   # sample rate of the simulated "analogue" world, Hz
fc = 3e6                       # carrier frequency, Hz
decim = 16                     # 16 MS/s / 16 = 1 MS/s complex baseband
t = np.arange(64000) / fs_rf   # 4 ms

def baseband(t: np.ndarray) -> np.ndarray:
    return 1.0 * np.exp(2j * np.pi * 100e3 * t) + 0.5 * np.exp(-2j * np.pi * 260e3 * t)

s = baseband(t)

# Up-conversion: one real signal on the air, x = I cos(wt) - Q sin(wt)
x = s.real * np.cos(2 * np.pi * fc * t) - s.imag * np.sin(2 * np.pi * fc * t)
spec = np.abs(np.fft.rfft(x))
f_axis = np.fft.rfftfreq(x.size, d=1 / fs_rf)
print("passband peaks (MHz):", np.sort(f_axis[np.argsort(spec)[-2:]]) / 1e6)

# Down-conversion: multiply by 2 exp(-j w t), then low-pass filter and decimate
mixed = x * 2 * np.exp(-2j * np.pi * fc * t)
taps = np.sinc(2 * 1e6 / fs_rf * np.arange(-64, 65)) * np.hamming(129)
taps /= taps.sum()             # unity gain at 0 Hz, cut-off near 1 MHz
filtered = np.convolve(mixed, taps, mode="same")
r = filtered[::decim]          # 1 MS/s complex baseband
ref = s[::decim]

core = slice(10, -10)          # skip the filter's edge transient
err = r[core] - ref[core]
print(f"recovered {r.size} samples at {fs_rf / decim / 1e6:.0f} MS/s")
print(f"error power relative to signal: "
      f"{10 * np.log10(np.mean(np.abs(err)**2) / np.mean(np.abs(ref[core])**2)):.1f} dB")
passband peaks (MHz): [2.74 3.1 ]
recovered 4000 samples at 1 MS/s
error power relative to signal: -50.6 dB

On the air the two tones sit at 3.1 MHz and 2.74 MHz: 100 kHz above and 260 kHz below the carrier, exactly as their baseband signs said. After down-conversion the recovered samples match the originals with an error 50.6 dB below the signal. We checked where that residue comes from. The 6 MHz term is suppressed far below it. Almost all of it is the filter’s small gain error at 100 kHz and 260 kHz, so a filter with a flatter passband would push it lower.

Real hardware adds errors that this simulation leaves out. The I and Q branches never have exactly equal gain or exactly 90° between them, which creates a weak mirror image of each tone. Leakage of the LO into the mixer output adds a spike at 0 Hz. And the transmitter and receiver oscillators never run at exactly the same frequency, so the recovered signal rotates slowly. That last effect, carrier frequency offset, is the first thing a real receiver has to estimate and remove.

Sampling rate and the Nyquist limit

How fast must you sample? For a real signal whose highest frequency is B, samples taken faster than 2B per second determine the signal exactly. This is the sampling theorem, and 2B is the Nyquist rate. Shannon stated it as Theorem 1 of his 1949 paper on communication in noise: a signal with no frequencies above W is completely determined by samples spaced 1/(2W) seconds apart. He credited Nyquist’s 1928 work on telegraphy with recognising the importance of that interval.

A complex signal relaxes this. Each complex sample is two real numbers, and the spectrum need not be symmetric. A complex sample rate fs covers the band from −fs/2 to +fs/2, which is fs hertz wide. So a 20 MHz wide signal needs a complex sample rate of at least 20 MS/s, in practice a little more to leave room for filter roll-off. The total number of real values per second, 40 million, is the same as real sampling at the Nyquist rate. Nothing is free. The data are arranged in a way that suits processing better.

When you tune an SDR to a centre frequency fc with a sample rate fs, you see the band from fc − fs/2 to fc + fs/2. Baseband 0 Hz is the centre frequency, and negative baseband frequencies are the lower half of the band.

Aliasing

A sampled signal cannot tell apart two frequencies that differ by a whole multiple of fs. At 1 MS/s, a tone at +640 kHz produces exactly the same samples as one at −360 kHz, because 640 kHz − 1000 kHz = −360 kHz. Energy outside the ±fs/2 window that reaches the ADC folds back into it and lands on top of whatever is there. This is aliasing. An analogue anti-aliasing filter before the ADC is the only cure, because after sampling the damage cannot be undone.

The last block adds a third tone at +640 kHz, amplitude 0.25, to the two from before, together with complex Gaussian noise. It computes an averaged spectrum: 400 frames of 50 samples each, a Hann window, and 20 kHz per bin. The chart plots that spectrum.

import numpy as np

rng = np.random.default_rng(1)
fs = 1e6                         # complex sample rate, Hz
nfft, frames = 50, 400           # 20 kHz bins, 400 frames averaged
n = np.arange(nfft * frames)

# +640 kHz lies outside the -500 to +500 kHz range that 1 MS/s complex sampling can hold
alias = np.exp(2j * np.pi * 640e3 * n / fs)
same = np.allclose(alias, np.exp(-2j * np.pi * 360e3 * n / fs))
print(f"+640 kHz and -360 kHz give identical samples at 1 MS/s: {same}")

s = (1.0 * np.exp(2j * np.pi * 100e3 * n / fs)
     + 0.5 * np.exp(-2j * np.pi * 260e3 * n / fs)
     + 0.25 * alias)
noise = (rng.standard_normal(n.size) + 1j * rng.standard_normal(n.size)) * np.sqrt(1e-3 / 2)
x = (s + noise).astype(np.complex64)

# Averaged periodogram with a Hann window, normalised to the strongest bin
w = np.hanning(nfft)
blocks = x.reshape(frames, nfft) * w
psd = np.fft.fftshift(np.mean(np.abs(np.fft.fft(blocks, axis=1))**2, axis=0))
freqs = np.fft.fftshift(np.fft.fftfreq(nfft, d=1 / fs))
psd_db = 10 * np.log10(psd / psd.max())

for k in range(1, nfft - 1):
    if psd_db[k] > -30 and psd_db[k] >= psd_db[k - 1] and psd_db[k] >= psd_db[k + 1]:
        print(f"peak at {freqs[k] / 1e3:5.0f} kHz: {psd_db[k]:6.1f} dB")
print(f"median noise floor: {np.median(psd_db):.1f} dB")
print("chart:", ", ".join(f"[{f / 1e3:.0f}, {p:.1f}]" for f, p in zip(freqs, psd_db)))
+640 kHz and -360 kHz give identical samples at 1 MS/s: True
peak at  -360 kHz:  -12.0 dB
peak at  -260 kHz:   -6.0 dB
peak at   100 kHz:    0.0 dB
median noise floor: -44.9 dB
chart: [-500, -45.1], [-480, -45.2], ... , [480, -45.2]

The full chart: line is printed by the code. The three peaks sit at 0 dB, −6.0 dB and −12.0 dB, which are the amplitude ratios 1, 0.5 and 0.25 expressed in decibels. The tone that went in at +640 kHz shows up at −360 kHz with nothing to mark it as an impostor. Each peak is three bins wide because the Hann window trades frequency resolution for low leakage between bins. The floor near −45 dB is the noise spread across 50 bins.

-50-40-30-20-100-400-2000200400power relative to peak (dB)baseband frequency (kHz)
Averaged power spectrum of a 1 MS/s complex baseband signal with tones at +100 kHz (0 dB), −260 kHz (−6 dB) and an aliased tone at −360 kHz (−12 dB) that was generated at +640 kHz, computed by the code above with a 50-point Hann-windowed FFT averaged over 400 frames.

Exercise

  1. Change the −260 kHz tone in the first block to +260 kHz. What does the real-part-only spectrum show now, and why can it no longer tell the two cases apart?
  2. In the mixing block, shorten the filter to 33 taps and then lengthen it to 257 taps. Record the error power in dB each time. Then filter s directly, without mixing, and compare. Is the error set by the 6 MHz term or by the passband?
  3. Add a small LO frequency error to the down-converter by using fc + 200 Hz in the receiver’s exponential. Plot the phase of r[core] / ref[core] over time. How fast does it rotate?
  4. Add IQ imbalance to the receiver by multiplying the Q branch by 1.05. Find the mirror image of the +100 kHz tone in the spectrum and measure its level relative to the tone.
  5. You want to receive a 15 MHz wide signal at 2.45 GHz. What is the lowest complex sample rate that holds it? Which band of frequencies does the anti-aliasing filter need to reject?

Summary

  • A narrowband passband signal is x(t) = I(t) cos(2π fc t) − Q(t) sin(2π fc t). The complex signal s(t) = I(t) + jQ(t) is its baseband equivalent and holds all the information.
  • Complex signals can have different content at positive and negative frequencies. At baseband, a negative frequency is a component below the carrier.
  • Up-conversion multiplies by the carrier. Down-conversion multiplies by 2 exp(−j2π fc t), low-pass filters and decimates. The numpy round trip above recovered the signal with an error 50.6 dB below it.
  • A complex sample rate fs holds the band from −fs/2 to +fs/2. Anything outside it aliases into it and must be filtered out before the ADC.

Further reading