A repeater identifying itself in CW over an FM carrier came back from the recogniser as "2-2-2-3-3-5-2-7-0-5-9-7-0-8-1-0" -- one digit per tone, sixteen characters of nothing, which cleared the five-character bar and cost the capture its waterfall. The rule now lives in one place, waterfall.is_readable, shared by the scanner and the waterfall command: voice, no Morse, and more than a handful of characters. bandsaunter waterfall --check-morse runs the CW decoder over the recordings a sidecar calls readable, for sidecars written before the decoder could hear an ident over an FM carrier, and draws -- and records the ident in -- the ones that have one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
1770 lines
84 KiB
Markdown
1770 lines
84 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.
|
||
|
||
Two programs: `bandsaunter` scans, and
|
||
[`saunterbrowse`](#browsing-what-you-recorded) reads back what it collected —
|
||
transcripts, identifications and playback, in one screen.
|
||
|
||
```
|
||
╭──────────────────────────────── 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
|
||
|
||
### From a package (Debian, Ubuntu, Mint)
|
||
|
||
```bash
|
||
./packaging/build-deb.sh # writes dist/bandsaunter_<version>_all.deb
|
||
sudo apt install ./dist/bandsaunter_*.deb
|
||
```
|
||
|
||
apt pulls in every dependency itself, and the package blacklists the DVB-T
|
||
driver that would otherwise claim the receiver. Nothing else to do.
|
||
|
||
Speech transcription is the one part that cannot come from Debian, because no
|
||
speech recogniser is packaged there. Installed this way, everything else works
|
||
and the install prints a short note saying how to add one. To have that arrive
|
||
by apt as well, build the repository below instead.
|
||
|
||
### From your own apt repository
|
||
|
||
For installing on several machines, or on a fresh one, without hunting for the
|
||
recogniser afterwards. `build-repo.sh` builds three packages and an apt index:
|
||
|
||
| Package | Arch | Contents |
|
||
|---|---|---|
|
||
| `bandsaunter` | all | the application |
|
||
| `bandsaunter-transcribe` | amd64 | faster-whisper and its dependencies, in `/usr/lib/bandsaunter/vendor` |
|
||
| `bandsaunter-model-base-en` | all | the `base.en` model, in `/usr/share/bandsaunter/models` |
|
||
|
||
```bash
|
||
./packaging/build-repo.sh # writes dist/repo/
|
||
rsync -a dist/repo/ server:/var/www/html/bandsaunter/
|
||
```
|
||
|
||
Serve that directory over HTTP from anywhere on the LAN, then on each machine:
|
||
|
||
```bash
|
||
echo 'deb [trusted=yes] http://server/bandsaunter ./' \
|
||
| sudo tee /etc/apt/sources.list.d/bandsaunter.list
|
||
sudo apt update
|
||
sudo apt install bandsaunter
|
||
```
|
||
|
||
That single command brings the recogniser and its model too — they are
|
||
`Recommends`, which apt installs by default. `--no-install-recommends` gets
|
||
just the application. Nothing reaches the network afterwards: the model is on
|
||
disk, so the first transcription works offline.
|
||
|
||
`[trusted=yes]` skips signing, which is the sensible trade on a private LAN.
|
||
To sign it instead, run `gpg --clearsign` over `dist/repo/Release` to produce
|
||
`InRelease` and drop the `[trusted=yes]`.
|
||
|
||
The vendored packages are appended to `sys.path`, never prepended, so anything
|
||
apt provides — numpy, PyYAML — still wins; the vendor directory only fills the
|
||
gap Debian leaves. `BANDSAUNTER_VENDOR_DIR` and `BANDSAUNTER_MODEL_DIR`
|
||
override both locations.
|
||
|
||
Rebuilding for a new version is the same command; `apt upgrade` picks it up.
|
||
|
||
### From source
|
||
|
||
```bash
|
||
sudo apt install rtl-sdr librtlsdr0 espeak-ng # Debian, Ubuntu, Mint
|
||
pip install -e .
|
||
```
|
||
|
||
## Dependencies
|
||
|
||
Everything required is packaged in Debian, Fedora and Arch, so nothing has to
|
||
be built.
|
||
|
||
| | Package | Debian/Ubuntu | Fedora | Arch | Needed for |
|
||
|---|---|---|---|---|---|
|
||
| **required** | librtlsdr | `librtlsdr0` | `rtl-sdr` | `rtl-sdr` | talking to the receiver at all |
|
||
| **required** | NumPy | `python3-numpy` | `python3-numpy` | `python-numpy` | all signal processing |
|
||
| **required** | SciPy | `python3-scipy` | `python3-scipy` | `python-scipy` | filters, resampling, spectra |
|
||
| **required** | Rich | `python3-rich` | `python3-rich` | `python-rich` | menus and the live display |
|
||
| **required** | PyYAML | `python3-yaml` | `python3-pyyaml` | `python-yaml` | settings file and profiles |
|
||
| *recommended* | eSpeak NG | `espeak-ng` | `espeak-ng` | `espeak-ng` | clearer spoken timestamps |
|
||
| *optional* | rtl-sdr tools | `rtl-sdr` | `rtl-sdr` | `rtl-sdr` | `rtl_test` and friends for diagnosis |
|
||
| *optional* | a speech recogniser | **pip only** | **pip only** | AUR | transcribing speech to text |
|
||
| *optional* | Matplotlib | `python3-matplotlib` | `python3-matplotlib` | `python-matplotlib` | nothing yet; reserved for plots |
|
||
|
||
Two notes on the optional ones:
|
||
|
||
**eSpeak NG is a recommendation, not a requirement.** Without it the spoken
|
||
timestamps come from a built-in formant synthesiser, so that feature works on
|
||
a machine with nothing else installed. With it they are clearer and render
|
||
about three times faster.
|
||
|
||
**No speech recogniser is packaged for Debian.** `faster-whisper`, `vosk` and
|
||
the pocketsphinx Python bindings are all absent from the archive, so
|
||
transcription can only be installed with pip:
|
||
|
||
```bash
|
||
pip install faster-whisper # best on radio audio, ~120 MB
|
||
pip install vosk # ~10 MB plus a 40 MB model, weaker on noise
|
||
bandsaunter transcribe --engines
|
||
```
|
||
|
||
That is why the plain `.deb` cannot depend on one. Debian Policy forbids
|
||
anything in the archive from requiring software outside it, and a `postinst`
|
||
that reaches out to PyPI would break offline and reproducible installs — so a
|
||
package in the archive simply cannot pull these in. Transcription is therefore
|
||
off by default and reports plainly when no recogniser is present, rather than
|
||
the install failing or the feature appearing broken.
|
||
|
||
A repository of your own is not bound by that rule, which is what
|
||
[`build-repo.sh`](#from-your-own-apt-repository) exploits: it packages
|
||
faster-whisper and its model itself, into a private directory rather than into
|
||
`dist-packages`, and lets apt install them alongside. Nothing is downloaded at
|
||
install time, and nothing collides with an apt-managed module.
|
||
|
||
Mixing the two is nonetheless fine here. Modern Debian marks the system
|
||
Python as externally managed (PEP 668), so a pip install lands in your user
|
||
site directory:
|
||
|
||
```bash
|
||
pip install --user faster-whisper # ~/.local/lib/python3.x/site-packages
|
||
```
|
||
|
||
which is on `sys.path` for the system interpreter. A bandsaunter installed
|
||
from the `.deb` into `/usr/lib/python3/dist-packages` picks it up with no
|
||
further configuration — verified, not assumed. A virtual environment works
|
||
too, as long as bandsaunter runs inside it.
|
||
|
||
Getting these into Debian proper would be a different matter: it would mean
|
||
packaging ctranslate2, tokenizers, onnxruntime and their dependencies, several
|
||
of which are large C++ or Rust projects, each to archive standards. That is
|
||
why none of them are there, and why the local repository vendors the wheels
|
||
instead of trying to do it properly.
|
||
|
||
### Other distributions
|
||
|
||
```bash
|
||
sudo dnf install rtl-sdr python3-numpy python3-scipy python3-rich \
|
||
python3-pyyaml espeak-ng # Fedora
|
||
sudo pacman -S rtl-sdr python-numpy python-scipy python-rich \
|
||
python-yaml espeak-ng # Arch
|
||
brew install librtlsdr espeak-ng && pip install -e . # macOS
|
||
```
|
||
|
||
### Letting your user reach the receiver
|
||
|
||
The DVB-T television driver claims RTL dongles on sight and has to be kept
|
||
away from them. The `.deb` does this for you; from source:
|
||
|
||
```bash
|
||
echo 'blacklist dvb_usb_rtl28xxu' | sudo tee /etc/modprobe.d/blacklist-rtlsdr.conf
|
||
sudo rmmod dvb_usb_rtl28xxu # or just unplug and replug the receiver
|
||
```
|
||
|
||
If the device is found but cannot be opened, your user needs permission for
|
||
it. Most distributions ship a udev rule with `rtl-sdr`; failing that:
|
||
|
||
```bash
|
||
echo 'SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2838", MODE="0666"' \
|
||
| sudo tee /etc/udev/rules.d/20-rtlsdr.rules
|
||
sudo udevadm control --reload-rules && sudo udevadm trigger
|
||
```
|
||
|
||
### Check it worked
|
||
|
||
```bash
|
||
bandsaunter devices --test # opens the receiver and captures a test block
|
||
bandsaunter scan -b 2m --simulate # exercises everything without hardware
|
||
```
|
||
|
||
## Versioning
|
||
|
||
Releases are named for the day they were made and a revision within that day:
|
||
|
||
```
|
||
2026-08-21_01 first build on the 21st
|
||
2026-08-21_02 second build the same day
|
||
2026-09-01_01
|
||
```
|
||
|
||
The revision is padded to two digits so that versions sort correctly as text —
|
||
without it, revision 10 would sort before revision 2.
|
||
|
||
Packaging tools cannot use that form directly, so it is converted at the edge
|
||
rather than kept as a second version string that could drift:
|
||
|
||
| Where | Form | Why |
|
||
|---|---|---|
|
||
| the program, `--version` | `2026-08-21_01` | what you asked for |
|
||
| pip, `pyproject.toml` | `2026.8.21.1` | PEP 440 forbids dashes and underscores in a release |
|
||
| dpkg, the `.deb` | `2026.08.21.01-1` | Debian versions may not contain underscores |
|
||
|
||
`bandsaunter/__init__.py` holds the date and revision; the other two forms are
|
||
derived from it, and the test suite checks that all three describe the same
|
||
release and that both pip and `dpkg --compare-versions` order them correctly.
|
||
|
||
## 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
|
||
```
|
||
|
||
Everything that can be given as a flag can be saved, and everything that can be
|
||
saved can be given as a flag — both front ends are generated from one table, so
|
||
they cannot drift apart. A profile written by an older version still loads: keys
|
||
it does not have take their defaults, and keys that no longer exist are reported
|
||
and ignored.
|
||
|
||
Every setting is explained in plain language — what it is, and when you would
|
||
change it — in three places: `bandsaunter config --describe <name>`, `?N` in the
|
||
settings menu, and `man bandsaunter`.
|
||
|
||
## 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 |
|
||
|
||
Two more categories exist but are not accepted by default: `carrier`
|
||
(unmodulated, real but empty) and `trunk` (a [trunking control
|
||
channel](#trunked-systems-and-their-control-channels)).
|
||
|
||
Everything else -- static, hum, switch-mode power supply harmonics, clock
|
||
spurs, bare carriers, trunking control channels -- 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.
|
||
|
||
### Which band it is in
|
||
|
||
Next to every frequency, on the live display and in the line-per-hit output,
|
||
is the name of the band it falls in:
|
||
|
||
```
|
||
time frequency band dur SNR identified as
|
||
21:14:07 146.52 MHz 2 m FM Simplex 5.0s 20.0 Narrowband FM voice
|
||
21:14:31 462.5625 MHz GMRS / FRS 4.2s 18.3 Narrowband FM voice
|
||
21:15:02 421 MHz 70 cm Amateur 12.7s 22.9 Narrowband FM voice
|
||
21:15:40 162.55 MHz NOAA Weather Radio 30.0s 31.4 Narrowband FM voice
|
||
```
|
||
|
||
`421 MHz` is the 70 cm amateur band, and being told so is quicker than
|
||
remembering where the band edges are. The names come from the same band plan
|
||
the presets do, so there is one table to keep right rather than two.
|
||
|
||
Several allocations usually cover any given frequency, and the narrowest wins
|
||
because it says the most: `146.52 MHz` comes back as *2 m FM Simplex* rather
|
||
than *2 m Amateur*, and `14.050 MHz` as *20 m CW / Digital*. Two exceptions,
|
||
both because the obvious answer would be the wrong one:
|
||
|
||
- **ISM yields to the allocation it shares.** 433.92 and 915 MHz are ISM
|
||
bands, but they are also 70 cm and 33 cm. A signal there is far more likely
|
||
to be worth naming as the amateur band, so it is — unless nothing else
|
||
covers it, in which case ISM is still the right answer.
|
||
- **Shortwave broadcast yields to amateur, where they overlap.** 3.9–4.0 and
|
||
7.2–7.3 MHz are broadcast in ITU Regions 1 and 3, and amateur in Region 2,
|
||
which is what this plan describes. 6 MHz really is 49 m shortwave, and
|
||
there is no amateur band anywhere near it, so that one is left alone.
|
||
|
||
The band name is written into each recording's sidecar too, so it travels with
|
||
the capture, and `saunterbrowse` will search on it — typing `/70 cm` finds
|
||
everything in the band without having to remember 420–450 MHz.
|
||
|
||
### Reading data signals
|
||
|
||
A great deal of what a scanner finds is not speech. Doorbells, tyre-pressure
|
||
sensors, weather stations, remote controls, paging, packet radio — all of it
|
||
carries something a receiver can read, and bandsaunter reads it:
|
||
|
||
```
|
||
21:14:07 433.92 MHz 70 cm Amateur 3.2s SNR 45.8 dB EV1527 / PT2262-style remote (93%)
|
||
EV1527 / PT2262-style remote 24 bits 516 baud x12 B2 35 4E
|
||
21:14:31 929.6125 MHz UHF / 900 MHz Paging 4.5s SNR 49.5 dB POCSAG 1200 (97%)
|
||
[1234568D] ENGINE 4 RESPOND
|
||
[0098765A] CALL EXT 4412
|
||
21:15:02 144.39 MHz 2 m Amateur 1.8s SNR 31.2 dB AX.25 / APRS (93%)
|
||
W1AW>APRS>WIDE1-1: !4142.45N/07243.63W-Newington CT
|
||
```
|
||
|
||
**Whatever the modulation, a data signal is the same shape once it has been
|
||
sliced**: a train of alternating runs whose *lengths* carry the information.
|
||
On-off keying gives that directly — the carrier is up or it is down — and
|
||
two-level FSK gives exactly the same thing from the discriminator, one tone or
|
||
the other. So both are reduced to runs, and everything after that is shared.
|
||
|
||
What the runs mean is the line code, and it is worked out from the runs alone
|
||
rather than configured, because each code makes a different prediction about
|
||
which of the two histograms is the bimodal one:
|
||
|
||
| Code | Pulses | Gaps | Who uses it |
|
||
|---|---|---|---|
|
||
| **PWM** | two lengths | constant, or the period is | EV1527, PT2262 and nearly every 433 MHz remote |
|
||
| **PPM** | constant | two lengths | the other half of the same market |
|
||
| **Manchester** | T and 2T only | T and 2T only | anything whose receiver recovers its own clock |
|
||
| **NRZ** | any whole number of symbols | same | what a framed protocol sits on |
|
||
|
||
Four-level FSK — C4FM, as P25, DMR and NXDN send it — is recognised as such and
|
||
read as symbols. Slicing it down the middle also produces bits, and they mean
|
||
nothing; a capture that had been coming back as "10783 bits of NRZ at 5335
|
||
baud" now says *4-level FSK, 5334 baud, no frame sync recognised*, which is
|
||
both true and useful. Where a frame sync word does appear, the system is named
|
||
outright.
|
||
|
||
### Protocols that can be read in full
|
||
|
||
Two carry their own framing and checksums, so a frame either passes or it does
|
||
not — and one that passes is not a guess:
|
||
|
||
**POCSAG** paging, at 512, 1200 or 2400 baud. Nothing in the signal announces
|
||
which rate it is, so all three are tried and the one whose 32-bit sync word
|
||
turns up is the right one. Every codeword is checked — and a single bit error
|
||
corrected — against the BCH code the standard puts there for exactly that. The
|
||
address, function letter and message text all come out.
|
||
|
||
**AX.25 / APRS** on 1200 baud AFSK. The frame check has to come out right
|
||
before a frame is reported at all. The sender's callsign, the digipeater path
|
||
and the payload are shown — and the callsign goes onto the map with everyone
|
||
else.
|
||
|
||
```bash
|
||
bandsaunter analyze capture.cf32 --rate 48000 # decode a file you already have
|
||
bandsaunter scan --no-decode-data # turn it off
|
||
saunterbrowse # decoded packets sit where a transcript would
|
||
```
|
||
|
||
### Believing a decode
|
||
|
||
This is the hard half. A decoder that always returns *something* is worse than
|
||
useless: noise sliced at a threshold produces runs, and runs produce bits.
|
||
Three things guard against that.
|
||
|
||
- **The runs have to fit.** A decode whose runs do not quantise to the line
|
||
code's own grid is thrown away.
|
||
- **Most of the capture has to agree.** A data signal is data all the way
|
||
through. One lucky window in eight is a coincidence — and that is exactly
|
||
what SSB voice produced before this check existed.
|
||
- **The packet has to repeat.** Much the strongest of the three. These
|
||
transmitters send the same thing three to ten times over, and bits that come
|
||
back identical every time did not come from noise.
|
||
|
||
A bare reading with none of that behind it — where the run lengths merely
|
||
happened to land on a grid — is reported as **nothing at all**, rather than as
|
||
a bit string with a low number beside it that somebody will read anyway. Across
|
||
27 recordings of speech, music, static, a bare carrier, Morse and PSK, the
|
||
decoder returns nothing 27 times.
|
||
|
||
And a decode that *does* have repeats or a checksum behind it outranks the
|
||
content check. A burst of keying demodulated as FM audio is a buzz, and the
|
||
speech detector likes a buzz — but a frame whose own checksum came out right is
|
||
not a statistic.
|
||
|
||
### Trunked systems and their control channels
|
||
|
||
Police, fire and most large business radio in the US runs on *trunked*
|
||
systems. Rather than giving each department a frequency of its own, the system
|
||
owns a pool of channels and hands one out per conversation. For that to work,
|
||
one frequency is given over entirely to a data stream that runs day and night
|
||
telling every radio in the fleet where to go next. That frequency is the
|
||
**control channel**.
|
||
|
||
It is the worst thing a scanner can find: loud, perfectly steady, never
|
||
silent, and with nothing on it to hear — just a harsh buzz. Left to itself a
|
||
scanner parks on it for the whole record limit, saves the file, and finds it
|
||
again on the next sweep, for as long as it runs.
|
||
|
||
bandsaunter recognises one and moves on, usually within a second or two:
|
||
|
||
```
|
||
TRUNK 856.561096 MHz -- Motorola SMARTNET / SmartZone (Type I/II) control channel, 3600 baud -- skipping
|
||
```
|
||
|
||
In the live display the recording panel turns yellow and says `TRUNK:` with
|
||
the system name instead of `REC`, and the end-of-run summary lists every
|
||
control channel found and where it was.
|
||
|
||
What identifies one is a constant-envelope data stream that never pauses, at a
|
||
symbol rate belonging to a known trunking standard:
|
||
|
||
| Symbol rate | Levels | System |
|
||
|---|---|---|
|
||
| 3600 baud | 2 | Motorola SMARTNET / SmartZone (Type I/II) |
|
||
| 9600 baud | 2 | EDACS / ProVoice |
|
||
| 1200 baud | 2 | MPT-1327 |
|
||
| 4800 baud | 4 | P25 or DMR Tier III |
|
||
| 2400 baud | 4 | NXDN / NEXEDGE |
|
||
|
||
The first two are called immediately — nothing else transmits at those rates
|
||
without pausing. The rest share their shape with an ordinary digital voice
|
||
call on the same system, so they are only judged to be a control channel once
|
||
the carrier has run unbroken for `--control-seconds` (20 s by default), which
|
||
is longer than a real conversation goes without taking a breath. Raise it if
|
||
digital voice is being skipped by mistake.
|
||
|
||
Sitting in a band where trunking is common raises confidence but is never
|
||
required — trunking is licensed on business pairs all over the spectrum, so
|
||
the shape of the signal has to be enough on its own.
|
||
|
||
```bash
|
||
bandsaunter scan -b 800-trunked # control channels named and skipped
|
||
bandsaunter scan -b 800-trunked --keep-control # record them (for a decoder)
|
||
bandsaunter scan -b 800-trunked --lockout-control # never look at them again
|
||
```
|
||
|
||
`--lockout-control` adds each one to the lock-out list as it is found; with
|
||
lock-out saving on (the default) that list is written to your settings file
|
||
and survives a restart.
|
||
|
||
### 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 — and it is run over
|
||
**every** capture once it has finished, whatever the classifier called it.
|
||
|
||
#### Short bursts, which is most of it
|
||
|
||
Most of the Morse on the air is not a conversation. It is a repeater, a beacon
|
||
or an unattended transmitter saying who it is and stopping — four to six
|
||
characters, over in a second or two:
|
||
|
||
```
|
||
147.06 MHz 5.3s SNR 31.2 dB CW / Morse at 20 WPM CW "DE K1AA"
|
||
K1AA Newington Radio Club — Newington, CT · FN31pr
|
||
```
|
||
|
||
That burst is a fraction of a capture the classifier named after whatever
|
||
filled the rest of it, so waiting for the label to say "CW" missed it. Short
|
||
is now the normal case rather than the awkward one, and a decode of two or
|
||
three characters gets in on its timing alone:
|
||
|
||
- every element within a third of a unit of one or three
|
||
- every character resolving to something in the table
|
||
- **and the keyed tone at least 20 dB above the rest of its band**
|
||
|
||
The last one is what separates an ident from a blip, and it is not
|
||
decoration. With four elements the dot length is fitted to those very
|
||
elements, so they land on the grid whatever produced them — a third of a
|
||
second of white noise decodes as a perfectly timed `V`. Measured over 200
|
||
noise blocks the loudest bin never rose 13 dB above the median of its band,
|
||
while keying at 3 dB SNR sits above 40, so 20 dB has room on both sides. One
|
||
keyed element is refused outright: a single pulse is an `E` or a `T` whether a
|
||
person sent it or the squelch opened on a click.
|
||
|
||
#### What the capture window cut off
|
||
|
||
A capture opens when the squelch does, which is in the middle of an element as
|
||
often as not. Half a character is not a smaller reading of what was sent — it
|
||
is a different one. A `K` missing its first dash is an `A`; a `W` missing its
|
||
first dot is an `M`.
|
||
|
||
So the character at a sliced end is dropped, and so is the rest of the word it
|
||
was in, because what is left of that word can read as a whole one: **`K1AA`
|
||
caught halfway through is `K1A`, which belongs to somebody else.** The full
|
||
text is still shown; it is the *identification* that is held to the stricter
|
||
standard. Over 1805 truncated captures of four different messages, that turns
|
||
107 invented callsigns into none, while still recovering 550 correct ones.
|
||
|
||
## Pictures
|
||
|
||
Three of the things a receiver can hear are images rather than sounds. All
|
||
three are analogue, all three encode brightness as a frequency, and all three
|
||
arrive as the audio the scanner already records — so they are looked for in
|
||
every recording and written out as PNG beside it.
|
||
|
||
| | Where | What it is |
|
||
|---|---|---|
|
||
| **SSTV** | 14.230 MHz, 144.5 MHz | amateur slow-scan television, in colour — Martin M1/M2, Scottie S1/S2/DX, Robot 36/72 |
|
||
| **APT** | 137–138 MHz | the NOAA weather satellites, one continuous picture per fifteen-minute pass |
|
||
| **HF fax** | 2–20 MHz, single sideband | the marine weather charts, 60–240 lines a minute |
|
||
|
||
```
|
||
147.06 MHz 118.0s SNR 24.1 dB SSTV (Martin M1)
|
||
picture: SSTV Martin M1 320x256 → ~/bandsaunter/0014.230000MHz--2026-08-29_14_02_11-usb.png
|
||
```
|
||
|
||
**None of the three is guessed at**, which is what makes it safe to try them
|
||
on every recording. SSTV announces itself with a VIS header that says which
|
||
mode follows. APT carries two different sync patterns exactly 1040 words
|
||
apart. Fax opens with twenty seconds of phasing — black lines with a pulse at
|
||
the start of each — that nothing else on the air sends. A decoder without one
|
||
of those draws static beautifully, and a directory of beautifully rendered
|
||
static is worse than an empty one. Measured over noise, tones, speech and
|
||
frequency-swept whistles: **no false pictures in 295 attempts**.
|
||
|
||
Accuracy, against transmissions built from the published specifications:
|
||
|
||
| | 30 dB SNR | 12 dB | 6 dB |
|
||
|---|---|---|---|
|
||
| SSTV (all seven modes) | 96–98% of pixels exact | 91–98% | — (header lost below 9 dB) |
|
||
| APT | 0.97 correlation | 0.94 | 0.88 |
|
||
| HF fax | 0.998 correlation | 0.99 | 0.97 |
|
||
|
||
Each mode's line timing is checked against its published line time — 446.446 ms
|
||
for Martin M1, 428.220 for Scottie S1, and so on — because a line a few
|
||
milliseconds long walks the picture off the bottom of the screen within ten
|
||
lines, and that is a thing worth failing a test over rather than noticing in a
|
||
PNG.
|
||
|
||
**A picture keeps its capture whatever the content check made of it.** A
|
||
satellite is a steady tone with a wobble on it and an SSTV transmission is a
|
||
whistle: neither is speech, neither has symbol structure, and both were being
|
||
discarded as "no signal content" *having already been recognised*.
|
||
|
||
Pictures take minutes rather than seconds, so `--record` has to be long enough
|
||
or what arrives is the top of one. A partial picture is kept and labelled
|
||
partial rather than thrown away — most SSTV captures are partial, and half a
|
||
picture is still a picture.
|
||
|
||
> **GRIB** is sometimes asked about in the same breath and is not a modulation:
|
||
> it is the binary format weather models are published in, and it travels by
|
||
> satellite data link and by e-mail rather than as something a receiver
|
||
> demodulates. Where a decoded byte stream begins with its magic number it is
|
||
> named; nothing here fetches or renders one.
|
||
|
||
## Waterfalls
|
||
|
||
Most of what a scanner records cannot be turned into words. A data burst, a
|
||
keyed carrier, a pager, a trunking control channel, a stretch of something
|
||
unidentified — the classifier names what it can and the rest is a WAV file
|
||
that tells you nothing until you open it in something else.
|
||
|
||
A waterfall says something about every signal there is, because it shows the
|
||
shape of the thing rather than its meaning: how wide it is, how long it
|
||
lasted, whether it was keyed, swept, hopping or steady, and whether it was
|
||
one signal or three side by side. So **every capture that produced no
|
||
readable words gets one drawn beside it** as a PNG — no voice, or voice that
|
||
came back from the recogniser with fewer than five characters, which is what
|
||
a recogniser handed something that is not speech reliably does.
|
||
|
||
A capture with Morse in it never counts as readable, however much the
|
||
recogniser made of it. A station identifying itself in CW over an FM
|
||
carrier comes back as a string of digits, one per tone — sixteen characters
|
||
of nothing, sailing past any bar you set. The ident is in the Morse text;
|
||
the signal itself is only visible as a picture.
|
||
|
||
```bash
|
||
bandsaunter waterfall # draw a directory already recorded
|
||
bandsaunter waterfall --all # including the ones that read fine
|
||
bandsaunter waterfall --check-morse # listen again before believing them
|
||
bandsaunter waterfall --redraw recordings/
|
||
```
|
||
|
||
`--check-morse` is for recordings made before the CW decoder could hear an
|
||
ident over an FM carrier: their sidecars call a repeater readable, because
|
||
the recogniser turned its tones into digits. It runs the decoder over the
|
||
recordings that would otherwise be skipped, draws the ones that turn out to
|
||
have an ident in them, and writes the ident into the sidecar so the browser
|
||
shows it and the next run needs no second listen.
|
||
|
||
Time runs down the picture and frequency across it, which is the way a
|
||
receiver draws one. The frequency scale is on top, the seconds down the
|
||
left, and the caption underneath says what the capture was.
|
||
|
||
**It says which picture it is, and that matters.** Where the raw IQ was kept
|
||
(`--save-iq`) this draws the radio spectrum around the tuned frequency — the
|
||
waterfall an operator would have been watching. Where only the audio was
|
||
kept, which is the usual case, it draws the demodulated audio instead: after
|
||
an FM detector the frequency axis is no longer radio frequency, and a picture
|
||
that did not say so would be a lie told in a convincing font. The caption
|
||
ends in `RF SPECTRUM` or `DEMODULATED AUDIO` accordingly.
|
||
|
||
The PNG is written the same way the SSTV and satellite pictures are — from
|
||
zlib and struct, with no imaging library — including the 5×7 font the axis
|
||
labels are drawn with, so a machine with nothing installed but numpy draws
|
||
the same picture as one with everything.
|
||
|
||
`saunterbrowse` marks the capture `waterfall`, gives the path in full, and
|
||
`o` prints it: there is no listening to a data burst. A decoded pager
|
||
message or a Morse ident still wins the panel, because a waterfall is a view
|
||
of a signal rather than a reading of one.
|
||
|
||
Pictures are around half a megabyte each — 542 of them for one directory of
|
||
677 recordings came to 302 MB, against 1.8 GB of audio. `--no-waterfall`
|
||
turns it off.
|
||
|
||
## Aircraft
|
||
|
||
```bash
|
||
bandsaunter adsb # listen on 1090 MHz until interrupted
|
||
bandsaunter adsb --frames # print every frame as it arrives
|
||
bandsaunter adsb --kml planes.kml # and write what was heard as a map
|
||
```
|
||
|
||
Every airliner overhead broadcasts its address, callsign, altitude, position
|
||
and speed twice a second, unencrypted, to nobody in particular.
|
||
|
||
```
|
||
ICAO callsign altitude position speed frames
|
||
4CA1FA RYR1234 35000 ft 51.5000, -0.1200 308 kt 126° 47
|
||
A0B1C2 UAL99 12000 ft 40.7000, -74.0000 180 kt 274° 31
|
||
```
|
||
|
||
A command of its own because **ADS-B does not fit through the scanner**: the
|
||
signalling is a megabit a second, which needs at least two megasamples a second
|
||
of raw receiver output, and the scan path decimates everything to a channel
|
||
12.5 kHz wide long before a decoder sees it.
|
||
|
||
Every frame carries a 24-bit checksum, so there is no threshold and nothing to
|
||
disbelieve — a frame passes or it is dropped. The one trap is that a frame of
|
||
all zeros satisfies that checksum, and silence between transmissions is exactly
|
||
that, so silence would otherwise decode as an endless stream of aircraft
|
||
`000000`.
|
||
|
||
A position takes **two** frames. The encoding sends a fraction of a zone rather
|
||
than a coordinate, so one frame alone is ambiguous by hundreds of miles; an
|
||
aircraft is placed once an even and an odd frame have both arrived, about a
|
||
second apart. A pair that straddles a longitude-zone boundary is refused rather
|
||
than resolved against two different grids.
|
||
|
||
An aerial cut for 1090 MHz is the difference between hearing the airport and
|
||
hearing the county; the whip supplied with a dongle is a quarter of the length
|
||
it wants.
|
||
|
||
## Meters and weather sensors
|
||
|
||
Two things on the ISM bands are worth naming rather than reporting as hex.
|
||
|
||
```
|
||
915.0 MHz decoded: electricity meter 12345678 reading 987654
|
||
433.92 MHz decoded: AcuRite sensor 1234 temperature 21.5 C humidity 48% channel A
|
||
```
|
||
|
||
**Utility meters.** The Itron ERT modules fitted to electricity, gas and water
|
||
meters across North America broadcast their reading every thirty seconds or so
|
||
on 902–928 MHz, in the clear, so a van can drive past and read a street. The
|
||
message says which meter, what kind, what the register reads, and whether the
|
||
tamper switches have been tripped.
|
||
|
||
**AcuRite sensors.** The 433.92 MHz outdoor sensors sold with every consumer
|
||
weather station send temperature, humidity, battery state and a channel letter
|
||
every sixteen seconds.
|
||
|
||
Neither is guessed at: a meter message carries a 16-bit BCH check and a sensor
|
||
message a checksum and four parity bits, and nothing is reported that has not
|
||
satisfied them. Both are implemented from their published descriptions and
|
||
checked against frames built from the same descriptions — which proves the
|
||
framing and the arithmetic, and is not the same as having held a meter.
|
||
|
||
## Hex into words
|
||
|
||
Everything else that decodes to bits gets read rather than dumped:
|
||
|
||
```
|
||
EV1527 / PT2262: address 0x8B2F1 button B
|
||
text (8-bit ASCII): "STATION OPEN"
|
||
0000 53 54 41 54 49 4F 4E 20 |STATION |
|
||
0008 4F 50 45 4E 0D 0A 00 91 |OPEN....|
|
||
```
|
||
|
||
A decoder that stops at a bit string has done half the job, and `4A 3F 1B 22`
|
||
is a true answer to "what did the doorbell say" and not a useful one. Where the
|
||
packet is a shape somebody standardised its fields are named; where there is
|
||
text in it the text is read out; and underneath either, always, the bytes in
|
||
groups with their printable characters beside them.
|
||
|
||
The text search is the part that needs care, and **printability is not
|
||
evidence**. Every framing is tried at every bit offset in both bit orders —
|
||
about forty readings of each packet — and seven-bit values are printable three
|
||
times in four, so a bar set on printability alone called **64% of random
|
||
payloads text**. What separates a message from a coincidence is that real text
|
||
is nearly all one case where random letters are half and half, is about two
|
||
fifths vowels where random letters over 52 are a fifth, and is mostly letters
|
||
and digits where random draws punctuation one time in four. Together with a
|
||
length bar those take random payloads to **under 0.5%**, which is measured in
|
||
the test suite and fails there if it stops being true.
|
||
|
||
### Single sideband
|
||
|
||
SSB needs no `--mode usb`. Nothing else does either, but SSB is the mode where
|
||
it would matter: FM and AM detectors do not care where in their passband a
|
||
signal sits, while an SSB demodulator is a filter that opens at the suppressed
|
||
carrier. Tune to the middle of the voice — which is where a detector naturally
|
||
lands, a couple of kilohertz up — and its lower half is filtered off while the
|
||
rest comes out shifted down by the error. That is the mistuned sound, and it
|
||
makes the recording useless rather than merely imperfect.
|
||
|
||
So the carrier is measured rather than assumed. Speech puts most of its power
|
||
in the first few hundred hertz above the carrier, so an SSB signal's occupied
|
||
band is lopsided: the loud end is the carrier end. That locates the carrier to
|
||
within about a hundred hertz and names the sideband at the same time — energy
|
||
bunched at the low edge is upper sideband, at the high edge lower.
|
||
|
||
The frequency in the filename is therefore the carrier, the one you would dial
|
||
into a radio, not the middle of the voice.
|
||
|
||
Where the signal has no lean to read — a data mode inside an SSB segment, or a
|
||
steady tone — the band plan decides, and it is right where the folklore is
|
||
wrong: 60 m and the HF utility bands are upper sideband well below the 10 MHz
|
||
that "LSB below, USB above" splits on.
|
||
|
||
## 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.
|
||
|
||
**One transcript per transmission, and none is ever overwritten.** The
|
||
timestamp is part of the name, so two overs on the same frequency cannot land
|
||
on the same file — a second transmission on 146.52 MHz writes
|
||
`...12_19_44-nfm_transcription.txt` beside the first, not over it.
|
||
|
||
With `--combine` there is one recording per frequency, so there is one
|
||
transcript per frequency too, and it works the other way: each over is
|
||
**appended** with the time it was heard, and an unattended receiver keeps
|
||
adding to it night after night.
|
||
|
||
```
|
||
[2026-08-21 12:18:38] Net control, this is W1AW, standing by.
|
||
[2026-08-21 12:19:44] Roger, copy that, back to you.
|
||
```
|
||
|
||
Both behaviours have tests that run a real scan and check the files, including
|
||
one that runs a second scan into the same directory and asserts the earlier
|
||
text is still at the top.
|
||
|
||
**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
|
||
sudo apt install bandsaunter-transcribe # from your own repository
|
||
pip install faster-whisper # or straight from PyPI
|
||
```
|
||
|
||
| 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.
|
||
|
||
**No voice-activity filter runs inside the recogniser.** It used to, and it
|
||
cost words: measured across a night of land-mobile captures it dropped 5–15%
|
||
of what the same model finds without it — 491 words against 507, 339 against
|
||
384, 263 against 310 — because a single-word over between two transmissions
|
||
looks to a VAD exactly like the noise it is there to remove. On a scanner
|
||
those short replies are the ones worth having.
|
||
|
||
What replaces it is a single question asked of the whole capture: does
|
||
anything in it rise above its own noise? Nothing does in digital silence
|
||
(0.0 dB of contrast) or in hiss at any level (0.7 dB), while the quietest
|
||
real capture of that night gives 8.9 dB and most give 10–27. Below 3 dB the
|
||
clip is refused before a recogniser sees it — which matters, because with no
|
||
filter at all Whisper hands back *"You"* for five seconds of hiss as
|
||
confidently as it hands back a sentence. The check can only veto a capture
|
||
entirely, never trim one, so the short over in the middle of a quiet channel
|
||
survives.
|
||
|
||
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
|
||
```
|
||
|
||
### Fitting the window
|
||
|
||
The display is redrawn in place several times a second, which only works while
|
||
the frame is exactly where it was last drawn. Two things follow.
|
||
|
||
On a short terminal the optional parts are given up in order — the spectrum
|
||
row, then the list of recorded signals, then the key hints, and last of all the
|
||
receiver panel, which says nothing that changes. Never given up: the sweep line
|
||
and, while one is running, the recording.
|
||
|
||
Resizing the window redraws everything from a blank screen. The frame that was
|
||
on it was drawn for a window that no longer exists — and the terminal has
|
||
already reflowed everything above it — so anything printed before the scan
|
||
started scrolls away at that point. `--plain` prints one line per hit and needs
|
||
none of this, which is what to use over a pipe or into a log.
|
||
|
||
## 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 this run and every later one |
|
||
| `+` / `-` | adjust the squelch threshold |
|
||
|
||
There is no live display over ssh, in a log file, or piped to another program:
|
||
`--plain` prints one line per recording instead, and is chosen automatically
|
||
whenever output is not a terminal. It is a saved setting like any other, so a
|
||
headless machine can be told once and never asked again.
|
||
|
||
### Lock-outs
|
||
|
||
A pager transmitter down the road, or a birdie the receiver makes itself, is
|
||
worth shutting out permanently. Pressing `l` writes the frequency back to the
|
||
settings file the run started from, so it is still locked out tomorrow. Only
|
||
that one setting is written back — options passed on the command line for a
|
||
single run stay one-off — and `--no-save-lockouts` keeps a lock-out to the
|
||
current run.
|
||
|
||
Lock-outs can also be given directly, several at a time, as single frequencies
|
||
or as spans:
|
||
|
||
```bash
|
||
bandsaunter scan -r 144M-148M --lockout "162.55M, 450M-455M"
|
||
bandsaunter config lockout="88M-108M, 146.52M"
|
||
```
|
||
|
||
A single frequency is widened by `--lockout-width` (12.5 kHz by default); a
|
||
span is taken exactly as written, since a noisy stretch of spectrum has a
|
||
definite width rather than a point with a guess around it. Ranges accept the
|
||
same forms as everywhere else — `450M-455M`, `450-455M`, `88M to 108M`.
|
||
|
||
`saunterbrowse` writes to the same list: pressing `m` over a recording locks
|
||
out the frequency it was heard on, which is usually when you find out that a
|
||
frequency is not worth listening to.
|
||
|
||
`--lockout-control` adds each trunking control channel to the list as it is
|
||
found. Two runs never write anything back: `--no-config` has no settings file
|
||
to write to, since the point of the flag is to leave the saved settings alone;
|
||
and `--simulate` is looking at an invented band, whose frequencies would sit in
|
||
a real settings file for ever, skipping whatever genuine signal happened to
|
||
land near one. Both still lock out for the run in hand, and say so.
|
||
|
||
## Browsing what you recorded
|
||
|
||
A long scan leaves hundreds of files. `saunterbrowse` is a second program in
|
||
the same package for reading them:
|
||
|
||
```bash
|
||
saunterbrowse # opens the scanner's output directory
|
||
saunterbrowse /mnt/recordings # or any other
|
||
```
|
||
|
||
Arrow keys move through the recordings; the transcript of whichever one is
|
||
highlighted fills the top of the screen, because that is the part you actually
|
||
want to read. Under it are the identification, the confidence, the CTCSS tone
|
||
or symbol rate where there is one, and the bands the frequency falls in.
|
||
|
||
```
|
||
╭──────────────────────────────────────────────────────────── 4 of 126 ─╮
|
||
│ 146.88 MHz NFM Sat 26-08-22 01:01:44 pm 42.8s SNR 17.6 dB voice │
|
||
╰───────────────────────────────────────────────────────────────────────╯
|
||
╭─ transcript ──────────────────────────────────────────────────────────╮
|
||
│ │
|
||
│ Alright, moving on. It is the 4th Saturday of the month. There is │
|
||
│ an HF net at 1.30pm on 7.242 megahertz. Are there any │
|
||
│ announcements for the net? │
|
||
│ │
|
||
╰───────────────────────────────────────────────────────────────────────╯
|
||
╭───────────────────────────────────────────────────────────────────────╮
|
||
│ Narrowband FM voice (CTCSS 110.9 Hz) 88% CTCSS 110.9 Hz │
|
||
│ 2 m Amateur · 2 m FM Simplex · 2 m Repeater Outputs │
|
||
╰───────────────────────────────────────────────────────────────────────╯
|
||
╭─ recordings in /mnt/global/bandsaunter ───────────────────────────────╮
|
||
│ 856.561096 MHz 26-08-22 01:10:25 pm fsk 4m00s Motorola SMARTNE… │
|
||
│ 158.294200 MHz 26-08-22 01:05:15 pm nfm 20.1s Steven, I'm over… │
|
||
│ › 146.88 MHz 26-08-22 01:01:44 pm nfm 42.8s Alright, moving … │
|
||
│ 146.88 MHz 26-08-22 01:00:44 pm nfm 35.2s Check out commun… │
|
||
╰───────────────────────────────────────────────────────────────────────╯
|
||
↑↓ move ⏎ play space stop / search t read S I N file d delete m mask q quit
|
||
```
|
||
|
||
| Key | What it does |
|
||
|---|---|
|
||
| `↑` `↓` `k` `j` | move through the recordings |
|
||
| `PgUp` `PgDn` `Home` `End` | a screenful, or straight to either end |
|
||
| `Enter` | play the highlighted recording |
|
||
| `space` | stop playing |
|
||
| `t` | read the whole transcript full screen, scrolling |
|
||
| `/` | filter — by frequency, filename, identification, **or anything that was said** |
|
||
| — | callsigns are found and looked up automatically; no key needed |
|
||
| `s` | sort by date/time, frequency or length |
|
||
| — | each line gives the date and time as `YY-mm-dd hh:mm:ss am/pm`, newest first |
|
||
| `r` | re-read the directory, picking up what a running scan has written |
|
||
| `o` | print the file's path and quit |
|
||
| — | a picture is marked in the list, with the path of its PNG |
|
||
| `S` `I` `N` | file it into `saved/`, `investigate/` or `noise/` |
|
||
| `u` | put the last one filed back |
|
||
| `d` | delete it and its sidecars, for good — asks first |
|
||
| `m` | lock this frequency out, so no later scan stops on it |
|
||
| `q` | quit |
|
||
|
||
### Dealing with what you find
|
||
|
||
A night's scan leaves hundreds of files, most worth nothing and a few of them
|
||
the reason you left it running. Sorting that out is one key per recording,
|
||
going down the list:
|
||
|
||
```
|
||
S saved/ keep this one
|
||
I investigate/ come back to this one
|
||
N noise/ not a signal worth keeping
|
||
d delete it outright — asks first
|
||
m never record this frequency again
|
||
```
|
||
|
||
Each of `S I N` moves the whole capture — the `.wav`, the JSON sidecar, the
|
||
raw IQ if it was kept, the transcript and the decoded data — because a
|
||
recording in one directory and its transcript in another is a pair nothing
|
||
will ever put back together. If a move cannot be finished, whatever already
|
||
moved is put back.
|
||
|
||
The subdirectories are ordinary directories inside the recordings directory,
|
||
so a scan writing there never looks in them, and `saunterbrowse
|
||
~/bandsaunter/saved` reads one back. `u` puts the last one filed back — one
|
||
step, so that a mistyped key costs nothing. `d` asks first, because nothing
|
||
puts that back.
|
||
|
||
`m` is the other half of the same job. A birdie or a pager transmitter that
|
||
fills the directory night after night is a scanning problem, not a recording
|
||
one, so this writes the frequency into the lock-out list in your settings —
|
||
the same list [the scanner's own `l` key](#lock-outs) writes to. It
|
||
takes effect on the next scan; one already running read its settings when it
|
||
started. Locking a frequency out does not delete what has already been
|
||
recorded on it, so pressing `m` and then `d` is the usual thing to do.
|
||
|
||
### Detected callsigns
|
||
|
||
Under the transcript, every callsign heard in it is listed with the name and
|
||
location on its licence:
|
||
|
||
```
|
||
╭─ transcript ──────────────────────────────────────────────────────────╮
|
||
│ │
|
||
│ Alright, moving on. There is an HF net at 1.30pm on 7.242 │
|
||
│ megahertz. Are there any announcements? Alright, KU 0W. │
|
||
│ │
|
||
│ DETECTED CALLSIGNS: │
|
||
│ KU0W Rod R Gowdy — Tucson, AZ · Extra · DM42lj · 85742 │
|
||
│ │
|
||
╰───────────────────────────────────────────────────────────────────────╯
|
||
```
|
||
|
||
Note what the recogniser actually wrote: **"KU 0W"**, with a space. Speech
|
||
recognisers are poor at callsigns — they are not words, they are said one
|
||
character at a time — so a callsign arrives broken wherever the speaker
|
||
paused, and an operator who spells it out gets *"kilo uniform zero whiskey"*
|
||
written down verbatim.
|
||
|
||
A recogniser has never heard of the phonetic alphabet, so it writes what the
|
||
words sounded like and does whatever it likes with the spacing. All of these
|
||
are one callsign, and all of them read back correctly:
|
||
|
||
| What the recogniser wrote | Why |
|
||
|---|---|
|
||
| `KU 0W`, `K7 RA` | broken where the speaker paused |
|
||
| `kilo uniform zero whiskey` | spelled out, one word per character |
|
||
| `Whiskey-One-Alpha-Whiskey` | spelled out and hyphenated |
|
||
| `WhiskeyOneAlphaWhiskey`, `Whiskey1AlphaWhiskey` | run together |
|
||
| `wiskey one alfa whisky` | spelled the way it sounded |
|
||
| `whiskey one alpha, uh, whiskey` | said with a hesitation in the middle |
|
||
| `W1AW-4`, `W1AW/B`, `DL/W1AW` | a suffix, which is not part of the callsign |
|
||
| `WRUC 242`, `7-3-W-F-K-L-2-0-4` | a GMRS or business callsign, said the same ways |
|
||
|
||
A word is only taken apart when it is phonetic *all the way through*, which is
|
||
what keeps this away from English: "kilometre" begins with a phonetic word and
|
||
"victorious" contains one, and neither can be consumed to the end.
|
||
|
||
**Two shapes, not one.** An amateur callsign is a prefix, a district digit and
|
||
a suffix — `W1AW`, `KU0W`, `2E0ABC`. Everything else the FCC licenses is
|
||
called the other way round, letters first and then the digits: `WQVF960` is a
|
||
GMRS licence, `WXG204` an old Part 90 one. On 462 and 464 MHz those are most
|
||
of what is said, and reading only the amateur shape found none of them — nine
|
||
callsigns across five transcripts of one evening's GMRS traffic went by
|
||
unrecognised.
|
||
|
||
The shape is written as the three allocations that exist rather than as
|
||
"letters then digits", which would claim `KN95`, `WD40` and `KC135`. The
|
||
length matters for a second reason: `"7-3-W-F-K-L-2-0-4. 0-4-W-R-C-U"` is a
|
||
real transcript of someone spelling a callsign out, and a looser pattern read
|
||
the `0` that began the next one as part of this one.
|
||
|
||
Callsigns arrive from three directions and all three end up in the same list
|
||
and on the same map: **spoken and transcribed, sent in Morse, or carried in
|
||
the header of an APRS packet.** Neither of the last two involves a speech
|
||
recogniser, so a machine with none installed still builds a map.
|
||
|
||
**Morse over a carrier.** A base station identifying itself in CW does not
|
||
key its carrier: the carrier stays up and the ident is an audio tone keyed
|
||
inside it. A CW detector looking for a keyed carrier sees a carrier that
|
||
never stops, so none of it was being read — and on the land-mobile bands
|
||
that is nearly all of it. An ident of `KSQ330` sat in the middle of a
|
||
27-second capture on 154.369 MHz, cleanly keyed at 22 WPM, and the capture
|
||
was filed as voice with no Morse in it at all.
|
||
|
||
Two things were in the way. The decoder picks its tone and its key-down
|
||
threshold from the whole clip it is handed, so a half-minute recording with
|
||
five seconds of keying in the middle measures both from the other
|
||
twenty-five; and it treated the steady tone either side of the ident as a
|
||
character sliced by the window, dropping the first and last letter — and
|
||
with them, since a callsign is one word with no gap in it, the whole thing.
|
||
|
||
So the recorded audio of **every** capture is now searched, a few seconds at
|
||
a time, and a mark far longer than any dash is read as what it is rather
|
||
than as a truncated element. Nothing was loosened to make that work: each
|
||
window is judged by the same test a whole capture is.
|
||
|
||
Across 677 real captures it claimed Morse in four. Two were idents —
|
||
`KSQ330` and `WNRS309`, each an FCC land-mobile callsign, neither of which
|
||
the scanner had ever seen. One was a 20 WPM burst on 70 cm that reads as
|
||
`E7HNN`, which is plausible and unverified. The fourth was noise on
|
||
445.5 MHz reading as `T T T E E E E E E E E`, and that one taught the last
|
||
rule: E and T are the one-element characters, so a decode made only of them
|
||
can hardly be wrong — there is nothing in it to get wrong — and no station
|
||
has ever identified itself that way. With that rule the count is three.
|
||
|
||
Where the gaps came from decides whether they can be closed. A transcript's
|
||
spacing is the recogniser's guess, so `KU 0W` may be joined; a word gap in
|
||
Morse is seven dot units the sender chose, so `KU0W K` is a station signing
|
||
off, not a callsign one letter longer.
|
||
|
||
The other half of the problem is not inventing them. A browser that reports
|
||
callsigns nobody said is worse than one that reports none, so a run of words
|
||
is only accepted when none of its parts is an ordinary English word — *"or 3.
|
||
Can you open 4"* fits the shape once the punctuation is gone, and is not a
|
||
callsign. A single token said in one breath is trusted, because `W1BOY` is a
|
||
perfectly good callsign. Across 126 real transcripts from an overnight scan,
|
||
that turns three candidates into the one that was actually said.
|
||
|
||
```bash
|
||
saunterbrowse --callsigns # everyone who identified themselves, and where
|
||
saunterbrowse --no-lookup # find them, but contact nothing
|
||
```
|
||
|
||
Lookups use the FCC's own licence data via [callook.info](https://callook.info)
|
||
and fall back to [hamdb.org](https://hamdb.org), both of which need an account
|
||
or a key from nobody. The callsign is the only thing sent; results are cached
|
||
in `~/.cache/bandsaunter/callsigns.json`, so the same net is looked up once
|
||
however many nights you record it, and a lookup never delays the display — the
|
||
entry reads `looking up…` and fills itself in.
|
||
|
||
The second source is not a spare copy of the first. callook holds United
|
||
States amateur licences and nothing else, so `DL1ABC` and `VE3ABC` come back
|
||
`INVALID` from it and resolve perfectly well from the other; and when one
|
||
service is down or rate-limiting, the other usually is not. It is asked only
|
||
when the first has nothing, and which one answered is recorded.
|
||
|
||
**A GMRS or business callsign is not looked up at all**, and says so rather
|
||
than saying "unlisted". Every database reachable without an account is an
|
||
amateur register, and `WQVF960` was never in one — reporting it as missing
|
||
would blame the callsign for the absence of a source. It is still recognised,
|
||
still listed, and still described as what it is.
|
||
|
||
`--no-lookup` contacts nothing. Callsigns are still found and still described
|
||
from their own structure: the prefix is allocated by the ITU and the digit is
|
||
the US licensing district, so `VE3ABC` is Canada and `N7XYZ` is US district 7
|
||
with no database at all.
|
||
|
||
US amateur licence records are public by law and include the licensee's
|
||
address; that is what is shown.
|
||
|
||
### The map
|
||
|
||
A licence says where its holder is, so a list of callsigns is also a map. The
|
||
scanner writes one as it runs — `callsigns.kml` in the output directory —
|
||
which opens in Google Earth, QGIS, Marble or OsmAnd:
|
||
|
||
```bash
|
||
saunterbrowse --kml # build one from recordings already on disk
|
||
saunterbrowse --kml ~/heard.kml # or somewhere else
|
||
bandsaunter scan --kml "" # turn it off
|
||
```
|
||
|
||
Each station is **one placemark, not one per transmission**. Hearing the same
|
||
repeater twenty times in an evening is one operator, and twenty pins stacked on
|
||
the same rooftop would say less than one. The pin carries the callsign, the
|
||
licensee, the town, the grid square, and every frequency and time you heard
|
||
them, so clicking it answers "when did I hear this, and where on the dial".
|
||
|
||
The file is added to rather than replaced — by later scans, and by
|
||
`saunterbrowse --kml` over the same directory — so over a few weeks it stops
|
||
being a snapshot of one evening and becomes a picture of what your aerial can
|
||
actually reach.
|
||
|
||
Where a licence carries no coordinates the grid square is used instead, and the
|
||
placemark says so: a grid square is kilometres across where a licensed address
|
||
is a street. A callsign with no licence on file at all is still recorded, in a
|
||
folder named *no location on file* which starts switched off — that a station
|
||
was heard is worth keeping even when nothing says where it was.
|
||
|
||
It is XML, written atomically, so a scan interrupted halfway through leaves a
|
||
file that still opens. A file already there that is *not* readable as KML is
|
||
never overwritten.
|
||
|
||
### Searching what was said
|
||
|
||
Searching the transcripts is the point of it: *"did anyone mention the
|
||
repeater"* is a question about content, not about filenames.
|
||
|
||
```bash
|
||
saunterbrowse --list | grep -i "mile marker" # or ask it from a script
|
||
saunterbrowse --sort frequency # group by channel, not by time
|
||
saunterbrowse --sort date/time # newest first: the default
|
||
```
|
||
|
||
Playback is handed to whichever player is installed — `pw-play`, `paplay`,
|
||
`aplay`, `sox` or `ffplay`, in that order, or whatever `--player` names. The
|
||
recordings are ordinary WAVs and every desktop already has something that
|
||
plays them; a browser that cannot start would be worse than one that cannot
|
||
play. Over ssh, where there is usually no sound server at the far end, the
|
||
transcripts still work and only `Enter` has nothing to do.
|
||
|
||
Where a recording has no transcript the panel says which of the reasons
|
||
applies — Morse (decoded, and shown), data, a bare carrier, or speech that was
|
||
never offered to a recogniser — because those want different things done about
|
||
them.
|
||
|
||
It only ever reads. Nothing in the recordings directory is renamed, moved or
|
||
deleted.
|
||
|
||
## 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, trunked systems, 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.
|
||
|
||
### The manual page
|
||
|
||
`man bandsaunter` documents every command, option and setting, each with a
|
||
plain-language note on what it is and why you would turn it up, down, on or
|
||
off — written for someone who does not already speak radio. `man
|
||
saunterbrowse` does the same for the browser.
|
||
|
||
It is generated from the same settings table the menus and the flags come from,
|
||
so it cannot describe a setting the program does not have, or miss one it does:
|
||
|
||
```bash
|
||
./packaging/make-man.py # regenerate packaging/bandsaunter.1
|
||
./packaging/make-browse-man.py # and packaging/saunterbrowse.1
|
||
man -l packaging/bandsaunter.1 # read either without installing
|
||
```
|
||
|
||
The `.deb` installs it; installing from source does not, so read it from the
|
||
source tree with `man -l`.
|
||
|
||
## 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
|
||
bandsaunter scan -r 856.4M-856.7M --simulate # the control channel, skipped
|
||
```
|
||
|
||
The demo band holds 2 m FM voice with a CTCSS tone, a repeater, a CW beacon,
|
||
NOAA weather radio, airband AM, an FM broadcast station, P25-style digital
|
||
voice, a POCSAG pager, a bare carrier, a 433 MHz ISM remote, and a SMARTNET
|
||
control channel that never stops transmitting — because that last one is only
|
||
interesting if it behaves the way the real thing does.
|
||
|
||
## 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. SSB transmitters are filtered to their audio
|
||
passband first, since that filter is what makes a signal single-sideband, and
|
||
without it the simulated signal was several times wider than anything on the
|
||
air. Its FSK transmitters are shaped the way GFSK and C4FM shape a symbol
|
||
stream, because square-edged keying is a signal no licensed radio would
|
||
radiate, and its symbols are genuinely pseudo-random: an earlier version
|
||
multiplied the symbol index by an odd constant and took it modulo the level
|
||
count, which returns the low bits of a counter -- 0, 1, 0, 1 -- so every FSK
|
||
test was measuring a tone rather than data.
|
||
|
||
Its transmitters seed themselves deterministically, so a test that fails can be
|
||
made to fail again -- the one thing needed to fix it.
|
||
|
||
Settings have their own tests: every one is set to something other than its
|
||
default, saved, loaded back and compared, so nothing can quietly fail to
|
||
persist. Every setting must also be reachable from both the command line and
|
||
the menus, be read somewhere in the program, display a value that can be typed
|
||
straight back in, and appear in the manual page.
|
||
|
||
## 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.
|