bandsaunter/README.md
The Dust Council fb2bb3344b Name the band beside every frequency, and map who was heard
Two additions, both about turning a number into something meaningful.

A band column.  Next to every frequency -- on the live display, in the
line-per-hit output, in saunterbrowse's list and details -- is the name of
the band it falls in.  421 MHz is the 70 cm amateur band, and being told
so is quicker than remembering where the edges are.

The names come from the existing preset table, so there is one band plan
to keep right rather than two, but naming is not the job that table was
shaped for: several presets cover any frequency, some of them whole-tuner
sweeps that say nothing.  So the candidates are ranked.  Sweeps and the
"-complete" duplicates are dropped outright.  The narrowest of what is
left wins, because it says the most -- 146.52 MHz comes back as the 2 m
simplex calling channel rather than as the whole 2 m band.  Two exceptions
where the narrowest would be the wrong answer: ISM yields to the
allocation it shares (433.92 is 70 cm first, 915 is 33 cm first), and
shortwave broadcast yields to amateur where the two overlap, because
3.9-4.0 and 7.2-7.3 MHz are Region 1 and 3 broadcast but Region 2 amateur,
and this plan is documented as Region 2.  6 MHz really is 49 m shortwave
and is left alone.

The name is written into each capture's sidecar, so it travels with the
recording and an edit to the plan later cannot rewrite history, and
saunterbrowse searches on it: /70 cm finds the band without anyone having
to remember 420-450 MHz.

A map.  A licence says where its holder is, so a list of callsigns is also
a map.  Callsigns heard during a scan are now looked up as the transcripts
come in, announced on the display, and written to callsigns.kml in the
output directory; saunterbrowse --kml builds the same file from recordings
already on disk, and the two continue one map rather than starting two.

One placemark per station, not one per transmission: the same repeater
heard twenty times in an evening is one operator, and twenty pins on one
rooftop would say less than one.  Each pin carries the callsign, the
licensee, the town, the grid square, and every frequency and time it was
heard on.  The file is read back on open and added to, so later scans
build it up rather than replacing it.

Where a licence has no coordinates the grid square's centre is used and
the placemark says so -- a square is kilometres across where an address is
a street.  A callsign with no licence at all is still recorded, in a
folder that starts switched off, because that a station was heard is worth
keeping even when nothing says where.  A file already there that is not
readable as KML is never overwritten.

Also fixed along the way:

- The hit list's "no signals recorded yet" placeholder was one cell short
  of its row, so it landed in the SNR column and wrapped, making the panel
  taller than the layout had budgeted for and scrolling the display off a
  short terminal.  The identification column can no longer wrap either,
  which is what _hit_capacity has always assumed.

- Licence lookups now record coordinates.  The cache is versioned so that
  entries written before this are asked about again, rather than pinning
  every station to its grid square for good.

- CallsignBook.wait dropped joined threads; an all-night scan calls it
  after every transcript and the list only ever grew.

- Tests redirect XDG_CACHE_HOME, so a run no longer reads or writes the
  real lookup cache.

676 -> 761 tests.
2026-08-28 11:02:56 -07:00

1267 lines
59 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.94.0 and
7.27.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 420450 MHz.
### 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.
### 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.
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 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`.
`--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 22 Aug 13:01:44 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 13:10:25 fsk 4m00s Motorola SMARTNET / Smart… │
│ 158.294200 MHz 13:05:15 nfm 20.1s Steven, I'm over to Colvi… │
146.88 MHz 13:01:44 nfm 42.8s Alright, moving on. It is… │
│ 146.88 MHz 13:00:44 nfm 35.2s Check out communication o… │
╰───────────────────────────────────────────────────────────────────────╯
↑↓ move ⏎ play space stop / search t read s sort ? keys q
```
| 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 time, frequency or length |
| `r` | re-read the directory, picking up what a running scan has written |
| `o` | print the file's path and quit |
| `q` | quit |
### 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. All three forms read back to `KU0W`.
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),
which needs no account or key. 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.
`--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. Outside the US that structural description is all
there is — callook.info holds US licences only.
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
```
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.