Bandsaunter is intended as a universal radio scanner for USB SDR dongles. Receives, logs, and can display realtime ADS-B data from aircraft on a map, scans any number of frequency ranges and records what it finds, transcribes voice transmissions, finds call signs through transcription and does a lookup and logs the call sign holder. May decode CW/Morse (unverified) and focus is on identifying and decoding any number of other digital transmissions. The scanner can use arbitrary frequency ranges, or pick them out of a band plan catalog, and can scan, in a single pass, any number of frequency ranges, radio bands, and so forth. Logs json and waterfalls for further analysis. **THIS CODE WAS GENERATED WITH AI. IF YOU HATE AI, THIS IS YOUR WARNING.**
Find a file
The Dust Council 96fc21ac7d Aircraft, from the menus, on a live board, over a real map
Four things the ADS-B mode was missing, and one it was actively getting
wrong.

The band plan lists 1090 MHz because that is where ADS-B is, so choosing
it from the band plan is the obvious thing to do -- and it records the
bursts as clicks in a WAV file and decodes nothing, silently.  Both the
scanner and the menus now say so, before the sweep starts, and name the
mode that does decode it.  It is not refused: looking at the raw spectrum
is a fair thing to want.

Menu 5, Aircraft (ADS-B), is the whole mode without a command line.  Every
option on one screen with a line saying what it does, ?N for the long
version and the flag it corresponds to, l to listen, m to draw a map from
any log, s to keep the options.  The listening and the drawing moved into
bandsaunter/aircraft.py so the menus and the command line run the same
code.

While it listens the screen is a live board: one line per aircraft in the
order first heard, the counter climbing as frames arrive, height coloured
low warm to high cold with an arrow for climb or descent, the age of the
last report going green to red, and the line removed once nothing has been
heard for --hold seconds, everything below moving up.  The registers are
asked while it runs, so registration, type, operator and route fill
themselves in as the answers arrive.

--speed-unit knots|mph|kph changes the heading of that board, the speed
beside every aircraft on the map and the speeds in the report, and moves
the distances with it so that one picture never carries two different
miles.  The log stays in knots, which is what the aircraft broadcast.

And there is a real map under the flight paths: {z}/{x}/{y} tiles fetched
once, cached in ~/.cache/bandsaunter/tiles, reprojected from Web Mercator
pixel by pixel, inverted and dimmed so the aircraft stay the brightest
thing on the picture.  The PNGs are decoded here -- zlib and the five row
filters from the specification, checked byte for byte against Pillow on
real tiles -- so nothing new is depended on.  Tiles are cached and never
re-fetched, every request says who is asking, and the attribution is drawn
onto the picture, because a GIF travels without its readme.

conftest now fails any test that reaches for a tile server or a register.
It caught four of these on the way in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-09-04 00:05:32 -07:00
bandsaunter Aircraft, from the menus, on a live board, over a real map 2026-09-04 00:05:32 -07:00
packaging Aircraft, from the menus, on a live board, over a real map 2026-09-04 00:05:32 -07:00
tests Aircraft, from the menus, on a live board, over a real map 2026-09-04 00:05:32 -07:00
.gitignore Initial commit: bandsaunter, an RTL-SDR signal scanner 2026-08-21 20:50:20 -07:00
INSTALL.md Say what the terms are, and how to install under them 2026-09-03 22:58:18 -07:00
LICENSE Say what the terms are, and how to install under them 2026-09-03 22:58:18 -07:00
pyproject.toml Say what the terms are, and how to install under them 2026-09-03 22:58:18 -07:00
README.md Aircraft, from the menus, on a live board, over a real map 2026-09-04 00:05:32 -07:00

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

INSTALL.md has the step-by-step version, including the optional dependencies and what goes wrong first. The short forms:

From a package (Debian, Ubuntu, Mint)

./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
./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:

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

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:

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

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

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:

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:

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

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

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:

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:

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.

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:

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:

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:

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

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:

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).

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.

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.

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.

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.

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 137138 MHz the NOAA weather satellites, one continuous picture per fifteen-minute pass
HF fax 220 MHz, single sideband the marine weather charts, 60240 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) 9698% of pixels exact 9198% — (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.

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

bandsaunter adsb                  # listen on 1090 MHz until interrupted
bandsaunter adsb --frames         # print every frame as it arrives
bandsaunter adsb --simulate       # invent a sky, for a receiver with no aerial
bandsaunter flights               # read the log back: report, map, animation

While it listens, the screen is a live board of what is overhead:

╭─────────────────────────────────────────────────────────────────────────────╮
│ 1090 MHz  6 overhead   9 seen   1,284 frames   19/s   0:04:31   control-C    │
╰─────────────────────────────────────────────────────────────────────────────╯
callsign  ICAO    aircraft       altitude  speed kt  track     position           frames  last
BAW49     4008F6  B744 G-VROS      33,025↑       480  300° WNW   48.2775,-121.8050   1,204    0s
ASA412    A24C71  B738 N625AS      12,400↓       310  155° SSE   47.3323,-122.7387     412    1s
N517HP    A6F109  R44 N517HP        1,200         95  020° NNE   47.6485,-122.2647      88    2s

One line per aircraft, in the order they were first heard. The counter climbs as frames arrive, altitude is coloured low-warm to high-cold with an arrow for climb or descent, and the age of the last frame goes green → yellow → red. When nothing has been heard from an aircraft for hold seconds (45 by default) its line is removed and everything below moves up — the board is the sky now, not a list of everything ever heard. Nothing is lost by it: the log has every frame and the report at the end lists every aircraft.

The registers are asked while it listens, so the registration, type, operator and route fill themselves in on the line as the answers arrive. A narrow terminal drops the columns a website supplied and keeps the ones only the aircraft can give. --frames prints the raw stream instead, and a pipe or a log file gets a plain running count rather than a display that redraws four times a second.

Speeds in whatever you read in. --speed-unit knots|mph|kph (or the option in the menu) changes the column heading on the live display, the speed written beside every aircraft on the map, and the speeds in the report — and it moves the distances with them, so a map labelled in mph has a scale bar in statute miles and one in kph has kilometres, rather than two different miles on one picture. The log always keeps knots, because that is what the aircraft broadcast: the recording stays the thing that arrived, and the conversion happens at the moment of showing it to somebody.

Or from the menus: bandsaunter → 5, Aircraft (ADS-B). Every option is on one screen with what it does beside it, ?N explains any of them at length, l listens and m draws a map from a log — no flags to remember, and the options can be saved as the default.

This is not a scan, and the band plan's adsb preset will not do it. Sweeping 1090 MHz records the bursts as clicks in a WAV file and decodes nothing: the signalling is a megabit a second and the scan path is 12.5 kHz wide. Both the scanner and the menus now say so when a sweep is pointed at 1090 MHz or 978 MHz, rather than letting it run silently.

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. --simulate flies six imaginary aircraft past an imaginary receiver — real frames, real checksums, the same decoder — so the whole of the rest of this section can be tried before any of that is wired up.

What is written down

An aircraft is overhead for four minutes and then gone, so everything heard goes into a log as it arrives: adsb_<time>.jsonl in the output directory, one JSON object per frame, flushed as it is written because a listening session ends with control-C.

{"t":1788496791.486,"icao":"4008F6","df":17,"tc":19,
 "hex":"8D4008F69905A11E202C00D3450D","gs_kt":480.3,"track":300.0,"vs_fpm":640}

The raw frame goes down next to what was read out of it, because the frame is the evidence and everything else on the line is an opinion about it: a better decoder can be run over the same evening later. Beside it goes a readable report, one block per aircraft. A frame costs about 160 bytes on disk, so a busy sky is a few tens of megabytes an hour; --no-log listens without writing anything down.

Who the aircraft is

The frames say 4008F6, not "a Boeing 747 registered in the United Kingdom flying Heathrow to Seattle". That comes from a register, and two are asked — adsbdb for the airframe and the route, then hexdb — with the answers cached for a month. Nothing is sent to either but the address or the callsign that was heard on the air.

What can be answered without asking anybody is: the address block says which country registered the aircraft (fixed by treaty, so 4008F6 is British and A835AF is American with no network at all), and the first three letters of an airline callsign are its ICAO designator, so RYR1234 is Ryanair. --no-lookup stops at that.

4008F6  BAW49
    registration: G-VROS
    aircraft: Boeing Company 747-443
    operator: CELESTIAL AVIATION TRADING 14 LTD
    registered in: United Kingdom
    route: London Heathrow Airport → Seattle Tacoma International Airport
    heard: 2026-09-03 21:44:47 to 2026-09-03 21:48:46 (3 min 59 s, 132 frames)
    from: 48.2742, -121.7963
    to:   48.5334, -122.4725
    flew: 31.2 nm over 81 positions
    altitude: 33,000 to 35,475 ft
    speed: up to 480 kt

The moving map

bandsaunter flights                       # the newest log: report and a GIF
bandsaunter flights evening.jsonl --out sky.mp4 --speed 60
bandsaunter flights --out sky.png         # the whole evening in one picture
bandsaunter flights --kml --no-map        # for Google Earth instead

A log is a list of times and places; drawn on a map with the clock running it is an evening's air traffic. Every frame is a moment: each aircraft is drawn where it actually was then — interpolated between the position reports either side of it, and dead-reckoned from its last known speed and heading where none arrived — so an aircraft crossing the picture in ten seconds took the twenty minutes the data says it took. Nothing moves at a constant speed for the look of the thing, and an aircraft not heard from for five minutes stops being drawn rather than being flown on by guesswork.

Time runs at --speed seconds of flying per second of animation, or give --seconds and let it work the speed out. Altitude is the colour, low warm to high cold, with the key along the bottom; the trail behind each aircraft is the path it actually flew, in the colours of the heights it flew them at.

The GIF is written here from first principles — a palette, an LZW stream, frame differencing with a transparent index — in the same spirit as the PNGs elsewhere, so nothing but numpy is needed to draw one. Where ffmpeg happens to be installed, --out something.mp4 is smaller and smoother; where it is not, nothing breaks and a GIF is written instead.

The ground under it

There is a real map under the aircraft. A flight path over a black rectangle says how the aircraft moved and nothing about where it was; over a coastline it says which airport it left.

Standard {z}/{x}/{y} raster tiles are fetched the first time an area is drawn — OpenStreetMap by default — reprojected from Web Mercator onto the picture pixel by pixel, inverted and dimmed so that the map is the ground and the aircraft stay the brightest thing on it. The PNG tiles are decoded here, by the same reasoning the PNGs are written here: zlib, numpy and the five row filters from the specification, and no imaging library.

Using somebody else's tile server carries three obligations, and all three are met rather than assumed:

  • tiles are cached in ~/.cache/bandsaunter/tiles and never fetched twice, so redrawing an evening costs nothing and works with no network;
  • every request says who is asking, in the User-Agent;
  • the attribution is drawn onto the picture, because a GIF travels without the readme that would otherwise carry it.

A drawing is capped at a few dozen tiles — past that the zoom drops, since a coarser map still says where the coastline is. --no-basemap draws the tracks on their own, --tiles URL points at another server (your own, if you run one), and when there is no network and nothing cached the picture falls back to the plain grid it drew before.

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

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:

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:

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.

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 515% 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 1027. 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:

bandsaunter transcribe recordings/                 # every WAV in a directory
bandsaunter transcribe one.wav --stdout

Re-examine anything later:

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:

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:

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 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.

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 and fall back to 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:

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.

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

Menu 5, Aircraft (ADS-B), is the whole of the aircraft mode without a command line: nineteen options on one screen, each with a line saying what it does, ?N for the long version with the flag it corresponds to, l to listen and m to draw a map from any log in the recordings directory. s saves the options to ~/.config/bandsaunter/aircraft.yaml.

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:

./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:

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:

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

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.

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.

Licence

Copyright © 2026 The Dust Council.

bandsaunter is free software: you can redistribute it and modify it under the terms of the GNU General Public License, version 3 or later, as published by the Free Software Foundation. It is distributed in the hope that it will be useful, but with no warranty whatsoever — not even the implied warranty of merchantability or fitness for a particular purpose. The full text is in LICENSE, and at https://www.gnu.org/licenses/.