Substation is an SDR band scanner that detects, demodulates, and records radio transmissions automatically. Connect a USB SDR receiver, point it at a frequency band - Airband, PMR, Maritime, Amateur, or any conventional analogue band - and Substation will monitor every channel simultaneously, recording each transmission to its own audio file with full metadata.
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. Three independent noise rejection stages ensure you get real transmissions, not hiss. Recordings include embedded metadata - frequency, timestamp, modulation, and detected CTCSS/DCS tone codes - so every file is self-documenting.
Substation runs comfortably on a Raspberry Pi for 24/7 monitoring, and works equally well as a command-line tool or as a Python module integrated into your own applications.
- Signal Processing
- Supported Devices
- Quick Start
- Utility scripts
- Command Line
- Python Module Usage
- Configuration
- SoapySDR Installation
- Recording Metadata
- Gain Tuning
- Rejecting Empty/Noise Recordings
- Dynamics Curve (Experimental)
- Parallel Scans
- Resource and Performance Notes
- Limitations
- Author
- License
Substation's signal processing chain implements industry-standard DSP techniques - the same algorithms used in professional SDR receivers - in Python with NumPy and SciPy for accessibility without sacrificing quality.
The scanner divides the SDR's bandwidth into channels and analyses each one five times per 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 center frequency is automatically shifted by half a channel spacing whenever a channel would fall on the DC spike - a common SDR artifact caused by LO leakage - so no channel is ever masked.
High-sensitivity receivers often trigger on noise that crosses the SNR threshold. Substation applies three independent rejection stages to eliminate these false recordings:
- RF power variance - real signals (voice, data) fluctuate in power across the detection window; stationary noise does not. Channels with low variance are rejected before any demodulation occurs.
- Spectral flatness - when a 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 check - after a recording finishes, the complete file is analysed for spectral flatness. Recordings that are predominantly noise (e.g. a brief signal followed by hold-timer padding) are discarded.
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 USB sample-drop glitches from 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 removes subaudible signalling tones from the recording while the Goertzel-based detector identifies them and embeds the detected tone code in the file's metadata.
AM - used for civil and military airband - uses envelope detection with a smooth vectorised AGC (independent attack and release timings) that adapts to varying signal strength without pumping artifacts.
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.
Recordings are not just raw demodulated audio dumped to disk. Each file passes through several stages designed to produce clean, ready-to-use output:
- Spectral subtraction noise reduction, guided by the band-wide noise floor estimate, reduces background hiss while preserving voice clarity. A 2D gain-mask smoothing kernel minimises musical noise artifacts.
- 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 with a 0.98 ceiling (-0.18 dBTP) prevents inter-sample true-peak overshoot, ensuring recordings never exceed 0 dBTP.
- Broadcast WAV metadata (BEXT, EBU Tech 3285) embeds sample-accurate timestamps, frequency, modulation, and detected CTCSS/DCS codes directly in each file. Audio editors like Audacity, Reaper, and iZotope RX can place recordings on a timeline at their real capture time.
- FLAC output (optional) provides lossless compression with ~39% storage saving, with metadata stored as Vorbis comments.
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 per-channel analysis (segment PSD, demodulation) is performed lazily - only when a state transition is detected. Per-channel audio buffering 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.
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.
| 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, 60 - 260 MHz | 768 kHz | 18-bit | HF / VHF precision |
Any other device with a SoapySDR driver module installed can be used too - see Other SoapySDR Devices below.
A high-quality, low-cost general-purpose receiver. The natural starting point for new users - well-supported, easy to drive, 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 (>=0.3.0,<0.4.0) - Python binding |
--device-type |
rtl, rtlsdr, rtl-sdr |
| Best for | General VHF/UHF scanning, low cost, easy setup |
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 versions need; this is why the project pinspyrtlsdr<0.4.0. - 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
air_civil_bristol:
type: AIR
freq_start: 125.5e+6
freq_end: 126.0e+6
sample_rate: 1.024e6References
- 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
A wideband transceiver covering 1 MHz to 6 GHz with up to 20 MHz of instantaneous bandwidth - by far 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 - see INSTALL.md for the USB buffer tuning (usbcore.usbfs_memory_mb=1000) and INSTALL.md for the libhackrf-dev system package.
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_dbis silently clamped and stepped to the LNA's 8 dB grid and the VGA's 2 dB grid. Asking for 35 dB gets you 32. Check the startup log if the actual values matter. - High sample rates (~16-20 MHz) require raising the kernel USB buffer limit; otherwise samples will be 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+6References
- Manufacturer page: https://greatscottgadgets.com/hackrf/one/
- Python binding: https://pypi.org/project/python-hackrf/
A high-dynamic-range VHF/UHF receiver with a 12-bit ADC (≈16-bit effective from oversampling) and three independently tuneable gain stages. Considerably more sensitive than the RTL-SDR for the same money tier, with enough bandwidth (10 MHz) to cover practical surveillance bands 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 INFO level when the device starts up.
- 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: 12Run with:
substation --band pmr_airspy --device-type airspy --device-index 0References
- Manufacturer page: https://airspy.com/airspy-r2/
- SoapySDR driver: https://github.com/pothosware/SoapyAirspy
- SoapySDR project: https://github.com/pothosware/SoapySDR
A precision HF and lower-VHF receiver. Exceptional sensitivity and dynamic range in its bands; not a wideband scanner - its maximum bandwidth is 768 kHz. Best in class for HF listening, weak-signal work, and narrow-band airband / amateur scanning.
| Spec | Value |
|---|---|
| Frequency range | 0.5 kHz - 31 MHz, 60 - 260 MHz (two separate bands, not contiguous) |
| Max bandwidth | 768 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(also essential - without it the high sensitivity surfaces stationary noise as false 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 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 channels. If you see them, increase RF attenuation (
RF: -24or lower). - Requires a venv built with
--system-site-packages.
Working example band - Bristol airband:
air_civil_bristol_airspyhf:
type: AIR
freq_start: 125.5e+6
freq_end: 126.0e+6
sample_rate: 0.912e6
snr_threshold_db: 6
sdr_gain_db: auto
activation_variance_db: 3.0Run with:
substation --band air_civil_bristol_airspyhf --device-type airspyhf --device-index 0References
- Manufacturer page: https://airspy.com/airspy-hf-discovery/
- SoapySDR driver: https://github.com/pothosware/SoapyAirspyHF
- SoapySDR project: https://github.com/pothosware/SoapySDR
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 --findThe same sdr_gain_db, sdr_gain_elements, and sdr_device_settings config keys apply, and the wrapper's startup log will show the available gain elements, sample rates, antennas, and device-specific settings reported by the driver - use these to guide your configuration in the same way as the AirSpy cards above.
Reference: SoapySDR project
- Install dependencies (see INSTALL.md for SDR drivers and platform-specific setup).
- Install package in editable mode:
pip install -e .- Run (works out of the box with the default configuration):
substation --band air_civil_bristol --device-type rtlsdr --device-index 0Audio files are written to:
./audio/YYYY-MM-DD/<band>/<timestamp>_<band>_<channel>_<snr>dB_<device>_<index>.wav
Substation ships with a small scripts/ directory of one-shot user utilities. These are not part of the main scanner - they're tools that read the config or work with frequencies, and are run with python -m scripts.<name>.
Calculate optimal antenna lengths (half-wave dipole, quarter-wave vertical, 5/8-wave vertical, full-wave loop) for any configured band or any frequency:
python -m scripts.antenna --band hf_night_4mhz # use a configured band's centre frequency
python -m scripts.antenna --freq 4625e3 # use a manual frequency in Hz
python -m scripts.antenna --list # list all configured bandsFor 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 are reported in metres for HF/VHF and centimetres for UHF.
substation --band <band> [--config <path>] [--device-type rtlsdr|hackrf|airspy|airspyhf|soapy:<driver>] [--device-index N]
substation --list-bandsYou 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 finalized and closed
def my_recording_handler (band: str, ch: int, file_path: str) -> None:
print (f"Recording finished: {file_path}")
async def main () -> None:
"""
Initialize the scanner and respond to real-time events.
"""
# Load configuration
config_data = substation.config.load_config ()
# Initialize scanner instance
scanner = substation.scanner.RadioScanner (
config=config_data,
band_name="pmr",
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 ())See examples/scan_demo.py for a more detailed implementation.
Substation can forward 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 dependency:
pip install -e ".[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 |
Channel turns ON or OFF | band_name:str, channel_index:int, is_active:int(0/1), snr_db:float |
/radio/recording |
Recording finalised on disk | band_name:str, channel_index:int, file_path:str |
/sample/import |
Recording finalised (only if sampler_host set) |
file_path:str |
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.
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 and exit.--iq-file: path to a 2-channel IQ WAV file for offline playback (replaces live SDR).--center-freq: center 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).
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 2 channels (I and Q) at any sample rate. The center 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 center frequency doesn't need to match the band midpoint exactly.
Substation uses a two-layer configuration system:
config.yaml.defaultships with the package and contains all known bands and sensible defaults. This file is always loaded first.config.yaml(optional) is your user override file. Create it in the working directory and specify only the settings you want to change - everything else inherits from the defaults.
For example, to override just the audio output directory:
recording:
audio_output_dir: /mnt/ssd/audioTo override a single field in a specific band:
bands:
pmr:
snr_threshold_db: 6.0Use --config <path> to specify a different user override file. Use --list-bands to see all available bands.
The top-level keys are scanner, recording, band_defaults, and bands.
Scanner
scanner:
sdr_device_sample_size: 131072
band_time_slice_ms: 200
sample_queue_maxsize: 30
calibration_frequency_hz: 93.7e+6
stuck_channel_threshold_seconds: 60
sdr_device_sample_size: number of IQ samples per SDR callback. Higher values reduce callback overhead but increase latency.band_time_slice_ms: time slice used for PSD/SNR detection. Must be a multiple ofsdr_device_sample_size(rounded up internally).sample_queue_maxsize: async queue depth. 10-50 is typical; higher tolerates bursts but uses more RAM.calibration_frequency_hz: optional known signal for PPM correction; set tonullto disable.stuck_channel_threshold_seconds: optional duration in seconds after which a constant signal will trigger a "Stuck Channel" warning. Useful for identifying interference or stuck transmitters. Set tonullto disable.
Recording
recording:
buffer_size_seconds: 30
disk_flush_interval_seconds: 5
audio_sample_rate: 16000
audio_format: wav
audio_output_dir: "./audio"
fade_in_ms: 15
fade_out_ms: 50
soft_limit_drive: 1.25
buffer_size_seconds: max in-memory audio per channel before drops.disk_flush_interval_seconds: how often to flush to disk.audio_sample_rate: output rate (Hz).audio_format:wav(default) orflac. WAV embeds Broadcast WAV (BEXT) metadata with sample-accurate timestamps for timeline placement in audio editors. FLAC is lossless compressed (~39% smaller) with text-based metadata tags (no timeline positioning support).fade_in_ms/fade_out_ms: half-cosine fades applied to the padding region at channel start/stop (signal content is never attenuated).soft_limit_drive: post-processing soft limiter drive. Typical range 1.5-3.0 (higher = stronger limiting).noise_reduction_enabled: toggle spectral subtraction noise reduction (default: true).recording_hold_time_ms: duration in ms to continue recording after signal drops below threshold (default: 500).discard_empty_enabled: automatically discard noise-only recordings using spectral flatness analysis (default: true). Applies at two points: before activation (rejects noise triggers without starting a recording) and after recording close (catches recordings that became mostly noise). See Rejecting empty/noise recordings.min_recording_seconds: discard recordings shorter than this duration (default: 0.5). Catches brief transients (radar pulses, ignition noise) that pass the spectral checks but produce useless sub-second files. Set to0to disable.audio_silence_timeout_ms: stop recording when demodulated audio has been silent for this duration (default: 3000). Catches AM carriers that persist after voice stops, where RF SNR stays above threshold but there is no useful content. Set to0to disable and rely on RF-only detection.trim_carrier_transients: remove the sharp key-on/key-off click transients that AM transmitters produce (default: false). Only trims transients bordered by silence - voice transients (consonants) are never affected. Recommended for AM airband listening.
Band Defaults
band_defaults:
AIR:
channel_spacing: 8.333e+3
modulation: AM
snr_threshold_db: 4.5
sdr_gain_db: 30
These settings are merged into each band of the same type.
Bands
bands:
air_civil_bristol:
type: AIR
freq_start: 125.5e+6
freq_end: 126.0e+6
sample_rate: 1.0e+6
exclude_channel_indices: [33, 34]
Per-band keys:
freq_start/freq_end: Hz.channel_spacing: Hz.sample_rate: Hz. Must cover the band plus margins; higher rates increase CPU.channel_width: optional; defaults tochannel_spacing * 0.84.type: used to inherit defaults fromband_defaults.modulation:AM,NFM,USB, orLSB. USB/LSB use a Weaver-method SSB demodulator and are the right choice for HF voice - amateur convention is LSB below 10 MHz, USB above 10 MHz; HFGCS, VOLMET, and marine HF are all USB.recording_enabled: enable recording for this band. Optional, defaults tofalse(can also be set inband_defaults).snr_threshold_db: detection threshold (dB above noise floor).hysteresis_db: margin between ON and OFF thresholds (default 3.0). Channel turns OFF when SNR drops belowsnr_threshold_db - hysteresis_db. Lower values (e.g. 1.5) suit weak-signal scanning.activation_variance_db: optional minimum power variance (dB) across the detection window required for a channel to be considered active. Filters out stationary-noise triggers. Applies to all bands regardless of recording state. See Rejecting empty/noise recordings below. Defaults to3.0; set to0to disable.sdr_gain_db: numeric orauto.sdr_gain_elements: optional dict mapping gain element names to dB values for per-stage control (e.g.,{LNA: 10, MIX: 5, VGA: 12}). Available elements are logged at startup. Takes priority oversdr_gain_db.sdr_device_settings: optional dict of device-specific settings passed via SoapySDR (e.g.,{biastee: "true"}). Available settings are logged at DEBUG level on startup.exclude_channel_indices: 1-based channel numbers to skip (no analysis, no recording). These match the channel numbers shown in log output and filenames.device_overrides: per-device tuning - see Device-Specific Overrides below.
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: 12With 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 are canonical device family names:
--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) |
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}AirSpy devices (and any other soapy:<driver> device) require SoapySDR, which is installed at the system level:
# Raspberry Pi OS / Debian
sudo apt install -y soapysdr-tools python3-soapysdr
sudo apt install -y soapysdr-module-airspy # AirSpy R2
sudo apt install -y soapysdr-module-airspyhf # AirSpy HF+ Discovery
# If soapysdr-module-airspyhf is not in your distro's repos (e.g., Raspberry Pi OS),
# build from source instead:
sudo apt install -y libairspyhf-dev libsoapysdr-dev cmake
git clone https://github.com/pothosware/SoapyAirspyHF.git
cd SoapyAirspyHF && mkdir build && cd build
cmake .. && make && sudo make install && cd ../..
# Verify SoapySDR can see connected devices
SoapySDRUtil --findThe Python virtual environment must be created with --system-site-packages to access the system-installed SoapySDR bindings:
python3 -m venv --system-site-packages /home/si/venvs/substationEach recording embeds metadata directly in the audio file.
WAV format (default): Industry-standard Broadcast WAV (BWF/BEXT, EBU Tech 3285) with sample-accurate timestamps. 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 ~39% smaller than WAV but cannot carry the sample-accurate time_reference used for timeline placement in audio editors.
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":0,"channel_freq":446006250.0} |
Machine-readable JSON with 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 |
Sample count since midnight (for precise timing) |
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 enable
activation_variance_dbif you haven't already - variance rejection 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 INFO level on startup. Check these before setting values.
- 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).
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 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 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 scanner applies three independent gates, each catching a different kind of false positive. All three are modulation-agnostic - they work for voice, data, tones, beacons, and any future modulation type.
Real signals fluctuate over time: syllables, frame structure, burst patterns 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 channel turns ON, the scanner measures the standard deviation of the channel's 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 (~0.1 ms, reuses already-computed PSD data). It catches broadband stationary noise that happens to sit a few dB above the noise floor.
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, providing robust separation without per-modulation tuning.
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 is more expensive (~10-20 ms, requires demodulation + FFT) so it only runs after Gate 1 passes. Controlled by discard_empty_enabled (default: true).
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.
| Gate | Domain | When | What it catches | Cost |
|---|---|---|---|---|
| 1. Variance | RF PSD | Turn-ON | Broadband stationary noise | ~0.1 ms |
| 2. Flatness (preview) | Demodulated audio | Turn-ON | Narrowband noise that passes Gate 1 | ~10-20 ms |
| 3a. Min duration | Recording metadata | Turn-OFF | Brief transients (radar, ignition) that pass spectral checks | ~0 ms |
| 3b. Flatness (whole file) | Demodulated audio | Turn-OFF | Recordings that started real but became mostly noise | ~10-20 ms |
Imagine a "noisy" 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.
recording:
discard_empty_enabled: true # Gates 2 and 3 (default: true)
bands:
air_civil_bristol_airspyhf:
type: AIR
freq_start: 125.5e+6
freq_end: 126.0e+6
sample_rate: 0.912e6
snr_threshold_db: 6
activation_variance_db: 3.0 # Gate 1 threshold (default: 3.0)
sdr_gain_db: auto| Setting | Relationship |
|---|---|
snr_threshold_db |
Runs first. 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, default 3.0) |
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. |
All three gates suppress silently - no ON callback fires, no recording file is kept. Downstream consumers (OSC bridge, user scripts) only see activations and recordings that passed all applicable gates.
| 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 |
Gate 1 suppression is logged at DEBUG level:
Channel 18 suppressed: power variance 0.4 dB below threshold 3.0 dB (likely noise)
Gate 2 suppression is logged at DEBUG level:
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_airspyhf_59_6.0dB.wav
All three gates are modulation-agnostic:
- Gate 1 operates on raw channel power from FFT bins - works for any signal type
- 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
- No demodulator-specific tuning is needed
An optional per-sample noise-reduction stage 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 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. Samples below the floor are hard-zeroed.
- Above the threshold (the "boost" region), loud 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 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
dynamics_curve:
threshold_dbfs: -25.0 # Dividing line between cut and boost regions
cut_db: 6.0 # Reduction at the midpoint of the cut S-curve (max = 2× at floor)
boost_db: 1.5 # Peak boost in the boost hump
floor_dbfs: -60.0 # Hard silence below this level
cut_curve: 0.5 # 0..1; 0.5 = symmetric, <0.5 steeper near threshold
boost_curve: 0.5 # 0..1; same skew control for the boost humpThe function operates per-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.
Run one process per device:
substation --band air_civil_bristol --device-type rtlsdr --device-index 0
substation --band pmr --device-type rtlsdr --device-index 1If you need stricter real-time behavior, 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- 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_subtractionwhich is efficient and receives the band-wide noise floor for improved frame classification. The alternativeapply_noisereduceimplementation exists insubstation/dsp/noise_reduction.pyfor reference but is not used by default as it is significantly more CPU-intensive. - Queue size provides burst tolerance but uses RAM (each slice can be several MB).
If you see repeated Sample queue full warnings, reduce the band's sample_rate, exclude channels, or increase sample_queue_maxsize.
- Processing is slice-based; extremely wide bands or multiple high-rate scans can exceed real-time capacity on low-power CPUs.
- If you enable
apply_noisereduce(requires code change), it is CPU-intensive for long chunks; on constrained devices, stick with the defaultapply_spectral_subtractionor reducedisk_flush_interval_seconds.
- Supervisor dashboard (in progress) — a real-time browser dashboard that displays scanner state (active channels, SNR levels, noise floor, recordings) via WebSocket. The scanner emits structured events which the Supervisor server relays to connected clients. Install with
pip install -e ".[supervisor]"and enable inconfig.yaml.
Written by Simon Holliday (https://simonholliday.com/)
This project is licensed under the GNU Affero General Public License v3.0 (AGPL-3.0).
- Copyleft: Any modifications or improvements to this software must be shared back under the same license, even if used over a network.
- Attribution: You must give appropriate credit to the original author (Simon Holliday).
- Commercial Use: Permitted, provided you comply with the copyleft obligations of the AGPL-3.0.
See the LICENSE file for the full legal text.