Sweeps any set of frequency ranges, records what it finds, and works out what kind of signal it was. - Frequency ranges entered by hand or picked from a 135-entry US band plan, including whole-band and all-CW sweeps that resolve the demodulator per segment. - Detection calibrated against the peak-hold detector's own noise statistics, so the threshold means real margin over static rather than over the floor. - A content gate: captures are kept only if they carry voice, decodable CW, or an identified digital keying scheme. Speech is recognised by a pitch track that drifts, which static cannot imitate. - Identification of NFM/WFM/AM/SSB, CW with Morse decoded to text, P25, DMR, NXDN, D-STAR, POCSAG, FLEX, ACARS, AIS, APRS, n-FSK and n-PSK. - Gapless streaming capture, with the signal path fast enough to keep up in real time, so recordings play back at the right speed. - Optional one-file-per-frequency recording with spoken timestamps, and speech-to-text transcription. - Menus and command line generated from one settings table, so neither can offer something the other cannot; settings persist in ~/.config. 367 tests, run against synthetic signals, a built-in receiver simulator, and real hardware. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
723 lines
31 KiB
Markdown
723 lines
31 KiB
Markdown
# bandsaunter
|
|
|
|
A signal scanner, recorder and identifier for RTL-SDR receivers.
|
|
|
|
Give it any number of frequency ranges — typed in by hand or picked from a
|
|
built-in US band plan — and it sweeps them, stops on anything above the noise
|
|
floor, records it, and works out what kind of signal it was. CW/Morse is
|
|
decoded to text.
|
|
|
|
```
|
|
╭──────────────────────────────── receiver ────────────────────────────────╮
|
|
│ Rafael Micro R820T/R820T2 2.048 MS/s gain auto +0 ppm │
|
|
╰──────────────────────────────────────────────────────────────────────────╯
|
|
╭───────────────────────────────── sweep ──────────────────────────────────╮
|
|
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ step 4/6 146 MHz - 146.666667 MHz │
|
|
│ ▆ █ ▄▄▄ peak -18.0 dBFS │
|
|
│ recording cycle 2 hits 3 dropped 0 detections 7 up 0:04 │
|
|
╰──────────────────────────────────────────────────────────────────────────╯
|
|
╭──────────────────────────────────────────────────────────────────────────╮
|
|
│ REC 146.52 MHz [nfm] ███████████░░░░░░░░░ 11.4/30s SIGNAL SNR 27 dB │
|
|
╰──────────────────────────────────────────────────────────────────────────╯
|
|
╭───────────────────────────── recorded signals ───────────────────────────╮
|
|
│ 19:38:43 460.025 MHz 8.2s 27.0 P25 Phase 1 C4FM digital voice │
|
|
│ 19:38:43 144.1 MHz 8.2s 27.0 CW / Morse at 18 WPM "VVV DE…" │
|
|
│ 19:38:43 146.52 MHz 8.2s 27.0 Narrowband FM voice (CTCSS 100) │
|
|
╰──────────────────────────────────────────────────────────────────────────╯
|
|
```
|
|
|
|
## Install
|
|
|
|
```bash
|
|
sudo apt install rtl-sdr librtlsdr0 espeak-ng # Debian/Ubuntu
|
|
pip install -e .
|
|
```
|
|
|
|
### Dependencies
|
|
|
|
| | Package | Needed for |
|
|
|---|---|---|
|
|
| **required** | `librtlsdr0` (`rtl-sdr`) | talking to the dongle at all |
|
|
| **required** | `numpy`, `scipy` | all signal processing |
|
|
| **required** | `rich` | the menus and the live display |
|
|
| **required** | `PyYAML` | the settings file and profiles |
|
|
| *recommended* | `espeak-ng` | clearer spoken timestamps |
|
|
| *optional* | `faster-whisper` (pip) | transcribing speech to text |
|
|
|
|
`espeak-ng` is a **recommendation, not a requirement**: without it the spoken
|
|
timestamps are produced by a built-in formant synthesiser, so the feature
|
|
works on a machine with nothing else installed. With it they are clearer and
|
|
render about three times faster. Any of `espeak`, `pico2wave`, `flite` or
|
|
macOS `say` is used if found instead.
|
|
|
|
For a Debian package that maps to `Depends: librtlsdr0, python3-numpy,
|
|
python3-scipy, python3-rich, python3-yaml`, `Recommends: espeak-ng,
|
|
rtl-sdr` and `Suggests: python3-vosk` — transcription is off by default and
|
|
the scan reports it plainly when no recogniser is installed. `rtl-sdr` itself only supplies command-line tools bandsaunter does not
|
|
call — the library is what matters.
|
|
|
|
`bandsaunter scan --combine --dry-run` reports which engine will be used.
|
|
|
|
If the dongle is not accessible, the DVB-T kernel driver has usually claimed
|
|
it:
|
|
|
|
```bash
|
|
echo 'blacklist dvb_usb_rtl28xxu' | sudo tee /etc/modprobe.d/blacklist-rtl.conf
|
|
sudo rmmod dvb_usb_rtl28xxu
|
|
```
|
|
|
|
Check with `bandsaunter devices --test`.
|
|
|
|
## Quick start
|
|
|
|
```bash
|
|
bandsaunter # the menus: set up and scan
|
|
bandsaunter scan -b 2m -b marine-vhf # band-plan presets
|
|
bandsaunter scan -r 144M-148M -r 420M-450M # your own ranges
|
|
bandsaunter scan -b 2m --simulate # try it without hardware
|
|
```
|
|
|
|
## Two ways to drive it
|
|
|
|
Everything is available both ways. Run `bandsaunter` with no arguments for the
|
|
menus, or pass flags for scripting — the two are generated from the same
|
|
table of settings, so neither can offer something the other cannot.
|
|
|
|
```
|
|
1 Frequency ranges 3 configured
|
|
2 Band plan 107 US presets
|
|
3 Settings record no limit, hang 6s, squelch +12 dB, keep voice, cw
|
|
4 Saved settings and profiles
|
|
h Help
|
|
s Start scanning
|
|
q Quit
|
|
```
|
|
|
|
Settings are grouped, show their current value against the built-in default,
|
|
and carry their own help:
|
|
|
|
```
|
|
# setting value what it does
|
|
1 Record for * no limit longest one signal may hold the receiver
|
|
2 Wait for quiet * 6 s quiet time before the sweep resumes
|
|
3 Absolute limit 900 s ceiling on one capture, even when 'Record for' is 0
|
|
* differs from the built-in default
|
|
|
|
Number to change it, ?N for help on one, d to reset the group, blank to go back.
|
|
```
|
|
|
|
`?2` explains a setting in full, including the command-line flag that does the
|
|
same thing. Typing a search term instead of a number finds settings by any
|
|
word in their name or description — `voice score` finds the speech threshold.
|
|
|
|
Values may be typed with their units: `5 s`, `2.048 MHz`, `12 dB`, `48k`, or
|
|
`no limit` for the settings that accept 0.
|
|
|
|
## Settings that persist
|
|
|
|
Settings are saved to `~/.config/bandsaunter/config.yaml` and picked up by every
|
|
later run. Save them from the menus (**4 → s**) or from the command line:
|
|
|
|
```bash
|
|
bandsaunter config # open the settings menu
|
|
bandsaunter config hang_seconds=6 record_seconds=0 # set and save directly
|
|
bandsaunter config --show # every setting, with defaults
|
|
bandsaunter config --describe hang_seconds # explain one in full
|
|
bandsaunter config --path # where the file lives
|
|
bandsaunter config --reset # back to defaults
|
|
```
|
|
|
|
Three layers apply in order, each overriding the last:
|
|
|
|
1. the saved settings file
|
|
2. a named profile, if `--profile` is given
|
|
3. any flags on the command line
|
|
|
|
So a saved squelch of 12 dB stays in force while `--hang 1.5` overrides just
|
|
the hang for one run. `--no-config` ignores the file entirely; `--save` stores
|
|
the resulting settings as the new default.
|
|
|
|
Named profiles live beside it in the same directory:
|
|
|
|
```bash
|
|
bandsaunter scan -b 2m -b 70cm --record 0 --hang 6 --save-profile local
|
|
bandsaunter scan -p local
|
|
bandsaunter profiles
|
|
```
|
|
|
|
## Entering frequencies
|
|
|
|
**By hand** — repeat `-r` as many times as you like; there is no limit on the
|
|
number of start/end pairs.
|
|
|
|
```bash
|
|
bandsaunter scan -r 144M-148M -r 462.5M-467.8M -r 929M-932M
|
|
```
|
|
|
|
A range is `start-end`, with optional `/step` and `@mode`:
|
|
|
|
| Form | Meaning |
|
|
|---|---|
|
|
| `144M-148M` | explicit start and end |
|
|
| `144-148M` | the unit carries over to the left end |
|
|
| `146.52M` | a single frequency |
|
|
| `144M-148M/25k` | with a channel step |
|
|
| `144M-148M/25k@nfm` | and a forced demodulator |
|
|
|
|
Units may be written `144M`, `144 MHz`, `144000k`, or plain Hz. A bare number
|
|
below 10000 is read as MHz, so `-r 162.4-162.55` does what you expect.
|
|
|
|
**From the US band plan** — 107 presets across 18 categories:
|
|
|
|
```bash
|
|
bandsaunter bands --categories # list categories
|
|
bandsaunter bands --category Aviation # everything in one category
|
|
bandsaunter bands pager # search
|
|
bandsaunter scan -b gmrs -b railroad -b noaa-weather
|
|
```
|
|
|
|
Each preset carries its own channel spacing, demodulator and bandwidth, so
|
|
`-b marine-vhf` scans 25 kHz channels in NFM while `-b fm-broadcast` uses
|
|
200 kHz WFM, without being told.
|
|
|
|
Each amateur band also has a **complete** entry that covers the whole band and
|
|
picks the demodulator per segment, because a band is not one mode:
|
|
|
|
```bash
|
|
bandsaunter scan -b 2m-complete
|
|
```
|
|
|
|
scans 144-148 MHz as CW below 144.1, SSB to 144.3 and FM above it, so a CW
|
|
beacon at the bottom is decoded to text while a repeater at the top is
|
|
demodulated as FM — in one sweep. There is one for every band from 160 m to
|
|
33 cm: `160m-complete`, `80m-complete`, ... `70cm-complete`, `33cm-complete`.
|
|
|
|
Where an amateur band overlaps another service the amateur reading wins inside
|
|
a complete-band sweep — 433 MHz is treated as 70 cm rather than as the ISM
|
|
band it shares — while scanning `-r 433.9M` on its own still treats it as ISM.
|
|
|
|
A few presets stand for a *set* of others, so scattered segments can be picked
|
|
in one go:
|
|
|
|
```bash
|
|
bandsaunter scan -b all-cw --record 0 --hang 6
|
|
```
|
|
|
|
`all-cw` covers every CW allocation in the plan — 160, 80, 40, 30, 20, 17, 15,
|
|
12, 10, 6 and 2 metres — as eleven separate ranges rather than one span from
|
|
1.8 to 144 MHz. That is 1.25 MHz of spectrum in total, so a full pass takes
|
|
under a second and CW gets decoded to text as it turns up. Direct sampling
|
|
switches itself on for the HF segments and off again from 12 m upward; the HF
|
|
part needs an HF antenna to be worth anything.
|
|
|
|
The menus do both: browse the band plan by category, or type in start/end
|
|
pairs one after another. Ranges can be listed, removed, toggled on and off,
|
|
and have their demodulator changed from the ranges menu.
|
|
|
|
## The two dwell settings
|
|
|
|
These are the settings that decide how the scanner behaves when it finds
|
|
something:
|
|
|
|
| Setting | Flag | What it does |
|
|
|---|---|---|
|
|
| Record for X seconds before continuing | `--record 30` | The longest a single signal may hold the receiver. `0` means stay as long as it keeps transmitting. |
|
|
| Wait for X seconds of no signal before continuing | `--hang 3` | How long the channel must stay quiet before the sweep resumes. **Gaps shorter than this are recorded straight through.** |
|
|
|
|
```bash
|
|
bandsaunter scan -b 2m --record 30 --hang 3
|
|
```
|
|
|
|
Whichever comes first wins, and that is worth being clear about: **`--record`
|
|
overrides `--hang`.** A transmission still in progress at the record limit is
|
|
cut off there, however long the hang time is. If a recording keeps ending at
|
|
exactly 30 seconds, that is the default record limit doing it, not the hang —
|
|
set `--record 0`. The scan reports it when this happens.
|
|
|
|
Both are measured in **samples**, not wall-clock time, so a 30 second setting
|
|
produces a 30.0 second recording.
|
|
|
|
Supporting settings:
|
|
|
|
- `--min-record 0.5` — discard anything shorter, so brief noise spikes leave
|
|
nothing behind on disk.
|
|
- `--revisit 8` — ignore a frequency for this long after recording it, so a
|
|
busy repeater does not monopolise the sweep.
|
|
- `--max-record 900` — absolute ceiling on one capture, applied even when
|
|
`--record` is 0.
|
|
- `--threshold 8` — squelch, in dB above the measured noise floor.
|
|
|
|
### Capturing both sides of a conversation
|
|
|
|
`--hang` is what holds a recording open across the natural pauses in two-way
|
|
traffic. Set it longer than the gap between overs and the whole exchange lands
|
|
in one file:
|
|
|
|
```bash
|
|
bandsaunter scan -b 2m --record 0 --hang 6
|
|
```
|
|
|
|
`--record 0` is the important half. Without it the per-signal cap cuts the
|
|
exchange off mid-sentence no matter what the hang time is — the default 30
|
|
seconds is a common surprise. `--max-record` (default 900 s) still bounds an
|
|
unlimited capture so nothing runs away.
|
|
|
|
To make it the permanent default:
|
|
|
|
```bash
|
|
bandsaunter config record_seconds=0 hang_seconds=6
|
|
```
|
|
|
|
"Quiet" means *no real signal*, not merely a closed squelch. Silence, static
|
|
and interference all count towards the timer, so a burst of noise during a
|
|
pause does not reset it and park the receiver on a finished conversation.
|
|
Recognising that a signal carries nothing takes a couple of seconds of
|
|
evidence, so expect the tail to run a little past `--hang` in that case.
|
|
|
|
Once a capture has produced real content it is never abandoned as noise, since
|
|
a quiet spell between overs would otherwise throw the conversation away.
|
|
|
|
## Only real signals get recorded
|
|
|
|
A power threshold cannot tell a transmission from a hump of interference, so
|
|
every capture is checked for *content* before it is kept. Recording happens
|
|
only for:
|
|
|
|
| Category | What it means |
|
|
|---|---|
|
|
| `voice` | speech structure in the demodulated audio: a pitch track in the 70-400 Hz range that drifts the way intonation does, pauses between phrases, syllable-rate envelope modulation, and formants that move |
|
|
| `cw` | a keyed carrier whose timing resolves as Morse |
|
|
| `digital` | an identified keying scheme: discrete FSK levels, an M-PSK phase line, or on-off keying -- corroborated by a symbol rate |
|
|
|
|
Everything else -- static, hum, switch-mode power supply harmonics, clock
|
|
spurs, bare carriers -- is discarded, and the files it wrote are deleted.
|
|
|
|
The check runs *while* the capture is still going, so interference is dropped
|
|
after a second or two instead of holding the receiver for the whole record
|
|
time.
|
|
|
|
```bash
|
|
bandsaunter scan -b 2m # voice, CW and digital (default)
|
|
bandsaunter scan -b 2m --accept voice # voice only
|
|
bandsaunter scan -b ism-433 --accept digital # data bursts only
|
|
bandsaunter scan -b 2m --keep-carriers # also keep unmodulated carriers
|
|
bandsaunter scan -b 2m --keep-everything # no content check at all
|
|
```
|
|
|
|
Tuning knobs: `--min-voice-score` (0-1, default 0.45) sets how speech-like
|
|
audio must be; `--min-signal-score` sets the confidence needed to keep
|
|
anything; `--verify-max` caps how long a contentless capture is given to prove
|
|
itself.
|
|
|
|
### What makes this hold up against interference
|
|
|
|
Static is good at imitating most of the things that look like structure, so
|
|
each test is built so that noise cannot pass it:
|
|
|
|
- **A pitch track that moves gates the voice score**, rather than contributing
|
|
a share of it. Dynamics, syllable-rate modulation and energy in the voice
|
|
band are all things hiss does too; weighted alongside voicing they were
|
|
enough to carry noise over the line on their own. Speech is the only thing
|
|
here that produces a pitch period that drifts, so nothing is called voice
|
|
without one. A steady tone or mains hum has a perfectly stable "pitch" and
|
|
is rejected for exactly that reason. Measuring drift needs several voiced
|
|
frames, so the requirement eases for a short over that cannot supply them;
|
|
the steady-tone guard still applies.
|
|
- **A symbol rate has to hold still across the capture.** The estimator always
|
|
returns its best peak, so on noise it reports one rate for the first half of
|
|
a capture and a different one for the second. Real data keeps one.
|
|
- **Keying has to land on a grid.** On/off contrast alone is not evidence: a
|
|
signal fading across the squelch produces plenty of it, with run lengths
|
|
that fit no symbol period at all.
|
|
- **A phase line has to be created by the exponentiation.** An unmodulated
|
|
carrier -- including the gaps between phrases on an FM channel -- already
|
|
has a line at every power, and would otherwise look like textbook PSK.
|
|
- **The demodulator is chosen from the signal**, not from the band plan, by
|
|
probing briefly before recording. An AM signal inside a
|
|
band listed as FM would otherwise be recorded through the wrong detector,
|
|
giving audio that is useless to listen to and impossible to judge. Three
|
|
ratios settle it — how much the envelope varies, how far the tone swings,
|
|
and how much power sits in a carrier — because those hold steady over a
|
|
fraction of a second whatever is being said. Running the full classifier on
|
|
so short a probe was tried and is not reliable: speech makes any modulation
|
|
look bursty over half a second, and AM came back as on-off keying while FM
|
|
came back as AM. The probe is played into the recording rather than
|
|
discarded, so a short over does not lose its opening.
|
|
- **Content is judged only on the audio that was actually recorded**, and only
|
|
on the stretches the squelch called signal. Demodulating two ways and
|
|
keeping whichever scored higher is cherry-picking, and on noise one of the
|
|
two always flatters it.
|
|
|
|
The gate needs roughly two seconds of audio to judge speech reliably, so pair
|
|
it with `--record 5` or more rather than very short capture limits.
|
|
|
|
### Keeping up with the radio
|
|
|
|
An RTL-SDR only delivers samples while the host is actively reading. Anything
|
|
that arrives while the program is busy demodulating is discarded by the
|
|
driver, and a recording then holds *less* than really went by -- which plays
|
|
back too fast.
|
|
|
|
Two things keep that from happening:
|
|
|
|
- **Captures stream asynchronously.** A ring of USB transfers stays queued in
|
|
a background thread, so the dongle is never waiting for the host. Sweeping
|
|
still uses plain reads, because each dwell is an independent snapshot and a
|
|
gap between them costs nothing.
|
|
- **The signal path is fast enough to keep up.** Decimation computes only the
|
|
samples that survive, rather than filtering at the input rate and throwing
|
|
away seven of every eight outputs; the quarter-rate local oscillator is the
|
|
four-step cycle 1, -j, -1, +j and needs no trigonometry; and pitch tracking
|
|
runs through the FFT instead of a direct autocorrelation per frame. Together
|
|
those took the capture loop from 65% of the real-time budget to under 10%.
|
|
|
|
If the host does fall behind anyway, the scan reports how many samples were
|
|
lost rather than silently producing a fast recording.
|
|
|
|
### Why the threshold is what it is
|
|
|
|
The sweep uses peak-hold, which keeps the largest value each FFT bin reached
|
|
during the dwell. That finds bursty traffic that averaging would bury -- but
|
|
it also means noise alone rides several dB above the measured floor. On this
|
|
hardware, empty spectrum reaches 5-9 dB above a percentile floor with nothing
|
|
transmitting.
|
|
|
|
So `--threshold` is a margin over *noise*, not over the floor: the offset that
|
|
noise alone clears is computed from the detector (segment count and bin count)
|
|
and added automatically. A threshold of 8 means 8 dB of real headroom. It is
|
|
deliberately not measured from the spectrum -- a spread estimated from the
|
|
data reads five times higher across the packed broadcast FM band than on empty
|
|
spectrum, which would suppress exactly the stations you are looking for.
|
|
|
|
## Signal identification
|
|
|
|
Every recording is classified from its own IQ. The classifier measures
|
|
occupied bandwidth, envelope statistics, discriminator levels, phase
|
|
behaviour, spectral flatness and symbol rate, then combines those with the
|
|
frequency to name the signal:
|
|
|
|
| Family | Recognised as |
|
|
|---|---|
|
|
| Analogue voice | Narrowband FM (with CTCSS tone or DCS), wideband FM (stereo pilot detected), AM, SSB (USB/LSB) |
|
|
| CW | Keyed carrier, **decoded to text** with the speed in WPM |
|
|
| Digital voice | P25 C4FM, DMR (TDMA burst structure), NXDN, D-STAR |
|
|
| Data | POCSAG and FLEX paging, ACARS, AIS, APRS/AFSK1200, 2-FSK and 4-FSK, BPSK/QPSK/8-PSK |
|
|
| Other | Unmodulated carriers, OOK/ISM devices, ADS-B and UAT, DME/TACAN pulses, wideband OFDM/cellular |
|
|
|
|
Each result carries a confidence and the reasoning behind it:
|
|
|
|
```
|
|
146.520038 MHz 3.0s SNR 27.6 dB Narrowband FM voice (CTCSS 100.0 Hz) (88%)
|
|
4.9 kHz wide, 0.9 kHz rms deviation
|
|
```
|
|
|
|
Low SNR reduces confidence rather than producing a confident wrong answer.
|
|
|
|
### CW / Morse
|
|
|
|
Keyed carriers are decoded to text. The speed is measured from the signal, so
|
|
nothing has to be configured, and anything from about 8 to 40 WPM reads
|
|
reliably:
|
|
|
|
```
|
|
144.1 MHz 12.0s SNR 50.8 dB CW / Morse at 18 WPM CW "VVV DE W1AW FN31"
|
|
```
|
|
|
|
The decoder runs its own CW detector over the captured IQ, so Morse is found
|
|
even when the recording itself was made in FM or SSB.
|
|
|
|
## Output
|
|
|
|
Everything lands in one directory, named
|
|
`frequency--yyyy-mm-dd_hour_minute_second-modulation.wav`:
|
|
|
|
```
|
|
0014.058000MHz--2026-08-21_20_35_41-cw.wav
|
|
0098.299255MHz--2026-08-21_20_24_01-wfm.wav demodulated audio
|
|
0098.299255MHz--2026-08-21_20_24_01-wfm.json identification, features, timing
|
|
0098.299255MHz--2026-08-21_20_24_01-wfm.cf32 raw IQ (with --iq)
|
|
0098.299255MHz--2026-08-21_20_24_01-wfm.sigmf-meta SigMF sidecar (with --iq)
|
|
0098.299255MHz--2026-08-21_20_24_01-wfm_transcription.txt (with --transcribe)
|
|
0098.361991MHz--2026-08-21_20_24_18-wfm.wav
|
|
0146.520000MHz--2026-08-21_20_31_02-nfm.wav
|
|
1090.000000MHz--2026-08-21_20_38_12-raw.wav
|
|
scan_log.jsonl one line per hit
|
|
scan_log.csv the same, as a spreadsheet
|
|
```
|
|
|
|
Frequency leads and is padded to four digits, so a plain directory listing
|
|
sorts by frequency across the whole tuning range — unpadded, 1090 MHz would
|
|
sort before 146 MHz. Each channel's captures group together with the
|
|
timestamp ordering them. Every artefact of one capture shares a stem, and the
|
|
modulation suffix is what the signal was *identified* as, so the file is
|
|
renamed once the capture has been analysed.
|
|
|
|
### Where files go
|
|
|
|
The directory is asked for the first time bandsaunter is run and remembered
|
|
afterwards:
|
|
|
|
```
|
|
Recordings, transcripts and the scan log are all written to one directory.
|
|
Where would you like them?
|
|
|
|
recordings directory (~/bandsaunter):
|
|
```
|
|
|
|
It is an ordinary setting, so it can be changed at any time:
|
|
|
|
```bash
|
|
bandsaunter config output_dir=~/somewhere-else
|
|
bandsaunter scan -b 2m -o /tmp/just-this-once
|
|
```
|
|
|
|
The question is only asked when there is someone to answer it: a scan run from
|
|
a script or with output redirected uses the default rather than blocking, and
|
|
`--no-config` skips it entirely.
|
|
|
|
### One file per frequency
|
|
|
|
With `--combine`, each frequency gets a single file that every later reception
|
|
is appended to, so a whole watch on a channel plays back as one recording:
|
|
|
|
```bash
|
|
bandsaunter scan -b 2m --combine --record 0 --hang 6
|
|
```
|
|
|
|
```
|
|
recordings/
|
|
146.520000MHz.wav every transmission heard on 146.520, in order
|
|
147.100000MHz.wav
|
|
146.520692MHz--2026-08-21_13_21_20-nfm.json what each capture was
|
|
scan_log.csv
|
|
```
|
|
|
|
Each transmission is preceded by **its date and time read aloud**, so the file
|
|
says when everything was heard without needing the log open. Receptions within
|
|
`--combine-tolerance` (6.25 kHz by default) of each other count as the same
|
|
channel, which covers the few hundred hertz a detection wanders by.
|
|
|
|
The file is rewritten to stay valid after every append, so it can be opened
|
|
and played while the scan is still running, and a scan stopped part-way still
|
|
leaves a working recording. A later run continues the same file rather than
|
|
starting a new one.
|
|
|
|
By default the per-transmission WAVs are removed once they have been added, so
|
|
each recording exists in one place; `--keep-individual` keeps both. The `.json`
|
|
describing each capture is written either way.
|
|
|
|
`--no-announce` leaves the timestamps out, and `--announce-frequency` reads the
|
|
frequency out as well.
|
|
|
|
### Where the speech comes from
|
|
|
|
The announcements use an installed text-to-speech program if there is one
|
|
(`espeak-ng`, `espeak`, `pico2wave`, `flite`, `say`) and fall back to a
|
|
built-in formant synthesiser otherwise, so the feature works with nothing else
|
|
installed. `--announce-engine builtin` forces the built-in one; the scan plan
|
|
reports which is in use.
|
|
|
|
The two are given different wording. An installed engine gets ordinary text,
|
|
where punctuation is what produces the phrasing — and the obvious spellings
|
|
are traps: espeak-ng reads `14:38:05` as "fourteen thirty, eight zero five",
|
|
and an ISO date as "two thousand and twenty six dash zero eight dash twenty
|
|
one". It is given `August 21, twenty twenty six, at 14 38 and 05 seconds`
|
|
instead. The built-in synthesiser gets the word list it has pronunciations
|
|
for. Both are level-matched, so switching between them does not change how
|
|
loud the announcements sit against the recordings.
|
|
|
|
It only has to say numbers, month names and a handful of words, which makes
|
|
direct synthesis practical: a glottal source through a cascade of three
|
|
formant resonators, with the formant tracks interpolated between phonemes.
|
|
The test suite checks by LPC analysis that all ten vowels come out within
|
|
130 Hz of their intended first formant and 250 Hz of their second — summing
|
|
the resonators in parallel rather than cascading them loses the first formant
|
|
entirely and makes every vowel sound the same.
|
|
|
|
### Transcribing speech to text
|
|
|
|
With `--transcribe`, anything the content check identified as **voice** is
|
|
passed to a speech recogniser and the words are written beside the recording:
|
|
|
|
```bash
|
|
bandsaunter scan -b 2m --transcribe
|
|
```
|
|
|
|
```
|
|
146.520000MHz--2026-08-21_12_18_38-nfm.wav
|
|
146.520000MHz--2026-08-21_12_18_38-nfm_transcription.txt
|
|
146.520000MHz--2026-08-21_12_18_38-nfm.json
|
|
```
|
|
|
|
Only voice is transcribed — running a recogniser over Morse or a data burst
|
|
costs seconds and produces nothing. CW is decoded separately and appears in
|
|
the metadata as text already.
|
|
|
|
**A capture with nothing recognisable in it produces no file.** Music, a
|
|
carrier with an open mic, a fragment too short to make out: nothing is
|
|
written, rather than a directory of placeholders. The transcript is also
|
|
copied into the capture's `.json`, which names it only once it exists — so the
|
|
metadata never points at a file that was never created. The scan reports the
|
|
tally at the end:
|
|
|
|
```
|
|
2 transcript(s) written, 1 with no recognisable speech
|
|
```
|
|
|
|
Recognition takes seconds per capture, far longer than a capture itself, so it
|
|
runs on its own thread and the scan never waits for it; anything still queued
|
|
is finished when the scan stops. When recordings are being combined by
|
|
frequency there is one transcript per frequency too, each line stamped with
|
|
the time:
|
|
|
|
```
|
|
[2026-08-21 12:18:38] this is what the first transmission said
|
|
[2026-08-21 12:24:02] and this is the second
|
|
```
|
|
|
|
### Getting a recogniser
|
|
|
|
Unlike the spoken announcements, this needs an installed engine — recognition
|
|
depends on a trained model, so there is no built-in fallback.
|
|
|
|
```bash
|
|
bandsaunter transcribe --engines # what is installed
|
|
pip install faster-whisper # the recommended one
|
|
```
|
|
|
|
| Engine | Notes |
|
|
|---|---|
|
|
| `faster-whisper` | best on radio audio; ~120 MB of dependencies, model downloads on first use |
|
|
| `whisper` | the original; heavier |
|
|
| `whisper-cli` | whisper.cpp, no Python dependencies |
|
|
| `vosk` | ~10 MB plus a 40 MB model, fully offline, but weaker on noisy audio |
|
|
| `pocketsphinx` | tiny; poor on radio audio |
|
|
|
|
The difference is easy to measure. Both engines on the same 26-second
|
|
off-air recording:
|
|
|
|
```
|
|
faster-whisper 3.4s "August 21, 2026, at 13.42 and 28 seconds,
|
|
96.108 megahertz, 13.42 and 47 seconds, ..."
|
|
vosk 11.2s "august twenty one twenty twenty six at thirteen
|
|
forty two i'm twenty eight seconds ... forty
|
|
family factories ..."
|
|
```
|
|
|
|
Whisper is both more accurate and three times faster, and it punctuates.
|
|
Vosk's advantage is size and that it needs nothing after its model is
|
|
downloaded once.
|
|
|
|
`--transcribe-model` selects the size (`tiny.en`, `base.en`, `small.en`,
|
|
`medium.en`) and `--transcribe-language` fixes the language — worth setting,
|
|
since on a short noisy clip automatic detection often guesses wrong and
|
|
returns nonsense in another language.
|
|
|
|
Existing recordings can be transcribed after the fact:
|
|
|
|
```bash
|
|
bandsaunter transcribe recordings/ # every WAV in a directory
|
|
bandsaunter transcribe one.wav --stdout
|
|
```
|
|
|
|
Re-examine anything later:
|
|
|
|
```bash
|
|
bandsaunter analyze recordings/2026-08-19/.../iq.cf32 # identify
|
|
bandsaunter analyze recordings/2026-08-19/.../audio.wav # decode CW
|
|
```
|
|
|
|
## Live controls
|
|
|
|
The display sizes itself to the terminal, giving up the spectrum row, then the
|
|
hit list, then the key hints as space runs short. A frame taller than the
|
|
terminal cannot be redrawn in place, so an oversized one would leave a copy of
|
|
itself behind on every refresh.
|
|
|
|
For the same reason the driver's own messages are suppressed while a scan
|
|
runs: librtlsdr writes them straight to file descriptor 2 from C — including
|
|
`Allocating 15 zero-copy buffers` on *every* capture — and they draw over the
|
|
display and break its cursor tracking. `bandsaunter devices` still shows them,
|
|
since that is the command to run when something is wrong, and
|
|
`BANDSAUNTER_DRIVER_MESSAGES=1` restores them everywhere.
|
|
|
|
|
|
| Key | Action |
|
|
|---|---|
|
|
| `q` | stop |
|
|
| `p` | pause / resume |
|
|
| `s` | skip this signal, resume sweeping |
|
|
| `l` | lock out this frequency for the rest of the run |
|
|
| `+` / `-` | adjust the squelch threshold |
|
|
|
|
## Built-in help
|
|
|
|
Press `h` in the menus for topics covering setup, how the sweep works, why
|
|
nothing (or too much) is being recorded, capturing conversations, where files
|
|
go, HF reception and the keys available during a scan. Typing a setting name
|
|
there explains that setting instead.
|
|
|
|
From the command line, `bandsaunter config --describe <setting>` does the same,
|
|
and `bandsaunter scan --help` lists every flag grouped the same way as the menus.
|
|
|
|
## HF
|
|
|
|
Frequencies below 24 MHz need direct sampling, which most RTL-SDR dongles
|
|
support on the Q branch. It is selected automatically:
|
|
|
|
```bash
|
|
bandsaunter scan -b 40m-cw --record 60 # 40 m CW, decoded to text
|
|
bandsaunter scan -b am-broadcast
|
|
```
|
|
|
|
You will need an HF antenna; the tuner is bypassed in this mode, so there is
|
|
no front-end filtering or gain.
|
|
|
|
## How the sweep works
|
|
|
|
- The band is covered in steps of `sample_rate x usable_fraction / 2`. The
|
|
local oscillator is parked *below* the span each step covers, so the
|
|
RTL2832's DC spike never lands inside the frequencies being searched.
|
|
- The noise floor is measured per FFT bin as a sliding low percentile, which
|
|
follows the receiver's passband shape and steps over signals. There is no
|
|
warm-up period, and a station that transmits constantly does not learn
|
|
itself into the floor.
|
|
- The sweep uses peak-hold rather than averaging across each dwell, so bursty
|
|
traffic — CW, packet, a short over — is not averaged into the noise.
|
|
- On a hit, the receiver retunes with a quarter-rate LO offset (moving the DC
|
|
spike off the signal), probes once at ~60 Hz resolution to measure the real
|
|
occupied bandwidth, and picks the demodulator from that plus the band plan.
|
|
|
|
## Without hardware
|
|
|
|
`--simulate` swaps in a synthetic receiver carrying one of each interesting
|
|
signal type, which is also what the test suite runs against:
|
|
|
|
```bash
|
|
bandsaunter scan -r 144M-148M --simulate
|
|
```
|
|
|
|
## Testing
|
|
|
|
```bash
|
|
python -m pytest
|
|
```
|
|
|
|
Covers DSP invariants, frequency parsing, the classifier against synthetic
|
|
signals at several SNRs and random seeds, Morse decoding from 8 to 40 WPM, the
|
|
voice detector against synthetic speech and against noise, tones and hum, and
|
|
full scan runs through the simulator checking that `--record` and `--hang` are
|
|
obeyed, that static and bare carriers are never written to disk, and that
|
|
audio, IQ and metadata are correct.
|
|
|
|
The simulator's voice transmitters carry synthesised speech -- glottal pulses
|
|
through moving formants, compressed the way a real transmitter compresses,
|
|
then gated into syllables and phrases -- because sine tones would not exercise
|
|
the speech detector at all.
|
|
|
|
## Legal note
|
|
|
|
Receiving is not the same as being allowed to use or divulge what you hear. In
|
|
the US, the ECPA prohibits intercepting cellular and other private
|
|
communications, and rebroadcasting or acting on what you receive is separately
|
|
restricted. Check your local rules.
|