Initial commit: bandsaunter, an RTL-SDR signal scanner
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>
This commit is contained in:
commit
db3e0c79b9
39 changed files with 13473 additions and 0 deletions
723
README.md
Normal file
723
README.md
Normal file
|
|
@ -0,0 +1,723 @@
|
|||
# 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue