Subsystem

Substation

An SDR band scanner that detects, demodulates, and records radio transmissions automatically.

Connect a USB SDR receiver, point it at a frequency band - Amateur, CB, Airband, PMR, Maritime, or any conventional analogue band - and Substation monitors every radio channel simultaneously, detecting each transmission and recording it to its own audio file with full metadata. Out of the box it records only the bands that UK law opens to anyone, such as amateur and CB radio, and detects activity on the rest: see Reception and the law.

The scanner is designed for unattended, long-running operation. It handles the entire signal processing chain from raw IQ samples through to clean, archive-ready audio files: signal detection, demodulation (NFM, AM, USB, LSB), noise reduction, carrier transient removal, soft limiting, and automatic file management. Noise rejection checks each activation's RF power variance and audio spectral flatness, and each finished recording's length and spectral flatness, and discards what looks like hiss. Recordings include embedded metadata - frequency, timestamp, modulation, and any CTCSS or DCS tone detected - so every file is self-documenting.

Substation runs as a command-line tool or as a Python module in your own applications, including on low-power hardware such as a Raspberry Pi scanning a narrower band. The widest shipped bands, at 12.5 MHz, have not yet been shown to keep up in real time, and CTCSS and DCS tone detection has not yet been thoroughly tested with real radios: see Limitations.

Full documentation: https://subsystem.co/substation/

Signal processing

Substation's signal processing is written in Python, with NumPy and SciPy doing the numerical work.

Detection

The scanner divides the SDR's bandwidth into radio channels and analyses each one several times a second using Welch's Power Spectral Density method. Welch averaging across multiple overlapping FFT segments reduces noise variance, producing stable SNR measurements that don't jitter between slices. The noise floor tracks slowly via an exponential moving average, so brief transmissions stand out clearly against a stable background. A warmup period at startup absorbs the transient spikes that SDR hardware produces while its PLL and AGC settle.

The centre frequency is automatically shifted by half a radio channel spacing whenever a radio channel would fall on the DC spike - a common SDR artefact caused by LO leakage - so no radio channel is ever masked.

Noise rejection

High-sensitivity receivers often trigger on noise that crosses the SNR threshold. Substation rejects these false activations with independent checks:

  1. RF power variance - real signals (voice, data) fluctuate in power across the detection window; stationary noise does not. Radio channels with low variance are rejected before any demodulation occurs.
  2. Spectral flatness - when a radio channel first activates, the audio is speculatively demodulated and its spectral flatness (Wiener entropy) is measured. Noise has a flat spectrum; any real signal has a peaked one. Flat-spectrum activations are rejected before a recording starts.
  3. Post-recording checks - after a recording finishes, it is discarded if it is shorter than min_recording_seconds, or if the complete file, analysed for spectral flatness, is predominantly noise (e.g. a brief signal followed by hold-timer padding).

Demodulation

Each modulation type has a dedicated, stateful demodulator that maintains phase and filter continuity across processing blocks, eliminating the pops and glitches that occur at block boundaries in stateless designs.

NFM - the most common mode for PMR, amateur, and public safety - runs through a complete processing chain: IF decimation, polar discriminator, Hampel impulse blanker (suppresses glitches from IQ samples dropped over USB by devices like the AirSpy R2), 300µs de-emphasis, DC blocking, voice bandpass filter (300-3400 Hz), and CTCSS/DCS subaudible tone detection. The voice bandpass reduces subaudible signalling in the recording: the lowest CTCSS tones strongly, and the highest, near 250 Hz, only slightly, so they can remain faintly audible. A Goertzel detector looks for CTCSS tones and a Golay decoder reads DCS codes. A tone found is embedded in the file's metadata and delivered live on the scanner's channel_state event (as ctcss_hz / dcs_code kwargs), so OSC or dashboard consumers see the tone as a property of the activation, with no file parsing required. Tone detection has not yet been thoroughly tested with real radios, so treat a reported tone as a guide rather than a certainty, and the absence of one as inconclusive.

AM - used for civil and military airband - uses envelope detection with an AGC that follows the audio's peaks, rising at once and releasing slowly, so it adapts to varying signal strength without pumping or clipping.

SSB (USB and LSB) - used for HF amateur and maritime - implements the Weaver method for clean sideband separation with real-valued Butterworth filters on I and Q, followed by voice AGC.

Recording quality

Each recording passes through several stages between demodulation and disk:

Efficiency

The scanner is designed for 24/7 operation on low-power hardware. All DSP runs through NumPy and SciPy's compiled backends. FFT segments use zero-copy memory stride tricks. Expensive work runs only when it is needed: the segment PSD only when a radio channel changes state, and demodulation only while a radio channel records, or briefly when one turns on, to check it for noise. Audio buffering for each radio channel uses a pre-allocated ring buffer with modulo wrap-around, avoiding per-flush memory allocation. IIR filter states use float64 precision to prevent rounding drift in long-running sessions.


Supported devices

To use this software, a compatible Software Defined Radio (SDR) USB device is required. Each supported device below has a self-contained card with its specifications, recommended starting configuration, common gotchas, and a copy-pasteable example band so you can get a working scan in a few minutes. Different SDR devices have very different capabilities - settings that work well on one device may need adjusting on another, and the cards capture the differences that actually matter in practice.

Quick reference

Device Frequency range Max BW ADC Best for
RTL-SDR Blog V4 / V3 24 MHz - 1.766 GHz 2.4 MHz 8-bit General VHF/UHF, low cost
HackRF One 1 MHz - 6 GHz 20 MHz 8-bit Wideband monitoring
AirSpy R2 24 MHz - 1.8 GHz 10 MHz 12-bit High-quality VHF/UHF
AirSpy HF+ Discovery 0.5 kHz - 31 MHz, 64 - 260 MHz 912 kHz 18-bit HF / VHF precision

Any other device with a SoapySDR driver module installed can be used too - see Other SoapySDR devices below.

RTL-SDR Blog V4 / V3

A high-quality, low-cost general-purpose receiver. The natural starting point for new users - well supported, and good enough for most VHF/UHF scanning. Limited dynamic range from its 8-bit ADC.

Spec Value
Frequency range 24 MHz - 1.766 GHz (with gaps)
Max bandwidth 2.4 MHz
Sample rates Continuous, up to 2.4 MHz (typical: 2.048 MHz)
ADC resolution 8-bit
Gain architecture Single stage
AGC Hardware AGC
Driver pyrtlsdr - Python binding (version range in pyproject.toml)
--device-type rtl, rtlsdr, rtl-sdr
Best for General VHF/UHF scanning at low cost

Setup - see INSTALL.md for the librtlsdr fork build and the DVB-T driver blacklist step.

Recommended starting config

Gotchas

Working example band - Bristol airband, as shipped in the default config:

air_civil_bristol:
    type: AIR
    freq_start: 125.5e+6
    freq_end: 126.0e+6
    sample_rate: 1.024e6
    exclude_channel_indices: [26, 27, 34, 35]
    device_overrides:
      airspyhf:
        sample_rate: 0.912e6
        snr_threshold_db: 6
        sdr_gain_db: auto
        activation_variance_db: 3.0

The excluded radio channels and the AirSpy HF+ overrides were both tuned for one location and one receiver, so review them for yours. device_overrides applies only when that device is selected, so an RTL-SDR uses the base values.

References

HackRF One

A wideband transceiver covering 1 MHz to 6 GHz with up to 20 MHz of instantaneous bandwidth - the widest single-tune capture of any device here. The trade-off is no hardware AGC and the same 8-bit ADC dynamic-range limit as the RTL-SDR.

Spec Value
Frequency range 1 MHz - 6 GHz
Max bandwidth 20 MHz (16 MHz is the practical reliable maximum)
Sample rates Continuous, 2 - 20 MHz
ADC resolution 8-bit
Gain architecture LNA (0-40 dB, 8 dB steps) + VGA (0-62 dB, 2 dB steps)
AGC None - auto falls back to a sensible default and warns
Driver python_hackrf (with fallback to hackrf / pyhackrf)
--device-type hackrf, hackrf-one, hackrfone
Best for Wideband monitoring, multi-band capture in a single tune

Setup - install the HackRF extra with pip install "substation[hackrf]", which builds the python_hackrf binding against the libhackrf-dev system package (see INSTALL.md). See INSTALL.md for the USB buffer tuning (usbcore.usbfs_memory_mb=1000).

Recommended starting config

Gotchas

Working example band

dmr:
    type: DMR
    freq_start: 452.5e+6
    freq_end: 460.5e+6
    sample_rate: 12.5e+6

Scanning a band this wide in real time also depends on the computer keeping up with it: see Limitations.

References

AirSpy R2

A high-dynamic-range VHF/UHF receiver with a 12-bit ADC (≈16-bit effective from oversampling) and three independently tuneable gain stages. Its 10 MHz of bandwidth covers a wide band in a single tune.

Spec Value
Frequency range 24 MHz - 1.8 GHz
Max bandwidth 10 MHz
Sample rates Discrete: 2.5 MHz or 10 MHz
ADC resolution 12-bit (≈16-bit effective from oversampling)
Gain architecture LNA + Mixer + VGA (per-element control via sdr_gain_elements)
AGC None - sdr_gain_db: auto is mapped to a fixed manual default (see below)
Driver SoapySDR + soapysdr-module-airspy (system package)
--device-type airspy, airspy-r2, airspyr2
Best for High-quality VHF/UHF, wide single-band capture, weak-signal work

Setup - see INSTALL.md for the SoapySDR core and the AirSpy module. The Python venv must be created with --system-site-packages so it can access the system-installed SoapySDR Python bindings.

Recommended starting config

Gotchas

Working example band - PMR446 with per-element gain control:

pmr_airspy:
    type: PMR
    freq_start: 446.00625e+6
    freq_end: 446.19375e+6
    sample_rate: 2.5e6
    sdr_gain_elements:
      LNA: 10
      MIX: 5
      VGA: 12

Run with:

substation --band pmr_airspy --device-type airspy --device-index 0

References

AirSpy HF+ Discovery

A precision HF and lower-VHF receiver, with high sensitivity and dynamic range in its bands. It is not a wideband scanner: its maximum bandwidth is 912 kHz. It suits HF listening, weak-signal work, and narrow-band airband and amateur scanning.

Spec Value
Frequency range 0.5 kHz - 31 MHz, 64 - 260 MHz (two separate bands, not contiguous)
Max bandwidth 912 kHz
Sample rates Discrete: typically 0.192, 0.228, 0.384, 0.456, 0.650, 0.768, 0.912 MHz (see log)
ADC resolution 18-bit
Gain architecture LNA on/off (0 or +6 dB) + RF attenuator (-48 to 0 dB)
AGC Hardware multi-loop AGC (recommended starting point)
Driver SoapySDR + soapysdr-module-airspyhf (system package)
--device-type airspyhf, airspy-hf, airspyhf+
Best for HF and lower-VHF precision work, weak-signal listening, narrow-band scanning

Setup - see INSTALL.md. On Raspberry Pi OS the soapysdr-module-airspyhf package may not be available in the distro repos; the install guide covers building it from source. As with the AirSpy R2, the venv must be created with --system-site-packages.

Recommended starting config

Gotchas

Working example band - Bristol airband, as shipped in the default config. The HF+-specific tuning lives in a device_overrides section, so the same band works on an RTL-SDR (base values) and on the HF+ (override values) without duplicating the band:

air_civil_bristol:
    type: AIR
    freq_start: 125.5e+6
    freq_end: 126.0e+6
    sample_rate: 1.024e6           # base value, used by RTL-SDR
    exclude_channel_indices: [26, 27, 34, 35]
    device_overrides:
      airspyhf:                    # applied when --device-type is airspyhf
        sample_rate: 0.912e6
        snr_threshold_db: 6
        sdr_gain_db: auto
        activation_variance_db: 3.0

Run with:

substation --band air_civil_bristol --device-type airspyhf --device-index 0

References

Other SoapySDR devices

Any device with a SoapySDR driver module installed can be used via --device-type soapy:<driver> (for example, soapy:lime or soapy:plutosdr). To discover what's connected and what driver name to use, run:

SoapySDRUtil --find

The same sdr_gain_db, sdr_gain_elements, and sdr_device_settings config keys apply. The wrapper logs the available gain elements, sample rates, antennas, and device-specific settings reported by the driver at DEBUG level on startup - run with --log-level DEBUG when configuring a new device and use that capability dump to guide your configuration in the same way as the AirSpy cards above.

Reference: SoapySDR project

Quick start

  1. Install the SDR drivers and platform dependencies (see INSTALL.md).
  2. Install substation:
pip install substation                                    # from PyPI
# or: pip install git+https://github.com/simonholliday/substation.git   # latest from GitHub
  1. Create a starter config in your working directory (optional: it runs on the built-in defaults without one):
substation --init          # writes ./config.yaml, the fully-commented defaults
  1. Run:
substation --band amateur_2m --device-type rtlsdr --device-index 0

The scanner logs each radio channel on the 2 m amateur band as it becomes active, and records each transmission to its own file. The scan is running once the log says Detection enabled. The 2 m band can be quiet, so a first recording may take a while. Out of the box only amateur and CB bands record: the rest, such as airband and PMR446, only detect, as the next section explains. Recordings are written to:

./audio/YYYY-MM-DD/<band>/<date>_<time>_<band>_<channel>_<freq>_<snr>dB_<device>_<index>.wav

Reception and the law

Many radio services may not lawfully be listened to without permission, and the law differs from country to country. Every band Substation ships carries a reception_class saying how UK law treats it, and the class decides whether the band records out of the box:

Class What it covers Out of the box
general What Ofcom calls general reception: licensed broadcasting, amateur and CB radio, and weather and navigation transmissions Records
not_general Services outside general reception, such as PMR446, business radio, marine, military airband, and emergency services, which Ofcom says it is illegal to listen to Detects activity without recording
unsettled Bands where the position is unclear, such as civil airband, where Ofcom will not say that listening is an offence Detects activity without recording

In the UK, using a receiver to learn the contents, sender, or addressee of a transmission that is not general reception is an offence under the Wireless Telegraphy Act 2006, even if you tell no one. Elsewhere the law differs: the United States, for example, allows receiving unencrypted public-safety, marine, and air radio, and Germany forbids it. The classes describe UK law only, and are not legal advice: the law where you are decides what you may receive and record.

Where your law allows it, switch recording on for a band in your config.yaml:

bands:
  air_civil_bristol:
    recording_enabled: true

--list-bands shows each band's class and whether it records. A band you define yourself takes its template's class when it sets none of its own, so a band of type: CB records and one of type: PMR only detects. A band with no class records only if you give it reception_class: general or recording_enabled: true.

Utility scripts

Substation installs a small companion command for one-shot tasks that read the config or work with frequencies. It's a separate console entry point, not part of the scanner itself.

Antenna length calculator

Calculate optimal antenna lengths (half-wave dipole, quarter-wave vertical, 5/8-wave vertical, full-wave loop) for any configured band or any frequency:

substation-antenna --band hf_night_4mhz   # use a configured band's centre frequency
substation-antenna --freq 4625e3          # use a manual frequency in Hz
substation-antenna --list                 # list all configured bands

For HF bands wider than ±2% of their centre frequency the report also shows the dipole's natural SWR window and the antenna lengths at the band edges, so you can decide whether to cut for the centre, an edge, or use a tuner. Lengths of a metre or more are in metres, and shorter ones in centimetres.

Command line

substation --band <band> [--config <path>] [--device-type rtlsdr|hackrf|airspy|airspyhf|soapy:<driver>] [--device-index N]
substation --list-bands

substation exits with status 1 when a scan stops because of an error, such as a receiver that fails or is unplugged, so a service manager can restart it. Stopping it with Ctrl+C exits with status 0.

Options:

Python module usage

You can also use the scanner as a library in your own code. This allows you to respond to radio events programmatically.

import asyncio

import substation.config
import substation.scanner

# State Callback: Triggered whenever a signal starts or stops
def my_state_handler (band: str, ch: int, active: bool, snr: float) -> None:
	print (f"Channel {ch} is now {'ON' if active else 'OFF'} ({snr:.1f} dB)")

# Recording Callback: Triggered when a file is finalised and closed, on a band that records
def my_recording_handler (band: str, ch: int, file_path: str) -> None:
	print (f"Recording finished: {file_path}")

async def main () -> None:

	"""
	Initialise the scanner and respond to real-time events.
	"""

	# Load configuration
	config_data = substation.config.load_config ()

	# Initialise scanner instance
	scanner = substation.scanner.RadioScanner (
		config=config_data,
		band_name="pmr",  # detects only, out of the box: see "Reception and the law"
		device_type="rtlsdr"
	)

	# Register the handlers
	scanner.add_state_callback (my_state_handler)
	scanner.add_recording_callback (my_recording_handler)

	# Start the asynchronous scan loop
	await scanner.scan ()

if __name__ == "__main__":
	asyncio.run (main ())

scan() runs until it is cancelled or, for IQ file playback, until the file ends. Anything that stops it early is raised once the recordings and the device are closed.

See examples/scan_demo.py for a more detailed implementation (in the source repository).

OSC event forwarding

Substation can forward radio channel state changes and saved recordings as OSC (Open Sound Control) messages, so downstream tools - MIDI sequencers, sample players, VJ software, lighting rigs - can react to radio activity in real time. Install the optional extra:

pip install "substation[osc]"

Then attach an OscEventSender to any RadioScanner instance:

import substation.osc_sender

osc_sender = substation.osc_sender.OscEventSender(
    host='127.0.0.1', port=9000,          # sequencer endpoint
    sampler_host='127.0.0.1',             # optional: also notify a sampler
    sampler_port=9002,
)
osc_sender.attach(scanner)

The sender emits the following OSC messages:

Address When Arguments
/radio/state Radio channel turns ON or OFF band_name:str, channel_index:int, is_active:int(0/1), snr_db:float, ctcss_hz:float, dcs_code:int
/radio/recording Recording finalised on disk band_name:str, channel_index:int, file_path:str, ctcss_hz:float, dcs_code:int
/sample/import Recording finalised (only if sampler_host set) file_path:str

ctcss_hz and dcs_code carry any subaudible tone detected on the activation; tone detection has not yet been thoroughly tested with real radios (see Demodulation). OSC has no native null, so 0.0 / 0 mean "no tone detected" (valid CTCSS tones start at 67 Hz, and DCS codes are always nonzero, so these sentinels are unambiguous). DCS codes are octal, and dcs_code is the code's integer value, so DCS 023 arrives as 19; format it in octal to show it as a radio does.

Sends are non-blocking UDP (fire-and-forget); transient socket errors are logged as warnings and never raised back into the scanner. See examples/scan_osc.py for a working script (in the source repository).

IQ file playback

You can process a previously captured IQ file through the scanner pipeline instead of a live SDR device. The file is streamed at full speed (not real-time) with a virtual clock providing accurate timestamps for output recordings.

substation --band pmr \
  --iq-file "baseband_446059313Hz_16-13-20_16-03-2025.wav" \
  --center-freq 446059313 \
  --start-time "2025-03-16 16:13:20"

The IQ file must be a WAV with two audio channels (I and Q), as 16-bit PCM, at any sample rate. RF64 and WAVE_FORMAT_EXTENSIBLE files work too, and files over 4 GB whose header sizes have overflowed. The centre frequency is the frequency the SDR was tuned to when recording. The file's sample rate is read from the WAV header. The band span must fit within the file's bandwidth - the centre frequency doesn't need to match the band midpoint exactly.

Configuration

Substation uses a two-layer configuration system:

For example, to override only the audio output directory:

recording:
  audio_output_dir: /mnt/ssd/audio

To override a single field in a specific band:

bands:
  pmr:
    snr_threshold_db: 6.0

Use --config <path> to specify a different user override file. Use --list-bands to see all available bands.

The top-level sections are scanner, recording, band_defaults, and bands. Each entry in band_defaults is a template: a band with the same type inherits its values, and sets only what differs. A band's reception_class decides whether it records when it does not set recording_enabled (see Reception and the law). Device-specific tuning for a band goes in its device_overrides, described below.

Every setting, with its type, default, limits, and examples, is in the configuration reference: https://subsystem.co/substation/configuration/

Device-specific overrides

Different SDR devices have different sample rates, gain architectures, and sensitivity characteristics. Rather than creating a separate band definition for each device (e.g. pmr_rtlsdr, pmr_airspy, pmr_hackrf), you can define a band once and provide per-device tuning with device_overrides.

How it works: When you run substation --band pmr --device-type airspy, the scanner checks if the pmr band has a device_overrides.airspy section. If so, those fields are merged onto the band config, overriding the base values. Fields not mentioned in the override keep their base values.

bands:
  pmr:
    type: PMR
    freq_start: 446.00625e+6
    freq_end: 446.19375e+6
    sample_rate: 1.024e6          # default for RTL-SDR
    device_overrides:
      airspy:                      # applied when --device-type is airspy
        sample_rate: 2.5e6
        sdr_gain_elements:
          LNA: 14
          MIX: 5
          VGA: 12

With this configuration:

Override keys name a device family. Any --device-type spelling of a family works as its key, in any letter case:

--device-type aliases Override key
rtl, rtlsdr, rtl-sdr rtlsdr
hackrf, hackrf-one, hackrfone hackrf
airspy, airspy-r2, airspyr2 airspy
airspyhf, airspy-hf, airspyhf+ airspyhf
soapy:<driver> the driver name (e.g. lime)

A key that names no device family the scanner knows logs a warning at startup, in case it is a typo. For a SoapySDR driver, write the key as soapy:<driver> to say it is intended.

Supported override fields: sample_rate, sdr_gain_db, sdr_gain_elements, sdr_device_settings, snr_threshold_db, activation_variance_db.

The default config ships with some device overrides already set - for example, air_civil_bristol has an airspyhf override with tuning appropriate for the AirSpy HF+ Discovery. You can add your own overrides in config.yaml using the standard inheritance mechanism:

# config.yaml - user overrides only
bands:
  pmr:
    device_overrides:
      airspy:
        sample_rate: 2.5e6
        sdr_gain_elements: {LNA: 14, MIX: 5, VGA: 12}

SoapySDR installation (AirSpy and other devices)

AirSpy devices, and any other soapy:<driver> device, need SoapySDR installed at the system level, with a module for each kind of device. The Python virtual environment must then be created with --system-site-packages, so that it can see SoapySDR's system-installed bindings. The steps for Debian, Ubuntu, Raspberry Pi OS, and Fedora are in section 4 of INSTALL.md.

Recording metadata

Each recording embeds metadata directly in the audio file.

WAV format (default): Industry-standard Broadcast WAV (BWF/BEXT, EBU Tech 3285) with the time each recording starts, at the transmission's onset. Audio editors like Audacity, Reaper, and iZotope RX can place recordings on a timeline at their real capture time. These are standard .wav files that play in any audio player.

FLAC format: Vorbis comment tags store the same fields (band, frequency, date, time, modulation) as text. FLAC files are smaller than WAV, by an amount that depends on the band and the signal, and audio editors cannot use their time_reference tag for timeline placement, which they read only from a BEXT chunk.

Metadata example

If you open a recording in a professional audio tool or a BWF viewer, you will see fields like these:

Field Example value Description
Description {"band":"pmr","channel_index":1,"channel_freq":446006250.0} Machine-readable JSON with radio channel details
Coding History A=PCM,F=16000,W=16,M=mono,T=NFM;Frequency=446.00625MHz Technical signal chain (Algorithm, Rate, Modulation)
Originator Substation The software that created the file
Origination Date 2026-01-27 Date the recording started
Time Reference 1152000 Audio sample count since midnight (for precise timing)

Gain tuning

Each device card above carries the gain settings that work as a starting point for that specific device. This section explains the why behind those settings - the principles that apply to any SDR with multiple gain stages, so you can reason about adjustments when the defaults aren't quite right.

SDR gain controls how much the received signal is amplified before digitisation. Too little gain and weak signals are lost in the noise floor; too much and strong signals overdrive the ADC, causing distortion and spurious detections.

Simple approach (recommended starting point): set sdr_gain_db to a numeric value or auto. When set to a single number, the driver distributes the gain across the device's internal stages automatically - this produces good results for most setups without any per-element knowledge. Start here and only move to per-element tuning if you want to squeeze out the last bit of performance.

Per-element tuning (advanced): devices with multiple gain stages (like the AirSpy R2) allow individual control via sdr_gain_elements. This can improve reception quality because the order of gain stages matters for noise performance:

Stage Role Tuning guidance
LNA (Low-Noise Amplifier) First amplifier in the chain. Has the greatest impact on overall noise figure. Set as high as possible without overloading from strong nearby signals. This is where sensitivity is won or lost.
Mixer Frequency conversion stage. Moderate gain. Too high increases intermodulation distortion (ghost signals from mixing products of strong stations).
VGA (Variable Gain Amplifier) Final gain stage before the ADC. Use to bring the overall signal level into the ADC's optimal range. Boosting here amplifies noise from earlier stages equally, so it contributes the least to sensitivity.

The general principle is: maximise gain early in the chain (LNA) and minimise gain late (VGA), within the limits of what doesn't cause overload. This keeps the signal-to-noise ratio as high as possible through the receive chain.

SNR threshold tuning:

The snr_threshold_db setting controls how far above the noise floor a signal must be before it's detected. Each device card above lists a sensible starting value for that hardware. To adjust:

General tips:

Rejecting empty/noise recordings

The problem

SNR thresholds detect any signal that's louder than the noise floor - but they can't distinguish a real signal from a noisy one. With sensitive receivers like the AirSpy HF+ Discovery, you'll often see radio channels register 6-10 dB SNR yet contain only hissing static when played back. Raising snr_threshold_db doesn't help: the SNR is genuinely high, because the noise in that radio channel really is louder than the band-wide noise floor.

What's needed is a way to tell noise apart from real signals - and a single check isn't enough, because noise comes in different flavours that fool different detectors.

The solution: layered noise rejection

The scanner applies independent gates, each catching a different kind of false positive. Every gate is modulation-agnostic: it works for voice, data, tones, and beacons alike.

Gate 1 - RF power variance (activation_variance_db)

Real signals fluctuate over time: syllables, frame structure, and bursts all produce 5-15 dB power swings within a 200 ms detection window. Stationary noise produces near-constant power (standard deviation ~1-2 dB).

At the moment a radio channel turns ON, the scanner measures the standard deviation of its power across the 8 Welch PSD segments. If the standard deviation falls below activation_variance_db (default 3.0 dB), the activation is suppressed - no ON event fires, no recording starts.

This is the cheapest check, since it reuses PSD data already computed. It catches broadband stationary noise that happens to sit a few dB above the noise floor.

Gate 2 - audio spectral flatness (discard_empty_enabled)

Some noise passes Gate 1 - for example, narrowband interference with enough temporal variance to look "active" in the RF domain, but no actual signal content when demodulated. Gate 2 catches this by speculatively demodulating the first IQ block and computing the spectral flatness (Wiener entropy) of the resulting audio.

Noise has a flat power spectrum (flatness 0.3-0.5). Any real signal - voice, data, tones - has a peaked spectrum (flatness < 0.04). The threshold of 0.15 sits in the large gap between the two groups, so no per-modulation tuning is needed.

If the flatness exceeds 0.15, the activation is suppressed - same as Gate 1. The speculative demodulation result is discarded; the main demodulation path runs fresh with proper trim boundaries if the check passes.

This check costs more, since it demodulates the audio and computes an FFT, so it runs only after Gate 1 passes. Controlled by discard_empty_enabled (default: true).

Gate 3 - post-recording spectral flatness (discard_empty_enabled)

Gates 1 and 2 both operate at turn-ON time. Gate 3 operates at turn-OFF time, on the finished recording.

A signal can legitimately pass Gates 1 and 2 (the first block has real content) but produce a mostly-empty recording - for example, a brief 200 ms transmission followed by several seconds of hold-timer noise. The overall recording's spectral flatness will be high even though the first block was clean.

After the WAV file is closed, the scanner reads it back and computes spectral flatness on the full audio. If the flatness exceeds 0.15, the file is deleted before any recording-finished callbacks fire.

How the gates differ

Gate Domain When What it catches Cost
1. Variance RF PSD Turn-ON Broadband stationary noise Lowest: reuses the PSD already computed
2. Flatness (preview) Demodulated audio Turn-ON Narrowband noise that passes Gate 1 Demodulates the first block and computes an FFT
3a. Min duration Recording metadata Turn-OFF Brief transients (radar, ignition) that pass spectral checks Reads the recording's length
3b. Flatness (whole file) Demodulated audio Turn-OFF Recordings that started real but became mostly noise Reads the file back and analyses it

Example

Imagine a "noisy" radio channel with average power 9 dB above the noise floor and a real voice transmission also at 9 dB SNR:

Source Avg SNR Per-segment power (dB above floor) Std dev Audio flatness
Stationary noise 9 dB 9.1, 8.8, 9.0, 9.2, 8.9, 9.1, 8.7, 9.2 0.18 dB 0.38
Voice transmission 9 dB 4.0, 12.5, 14.1, 7.0, 13.8, 11.2, 5.5, 3.9 4.3 dB 0.003

The noise is caught by Gate 1 (variance 0.18 < 3.0). If it somehow passed Gate 1, Gate 2 would catch it (flatness 0.38 > 0.15). The voice passes both cleanly.

Configuration

Gate 1 is set for each band by activation_variance_db, Gates 2 and 3b by discard_empty_enabled in the recording section, and Gate 3a by min_recording_seconds. Every gate is on by default. Each setting's default and limits are in the configuration reference: https://subsystem.co/substation/configuration/

How it interacts with other settings

Setting Relationship
snr_threshold_db Runs first. Radio channels below the SNR threshold never reach the noise gates.
activation_variance_db Gate 1, only on turn-on transitions, only when the SNR check passed.
discard_empty_enabled Gates 2 and 3b. Gate 2 runs after Gate 1 passes. Gate 3b runs on recording close.
min_recording_seconds Gate 3a. Runs on recording close, before Gate 3b. Set to 0 to disable.
Hysteresis (hysteresis_db) Unchanged. Once a recording starts, it continues until SNR drops below snr_threshold_db - hysteresis_db.
Hold time (recording_hold_time_ms) Unchanged. Brief drops in SNR during active recording are tolerated. Gate 3b may discard if the hold timer extends the recording far beyond the actual signal.

Gates 1 and 2 suppress an activation silently: no ON callback fires, and no recording starts. Gate 3 deletes a finished recording before recording_saved fires, and emits recording_discarded instead. Downstream consumers (OSC bridge, user scripts) see only activations that passed Gates 1 and 2, and only saved recordings that passed Gate 3.

Tuning guidance

Symptom Action
Defaults work Leave them - activation_variance_db: 3.0 and discard_empty_enabled: true handle most cases
Real signals (voice, data) being rejected by Gate 1 Lower activation_variance_db: try 2.0 or 2.5
Noise still triggers recordings (passes Gate 1) Gate 2 should catch it automatically; if not, raise activation_variance_db to 4.0 or 5.0
Want to disable Gate 1 Set activation_variance_db: 0
Want to disable Gates 2 and 3 Set discard_empty_enabled: false

How to confirm it's working

Gate 1 suppression is logged at DEBUG level:

Radio channel 18 suppressed: power variance 0.4 dB below threshold 3.0 dB (likely noise)

Gate 2 suppression is logged at DEBUG level:

Radio channel 18 suppressed: audio is noise-only (spectral flatness 0.38)

Gate 3 discards are logged at INFO level:

Discarded empty recording: 2026-04-11_15-09-28_air_civil_bristol_59_125.983333MHz_6.0dB_airspyhf_0.wav

Generality

Every gate is modulation-agnostic:

Dynamics curve (experimental)

An optional noise-reduction stage, applied to each audio sample, that runs during recording, after spectral subtraction and before the soft limiter. It applies a smooth nonlinear transfer curve in dBFS:

Together the two regions widen the overall dynamic range. It works for any modulation type, has no envelope follower, and adds negligible CPU.

This is off by default and is intended for A/B comparison testing. To enable it on your installation:

recording:
    dynamics_curve_enabled: true

The curve's shape is set by the dynamics_curve settings, each described in the configuration reference: https://subsystem.co/substation/configuration/

The function operates on each audio sample (no envelope follower, no attack/release), so very aggressive parameter values can introduce mild harmonic distortion on signals near the threshold. The defaults are conservative enough that this is benign on voice; if you hear an "edge" on the loudest syllables, lower cut_db and boost_db. If a recording sounds completely silent, you have probably set floor_dbfs too high - try -60 or lower.

The function clamps its output to the ±1.0 range as belt-and-braces speaker protection. If your configuration would otherwise drive the boost region above 0 dBFS, a warning is logged at startup so you can dial it back before listening.

Parallel scans (multiple devices)

Run one process per device:

substation --band air_civil_bristol --device-type rtlsdr --device-index 0
substation --band pmr --device-type rtlsdr --device-index 1

If you need stricter real-time behaviour, you can pin each scan to a CPU core:

taskset -c 2 substation --band air_civil_bristol --device-index 0
taskset -c 3 substation --band pmr --device-index 1

Resource and performance notes

If you see repeated Sample queue full warnings, scan a narrower band at a lower sample_rate, or exclude radio channels you do not need. A larger sample_queue_maxsize absorbs bursts, such as several radio channels activating at once, but if processing falls behind all the time the queue fills whatever its size.

Limitations

Author

Written by Simon Holliday (https://simonholliday.com/)

This project is managed with Subroutine.

Licence

Substation is released under the GNU Affero General Public License v3.0 (AGPLv3).

You are free to use, modify, and distribute this software under the terms of the AGPL. If you run a modified version of Substation as part of a network service, you must make the source code available to its users.