Metadata-Version: 2.4
Name: substation
Version: 0.6.0
Summary: An SDR band scanner for RTL-SDR, HackRF, AirSpy, and SoapySDR devices
Author-email: Simon Holliday <simon.holliday@protonmail.com>
License-Expression: AGPL-3.0-or-later
Project-URL: Documentation, https://subsystem.co/substation/
Project-URL: Repository, https://github.com/simonholliday/substation
Project-URL: Issues, https://github.com/simonholliday/substation/issues
Keywords: sdr,radio,scanner,rtl-sdr,hackrf,airspy,soapysdr
Classifier: Programming Language :: Python :: 3
Classifier: Operating System :: POSIX :: Linux
Classifier: Topic :: Communications :: Ham Radio
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: numpy>=1.24.0
Requires-Dist: pydantic>=2.7
Requires-Dist: pyrtlsdr<0.4.0,>=0.3.0
Requires-Dist: PyYAML>=6.0
Requires-Dist: scipy>=1.10.0
Requires-Dist: soundfile>=0.12.1
Requires-Dist: mutagen>=1.45
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == "dev"
Requires-Dist: mypy<1.18,>=1.0; extra == "dev"
Requires-Dist: types-PyYAML; extra == "dev"
Provides-Extra: osc
Requires-Dist: python-osc>=1.8; extra == "osc"
Provides-Extra: noisereduce
Requires-Dist: noisereduce>=3.0.3; extra == "noisereduce"
Provides-Extra: hackrf
Requires-Dist: python_hackrf; extra == "hackrf"
Dynamic: license-file

# 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](#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](#limitations).

**Full documentation: [https://subsystem.co/substation/](https://subsystem.co/substation/)**

- Configuration reference: [https://subsystem.co/substation/configuration/](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:

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

### Demodulation

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

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

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

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

### Recording quality

Each recording passes through several stages between demodulation and disk:

- **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](#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](https://github.com/simonholliday/substation/blob/main/INSTALL.md#1-rtl-sdr-blog-v4-driver) for the librtlsdr fork build and the DVB-T driver blacklist step.

**Recommended starting config**
- `snr_threshold_db: 4.5`
- `sdr_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.048e6` for most bands

**Gotchas**
- The Blog V4 needs the [rtl-sdr-blog fork](https://github.com/rtlsdrblog/rtl-sdr-blog) of librtlsdr. The standard distro `librtlsdr` is missing the `rtlsdr_set_dithering` symbol that newer pyrtlsdr releases need, which is why `pyproject.toml` holds 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:

```yaml
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/](https://www.rtl-sdr.com/about-rtl-sdr/)
- Driver fork: [https://github.com/rtlsdrblog/rtl-sdr-blog](https://github.com/rtlsdrblog/rtl-sdr-blog)
- Python binding: [https://github.com/pyrtlsdr/pyrtlsdr](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](https://github.com/simonholliday/substation/blob/main/INSTALL.md#3-os-dependencies)). See [INSTALL.md](https://github.com/simonholliday/substation/blob/main/INSTALL.md#2-system-optimisation-usb-buffering) for the USB buffer tuning (`usbcore.usbfs_memory_mb=1000`).

**Recommended starting config**
- `snr_threshold_db: 6`
- `sdr_gain_db: 36` (or `auto` to accept the LNA=32 / VGA=30 default)
- `activation_variance_db: 3.0`
- `sample_rate: 16e6` for the widest single capture; lower (2-4 MHz) for narrow bands

**Gotchas**
- **No hardware AGC.** Setting `sdr_gain_db: auto` does 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_db` sets 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](https://github.com/simonholliday/substation/blob/main/INSTALL.md#2-system-optimisation-usb-buffering).
- 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**

```yaml
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](#limitations).

**References**
- Manufacturer page: [https://greatscottgadgets.com/hackrf/one/](https://greatscottgadgets.com/hackrf/one/)
- Python binding: [https://pypi.org/project/python-hackrf/](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](https://github.com/simonholliday/substation/blob/main/INSTALL.md#4-soapysdr--airspy-support) 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: auto` is fine to start with - see the AGC gotcha below for what it actually does
- `activation_variance_db: 3.0`
- `sample_rate: 2.5e6` for narrow bands, `10e6` for 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: auto` is not real AGC.** SoapyAirspy reports `hasGainMode == True` but the underlying R2 hardware does not provide a working closed-loop AGC. Substation detects this and falls back to a fixed manual gain of `LNA=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, set `sdr_gain_db` (numeric) or `sdr_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](#gain-tuning) below). The element names and ranges are logged at DEBUG level when the device starts up, so run with `--log-level DEBUG` when configuring a new device.
- Requires a venv built with `--system-site-packages`.

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

```yaml
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:

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

**References**
- Manufacturer page: [https://airspy.com/airspy-r2/](https://airspy.com/airspy-r2/)
- SoapySDR driver: [https://github.com/pothosware/SoapyAirspy](https://github.com/pothosware/SoapyAirspy)
- SoapySDR project: [https://github.com/pothosware/SoapySDR](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](https://github.com/simonholliday/substation/blob/main/INSTALL.md#4-soapysdr--airspy-support). 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](#rejecting-emptynoise-recordings))
- `sample_rate: 0.912e6` for 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: 0` means *no* attenuation (maximum signal); `RF: -24` means 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: -24` or 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:

```yaml
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:

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

**References**
- Manufacturer page: [https://airspy.com/airspy-hf-discovery/](https://airspy.com/airspy-hf-discovery/)
- SoapySDR driver: [https://github.com/pothosware/SoapyAirspyHF](https://github.com/pothosware/SoapyAirspyHF)
- SoapySDR project: [https://github.com/pothosware/SoapySDR](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:

```bash
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](https://github.com/pothosware/SoapySDR)

## Quick start
1) Install the SDR drivers and platform dependencies (see [INSTALL.md](https://github.com/simonholliday/substation/blob/main/INSTALL.md)).
2) Install substation:
```bash
pip install substation                                    # from PyPI
# or: pip install git+https://github.com/simonholliday/substation.git   # latest from GitHub
```
3) Create a starter config in your working directory (optional: it runs on the built-in defaults without one):
```bash
substation --init          # writes ./config.yaml, the fully-commented defaults
```
4) Run:

```bash
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`:

```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:

```bash
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
```bash
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.yaml` in CWD if it exists).
- `--band`, `-b`: band name to scan (required unless `--list-bands`).
- `--device-type`, `-t`: `rtlsdr`, `hackrf`, `airspy`, `airspyhf`, or `soapy:<driver>` (default `rtlsdr`).
- `--device-index`, `-i`: device index (default `0`).
- `--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 to `config.yaml` in the current directory, as a starting point, and exit.
- `--log-level`: how much to log: `DEBUG`, `INFO`, `WARNING`, or `ERROR` (default `INFO`). `DEBUG` adds 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.

```python
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](https://github.com/simonholliday/substation/blob/main/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:

```bash
pip install "substation[osc]"
```

Then attach an `OscEventSender` to any `RadioScanner` instance:

```python
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](#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](https://github.com/simonholliday/substation/blob/main/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.

```bash
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.default`** ships 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. Run `substation --init` to drop a copy of the fully-commented defaults into the current directory as a starting point (it won't overwrite an existing `config.yaml`).

For example, to override only the audio output directory:
```yaml
recording:
  audio_output_dir: /mnt/ssd/audio
```

To override a single field in a specific band:
```yaml
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](#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/](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.

```yaml
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:

```yaml
# 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](https://github.com/simonholliday/substation/blob/main/INSTALL.md#4-soapysdr--airspy-support).

## 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_db`](#rejecting-emptynoise-recordings) has 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. Set `hysteresis_db` lower 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 DEBUG` and 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 SATURATION` warnings. Lower `sdr_gain_db` for 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/](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:

```yaml
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/](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:

```bash
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:

```bash
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 alternative `apply_noisereduce` implementation exists in `substation/dsp/noise_reduction.py` for reference but is not used by default as it is significantly more CPU-intensive. It needs the `noisereduce` library, installed with `pip 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 transfer` until that limit is raised (see [INSTALL.md](https://github.com/simonholliday/substation/blob/main/INSTALL.md#2-system-optimisation-usb-buffering)).

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`, and `dmr`, 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 wherever `Processing overrun` warnings appear, scan a narrower band, such as one of `dmr_1` to `dmr_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 the `noisereduce` extra), it is CPU-intensive for long chunks; on constrained devices, stick with the default `apply_spectral_subtraction` or reduce `disk_flush_interval_seconds`.

## Author
Written by Simon Holliday ([https://simonholliday.com/](https://simonholliday.com/))

This project is managed with [Subroutine](https://github.com/simonholliday/subroutine).

## Licence

Substation is released under the [GNU Affero General Public License v3.0](https://github.com/simonholliday/substation/blob/main/LICENSE) (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.
