Subsystem Search

What a recording holds

By the end of this chapter, your recordings sit on an audio editor's timeline at the moment each was made, in the format you choose.

The folder so far

The radio folder as Setting up your receiver left it: Substation installed in an environment inside it, switched on, its defaults kept as defaults.yaml, your band in config.yaml with settings for each receiver you own, and three test signals 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
    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
# 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
# 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())
$ python make_loud.py

A transmission with a tone

Many radios can send a low tone with their voice, which a radio receiving it usually filters out of what it plays. One kind, CTCSS, is a steady note between 67 and 254.1 Hz, one of a list that radios share, and a receiver set to the same note stays silent for every transmission that does not carry it, so that groups sharing a radio channel need not hear each other. DCS does the same with a slow stream of code. Wikipedia's article on CTCSS says more.

Substation listens for one as each transmission on an NFM band starts, and writes any it hears into the recording. A fourth script makes a transmission with a tone of 88.5 Hz under its voice. 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_tone.py in the radio folder.

The script, make_tone.py, to copy as it is
# Makes test-tone.wav: nine seconds of the 2m band as a receiver hands it over,
# with a transmission like the one in test-signal.wav, carrying a low tone under
# its voice, as many radios send.

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 tone under the voice, in hertz.
tone = 88.5

# 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 and a half seconds of hiss first, so that Substation has measured it
# before the transmission starts, and nine seconds in all.
start = 3.5
seconds = 9

# The hiss is random, but the same every time the script runs.
noise = numpy.random.default_rng(3)

with wave.open("test-tone.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))

		# The tone, as steady as the voice is not.
		under = 4 * numpy.sin(2 * numpy.pi * tone * t)

		# FM: the sound swings the transmission's frequency to either side of its own.
		phase = 2 * numpy.pi * (frequency - centre) * t + loudness * voice + under

		# 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, and it writes test-tone.wav beside it:

$ python make_tone.py

Scan it, keeping the lines about recording and radio channels:

$ substation --band amateur_2m_simplex --iq-file test-tone.wav --center-freq 145000000 --start-time "2026-10-05 21:00:00" 2>&1 | grep -E "Recording:|Radio channel"
… - INFO - Recording: ENABLED (16000 Hz mono WAV to ./audio)
… - INFO - Radio channel 25 ON (f = 145.50000 MHz, SNR = 20.5dB, recording: YES)
… - INFO - Radio channel 25 (145.50000 MHz): CTCSS 88.5 Hz
… - INFO - Radio channel 25 OFF (f = 145.50000 MHz, SNR = 0.0dB, recording: YES)

Substation heard the tone, and says so in the line after the radio channel turned on: CTCSS 88.5 Hz. A DCS code would show as DCS and its number.

The tone is turned down in the recording, though not always out of it. Substation keeps a voice's range, 300 to 3,400 Hz, and turns down what is below it, the more so the further below it is: the lowest CTCSS tone, 67 Hz, by about 27 dB, and this one, 88.5 Hz, by about 22 dB, but the highest, near 250 Hz, by only about 5 dB, so a high tone can stay faintly audible under the voice.

Its name

The recording is in your band's folder for the day, as before:

$ ls audio/2026-10-05/amateur_2m_simplex
2026-10-05_21-00-03_amateur_2m_simplex_25_145.5MHz_20.5dB_file_0.wav

Its name is for you and your file manager, and reads from the left:

Since the date and time come first, sorting a folder's recordings by name sorts them by when they were made, as ls does. A recording that would take the name of one already there gets _2 on the end, as Recordings of nothing showed.

Inside the file

The recording is a Broadcast WAV file: a WAV file with a block of details about the recording inside it, in the form the European Broadcasting Union set down for broadcasters' recordings. It plays in any audio player, and an editor that reads the details knows when the recording was made, and on which frequency, whatever the file is called. This recording holds:

Field This recording What it says
Description {"band":"amateur_2m_simplex","channel_index":25,"channel_freq":145500000.0,"ctcss":88.5} The band, the radio channel and its frequency in hertz, and the tone
Originator Substation What made it
Originator reference A reference Substation makes up for each recording Different in every recording, so that no two are alike
Origination date 2026-10-05 The day it starts
Origination time 21:00:03 The time it starts, to the second
Time reference 1209655924 The moment it starts, counted in audio samples since midnight
Coding history A=PCM,F=16000,W=16,M=mono,T=NFM;Frequency=145.50000MHz;CTCSS=88.5Hz How it was made: 16,000 audio samples a second, 16 bits each, one audio channel, from NFM on 145.5 MHz, with its tone

The time reference is the one an editor places it by. The recording holds 16,000 audio samples a second, so 1209655924 is 75,603.495 seconds after midnight: 21:00:03.495, within a hundredth of a second of when the transmission began.

To see them for yourself, FFmpeg's ffprobe command lists them among a file's tags, under names of its own.

On a timeline

An editor that reads the time reference places each recording at the moment it was made, so a night's recordings sit along the timeline with the gaps between them, as they happened. None of this is checked when this site is built, since it needs an editor:

The time reference counts from midnight, so a timeline holds one day: open each day's folder in a session of its own.

A tone is a guide

Substation wrote the tone into the recording twice: as "ctcss":88.5 in its description, and as CTCSS=88.5Hz at the end of its coding history. A DCS code is written the same way, as "dcs" and DCS=.

Treat a tone it reports as a guide rather than a certainty, and a tone it does not report as inconclusive. Tone detection has not yet been thoroughly tested with real radios, and it listens only as a transmission starts, so it can miss a tone that is there, or take something in a voice for one.

WAV or FLAC

Substation records WAV unless you say otherwise, as the log's Recording: line said: 16000 Hz mono WAV, sixteen thousand audio samples a second, which holds the whole of a voice as a radio channel carries it, in one audio channel. audio_sample_rate sets that number and audio_format the format. To record FLAC instead, add this to the end of config.yaml, below your band:

recording:
  audio_format: flac

Scan the test signal again, and the log says so:

$ substation --band amateur_2m_simplex --iq-file test-tone.wav --center-freq 145000000 --start-time "2026-10-05 21:00:00" 2>&1 | grep "Recording:"
… - INFO - Recording: ENABLED (16000 Hz mono FLAC to ./audio)

The FLAC recording takes the same name, with its own ending:

$ ls audio/2026-10-05/amateur_2m_simplex
2026-10-05_21-00-03_amateur_2m_simplex_25_145.5MHz_20.5dB_file_0.flac
2026-10-05_21-00-03_amateur_2m_simplex_25_145.5MHz_20.5dB_file_0.wav

FLAC packs a recording into a smaller file without losing any of it: how much smaller depends on the band and the signal. It holds the same details too, but as text tags, since FLAC has no place for Broadcast WAV's block of details, and it is that block an editor places a recording by.

So choose WAV to place recordings on a timeline, and FLAC to keep more of them in the same space. This guide goes on with WAV: set it back, or delete the two lines, which does the same.

recording:
  audio_format: wav

An experiment: the dynamics curve

Substation has one more stage a recording can go through, off unless you switch it on: the dynamics curve, an experiment kept for comparing by ear. It makes the quietest sounds in a recording quieter, which lowers the hiss between words, and the loudest a little louder, which gives a voice presence. Switch it on with dynamics_curve_enabled:

recording:
  audio_format: wav
  dynamics_curve_enabled: true

Scan the test signal again, and the new recording takes the next name, with _2 on the end:

$ substation --band amateur_2m_simplex --iq-file test-tone.wav --center-freq 145000000 --start-time "2026-10-05 21:00:00" 2>&1 | grep "Radio channel 25 ON"
… - INFO - Radio channel 25 ON (f = 145.50000 MHz, SNR = 20.5dB, recording: YES)
$ ls audio/2026-10-05/amateur_2m_simplex
2026-10-05_21-00-03_amateur_2m_simplex_25_145.5MHz_20.5dB_file_0.flac
2026-10-05_21-00-03_amateur_2m_simplex_25_145.5MHz_20.5dB_file_0.wav
2026-10-05_21-00-03_amateur_2m_simplex_25_145.5MHz_20.5dB_file_0_2.wav

Play the two WAV files one after the other. In the second, the quiet moments between the buzz's syllables are quieter, and its loudest moments a little louder. The difference is small on a test signal. On a real transmission, it is the hiss between words that the curve lowers. Where the curve cuts and where it lifts are set in dynamics_curve, and each setting there says what it changes. If the loudest syllables gain an edge, lower its cut_db and boost_db.

When you have heard enough, switch it off again, or delete the line:

recording:
  audio_format: wav
  dynamics_curve_enabled: false

The next chapter turns from what a receiver hears to the antenna it hears it through: how long to cut one for the band you scan.