Subsystem Search

Substation with Subsample and Subsequence

By the end of this chapter, every recording Substation keeps arrives in your Subsample library, and every transmission it hears is an event Subsequence can play from.

Straight into Subsample took recordings into Subsample with two settings. This chapter sends Subsequence each transmission as well, shows what Substation sends each of them, and sets them up on more than one computer. It needs no Python of your own.

The folder so far

The radio folder as An antenna for your band 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 four test signals made. The program from Substation in your own code, if you wrote it, is not needed here.

$ 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
# 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())
$ python make_tone.py

Messages between programs

OSC, Open Sound Control, is how one program tells another what has happened, in a message sent over the network, even when both programs are on the same computer. Each message has an address, which says what happened, such as /radio/state, and values, which say the rest. A program listens for messages on a port, a number that picks it out from the other programs on its computer.

Substation sends two kinds of program what it hears:

Switching it on

Add this to the end of config.yaml, below your band:

osc:
  sequencer_enabled: true
  sampler_enabled: true

Each is sent only when its setting is on, and both are off unless you switch them on.

What they receive

To see what Substation sends without either app running, a script stands in for both. You do not need to know Python to use Substation, and this script only stands in for Subsequence and Subsample, so this site can check what reaches them: you need not read it. Copy it as it is into a file named stand_in.py in the radio folder.

The script, stand_in.py, to copy as it is
# Stands in for Subsequence and Subsample, so you can see what Substation sends
# them: each OSC message arriving where Subsequence listens is written into
# subsequence.txt, and each arriving where Subsample listens into subsample.txt.

import threading

import pythonosc.dispatcher
import pythonosc.osc_server


def listen (port, name):

	# Writes one message as a line: its address, then its values, each a number
	# to one decimal place or a word as it came.
	def write (address, *values):
		shown = [f"{value:.1f}" if isinstance(value, float) else str(value) for value in values]
		with open(name, "a") as file:
			print(address, *shown, file=file)

	# Listens on this computer at the port, in the background.
	dispatcher = pythonosc.dispatcher.Dispatcher()
	dispatcher.set_default_handler(write)
	server = pythonosc.osc_server.BlockingOSCUDPServer(("127.0.0.1", port), dispatcher)
	threading.Thread(target=server.serve_forever, daemon=True).start()


# Subsequence listens on port 9000, and Subsample on 9002.
listen(9000, "subsequence.txt")
listen(9002, "subsample.txt")
print("Listening as Subsequence on port 9000, and as Subsample on 9002")

# Waits until you stop it with Ctrl+C.
try:
	threading.Event().wait()
except KeyboardInterrupt:
	pass

Start it in a terminal of its own, with Subsequence and Subsample stopped, since it listens on their ports. A new terminal opens in your home folder with the environment switched off, so go into the radio folder and run it with the environment's own Python, .venv/bin/python, which needs no switching on. It runs until you stop it with Ctrl+C:

$ cd radio && .venv/bin/python stand_in.py
Listening as Subsequence on port 9000, and as Subsample on 9002

Then, in another terminal, scan the test signal with a tone from What a recording holds, at a time of its own so that its recording takes a name of its own:

$ substation --band amateur_2m_simplex --iq-file test-tone.wav --center-freq 145000000 --start-time "2026-10-05 20:30:00" 2>&1 | grep -E "OSC|Radio channel"
… - INFO - OSC sender → sequencer 127.0.0.1:9000, sampler 127.0.0.1:9002
… - 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)

The log's OSC sender line says where the messages go. What arrived where Subsequence listens:

$ cat subsequence.txt
/radio/state amateur_2m_simplex 25 1 20.5 88.5 0
/radio/state amateur_2m_simplex 25 0 0.0 0.0 0
/radio/recording amateur_2m_simplex 25 /home/keanu/radio/audio/2026-10-05/amateur_2m_simplex/2026-10-05_20-30-03_amateur_2m_simplex_25_145.5MHz_20.5dB_file_0.wav 88.5 0

A 0 for a tone means none was heard, since an OSC message has no way to send nothing. The tone is a guide rather than a certainty, as What a recording holds explained. A DCS code arrives as a plain number, so DCS 023, a code in base 8 as radios show it, arrives as 19.

What arrived where Subsample listens:

$ cat subsample.txt
/sample/import /home/keanu/radio/audio/2026-10-05/amateur_2m_simplex/2026-10-05_20-30-03_amateur_2m_simplex_25_145.5MHz_20.5dB_file_0.wav

/sample/import comes when a recording is saved, with where it is, so that Subsample can load it into its library.

Playing from them

Subsequence can play from each transmission. Its guide's Messages from Substation and Subsample has a piece fire a tom fill whenever a /radio/state arrives whose third value is 1, as a radio channel turns on. The other values can play their part: the radio channel can choose a note, and how far above the hiss it was can set how loud.

Subsample takes each recording into its library as it arrives, ready to play, as Straight into Subsample showed, which also saves the recordings into Subsample's own folder, so they are there again each time it starts.

On another computer

Each message can go to another computer on your network, by its address, with sequencer_host and sampler_host, and to another port with sequencer_port and sampler_port:

osc:
  sequencer_enabled: true
  sequencer_host: 192.0.2.20
  sampler_enabled: true

A firewall on the other computer must let the messages in, on the port its app listens on: 9000/udp for Subsequence, and 9002/udp for Subsample. Substation only sends, so its own computer needs no change. Where that firewall is firewalld, as on Fedora, this lets them in for Subsequence:

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

$ sudo firewall-cmd --add-port=9000/udp --permanent
$ sudo firewall-cmd --reload

The address above is an example: use the address of the computer Subsequence runs on. With these settings, Substation sends to Subsequence there and to Subsample here, and the next chapter leaves it scanning around the clock.