A headless GNU Radio FM receiver tested on synthetic IQ
Generate an FM test signal in NumPy, save it as SigMF, demodulate it with a headless GNU Radio 3.10 flowgraph and check the recovered tone frequency, deviation and audio SNR in code.
| Runs on | Laptop CPU (macOS, Linux or Windows), GNU Radio 3.10 and NumPy, under 5 seconds |
|---|---|
| Time | about 3 hours |
| Stack | python, numpy, gnuradio, soapysdr |
What you will build
This project builds a wideband FM receiver in GNU Radio and tests it without a radio. A NumPy script frequency-modulates a 1 kHz tone, adds a 5 kHz carrier offset and noise, and writes the result as a SigMF recording: a raw complex64 data file plus a JSON metadata file. A headless GNU Radio flowgraph reads the file once, filters it, demodulates it with a quadrature discriminator and decimates to 48 kHz audio. The script then measures the audio in code: tone frequency, peak deviation, carrier offset and audio SNR. It prints PASS or FAIL.
Testing on a signal you made yourself means you know the right answer. In our run the receiver recovered the tone at 1000.000 Hz, the deviation as 60.14 kHz (60 kHz was transmitted) and the carrier offset as 5.00 kHz. A sweep of the carrier-to-noise ratio (CNR) then shows the FM threshold: above about 10 dB CNR each dB of CNR buys a dB of audio SNR, and below it the audio SNR falls away fast.
The section “On a real radio” swaps the file for an RTL-SDR and a broadcast station. That part needs a radio connected.
The lessons on your first GNU Radio flowgraph, recording and reading IQ and complex baseband and IQ cover the background. The GNU Radio platform page covers installation.
What you need
- GNU Radio 3.10 with its Python bindings, and NumPy. We ran it with GNU Radio 3.10.12 and NumPy 2.5.4 on Python 3.12 from conda-forge on an Apple Silicon Mac mini. The flowgraph uses only the
blocks,filterandanalogmodules, with no GUI blocks. - About 4 MB of free disk space in the working directory for two short recordings.
- For the optional hardware section: an RTL-SDR receiver, the gr-soapy blocks (part of GNU Radio 3.10) and the SoapyRTLSDR driver module.
Design
The test signal
The message is a 1 kHz sine at 0.8 of full scale. Full scale is 75 kHz deviation, the maximum that Recommendation ITU-R BS.450-4 gives for FM sound broadcasting in Western Europe and the United States, so the peak deviation is 60 kHz. The instantaneous frequency is 60 kHz × sin(2π × 1000 t) plus a 5 kHz offset, which stands in for a receiver tuned slightly off the station. The phase is the running sum of the instantaneous frequency, and the IQ samples are exp(jφ), at 240 kHz.
Carson’s rule puts most of the FM power within 2 × (60 + 1) = 122 kHz, so the signal fits in the 240 kHz sample rate with room for the offset. Complex white noise sets the CNR, measured over the full 240 kHz. The receiver’s channel filter then removes the noise above 100 kHz, so the CNR inside the filter is somewhat higher than the label.
SigMF
The recording is two files with the same base name. tone_fm.sigmf-data holds the raw samples as interleaved little-endian 32-bit floats, which is what numpy.complex64.tofile writes and what GNU Radio’s gr.sizeof_gr_complex items are. tone_fm.sigmf-meta is JSON with three top-level keys, global, captures and annotations. The SigMF schema requires core:datatype and core:version in global. We give cf32_le for the datatype and 1.2.6, the latest SigMF release at the time of writing, for the version. We add the sample rate, a description, one capture segment starting at sample 0 and one annotation labelling the tone. The receiver reads the sample rate from the metadata rather than trusting a constant. The SigMF specification lists every field.
The flowgraph
blocks.file_source(gr.sizeof_gr_complex, path, False) reads the data file once. With repeat=False it signals the end of the stream at the end of the file, so tb.run() returns. A 29-tap low-pass filter (100 kHz cut-off, 20 kHz transition) trims the noise near the band edges.
analog.quadrature_demod_cf(gain) computes the angle of x[n] × conj(x[n − 1]), the phase step between samples, and multiplies it by the gain. A phase step of 2π f / fs radians means an instantaneous frequency f, so a gain of fs / (2π × 75 kHz) makes the output 1.0 at 75 kHz deviation. GNU Radio’s own analog.wfm_rcv block uses the same scaling. The DC level of the output is then the carrier offset divided by 75 kHz.
A decimating filter.fir_filter_fff with a 15 kHz low-pass filter and a factor of 5 produces 48 kHz audio. A blocks.vector_sink_f collects it. The transmitter applies no pre-emphasis, so the receiver applies no de-emphasis.
Measuring the tone
After dropping the first 50 ms of filter start-up, the script removes the mean (the offset), applies a Hann window and finds the largest FFT bin. The bins are 1.05 Hz apart. A parabola through the logarithm of the peak bin and its two neighbours locates the peak to a small fraction of a bin. The deviation is √2 times the RMS of the audio times 75 kHz. The audio SNR is the power within 20 Hz of the tone over the power in the rest of the 20 Hz to 15 kHz band.
Code
Save this as fm_receiver.py. It is one block. The first line, # gnuradio, is a marker for this site’s code checker and has no effect when you run the file.
# gnuradio
# fm_receiver.py: make an FM test signal, save it as SigMF, demodulate it with GNU Radio,
# and check the recovered audio tone. Complex samples are numpy.complex64 at 240 kHz.
import json
import math
from pathlib import Path
import numpy as np
from gnuradio import analog, blocks, filter, gr
FS = 240_000 # complex baseband sample rate (Hz)
DURATION = 1.0 # seconds of IQ to generate
TONE_HZ = 1_000.0 # audio test tone
MAX_DEV = 75_000.0 # peak frequency deviation for a full-scale tone (Hz)
TONE_LEVEL = 0.8 # tone amplitude relative to full scale, so the deviation is 60 kHz
CARRIER_OFFSET = 5_000 # transmitter tuned 5 kHz away from the receiver (Hz)
CNR_DB = 20.0 # carrier-to-noise ratio over the full 240 kHz
AUDIO_DECIM = 5 # 240 kHz / 5 = 48 kHz audio
BASE = Path("tone_fm")
def make_recording(base: Path, cnr_db: float, seed: int = 7) -> int:
"""Frequency-modulate a tone, add a carrier offset and noise, write SigMF data and metadata."""
rng = np.random.default_rng(seed)
n = int(FS * DURATION)
t = np.arange(n) / FS
message = TONE_LEVEL * np.sin(2 * np.pi * TONE_HZ * t)
inst_freq = MAX_DEV * message + CARRIER_OFFSET # Hz
phase = 2 * np.pi * np.cumsum(inst_freq) / FS
noise_std = math.sqrt(10 ** (-cnr_db / 10) / 2)
iq = np.exp(1j * phase) + noise_std * (rng.standard_normal(n) + 1j * rng.standard_normal(n))
iq.astype(np.complex64).tofile(base.with_suffix(".sigmf-data"))
meta = {
"global": {
"core:datatype": "cf32_le",
"core:sample_rate": FS,
"core:version": "1.2.6",
"core:description": f"Synthetic FM: 1 kHz tone, 60 kHz deviation, 5 kHz offset, {cnr_db:g} dB CNR",
"core:recorder": "fm_receiver.py (numpy)",
},
"captures": [{"core:sample_start": 0}],
"annotations": [{"core:sample_start": 0, "core:sample_count": n, "core:label": "1 kHz test tone"}],
}
base.with_suffix(".sigmf-meta").write_text(json.dumps(meta, indent=2))
return n
def demodulate(base: Path) -> tuple[np.ndarray, float]:
"""Read the SigMF metadata, then run a finite GNU Radio flowgraph over the data file."""
meta = json.loads(base.with_suffix(".sigmf-meta").read_text())
assert meta["global"]["core:datatype"] == "cf32_le"
fs_in = float(meta["global"]["core:sample_rate"])
tb = gr.top_block()
src = blocks.file_source(gr.sizeof_gr_complex, str(base.with_suffix(".sigmf-data")), False)
chan = filter.fir_filter_ccf(1, filter.firdes.low_pass(1.0, fs_in, 100e3, 20e3)) # trim noise near fs/2
demod = analog.quadrature_demod_cf(fs_in / (2 * math.pi * MAX_DEV)) # 1.0 = 75 kHz deviation
audio = filter.fir_filter_fff(AUDIO_DECIM, filter.firdes.low_pass(1.0, fs_in, 15e3, 3e3))
sink = blocks.vector_sink_f()
tb.connect(src, chan, demod, audio, sink)
tb.run() # returns when file_source reaches the end of the file
return np.array(sink.data(), dtype=np.float64), fs_in / AUDIO_DECIM
def analyse(y: np.ndarray, audio_rate: float) -> dict[str, float]:
"""Tone frequency (Hann window, parabolic peak), peak deviation, carrier offset and audio SNR."""
y = y[int(0.05 * audio_rate):] # drop 50 ms of filter start-up
dc = float(np.mean(y))
ac = y - dc
spec = np.abs(np.fft.rfft(ac * np.hanning(len(ac)))) ** 2
freqs = np.fft.rfftfreq(len(ac), 1 / audio_rate)
k = int(np.argmax(spec[1:])) + 1
a, b, c = np.log(spec[k - 1:k + 2])
f_est = (k + 0.5 * (a - c) / (a - 2 * b + c)) * audio_rate / len(ac)
tone = np.abs(freqs - f_est) < 20
rest = ~tone & (freqs > 20) & (freqs < 15e3)
return {"freq": f_est, "bin": audio_rate / len(ac), "dev": math.sqrt(2) * np.std(ac) * MAX_DEV,
"offset": dc * MAX_DEV, "snr": 10 * np.log10(spec[tone].sum() / spec[rest].sum())}
n = make_recording(BASE, CNR_DB)
print(f"wrote {n} samples ({BASE.with_suffix('.sigmf-data').stat().st_size} bytes) and {BASE}.sigmf-meta")
y, audio_rate = demodulate(BASE)
print(f"flowgraph done: {len(y)} audio samples at {audio_rate:.0f} Hz")
r = analyse(y, audio_rate)
print(f"tone frequency: {r['freq']:.3f} Hz (FFT bin spacing {r['bin']:.2f} Hz)")
print(f"peak deviation: {r['dev'] / 1e3:.2f} kHz, carrier offset from DC: {r['offset'] / 1e3:.2f} kHz")
print(f"audio SNR in 20 Hz to 15 kHz: {r['snr']:.1f} dB")
ok = abs(r["freq"] - TONE_HZ) < 1.0 and abs(r["dev"] - TONE_LEVEL * MAX_DEV) < 1e3
print("PASS" if ok else "FAIL")
print("CNR (dB) audio SNR (dB) tone (Hz) deviation (kHz)")
for cnr in range(0, 31, 2):
make_recording(Path("sweep"), cnr, seed=100 + cnr)
r = analyse(*demodulate(Path("sweep")))
print(f"{cnr:>8} {r['snr']:>14.1f} {r['freq']:>9.2f} {r['dev'] / 1e3:>15.2f}")
The sweep at the end reuses the same three functions with a different CNR and a different noise seed for each point.
Run
conda create -n fmrx -c conda-forge gnuradio-core numpy
conda activate fmrx
python fm_receiver.py
The whole run, including 16 sweep points, takes under 1 s on our machine. It leaves tone_fm.sigmf-data, tone_fm.sigmf-meta and the last sweep recording in the working directory. Each data file is 1,920,000 bytes: 240,000 samples of 8 bytes.
Test
This is the output from our run.
wrote 240000 samples (1920000 bytes) and tone_fm.sigmf-meta
flowgraph done: 48000 audio samples at 48000 Hz
tone frequency: 1000.000 Hz (FFT bin spacing 1.05 Hz)
peak deviation: 60.14 kHz, carrier offset from DC: 5.00 kHz
audio SNR in 20 Hz to 15 kHz: 46.3 dB
PASS
CNR (dB) audio SNR (dB) tone (Hz) deviation (kHz)
0 5.2 1000.00 48.34
2 9.6 1000.00 54.04
4 15.2 1000.00 57.99
6 22.1 1000.00 59.71
8 31.4 1000.00 60.12
10 35.6 1000.00 60.14
12 38.3 1000.00 60.14
14 40.4 1000.00 60.14
16 42.3 1000.00 60.14
18 44.3 1000.00 60.14
20 46.4 1000.00 60.14
22 48.3 1000.00 60.14
24 50.4 1000.00 60.14
26 52.3 1000.00 60.14
28 54.3 1000.00 60.14
30 56.4 1000.00 60.14
What each line tells you:
- Tone frequency. 1000.000 Hz. A 1 kHz error tolerance would hide most bugs, so the script asks for better than 1 Hz. A tone at the wrong frequency usually means the metadata sample rate and the true rate disagree.
- Peak deviation. 60.14 kHz against 60 kHz transmitted. A deviation that is off by a factor of 2π points at the demodulator gain. A deviation that is too low with a clean tone points at a channel filter that cuts the FM sidebands.
- Carrier offset. 5.00 kHz, read from the mean of the audio. A real receiver uses this to correct its tuning.
- Audio SNR. 46.3 dB at 20 dB CNR. FM trades bandwidth for SNR: the signal occupies about 122 kHz, but the audio band is only 15 kHz.
- The sweep. From 10 dB to 30 dB CNR the audio SNR rises from 35.6 dB to 56.4 dB, close to 1 dB per dB. From 8 dB down it falls faster: 31.4 dB at 8 dB, 22.1 dB at 6 dB and 5.2 dB at 0 dB. At 0 dB the measured deviation also drops to 48.34 kHz. Below threshold the noise occasionally drives the phase around the origin, and each such event puts a spike, a click, in the audio.
On a real radio
This section needs a radio connected. The code below will not run without an RTL-SDR plugged in, the gr-soapy blocks and the SoapyRTLSDR driver module installed.
The RTL-SDR Blog V4 tunes from 500 kHz to 1.766 GHz with an R828D tuner, according to RTL-SDR Blog. RTL-SDR Blog’s general RTL-SDR page gives a native ADC resolution of 8 bits, a maximum sample rate of 3.2 MS/s with 2.56 MS/s as the highest rate that does not drop samples, and says RTL-SDRs cannot transmit. Those pages do not state a channel count. See the hardware table for more.
The changes from the test bench:
- Source.
soapy.source("driver=rtlsdr", "fc32", 1, "", "bufflen=16384", [""], [""])replaces the file source. The arguments follow the gr-soapy RTL-SDR block template in the GNU Radio 3.10 source tree. - Sample rate. The radio runs at 1.92 MS/s, inside the rate that does not drop samples. The channel filter decimates by 8 to the same 240 kHz as the test bench, so the rest of the chain is unchanged.
- Finite run.
blocks.headstops the flowgraph after 5 s of samples. - De-emphasis. Broadcasters pre-emphasise the audio. ITU-R BS.450-4 gives 50 µs in Europe and 75 µs in the United States.
analog.fm_deemph(fs, tau)undoes it. Its default is 75 µs, so settaufor your region. - Stereo. A stereo station adds a 19 kHz pilot tone and a stereo difference signal above it, per BS.450-4. The 15 kHz audio filter removes both and leaves mono audio.
# needs-hardware
# fm_rtlsdr.py: receive 5 s of a broadcast FM station with an RTL-SDR through gr-soapy,
# demodulate it with the same chain as fm_receiver.py and save the audio as a WAV file.
import math
import wave
import numpy as np
from gnuradio import analog, blocks, filter, gr, soapy
STATION_HZ = 98.5e6 # replace with a station you can receive
SDR_RATE = 1.92e6 # RTL-SDR sample rate, decimated by 8 to 240 kHz
DECIM_RF = 8
FS = SDR_RATE / DECIM_RF
AUDIO_DECIM = 5 # 48 kHz audio
MAX_DEV = 75e3
TAU = 50e-6 # de-emphasis: 50 us in Europe, 75 us in the United States
SECONDS = 5
tb = gr.top_block()
src = soapy.source("driver=rtlsdr", "fc32", 1, "", "bufflen=16384", [""], [""])
src.set_sample_rate(0, SDR_RATE)
src.set_frequency(0, STATION_HZ)
src.set_gain_mode(0, False)
src.set_gain(0, 30.0)
head = blocks.head(gr.sizeof_gr_complex, int(SDR_RATE * SECONDS))
chan = filter.fir_filter_ccf(DECIM_RF, filter.firdes.low_pass(1.0, SDR_RATE, 100e3, 20e3))
demod = analog.quadrature_demod_cf(FS / (2 * math.pi * MAX_DEV))
audio = filter.fir_filter_fff(AUDIO_DECIM, filter.firdes.low_pass(1.0, FS, 15e3, 3e3))
deemph = analog.fm_deemph(FS / AUDIO_DECIM, TAU)
sink = blocks.vector_sink_f()
tb.connect(src, head, chan, demod, audio, deemph, sink)
tb.run()
y = np.array(sink.data())
pcm = np.int16(np.clip(y / (np.max(np.abs(y)) + 1e-12), -1, 1) * 32000)
with wave.open("station.wav", "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(int(FS / AUDIO_DECIM))
w.writeframes(pcm.tobytes())
print(f"saved {len(pcm) / (FS / AUDIO_DECIM):.1f} s of audio to station.wav")
Start with a gain of 30 dB and adjust it. An 8-bit ADC clips easily on a strong local station. Run analyse from the test bench on the received audio to see the carrier offset of your dongle’s crystal at the station frequency.
Rules on receiving broadcasts, and on recording or sharing what you receive, are set nationally and differ between countries. Check your regulator’s rules before you record (Ofcom in the UK, the FCC in the United States). Broadcast programmes are copyright material, so keep recordings to yourself. This receiver transmits nothing.
Things to try
- Use
wfm_rcv. Replace the demodulator, audio filter and the missing de-emphasis withanalog.wfm_rcv(fs_in, AUDIO_DECIM). It applies 75 µs de-emphasis, which attenuates the 1 kHz tone a little, so expect the measured deviation to change. Measure by how much. - Move the offset. Set
CARRIER_OFFSET = 40_000. In our run the tone was still exact, but the audio SNR at 20 dB CNR fell from 46.3 dB to 33.3 dB, because the upper FM sidebands now run into the 100 kHz channel filter. Add a frequency-translating filter (filter.freq_xlating_fir_filter_ccf) that shifts by the measured offset and compare. - Pre-emphasis. Pre-emphasise the message in the transmitter with a first-order filter of 50 µs, add
analog.fm_deemph(audio_rate, 50e-6)to the receiver and check that the deviation measurement returns to 60 kHz. - Two tones. Add a 3 kHz tone at a lower level and extend
analyseto report both. Look for intermodulation products at 2 kHz and 4 kHz when the channel filter is made too narrow. - Narrowband FM. Set
MAX_DEV = 5_000,FS = 48_000, the channel filter to 8 kHz and the audio filter to 3 kHz. That is a narrowband FM signal. Check where the threshold in the CNR sweep moves. - Write SigMF with the library. Install the
sigmfPython package (version 1.14.0 at the time of writing), write the recording with it and run itssigmf_validatecommand on the metadata file.
Summary
- Test a receiver on IQ you generated. You know the answer, so the test can be numeric and automatic.
- SigMF is a raw
cf32_ledata file plus JSON metadata. Read the sample rate from the metadata, not from a constant in the receiver. blocks.file_sourcewithrepeat=Falseends the stream at the end of the file, so a headless flowgraph runs to completion and returns.quadrature_demod_cfwith gain fs / (2π × 75 kHz) outputs the instantaneous frequency in units of full-scale deviation. Its mean is the carrier offset.- Above about 10 dB CNR the audio SNR rose 1 dB per dB in our run. Below it the FM threshold set in and the audio SNR collapsed.