Setting up your receiver
By the end of this chapter, your receiver is set for its band: its gain, how many IQ samples it takes a second, and a band that suits each receiver you own.
The folder so far
The radio folder as Recordings of nothing left it: Substation installed in an environment inside it, switched on, its defaults kept as defaults.yaml, your band in config.yaml with its threshold at 4.5 dB and its variance check at 3 dB, and both test signals made.
mkdir radio
cd radio
python3 -m venv .venv
source .venv/bin/activate
pip install "substation==0.8.2"
substation --init
mv config.yaml defaults.yaml
bands:
amateur_2m_simplex:
type: NFM
reception_class: general
freq_start: 145.2e+6
freq_end: 145.5625e+6
channel_spacing: 12.5e+3
sample_rate: 2.4e+6
snr_threshold_db: 4.5
activation_variance_db: 3
# Makes test-signal.wav: eight seconds of the 2m band as a receiver hands it over,
# hiss with a transmission in the middle, for Substation to scan in place of a receiver.
import wave
import numpy
# The frequency the transmission is sent on, in hertz, and how many seconds it lasts.
frequency = 145_500_000
length = 3
# The frequency the recording is centred on, and how many IQ samples it holds
# a second, as a receiver set for the 2m band hands them over.
centre = 145_000_000
rate = 2_400_000
# Two seconds of hiss first, while Substation measures it, and three after.
start = 2
seconds = start + length + 3
# The hiss is random, but the same every time the script runs.
noise = numpy.random.default_rng(1)
with wave.open("test-signal.wav", "wb") as file:
# Each IQ sample as two 16-bit numbers, I and Q, held as two audio channels.
file.setnchannels(2)
file.setsampwidth(2)
file.setframerate(rate)
# A second at a time, so the script needs little memory.
for second in range(seconds):
# The moment of each IQ sample, in seconds.
t = second + numpy.arange(rate) / rate
# The receiver's own hiss.
hiss = (noise.standard_normal(rate) + 1j * noise.standard_normal(rate)) * 0.01
# A buzz like a voice: overtones of a low note, each softer than the one below.
voice = sum(5.3 / n ** 2 * numpy.sin(2 * numpy.pi * 150 * n * t) for n in range(2, 7))
# Louder and softer six times a second, like syllables.
loudness = numpy.abs(numpy.sin(2 * numpy.pi * 3 * t))
# FM: the sound swings the transmission's frequency to either side of its own.
phase = 2 * numpy.pi * (frequency - centre) * t + loudness * voice
# The transmission, only while it is on, added to the hiss.
on = (t >= start) & (t < start + length)
iq = hiss + numpy.where(on, 0.3 * numpy.exp(1j * phase), 0)
# Written as pairs of 16-bit numbers, I then Q.
pairs = numpy.stack([iq.real, iq.imag], axis=1)
file.writeframes((pairs * 32767).astype("<i2").tobytes())
python make_signal.py
# Makes test-noise.wav: six seconds of the 2m band as a receiver hands it over,
# with three things in it strong enough to cross a threshold: a steady noise,
# a burst too short to be a transmission, and a transmission.
import wave
import numpy
# The frequency of each, in hertz.
steady = 145_300_000
burst = 145_400_000
transmission = 145_500_000
# The frequency the recording is centred on, and how many IQ samples it holds
# a second, as a receiver set for the 2m band hands them over.
centre = 145_000_000
rate = 2_400_000
# Six seconds in all.
seconds = 6
# The hiss and the noise are random, but the same every time the script runs.
noise = numpy.random.default_rng(2)
# How far the steady noise has wandered, carried from one second to the next.
drift = 0.0
with wave.open("test-noise.wav", "wb") as file:
# Each IQ sample as two 16-bit numbers, I and Q, held as two audio channels.
file.setnchannels(2)
file.setsampwidth(2)
file.setframerate(rate)
# A second at a time, so the script needs little memory.
for second in range(seconds):
# The moment of each IQ sample, in seconds.
t = second + numpy.arange(rate) / rate
# The receiver's own hiss.
hiss = (noise.standard_normal(rate) + 1j * noise.standard_normal(rate)) * 0.01
# The steady noise: a signal whose frequency wanders at random, carrying nothing.
wander = drift + numpy.cumsum(noise.standard_normal(rate)) * 0.02
drift = wander[-1]
steady_noise = 0.03 * numpy.exp(1j * (2 * numpy.pi * (steady - centre) * t + wander))
# The test signal's buzz, rising and falling, for the burst and the transmission.
voice = sum(5.3 / n ** 2 * numpy.sin(2 * numpy.pi * 150 * n * t) for n in range(2, 7))
loudness = numpy.abs(numpy.sin(2 * numpy.pi * 3 * t))
# The burst, a twentieth of a second long, three seconds in.
bursting = (t >= 3) & (t < 3.05)
brief = numpy.where(bursting, 0.3 * numpy.exp(1j * (2 * numpy.pi * (burst - centre) * t + loudness * voice)), 0)
# The transmission, from two seconds in to four.
talking = (t >= 2) & (t < 4)
sent = numpy.where(talking, 0.3 * numpy.exp(1j * (2 * numpy.pi * (transmission - centre) * t + loudness * voice)), 0)
# All of it together, written as pairs of 16-bit numbers, I then Q.
iq = hiss + steady_noise + brief + sent
pairs = numpy.stack([iq.real, iq.imag], axis=1)
file.writeframes((pairs * 32767).astype("<i2").tobytes())
python make_noise.py
Gain
Gain is how much a receiver amplifies what its antenna picks up, before its ADC turns it into numbers, and it is measured in decibels. With too little, a weak transmission is lost in the receiver's own hiss. With too much, a strong one reaches the largest number the ADC can make and is cut off there, which is called overload, and what Substation hears is then distorted.
A band sets its gain with sdr_gain_db: a number of decibels, or auto. Neither your band nor its NFM template sets one, so it takes auto, which is why its scans have logged SDR Gain: auto. amateur_2m sets 36, which the scan in A first scan logged as SDR Gain: 36.0.
What auto does depends on the receiver:
- An RTL-SDR and an Airspy HF+ Discovery set their own gain as they go, which suits most bands.
- A HackRF One cannot, so
autosets 32 dB on its LNA and 30 dB on its VGA, two of the stages described below, and warns that it has. - An Airspy R2 says it can, but does not, so
autosets 10 dB on its LNA, 5 dB on its mixer and 12 dB on its VGA, 27 dB in all. - Any other receiver through SoapySDR sets its own where it can, and is otherwise set to the middle of its range, with a warning.
A number gives you the say instead: 20 to 40 dB is usual on an RTL-SDR. On a HackRF One, a number sets both of the stages described below, each to the most it takes at or below the number, so the gain in all is about twice it: 36 dB sets 32 dB on its LNA and 36 dB on its VGA, 68 dB in all, 6 dB more than auto sets.
The right gain depends on your antenna, your band and what is around you, so try a few. A rooftop antenna in a city needs a different gain from a small whip antenna in the countryside. A band whose transmitters are stronger needs less: the airband less than PMR446, since an aircraft's radio commonly sends 6 to 16 watts, and a PMR446 handheld at most half a watt.
Gain in stages
A receiver amplifies in stages, one after another. The first, the low-noise amplifier or LNA, matters most, because every stage after it amplifies whatever hiss it adds, along with the signal. So set the LNA as high as it goes without overload. Keep a mixer, where a receiver has one, moderate, since too much gain there mixes strong stations into ghosts of themselves at other frequencies. Then set the last stage, the variable-gain amplifier or VGA, to bring the level up to where the ADC measures it best.
A single number in sdr_gain_db leaves the stages to Substation. On a HackRF One it sets each of the two stages as near the number as that stage's steps allow, the LNA in steps of 8 dB up to 40, and the VGA in steps of 2 dB up to 62, and the log says what each was set to.
sdr_gain_elements sets each stage by name, on a receiver with several, and takes the place of sdr_gain_db there. The Airspy R2's are LNA, MIX and VGA. Each receiver names its own, and Substation lists them, with what each can be set to, as it starts with --log-level DEBUG.
The Airspy HF+ Discovery is the exception. Its LNA is only on or off, adding 0 or 6 dB, and its other stage, RF, is an attenuator, which cuts the signal rather than amplifying it, so it counts down: RF: 0 cuts nothing, and RF: -24 cuts 24 dB. When it overloads, the same transmission shows on radio channels beside its own, and more attenuation is the cure.
Too much gain
A transmitter close to your antenna, such as your own handheld radio, can overload the receiver at the gain a band sets. A third script makes one: the transmission in test-signal.wav, ten times as strong, and cut off at the largest number a receiver can hand over, as an overloaded receiver's is. Like the others, it only stands in for a receiver, and you need not read it: copy it as it is into a file named make_loud.py in the radio folder.
The script, make_loud.py, to copy as it is
# Makes test-loud.wav: eight seconds of the 2m band as a receiver hands it over,
# with a transmission so strong it overloads the receiver, as one sent close by
# would with the gain turned up too far.
import wave
import numpy
# The frequency the transmission is sent on, in hertz, and how many seconds it lasts.
frequency = 145_500_000
length = 3
# The frequency the recording is centred on, and how many IQ samples it holds
# a second, as a receiver set for the 2m band hands them over.
centre = 145_000_000
rate = 2_400_000
# Three seconds of hiss first, while Substation measures it, and two after.
start = 3
seconds = start + length + 2
# The hiss is random, but the same every time the script runs.
noise = numpy.random.default_rng(1)
with wave.open("test-loud.wav", "wb") as file:
# Each IQ sample as two 16-bit numbers, I and Q, held as two audio channels.
file.setnchannels(2)
file.setsampwidth(2)
file.setframerate(rate)
# A second at a time, so the script needs little memory.
for second in range(seconds):
# The moment of each IQ sample, in seconds.
t = second + numpy.arange(rate) / rate
# The receiver's own hiss.
hiss = (noise.standard_normal(rate) + 1j * noise.standard_normal(rate)) * 0.01
# A buzz like a voice, louder and softer like syllables, as before.
voice = sum(5.3 / n ** 2 * numpy.sin(2 * numpy.pi * 150 * n * t) for n in range(2, 7))
loudness = numpy.abs(numpy.sin(2 * numpy.pi * 3 * t))
phase = 2 * numpy.pi * (frequency - centre) * t + loudness * voice
# The transmission, ten times as strong as the one in test-signal.wav.
on = (t >= start) & (t < start + length)
iq = hiss + numpy.where(on, 3 * numpy.exp(1j * phase), 0)
# A receiver can hand over nothing stronger than its largest number,
# so everything stronger is cut off there, as an overloaded one does.
pairs = numpy.clip(numpy.stack([iq.real, iq.imag], axis=1), -1, 1)
file.writeframes((pairs * 32767).astype("<i2").tobytes())
Run it, and it writes test-loud.wav beside it:
python make_loud.py
Scan it, keeping the lines about detection and overload:
substation --band amateur_2m_simplex --iq-file test-loud.wav --center-freq 145000000 --start-time "2026-10-05 23:00:00" 2>&1 | grep -E "Detection|SATURATION|Radio channel"
No radio channel turned on, and nothing was recorded. Substation drops every slice in which more than one IQ sample in twenty reaches the ADC's limit, because overload spreads a transmission across the whole band and would turn on radio channels where nothing is sent. Where fewer do, it warns without Dropping slice and carries on. With a receiver, lower the band's sdr_gain_db, or move the transmitter further from the antenna, until the warnings stop.
How many IQ samples a second
A band's sample_rate is how many IQ samples a second its receiver takes, and so how wide a stretch of frequencies it hands over, as What a software-defined radio is explained. It must cover the band, with a little room at each edge for the radio channels there. To see what happens when it does not, lower your band's to a quarter of a million a second:
bands:
amateur_2m_simplex:
type: NFM
reception_class: general
freq_start: 145.2e+6
freq_end: 145.5625e+6
channel_spacing: 12.5e+3
sample_rate: 0.25e+6
snr_threshold_db: 4.5
activation_variance_db: 3
--list-bands works out what a band needs, and says when its rate falls short:
substation --list-bands
A scan refuses the band, and says how to fix it:
substation --band amateur_2m_simplex --iq-file test-signal.wav --center-freq 145000000 --start-time "2026-10-05 21:00:00"
Put it back to 2.4 million, which an RTL-SDR takes and this guide's test signals are made at:
bands:
amateur_2m_simplex:
type: NFM
reception_class: general
freq_start: 145.2e+6
freq_end: 145.5625e+6
channel_spacing: 12.5e+3
sample_rate: 2.4e+6
snr_threshold_db: 4.5
activation_variance_db: 3
More IQ samples a second cost your computer more work, and each receiver takes only some rates:
- An RTL-SDR Blog V3 or V4 takes up to 2.4 million on the V3 and 2.56 million on the V4, though 2.56 million has not been tested with Substation. The V4 takes up to 3.2 million, but drops IQ samples above 2.56 million, its maker says. 2.048 million is the usual starting point.
- A HackRF One takes from 2 to 20 million, and 16 million is the most it takes reliably. At that rate it needs the USB memory raised as Installing Substation describes.
- An Airspy R2 takes only 2.5 or 10 million.
- An Airspy HF+ Discovery takes only a few rates, up to 912 thousand, which depend on its firmware and are listed as it starts with
--log-level DEBUG.
An Airspy given a rate it does not take uses the nearest one it does, with a warning, and the log says which it used in a line beginning Using device sample rate, with the rate the band asked for beside it.
As a scan starts, Substation measures how strong the IQ samples are that an Airspy hands over, or any receiver it reaches through SoapySDR, and logs it in a line beginning IQ calibration:. Where they are far weaker than its decoding expects, as the HF+ Discovery's are, Substation scales them up, and says by how much in a line beginning IQ sample scale:. There is nothing for you to do.
Which receiver
--device-type names the kind of receiver, as A first scan showed, and --device-index says which one of that kind, counting from 0, when more than one is plugged in. This scans with the second RTL-SDR:
Example not checked: it is not run when this site is built.
substation --band amateur_2m_simplex --device-type rtlsdr --device-index 1
One band for every receiver you own
A band can hold settings for each kind of receiver in device_overrides, so one band suits every receiver you own, rather than a copy of the band for each. Under a key naming a kind of receiver, as --device-type does, go the settings that replace the band's own when a scan names that kind: any of sample_rate, sdr_gain_db, sdr_gain_elements, sdr_device_settings, snr_threshold_db and activation_variance_db. This is your band with a starting point for each of the other receivers this guide has met:
bands:
amateur_2m_simplex:
type: NFM
reception_class: general
freq_start: 145.2e+6
freq_end: 145.5625e+6
channel_spacing: 12.5e+3
sample_rate: 2.4e+6
snr_threshold_db: 4.5
activation_variance_db: 3
device_overrides:
hackrf:
sample_rate: 2.0e+6
sdr_gain_db: 36
snr_threshold_db: 6
airspy:
sample_rate: 2.5e+6
sdr_gain_elements:
LNA: 10
MIX: 5
VGA: 12
snr_threshold_db: 6
airspyhf:
sample_rate: 0.912e+6
snr_threshold_db: 6
- An RTL-SDR takes the band's own settings, with
autogain and a threshold of 4.5 dB, since no key names it. - A HackRF One takes its lowest rate, which covers the band, and 36 dB.
- An Airspy R2 takes the lower of its two rates, and its gain by stage, written out at what
autosets, so you can raise its LNA first. - An Airspy HF+ Discovery takes its widest rate, more than the band needs, and keeps
autogain. Keep its variance check on: with the check off, a receiver as sensitive as this one turns radio channels on with steady noise. - The other three start at a threshold of 6 dB. The two Airspys are sensitive enough that 4.5 dB turns radio channels on with what is nearly hiss.
A key does nothing until a scan names its kind of receiver, as this one does the Airspy R2, so keep only the keys for receivers you own:
Example not checked: it is not run when this site is built.
substation --band amateur_2m_simplex --device-type airspy
A key may be any spelling --device-type takes, in any letter case: rtl-sdr and RTLSDR both name the RTL-SDR, as rtlsdr does. A key Substation does not know is warned about whenever it reads the file, in case it is a mistake.
Another receiver through SoapySDR
Any receiver with a SoapySDR module works with Substation, named as soapy: followed by its driver's name: soapy:lime for a LimeSDR. SoapySDRUtil --find lists what is plugged in, with each receiver's driver:
Example not checked: it is not run when this site is built.
SoapySDRUtil --find
Start it with --log-level DEBUG, and Substation logs what the receiver says about itself: its gain stages and their ranges, the rates it takes, its antennas, and the settings it offers.
Example not checked: it is not run when this site is built.
substation --band amateur_2m_simplex --device-type soapy:lime --log-level DEBUG
A band's sdr_gain_elements and sample_rate take what it lists there, and its sdr_device_settings takes the rest, such as switching on a bias tee, which sends power up the antenna cable to an amplifier at the antenna. In device_overrides, write the key as soapy: and the driver's name, to say it is meant.
The next chapter opens a recording: what it holds, and how an audio editor places it on a timeline at the moment it was made.