Subsystem Search

A first scan

By the end of this chapter, Substation has a folder of its own with its settings in it, it has scanned the 2m band, and you have a recording of a transmission it heard, from the air with your receiver or from a test signal without one.

The folder so far

The radio folder as Installing Substation left it: Substation installed in an environment inside it, switched on.

$ mkdir radio
$ cd radio
$ python3 -m venv .venv
$ source .venv/bin/activate
$ pip install "substation==0.8.2"
…
Successfully installed … substation-…

Substation's settings

Substation reads its settings from a file called config.yaml, in the folder you run it from, and runs on its own defaults without one. Ask it to write one, to see what they are:

$ substation --init
Created /home/keanu/radio/config.yaml
This is the full default configuration, fully commented. Edit it to suit
your hardware and bands, then start a scan, e.g. `substation --band amateur_2m`.
Any setting you delete falls back to the built-in default.

It holds every setting Substation has, each at its default with a comment saying what it does, and every band it ships. You need change nothing in it yet. It refuses to write over a config.yaml that is already there.

A scan of the air

With your receiver plugged in, its antenna attached, scan the 2m band:

Example not checked: it is not run when this site is built.

$ substation --band amateur_2m

That reaches an RTL-SDR. For another receiver, name it with --device-type: hackrf for a HackRF One, airspy for an Airspy R2, airspyhf for an Airspy HF+ Discovery, or soapy: and its driver's name for another receiver through SoapySDR.

Substation logs what it does, a line at a time. It first measures the noise floor, the level of hiss its receiver hears with nobody transmitting, and says so: Noise floor warmup complete, then Detection enabled. From then on it watches all 161 radio channels of the band at once. When a transmission rises far enough above the noise floor, its radio channel turns on, as Radio channel 121 ON, with the frequency and the signal-to-noise ratio, how far above the hiss the signal is, in decibels. It turns off when the transmission ends, and the recording is saved. Ctrl-C stops the scan.

The 2m band can be quiet, so a first recording may take a while.

A scan of a test signal

Without a receiver, or to try Substation on a transmission you know is there, make one. A short script writes a file of the IQ samples a receiver would hand over: eight seconds of hiss on the 2m band, with a transmission at 145.5 MHz in the middle of it.

You do not need to know Python to use Substation, which runs from commands and a settings file. This script, written in Python, only stands in for a receiver, and you need not read it: copy it as it is into a file named make_signal.py in the radio folder, in any text editor. Later chapters make their test signals the same way.

The script, make_signal.py, to copy as it is
# 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())

Run it with the environment switched on, which has everything it needs. It prints nothing, and writes test-signal.wav beside it:

$ python make_signal.py

Then scan it as Substation scans the air, with three more options: --iq-file, the file to scan in place of a receiver; --center-freq, the frequency it is centred on, in hertz; and --start-time, the moment it counts as having started, which names and dates what Substation records from it.

$ substation --band amateur_2m --iq-file test-signal.wav --center-freq 145000000 --start-time "2026-10-05 21:00:00"
… - INFO - DC offset: shifted the centre frequency +6250 Hz to avoid masking a radio channel at 145.00000 MHz
… - INFO - Initialized scanner for band 'amateur_2m'
… - INFO - Frequency range: 144.00000 - 146.00000 MHz
… - INFO - Number of radio channels: 161
… - INFO - Center frequency: 145.00625 MHz
… - INFO - Required bandwidth: 2.02300 MHz (inc. 6.2kHz edge margin)
… - INFO - Sample rate: 2.400 MHz
… - INFO - SNR threshold: 4.5 dB ON / 1.5 dB OFF (hysteresis)
… - INFO - SDR Device: file (index 0)
… - INFO - Modulation: NFM
… - INFO - Recording: ENABLED (16000 Hz mono WAV to ./audio)
… - INFO - IQ file playback: test-signal.wav, center 145.000000 MHz, start 2026-10-05 21:00:00
… - INFO - Starting scan...
… - INFO - Setting up SDR device...
… - INFO - IQ file: test-signal.wav, 2.400 MHz, 19200000 frames (8s / 0.0h), center 145.000000 MHz
… - INFO - Using device center frequency: 145.000000 MHz (band midpoint: 145.006250 MHz)
… - INFO - SDR Gain: 36.0
… - INFO - SDR device configured successfully
… - INFO - Noise estimation regions: 162 gaps, 8748 bins total
… - INFO - FFT size: 65536 bins, frequency resolution: 36.6 Hz
… - INFO - Welch segments: 8, samples per slice: 524288
… - INFO - DC spike exclusion: 7 bins around center
… - INFO - Started async SDR streaming
… - INFO - Time slice: 200 ms (524288 samples)
… - INFO - Noise floor warmup complete (6.8 dB). Detection enabled.
… - INFO - Radio channel 121 ON (f = 145.50000 MHz, SNR = 15.3dB, recording: YES)
… - INFO - Radio channel 121 OFF (f = 145.50000 MHz, SNR = -0.2dB, recording: YES)
… - INFO - IQ file playback complete
… - INFO - Cancelled async SDR streaming
… - INFO - SDR device closed

Every line starts with the date and time, shown here as … because yours will differ. The first lines say what Substation is about to scan: the band's range, its 161 radio channels, and where it records to. Then come the lines a scan of the air prints too: the noise floor measured, detection enabled, and radio channel 121 turning on at 145.5 MHz, 15.3 dB above the hiss, then off when the transmission ends. Substation heard the transmission, and recorded it.

A file is read as fast as Substation can read it, not in real time, and the scan stops at its end, where a scan of the air goes on until Ctrl-C.

Substation scans any IQ file made the same way, a recording of the air made with other software among them: a WAV file whose two audio channels hold I and Q as 16-bit numbers, with more IQ samples per second than the band is wide in hertz. Its --center-freq is the frequency the receiver was tuned to.

Where a recording lands

Recordings go in a folder called audio, beside config.yaml, in a folder for each day and in that a folder for each band:

$ ls audio/2026-10-05/amateur_2m
2026-10-05_21-00-02_amateur_2m_121_145.5MHz_15.3dB_file_0.wav

The name says what it is: the date and time the transmission began, the band, the radio channel and its frequency, how far above the hiss it started, and where it came from, file_0 for a test signal, where a receiver's recording says rtlsdr_0 for the first RTL-SDR. Play it in any audio player: it is a buzz, rising and falling six times a second, which is what the script sent.

The next chapter, for musicians, sends every recording straight into Subsample. The one after it looks at the bands Substation defines, which of them it records by default, and a band of your own.