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/
- Configuration reference: https://subsystem.co/substation/configuration/
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:
- 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.
- 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.
- 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:
- Spectral subtraction noise reduction estimates the background hiss from the quietest moments of each recording's first audio, and reduces it while preserving voice clarity. A 2D gain-mask smoothing kernel minimises musical noise artefacts.
- Carrier transient trimming (optional) detects and removes the sharp clicks that AM transmitters produce at key-on and key-off, using shape-based detection that distinguishes carrier transients from voice plosives.
- Half-cosine fades at recording boundaries prevent clicks from sudden onset or cutoff.
- Soft limiting via a tanh waveshaper rounds off peaks as they near full scale: audio up to full scale comes out at no more than 0.98 of it (-0.18 dBFS), leaving headroom for the small overshoot between audio samples that voice-band audio produces.
- Broadcast WAV metadata (BEXT, EBU Tech 3285) embeds each recording's start time, frequency, and modulation directly in the file, with any CTCSS tone or DCS code detected. Audio editors like Audacity, Reaper, and iZotope RX can place recordings on a timeline at their real capture time.
- FLAC output (optional) compresses recordings losslessly, to a size that depends on the band and the signal, with metadata stored as Vorbis comments. Its compression level was chosen by encoding real PMR recordings on a Raspberry Pi at every level: the highest levels gave almost no further reduction and cost noticeably more CPU time.
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
snr_threshold_db: 4.5sdr_gain_db: auto(engages hardware AGC, which is well-tuned for most bands)activation_variance_db: 3.0(default - leave alone unless you see false triggers)sample_rate: 2.048e6for most bands
Gotchas
- The Blog V4 needs the rtl-sdr-blog fork of librtlsdr. The standard distro
librtlsdris missing thertlsdr_set_ditheringsymbol that newer pyrtlsdr releases need, which is whypyproject.tomlholds pyrtlsdr below the release that requires it. - The default Linux DVB-T driver claims the device on insertion as a TV tuner - it must be blacklisted (INSTALL.md covers this).
- The 8-bit ADC limits dynamic range. A strong adjacent station can desensitise weak ones in the same capture.
- Manual gain values are typically 20-40 dB if you don't want AGC.
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
- Manufacturer page: https://www.rtl-sdr.com/about-rtl-sdr/
- Driver fork: https://github.com/rtlsdrblog/rtl-sdr-blog
- Python binding: https://github.com/pyrtlsdr/pyrtlsdr
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
snr_threshold_db: 6sdr_gain_db: 36(orautoto accept the LNA=32 / VGA=30 default)activation_variance_db: 3.0sample_rate: 16e6for the widest single capture; lower (2-4 MHz) for narrow bands
Gotchas
- No hardware AGC. Setting
sdr_gain_db: autodoes not enable AGC - there isn't one. The wrapper logs a warning and sets sensible defaults (LNA=32, VGA=30) so the device still works. - A numeric
sdr_gain_dbsets both the LNA and the VGA, each clamped and stepped to its own grid: asking for 35 dB sets the LNA to 32 dB (8 dB steps) and the VGA to 34 dB (2 dB steps). The startup log shows the values applied. - High sample rates (~16-20 MHz) require raising the kernel USB buffer limit; otherwise IQ samples are dropped. See INSTALL.md.
- The 8-bit ADC has the same dynamic-range caveats as the RTL-SDR - wide captures including a strong station can desensitise weak ones.
- Multiple Python bindings exist (
python_hackrf,hackrf,pyhackrf) with different APIs; the wrapper auto-detects whichever is installed.
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
- Manufacturer page: https://greatscottgadgets.com/hackrf/one/
- Python binding: https://pypi.org/project/python-hackrf/
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
snr_threshold_db: 6(the higher sensitivity makes the RTL default 4.5 dB too noisy)sdr_gain_db: autois fine to start with - see the AGC gotcha below for what it actually doesactivation_variance_db: 3.0sample_rate: 2.5e6for narrow bands,10e6for wide ones
Gotchas
- Sample rates are discrete. Asking for anything other than 2.5 MHz or 10 MHz silently snaps to the nearest supported rate and logs a warning. Always check the startup log to confirm the rate the device actually accepted.
sdr_gain_db: autois not real AGC. SoapyAirspy reportshasGainMode == Truebut the underlying R2 hardware does not provide a working closed-loop AGC. Substation detects this and falls back to a fixed manual gain ofLNA=10, MIX=5, VGA=12(27 dB total) - the same LNA-first values you would set by hand. This works well for typical PMR / VHF / UHF reception. If you want different values, setsdr_gain_db(numeric) orsdr_gain_elements(per-stage dict) explicitly in your band config.- For per-element tuning, maximise LNA first, set Mixer moderate, fine-tune with VGA (this is the LNA-first principle described in Gain tuning below). The element names and ranges are logged at DEBUG level when the device starts up, so run with
--log-level DEBUGwhen configuring a new device. - Requires a venv built with
--system-site-packages.
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
- Manufacturer page: https://airspy.com/airspy-r2/
- SoapySDR driver: https://github.com/pothosware/SoapyAirspy
- SoapySDR project: https://github.com/pothosware/SoapySDR
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
snr_threshold_db: 6(essential - the device is sensitive enough that the RTL default 4.5 dB triggers on near-noise)sdr_gain_db: auto(engages the well-tuned hardware multi-loop AGC)activation_variance_db: 3.0, the default (also essential - with the check turned off, the high sensitivity surfaces stationary noise as false radio channel activations; see Rejecting empty/noise recordings)sample_rate: 0.912e6for the widest capture
Gotchas
- Sample rates are discrete. The exact list depends on firmware - check the startup log for the rates your device actually reports. Asking for an unsupported rate silently snaps to the nearest and logs a warning.
- The RF gain element is an attenuator, not an amplifier. Negative dB.
RF: 0means no attenuation (maximum signal);RF: -24means 24 dB of attenuation. This is the opposite of every other device here. - The LNA is binary (0 or 6 dB) - there is no smooth manual control of the front end.
- CF32 IQ samples are delivered well below the [-1, 1] range that the demodulator expects. The wrapper auto-calibrates this on startup by measuring the median RMS of warmup blocks and applying a normalisation scale; you'll see an
IQ calibration: ...line in the startup log. No user action required. - Front-end overload looks like duplicate signals on adjacent radio channels. If you see them, increase RF attenuation (
RF: -24or lower). - Requires a venv built with
--system-site-packages.
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
- Manufacturer page: https://airspy.com/airspy-hf-discovery/
- SoapySDR driver: https://github.com/pothosware/SoapyAirspyHF
- SoapySDR project: https://github.com/pothosware/SoapySDR
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
- Install the SDR drivers and platform dependencies (see INSTALL.md).
- Install substation:
pip install substation # from PyPI
# or: pip install git+https://github.com/simonholliday/substation.git # latest from GitHub
- 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
- 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:
--config,-c: path to user config override file (default:config.yamlin CWD if it exists).--band,-b: band name to scan (required unless--list-bands).--device-type,-t:rtlsdr,hackrf,airspy,airspyhf, orsoapy:<driver>(defaultrtlsdr).--device-index,-i: device index (default0).--list-bands: list available bands, with each band's reception class and whether it records, and exit. A band wider than its own sample rate can capture is marked as one that cannot be scanned: narrow it, or split it into several bands, in your own configuration.--init: write the default configuration toconfig.yamlin the current directory, as a starting point, and exit.--log-level: how much to log:DEBUG,INFO,WARNING, orERROR(defaultINFO).DEBUGadds what each device reports about itself at startup, such as its gain elements, and, when a scan fails, where in Substation it failed.--iq-file: path to an IQ WAV file, with I and Q as its two audio channels in 16-bit PCM, for offline playback (replaces live SDR).--center-freq: centre frequency of the IQ recording in Hz (required with--iq-file).--start-time: start time of the recording as"YYYY-MM-DD HH:MM:SS"(default:2000-01-01 00:00:00).
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:
config.yaml.defaultships bundled inside the package and contains every setting at its default value, and all known bands. This file is always loaded first, so the scanner works out of the box with no config file at all.config.yaml(optional) is your user override file. Put it in the working directory and specify only the settings you want to change - everything else inherits from the defaults. Runsubstation --initto drop a copy of the fully-commented defaults into the current directory as a starting point (it won't overwrite an existingconfig.yaml).
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:
--band pmr --device-type rtlsdr→ uses base config (sample_rate 1.024 MHz, default gain)--band pmr --device-type airspy→ applies the override (sample_rate 2.5 MHz, per-element gain)
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:
- If you're getting recordings that are mostly noise, raise the threshold by 1-2 dB at a time, and make sure
activation_variance_dbhas not been set to 0, which turns variance rejection off - it catches the noise triggers that the SNR check can't distinguish. - If you're missing transmissions you can hear on a handheld scanner, lower the threshold.
- The OFF threshold is
snr_threshold_db - hysteresis_db(default 3 dB below ON) to prevent rapid toggling. Sethysteresis_dblower for weak-signal scanning.
General tips:
- Available gain element names and their valid ranges are logged at DEBUG level on startup. Run with
--log-level DEBUGand check these before setting values (the active values are logged at INFO once applied). - Optimal values depend on your antenna, band, and local RF environment - a rooftop antenna in a city needs different gain from a small whip in a rural area.
- Airband (AM, 118-137 MHz) typically needs less gain than PMR (NFM, 446 MHz) because aircraft transmitters are more powerful (5-25W) than PMR handhelds (0.5W).
- A transmitter close to the antenna, such as your own handheld radio, can overload the receiver at a band's shipped gain. The scanner then discards every overloaded slice rather than risk false detections, so nothing is recorded, and the log shows
ADC SATURATIONwarnings. Lowersdr_gain_dbfor that band, or move the radio further away.
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:
- Gate 1 operates on raw radio channel power from FFT bins - works for any signal type, including detection-only bands with no demodulator (TETRA)
- Gates 2 and 3 operate on spectral flatness of demodulated audio - any non-noise signal (voice, data, tones, beacons) produces a peaked spectrum that passes the check. Gate 2 applies to every band whose modulation has a demodulator, including bands that only detect (e.g. DMR, ACARS), where its demodulation is purely speculative, so radio channel activation events stay clean even when nothing is recorded. Gate 3 applies to every recording
- No demodulator-specific tuning is needed
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:
- Below the threshold (the "cut" region), quiet audio samples are progressively reduced - a downward expander that suppresses background noise. The curve is a smoothstep S-curve with zero slope at both endpoints, so there is no audible kink at the threshold or the floor. Audio samples below the floor are hard-zeroed.
- Above the threshold (the "boost" region), loud audio samples are gently boosted - an upward expander that gives voice presence. The curve is a sin² hump with zero boost at both endpoints (so 0 dBFS audio samples pass through unchanged).
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
- Sample rate dominates CPU. Large bands at high sample rates increase FFT/PSD load.
- Overrun warnings indicate the processing of a slice exceeded its real-time window. This can lead to dropped IQ blocks (
Sample queue full). - Noise reduction runs during write/flush if enabled (default). It uses
apply_spectral_subtraction, which is efficient, and estimates the noise once per recording, from the quietest frames of the first audio written. The alternativeapply_noisereduceimplementation exists insubstation/dsp/noise_reduction.pyfor reference but is not used by default as it is significantly more CPU-intensive. It needs thenoisereducelibrary, installed withpip install "substation[noisereduce]". - Queue size provides burst tolerance but uses memory: each queued slice holds every IQ sample in it.
- RTL-SDR USB buffers: librtlsdr keeps 15 USB transfers of one slice each in flight, and Linux allows 16 MB of USB transfers by default. A slice of more than about 559,000 IQ samples, about 233 ms at 2.4 MHz once rounded up to whole blocks, therefore fails to stream with
Failed to submit transferuntil that limit is raised (see INSTALL.md).
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
- Processing time grows with a band's sample rate, and most of it runs on one CPU core. The three shipped bands at 12.5 MHz,
air_civil_1,air_civil_2, anddmr, have not yet been shown to keep up in real time: on the one desktop computer they have been tested on, processing fell behind and IQ samples were dropped, and faster computers are still to be tested. On a Raspberry Pi, or whereverProcessing overrunwarnings appear, scan a narrower band, such as one ofdmr_1todmr_5. - CTCSS and DCS 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.
- If you enable
apply_noisereduce(requires a code change and thenoisereduceextra), it is CPU-intensive for long chunks; on constrained devices, stick with the defaultapply_spectral_subtractionor reducedisk_flush_interval_seconds.
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.