Subsystem Search

Recordings of nothing

By the end of this chapter, you know how Substation keeps transmissions and throws noise away, and you can see it doing so in its log.

The folder so far

The radio folder as How Substation hears a transmission 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 the test signal made.

$ mkdir radio
$ cd radio
$ python3 -m venv .venv
$ source .venv/bin/activate
$ pip install "substation==0.8.2"
…
Successfully installed … substation-…
$ 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.
$ 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
# 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

Strong is not the same as sent

A threshold measures only how strong a radio channel is, so anything strong enough crosses it: a voice, but also noise in one radio channel that carries nothing, such as interference can make. Recorded, it would fill your folder with files of hiss. Raising snr_threshold_db does not help, because that noise really is louder than the band's noise floor. So Substation checks a radio channel that crosses the threshold before it turns it on, and checks each recording again before it keeps it.

A test of noise

A second script makes noise to test the checks with, beside a transmission they should keep: six seconds of the 2m band, with a steady noise on 145.3 MHz, a burst a twentieth of a second long on 145.4 MHz, and a two-second transmission on 145.5 MHz. Like make_signal.py, it only stands in for what a receiver would hear, and you need not read it: copy it as it is into a file named make_noise.py in the radio folder.

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

Run it, and it writes test-noise.wav beside it:

$ python make_noise.py

Scan it with --log-level DEBUG, which has Substation log the verdict of each check as well, and keep only the lines that matter. Substation writes its log to the terminal's error output, so 2>&1 sends that through the pipe, |, with everything else, and grep -E shows only the lines holding either phrase:

$ substation --band amateur_2m_simplex --iq-file test-noise.wav --center-freq 145000000 --start-time "2026-10-05 22:00:00" --log-level DEBUG 2>&1 | grep -E "Radio channel|Discarded"
… - DEBUG - Radio channel 9 suppressed: power variance 0.5 dB below threshold 3.0 dB (likely noise)
… - INFO - Radio channel 25 ON (f = 145.50000 MHz, SNR = 14.8dB, recording: YES)
… - DEBUG - Radio channel 9 suppressed: power variance 0.5 dB below threshold 3.0 dB (likely noise)
… - DEBUG - Radio channel 9 suppressed: power variance 0.6 dB below threshold 3.0 dB (likely noise)
… - DEBUG - Radio channel 9 suppressed: power variance 0.5 dB below threshold 3.0 dB (likely noise)
… - INFO - Radio channel 17 ON (f = 145.40000 MHz, SNR = 19.6dB, recording: YES)
… - DEBUG - Radio channel 9 suppressed: power variance 0.4 dB below threshold 3.0 dB (likely noise)
… - DEBUG - Radio channel 9 suppressed: power variance 0.6 dB below threshold 3.0 dB (likely noise)
… - DEBUG - Radio channel 9 suppressed: power variance 0.5 dB below threshold 3.0 dB (likely noise)
… - INFO - Radio channel 17 OFF (f = 145.40000 MHz, SNR = -0.5dB, recording: YES)
… - INFO - Discarded short recording (transmission 0.08s < 0.5s): 2026-10-05_22-00-02_amateur_2m_simplex_17_145.4MHz_19.6dB_file_0.wav
… - DEBUG - Radio channel 9 suppressed: power variance 0.5 dB below threshold 3.0 dB (likely noise)
… - DEBUG - Radio channel 9 suppressed: power variance 0.5 dB below threshold 3.0 dB (likely noise)
… - DEBUG - Radio channel 9 suppressed: power variance 0.4 dB below threshold 3.0 dB (likely noise)
… - DEBUG - Radio channel 9 suppressed: power variance 0.6 dB below threshold 3.0 dB (likely noise)
… - DEBUG - Radio channel 9 suppressed: power variance 0.6 dB below threshold 3.0 dB (likely noise)
… - INFO - Radio channel 25 OFF (f = 145.50000 MHz, SNR = -0.5dB, recording: YES)
… - DEBUG - Radio channel 9 suppressed: power variance 0.4 dB below threshold 3.0 dB (likely noise)
… - DEBUG - Radio channel 9 suppressed: power variance 0.4 dB below threshold 3.0 dB (likely noise)
… - DEBUG - Radio channel 9 suppressed: power variance 0.5 dB below threshold 3.0 dB (likely noise)
… - DEBUG - Radio channel 9 suppressed: power variance 0.4 dB below threshold 3.0 dB (likely noise)
… - DEBUG - Radio channel 9 suppressed: power variance 0.5 dB below threshold 3.0 dB (likely noise)
… - DEBUG - Radio channel 9 suppressed: power variance 0.4 dB below threshold 3.0 dB (likely noise)

The transmission on radio channel 25 was kept. The other two crossed the threshold and were thrown away, each by a different check.

Does its strength change?

A transmission's strength changes from moment to moment, with every syllable, while noise holds steady. When a radio channel crosses the threshold, Substation looks at how much its strength varied across the slice's eight measurements, and if that is less than the band's activation_variance_db, 3 dB unless the band says otherwise, it takes the radio channel for noise and does not turn it on: Radio channel 9 suppressed: power variance 0.5 dB below threshold 3.0 dB (likely noise). It asks again at every slice, which is why that line comes back about five times a second while the noise lasts.

To see what this check stops, switch it off for your band, at 0:

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: 0
$ substation --band amateur_2m_simplex --iq-file test-noise.wav --center-freq 145000000 --start-time "2026-10-05 22:00:00" --log-level DEBUG 2>&1 | grep -E "Radio channel|Discarded"
… - INFO - Radio channel 9 ON (f = 145.30000 MHz, SNR = 17.9dB, recording: YES)
… - INFO - Radio channel 25 ON (f = 145.50000 MHz, SNR = 14.8dB, recording: YES)
… - INFO - Radio channel 17 ON (f = 145.40000 MHz, SNR = 19.6dB, recording: YES)
… - INFO - Radio channel 17 OFF (f = 145.40000 MHz, SNR = -0.5dB, recording: YES)
… - INFO - Discarded short recording (transmission 0.08s < 0.5s): 2026-10-05_22-00-02_amateur_2m_simplex_17_145.4MHz_19.6dB_file_0.wav
… - INFO - Radio channel 25 OFF (f = 145.50000 MHz, SNR = -0.5dB, recording: YES)

Radio channel 9 turned on this time, and its recording, of nothing but noise, is in the folder:

$ ls audio/2026-10-05/amateur_2m_simplex
2026-10-05_22-00-02_amateur_2m_simplex_25_145.5MHz_14.8dB_file_0.wav
2026-10-05_22-00-02_amateur_2m_simplex_25_145.5MHz_14.8dB_file_0_2.wav
2026-10-05_22-00-02_amateur_2m_simplex_9_145.3MHz_17.9dB_file_0.wav

The transmission is there twice, once from each scan: a recording that would take the name of one already there gets _2 on the end instead. Switch the check back on, at 3, its default, or delete the line:

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

This check works only as a radio channel turns on, so noise that starts or stops while Substation scans, whose strength jumps as it does, can pass it.

Is it long enough?

A burst a twentieth of a second long is no transmission, but it was strong, and its strength changed, so radio channel 17 turned on. Substation recorded it, and when the radio channel turned off, threw the recording away: Discarded short recording (transmission 0.08s < 0.5s). It keeps no recording whose transmission is shorter than min_recording_seconds, half a second unless you set it, not counting the hold recorded after it. That catches brief bursts of interference, such as radar pulses and a car's ignition.

Is it as even as hiss?

Substation has two more checks, switched on and off together by discard_empty_enabled: one as a radio channel turns on, one as a recording closes. Each measures how evenly the decoded sound spreads across its frequencies, since hiss spreads evenly and a voice does not. At this release they reject hiss only on AM bands, such as the airband, and not on NFM or sideband bands like yours. Substation's README says why, under Limitations.

On an AM band, each logs what it rejects. The check as a radio channel turns on keeps it off, and says so in a DEBUG log, with how evenly the sound spread, from 0 to 1: Radio channel 18 suppressed: audio is noise-only (spectral flatness 0.38). The check as a recording closes throws it away, and says so in the log every scan shows: Discarded empty recording: and the recording's name.

When to change them

The next chapter sets up your receiver for its band: its gain, how many IQ samples it takes a second, and a band that suits each receiver you own.