# bandsaunter A signal scanner, recorder and identifier for RTL-SDR receivers. Give it any number of frequency ranges — typed in by hand or picked from a built-in US band plan — and it sweeps them, stops on anything above the noise floor, records it, and works out what kind of signal it was. CW/Morse is decoded to text. Two programs: `bandsaunter` scans, and [`saunterbrowse`](#browsing-what-you-recorded) reads back what it collected — transcripts, identifications and playback, in one screen. ``` ╭──────────────────────────────── receiver ────────────────────────────────╮ │ Rafael Micro R820T/R820T2 2.048 MS/s gain auto +0 ppm │ ╰──────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────── sweep ──────────────────────────────────╮ │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ step 4/6 146 MHz - 146.666667 MHz │ │ ▆ █ ▄▄▄ peak -18.0 dBFS │ │ recording cycle 2 hits 3 dropped 0 detections 7 up 0:04 │ ╰──────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────╮ │ REC 146.52 MHz [nfm] ███████████░░░░░░░░░ 11.4/30s SIGNAL SNR 27 dB │ ╰──────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────── recorded signals ───────────────────────────╮ │ 19:38:43 460.025 MHz 8.2s 27.0 P25 Phase 1 C4FM digital voice │ │ 19:38:43 144.1 MHz 8.2s 27.0 CW / Morse at 18 WPM "VVV DE…" │ │ 19:38:43 146.52 MHz 8.2s 27.0 Narrowband FM voice (CTCSS 100) │ ╰──────────────────────────────────────────────────────────────────────────╯ ``` ## Install **[INSTALL.md](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) ```bash ./packaging/build-deb.sh # writes dist/bandsaunter__all.deb sudo apt install ./dist/bandsaunter_*.deb ``` apt pulls in every dependency itself, and the package blacklists the DVB-T driver that would otherwise claim the receiver. Nothing else to do. Speech transcription is the one part that cannot come from Debian, because no speech recogniser is packaged there. Installed this way, everything else works and the install prints a short note saying how to add one. To have that arrive by apt as well, build the repository below instead. ### From your own apt repository For installing on several machines, or on a fresh one, without hunting for the recogniser afterwards. `build-repo.sh` builds three packages and an apt index: | Package | Arch | Contents | |---|---|---| | `bandsaunter` | all | the application | | `bandsaunter-transcribe` | amd64 | faster-whisper and its dependencies, in `/usr/lib/bandsaunter/vendor` | | `bandsaunter-model-base-en` | all | the `base.en` model, in `/usr/share/bandsaunter/models` | ```bash ./packaging/build-repo.sh # writes dist/repo/ rsync -a dist/repo/ server:/var/www/html/bandsaunter/ ``` Serve that directory over HTTP from anywhere on the LAN, then on each machine: ```bash echo 'deb [trusted=yes] http://server/bandsaunter ./' \ | sudo tee /etc/apt/sources.list.d/bandsaunter.list sudo apt update sudo apt install bandsaunter ``` That single command brings the recogniser and its model too — they are `Recommends`, which apt installs by default. `--no-install-recommends` gets just the application. Nothing reaches the network afterwards: the model is on disk, so the first transcription works offline. `[trusted=yes]` skips signing, which is the sensible trade on a private LAN. To sign it instead, run `gpg --clearsign` over `dist/repo/Release` to produce `InRelease` and drop the `[trusted=yes]`. The vendored packages are appended to `sys.path`, never prepended, so anything apt provides — numpy, PyYAML — still wins; the vendor directory only fills the gap Debian leaves. `BANDSAUNTER_VENDOR_DIR` and `BANDSAUNTER_MODEL_DIR` override both locations. Rebuilding for a new version is the same command; `apt upgrade` picks it up. ### From source ```bash sudo apt install rtl-sdr librtlsdr0 espeak-ng # Debian, Ubuntu, Mint pip install -e . ``` ## Dependencies Everything required is packaged in Debian, Fedora and Arch, so nothing has to be built. | | Package | Debian/Ubuntu | Fedora | Arch | Needed for | |---|---|---|---|---|---| | **required** | librtlsdr | `librtlsdr0` | `rtl-sdr` | `rtl-sdr` | talking to the receiver at all | | **required** | NumPy | `python3-numpy` | `python3-numpy` | `python-numpy` | all signal processing | | **required** | SciPy | `python3-scipy` | `python3-scipy` | `python-scipy` | filters, resampling, spectra | | **required** | Rich | `python3-rich` | `python3-rich` | `python-rich` | menus and the live display | | **required** | PyYAML | `python3-yaml` | `python3-pyyaml` | `python-yaml` | settings file and profiles | | *recommended* | eSpeak NG | `espeak-ng` | `espeak-ng` | `espeak-ng` | clearer spoken timestamps | | *optional* | rtl-sdr tools | `rtl-sdr` | `rtl-sdr` | `rtl-sdr` | `rtl_test` and friends for diagnosis | | *optional* | a speech recogniser | **pip only** | **pip only** | AUR | transcribing speech to text | | *optional* | Matplotlib | `python3-matplotlib` | `python3-matplotlib` | `python-matplotlib` | nothing yet; reserved for plots | Two notes on the optional ones: **eSpeak NG is a recommendation, not a requirement.** Without it the spoken timestamps come from a built-in formant synthesiser, so that feature works on a machine with nothing else installed. With it they are clearer and render about three times faster. **No speech recogniser is packaged for Debian.** `faster-whisper`, `vosk` and the pocketsphinx Python bindings are all absent from the archive, so transcription can only be installed with pip: ```bash pip install faster-whisper # best on radio audio, ~120 MB pip install vosk # ~10 MB plus a 40 MB model, weaker on noise bandsaunter transcribe --engines ``` That is why the plain `.deb` cannot depend on one. Debian Policy forbids anything in the archive from requiring software outside it, and a `postinst` that reaches out to PyPI would break offline and reproducible installs — so a package in the archive simply cannot pull these in. Transcription is therefore off by default and reports plainly when no recogniser is present, rather than the install failing or the feature appearing broken. A repository of your own is not bound by that rule, which is what [`build-repo.sh`](#from-your-own-apt-repository) exploits: it packages faster-whisper and its model itself, into a private directory rather than into `dist-packages`, and lets apt install them alongside. Nothing is downloaded at install time, and nothing collides with an apt-managed module. Mixing the two is nonetheless fine here. Modern Debian marks the system Python as externally managed (PEP 668), so a pip install lands in your user site directory: ```bash pip install --user faster-whisper # ~/.local/lib/python3.x/site-packages ``` which is on `sys.path` for the system interpreter. A bandsaunter installed from the `.deb` into `/usr/lib/python3/dist-packages` picks it up with no further configuration — verified, not assumed. A virtual environment works too, as long as bandsaunter runs inside it. Getting these into Debian proper would be a different matter: it would mean packaging ctranslate2, tokenizers, onnxruntime and their dependencies, several of which are large C++ or Rust projects, each to archive standards. That is why none of them are there, and why the local repository vendors the wheels instead of trying to do it properly. ### Other distributions ```bash sudo dnf install rtl-sdr python3-numpy python3-scipy python3-rich \ python3-pyyaml espeak-ng # Fedora sudo pacman -S rtl-sdr python-numpy python-scipy python-rich \ python-yaml espeak-ng # Arch brew install librtlsdr espeak-ng && pip install -e . # macOS ``` ### Letting your user reach the receiver The DVB-T television driver claims RTL dongles on sight and has to be kept away from them. The `.deb` does this for you; from source: ```bash echo 'blacklist dvb_usb_rtl28xxu' | sudo tee /etc/modprobe.d/blacklist-rtlsdr.conf sudo rmmod dvb_usb_rtl28xxu # or just unplug and replug the receiver ``` If the device is found but cannot be opened, your user needs permission for it. Most distributions ship a udev rule with `rtl-sdr`; failing that: ```bash echo 'SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2838", MODE="0666"' \ | sudo tee /etc/udev/rules.d/20-rtlsdr.rules sudo udevadm control --reload-rules && sudo udevadm trigger ``` ### Check it worked ```bash bandsaunter devices --test # opens the receiver and captures a test block bandsaunter scan -b 2m --simulate # exercises everything without hardware ``` ## Versioning Releases are named for the day they were made and a revision within that day: ``` 2026-08-21_01 first build on the 21st 2026-08-21_02 second build the same day 2026-09-01_01 ``` The revision is padded to two digits so that versions sort correctly as text — without it, revision 10 would sort before revision 2. Packaging tools cannot use that form directly, so it is converted at the edge rather than kept as a second version string that could drift: | Where | Form | Why | |---|---|---| | the program, `--version` | `2026-08-21_01` | what you asked for | | pip, `pyproject.toml` | `2026.8.21.1` | PEP 440 forbids dashes and underscores in a release | | dpkg, the `.deb` | `2026.08.21.01-1` | Debian versions may not contain underscores | `bandsaunter/__init__.py` holds the date and revision; the other two forms are derived from it, and the test suite checks that all three describe the same release and that both pip and `dpkg --compare-versions` order them correctly. ## Quick start ```bash bandsaunter # the menus: set up and scan bandsaunter scan -b 2m -b marine-vhf # band-plan presets bandsaunter scan -r 144M-148M -r 420M-450M # your own ranges bandsaunter scan -b 2m --simulate # try it without hardware ``` ## Two ways to drive it Everything is available both ways. Run `bandsaunter` with no arguments for the menus, or pass flags for scripting — the two are generated from the same table of settings, so neither can offer something the other cannot. ``` 1 Frequency ranges 3 configured 2 Band plan 107 US presets 3 Settings record no limit, hang 6s, squelch +12 dB, keep voice, cw 4 Saved settings and profiles h Help s Start scanning q Quit ``` Settings are grouped, show their current value against the built-in default, and carry their own help: ``` # setting value what it does 1 Record for * no limit longest one signal may hold the receiver 2 Wait for quiet * 6 s quiet time before the sweep resumes 3 Absolute limit 900 s ceiling on one capture, even when 'Record for' is 0 * differs from the built-in default Number to change it, ?N for help on one, d to reset the group, blank to go back. ``` `?2` explains a setting in full, including the command-line flag that does the same thing. Typing a search term instead of a number finds settings by any word in their name or description — `voice score` finds the speech threshold. Values may be typed with their units: `5 s`, `2.048 MHz`, `12 dB`, `48k`, or `no limit` for the settings that accept 0. ## Settings that persist Settings are saved to `~/.config/bandsaunter/config.yaml` and picked up by every later run. Save them from the menus (**4 → s**) or from the command line: ```bash bandsaunter config # open the settings menu bandsaunter config hang_seconds=6 record_seconds=0 # set and save directly bandsaunter config --show # every setting, with defaults bandsaunter config --describe hang_seconds # explain one in full bandsaunter config --path # where the file lives bandsaunter config --reset # back to defaults ``` Three layers apply in order, each overriding the last: 1. the saved settings file 2. a named profile, if `--profile` is given 3. any flags on the command line So a saved squelch of 12 dB stays in force while `--hang 1.5` overrides just the hang for one run. `--no-config` ignores the file entirely; `--save` stores the resulting settings as the new default. Named profiles live beside it in the same directory: ```bash bandsaunter scan -b 2m -b 70cm --record 0 --hang 6 --save-profile local bandsaunter scan -p local bandsaunter profiles ``` Everything that can be given as a flag can be saved, and everything that can be saved can be given as a flag — both front ends are generated from one table, so they cannot drift apart. A profile written by an older version still loads: keys it does not have take their defaults, and keys that no longer exist are reported and ignored. Every setting is explained in plain language — what it is, and when you would change it — in three places: `bandsaunter config --describe `, `?N` in the settings menu, and `man bandsaunter`. ## Entering frequencies **By hand** — repeat `-r` as many times as you like; there is no limit on the number of start/end pairs. ```bash bandsaunter scan -r 144M-148M -r 462.5M-467.8M -r 929M-932M ``` A range is `start-end`, with optional `/step` and `@mode`: | Form | Meaning | |---|---| | `144M-148M` | explicit start and end | | `144-148M` | the unit carries over to the left end | | `146.52M` | a single frequency | | `144M-148M/25k` | with a channel step | | `144M-148M/25k@nfm` | and a forced demodulator | Units may be written `144M`, `144 MHz`, `144000k`, or plain Hz. A bare number below 10000 is read as MHz, so `-r 162.4-162.55` does what you expect. **From the US band plan** — 107 presets across 18 categories: ```bash bandsaunter bands --categories # list categories bandsaunter bands --category Aviation # everything in one category bandsaunter bands pager # search bandsaunter scan -b gmrs -b railroad -b noaa-weather ``` Each preset carries its own channel spacing, demodulator and bandwidth, so `-b marine-vhf` scans 25 kHz channels in NFM while `-b fm-broadcast` uses 200 kHz WFM, without being told. Each amateur band also has a **complete** entry that covers the whole band and picks the demodulator per segment, because a band is not one mode: ```bash bandsaunter scan -b 2m-complete ``` scans 144-148 MHz as CW below 144.1, SSB to 144.3 and FM above it, so a CW beacon at the bottom is decoded to text while a repeater at the top is demodulated as FM — in one sweep. There is one for every band from 160 m to 33 cm: `160m-complete`, `80m-complete`, ... `70cm-complete`, `33cm-complete`. Where an amateur band overlaps another service the amateur reading wins inside a complete-band sweep — 433 MHz is treated as 70 cm rather than as the ISM band it shares — while scanning `-r 433.9M` on its own still treats it as ISM. A few presets stand for a *set* of others, so scattered segments can be picked in one go: ```bash bandsaunter scan -b all-cw --record 0 --hang 6 ``` `all-cw` covers every CW allocation in the plan — 160, 80, 40, 30, 20, 17, 15, 12, 10, 6 and 2 metres — as eleven separate ranges rather than one span from 1.8 to 144 MHz. That is 1.25 MHz of spectrum in total, so a full pass takes under a second and CW gets decoded to text as it turns up. Direct sampling switches itself on for the HF segments and off again from 12 m upward; the HF part needs an HF antenna to be worth anything. The menus do both: browse the band plan by category, or type in start/end pairs one after another. Ranges can be listed, removed, toggled on and off, and have their demodulator changed from the ranges menu. ## The two dwell settings These are the settings that decide how the scanner behaves when it finds something: | Setting | Flag | What it does | |---|---|---| | Record for X seconds before continuing | `--record 30` | The longest a single signal may hold the receiver. `0` means stay as long as it keeps transmitting. | | Wait for X seconds of no signal before continuing | `--hang 3` | How long the channel must stay quiet before the sweep resumes. **Gaps shorter than this are recorded straight through.** | ```bash bandsaunter scan -b 2m --record 30 --hang 3 ``` Whichever comes first wins, and that is worth being clear about: **`--record` overrides `--hang`.** A transmission still in progress at the record limit is cut off there, however long the hang time is. If a recording keeps ending at exactly 30 seconds, that is the default record limit doing it, not the hang — set `--record 0`. The scan reports it when this happens. Both are measured in **samples**, not wall-clock time, so a 30 second setting produces a 30.0 second recording. Supporting settings: - `--min-record 0.5` — discard anything shorter, so brief noise spikes leave nothing behind on disk. - `--revisit 8` — ignore a frequency for this long after recording it, so a busy repeater does not monopolise the sweep. - `--max-record 900` — absolute ceiling on one capture, applied even when `--record` is 0. - `--threshold 8` — squelch, in dB above the measured noise floor. ### Capturing both sides of a conversation `--hang` is what holds a recording open across the natural pauses in two-way traffic. Set it longer than the gap between overs and the whole exchange lands in one file: ```bash bandsaunter scan -b 2m --record 0 --hang 6 ``` `--record 0` is the important half. Without it the per-signal cap cuts the exchange off mid-sentence no matter what the hang time is — the default 30 seconds is a common surprise. `--max-record` (default 900 s) still bounds an unlimited capture so nothing runs away. To make it the permanent default: ```bash bandsaunter config record_seconds=0 hang_seconds=6 ``` "Quiet" means *no real signal*, not merely a closed squelch. Silence, static and interference all count towards the timer, so a burst of noise during a pause does not reset it and park the receiver on a finished conversation. Recognising that a signal carries nothing takes a couple of seconds of evidence, so expect the tail to run a little past `--hang` in that case. Once a capture has produced real content it is never abandoned as noise, since a quiet spell between overs would otherwise throw the conversation away. ## Only real signals get recorded A power threshold cannot tell a transmission from a hump of interference, so every capture is checked for *content* before it is kept. Recording happens only for: | Category | What it means | |---|---| | `voice` | speech structure in the demodulated audio: a pitch track in the 70-400 Hz range that drifts the way intonation does, pauses between phrases, syllable-rate envelope modulation, and formants that move | | `cw` | a keyed carrier whose timing resolves as Morse | | `digital` | an identified keying scheme: discrete FSK levels, an M-PSK phase line, or on-off keying -- corroborated by a symbol rate | Two more categories exist but are not accepted by default: `carrier` (unmodulated, real but empty) and `trunk` (a [trunking control channel](#trunked-systems-and-their-control-channels)). Everything else -- static, hum, switch-mode power supply harmonics, clock spurs, bare carriers, trunking control channels -- is discarded, and the files it wrote are deleted. The check runs *while* the capture is still going, so interference is dropped after a second or two instead of holding the receiver for the whole record time. ```bash bandsaunter scan -b 2m # voice, CW and digital (default) bandsaunter scan -b 2m --accept voice # voice only bandsaunter scan -b ism-433 --accept digital # data bursts only bandsaunter scan -b 2m --keep-carriers # also keep unmodulated carriers bandsaunter scan -b 2m --keep-everything # no content check at all ``` Tuning knobs: `--min-voice-score` (0-1, default 0.45) sets how speech-like audio must be; `--min-signal-score` sets the confidence needed to keep anything; `--verify-max` caps how long a contentless capture is given to prove itself. ### What makes this hold up against interference Static is good at imitating most of the things that look like structure, so each test is built so that noise cannot pass it: - **A pitch track that moves gates the voice score**, rather than contributing a share of it. Dynamics, syllable-rate modulation and energy in the voice band are all things hiss does too; weighted alongside voicing they were enough to carry noise over the line on their own. Speech is the only thing here that produces a pitch period that drifts, so nothing is called voice without one. A steady tone or mains hum has a perfectly stable "pitch" and is rejected for exactly that reason. Measuring drift needs several voiced frames, so the requirement eases for a short over that cannot supply them; the steady-tone guard still applies. - **A symbol rate has to hold still across the capture.** The estimator always returns its best peak, so on noise it reports one rate for the first half of a capture and a different one for the second. Real data keeps one. - **Keying has to land on a grid.** On/off contrast alone is not evidence: a signal fading across the squelch produces plenty of it, with run lengths that fit no symbol period at all. - **A phase line has to be created by the exponentiation.** An unmodulated carrier -- including the gaps between phrases on an FM channel -- already has a line at every power, and would otherwise look like textbook PSK. - **The demodulator is chosen from the signal**, not from the band plan, by probing briefly before recording. An AM signal inside a band listed as FM would otherwise be recorded through the wrong detector, giving audio that is useless to listen to and impossible to judge. Three ratios settle it — how much the envelope varies, how far the tone swings, and how much power sits in a carrier — because those hold steady over a fraction of a second whatever is being said. Running the full classifier on so short a probe was tried and is not reliable: speech makes any modulation look bursty over half a second, and AM came back as on-off keying while FM came back as AM. The probe is played into the recording rather than discarded, so a short over does not lose its opening. - **Content is judged only on the audio that was actually recorded**, and only on the stretches the squelch called signal. Demodulating two ways and keeping whichever scored higher is cherry-picking, and on noise one of the two always flatters it. The gate needs roughly two seconds of audio to judge speech reliably, so pair it with `--record 5` or more rather than very short capture limits. ### Keeping up with the radio An RTL-SDR only delivers samples while the host is actively reading. Anything that arrives while the program is busy demodulating is discarded by the driver, and a recording then holds *less* than really went by -- which plays back too fast. Two things keep that from happening: - **Captures stream asynchronously.** A ring of USB transfers stays queued in a background thread, so the dongle is never waiting for the host. Sweeping still uses plain reads, because each dwell is an independent snapshot and a gap between them costs nothing. - **The signal path is fast enough to keep up.** Decimation computes only the samples that survive, rather than filtering at the input rate and throwing away seven of every eight outputs; the quarter-rate local oscillator is the four-step cycle 1, -j, -1, +j and needs no trigonometry; and pitch tracking runs through the FFT instead of a direct autocorrelation per frame. Together those took the capture loop from 65% of the real-time budget to under 10%. If the host does fall behind anyway, the scan reports how many samples were lost rather than silently producing a fast recording. ### Why the threshold is what it is The sweep uses peak-hold, which keeps the largest value each FFT bin reached during the dwell. That finds bursty traffic that averaging would bury -- but it also means noise alone rides several dB above the measured floor. On this hardware, empty spectrum reaches 5-9 dB above a percentile floor with nothing transmitting. So `--threshold` is a margin over *noise*, not over the floor: the offset that noise alone clears is computed from the detector (segment count and bin count) and added automatically. A threshold of 8 means 8 dB of real headroom. It is deliberately not measured from the spectrum -- a spread estimated from the data reads five times higher across the packed broadcast FM band than on empty spectrum, which would suppress exactly the stations you are looking for. ## Signal identification Every recording is classified from its own IQ. The classifier measures occupied bandwidth, envelope statistics, discriminator levels, phase behaviour, spectral flatness and symbol rate, then combines those with the frequency to name the signal: | Family | Recognised as | |---|---| | Analogue voice | Narrowband FM (with CTCSS tone or DCS), wideband FM (stereo pilot detected), AM, SSB (USB/LSB) | | CW | Keyed carrier, **decoded to text** with the speed in WPM | | Digital voice | P25 C4FM, DMR (TDMA burst structure), NXDN, D-STAR | | Data | POCSAG and FLEX paging, ACARS, AIS, APRS/AFSK1200, 2-FSK and 4-FSK, BPSK/QPSK/8-PSK | | Other | Unmodulated carriers, OOK/ISM devices, ADS-B and UAT, DME/TACAN pulses, wideband OFDM/cellular | Each result carries a confidence and the reasoning behind it: ``` 146.520038 MHz 3.0s SNR 27.6 dB Narrowband FM voice (CTCSS 100.0 Hz) (88%) 4.9 kHz wide, 0.9 kHz rms deviation ``` Low SNR reduces confidence rather than producing a confident wrong answer. ### Which band it is in Next to every frequency, on the live display and in the line-per-hit output, is the name of the band it falls in: ``` time frequency band dur SNR identified as 21:14:07 146.52 MHz 2 m FM Simplex 5.0s 20.0 Narrowband FM voice 21:14:31 462.5625 MHz GMRS / FRS 4.2s 18.3 Narrowband FM voice 21:15:02 421 MHz 70 cm Amateur 12.7s 22.9 Narrowband FM voice 21:15:40 162.55 MHz NOAA Weather Radio 30.0s 31.4 Narrowband FM voice ``` `421 MHz` is the 70 cm amateur band, and being told so is quicker than remembering where the band edges are. The names come from the same band plan the presets do, so there is one table to keep right rather than two. Several allocations usually cover any given frequency, and the narrowest wins because it says the most: `146.52 MHz` comes back as *2 m FM Simplex* rather than *2 m Amateur*, and `14.050 MHz` as *20 m CW / Digital*. Two exceptions, both because the obvious answer would be the wrong one: - **ISM yields to the allocation it shares.** 433.92 and 915 MHz are ISM bands, but they are also 70 cm and 33 cm. A signal there is far more likely to be worth naming as the amateur band, so it is — unless nothing else covers it, in which case ISM is still the right answer. - **Shortwave broadcast yields to amateur, where they overlap.** 3.9–4.0 and 7.2–7.3 MHz are broadcast in ITU Regions 1 and 3, and amateur in Region 2, which is what this plan describes. 6 MHz really is 49 m shortwave, and there is no amateur band anywhere near it, so that one is left alone. The band name is written into each recording's sidecar too, so it travels with the capture, and `saunterbrowse` will search on it — typing `/70 cm` finds everything in the band without having to remember 420–450 MHz. ### Reading data signals A great deal of what a scanner finds is not speech. Doorbells, tyre-pressure sensors, weather stations, remote controls, paging, packet radio — all of it carries something a receiver can read, and bandsaunter reads it: ``` 21:14:07 433.92 MHz 70 cm Amateur 3.2s SNR 45.8 dB EV1527 / PT2262-style remote (93%) EV1527 / PT2262-style remote 24 bits 516 baud x12 B2 35 4E 21:14:31 929.6125 MHz UHF / 900 MHz Paging 4.5s SNR 49.5 dB POCSAG 1200 (97%) [1234568D] ENGINE 4 RESPOND [0098765A] CALL EXT 4412 21:15:02 144.39 MHz 2 m Amateur 1.8s SNR 31.2 dB AX.25 / APRS (93%) W1AW>APRS>WIDE1-1: !4142.45N/07243.63W-Newington CT ``` **Whatever the modulation, a data signal is the same shape once it has been sliced**: a train of alternating runs whose *lengths* carry the information. On-off keying gives that directly — the carrier is up or it is down — and two-level FSK gives exactly the same thing from the discriminator, one tone or the other. So both are reduced to runs, and everything after that is shared. What the runs mean is the line code, and it is worked out from the runs alone rather than configured, because each code makes a different prediction about which of the two histograms is the bimodal one: | Code | Pulses | Gaps | Who uses it | |---|---|---|---| | **PWM** | two lengths | constant, or the period is | EV1527, PT2262 and nearly every 433 MHz remote | | **PPM** | constant | two lengths | the other half of the same market | | **Manchester** | T and 2T only | T and 2T only | anything whose receiver recovers its own clock | | **NRZ** | any whole number of symbols | same | what a framed protocol sits on | Four-level FSK — C4FM, as P25, DMR and NXDN send it — is recognised as such and read as symbols. Slicing it down the middle also produces bits, and they mean nothing; a capture that had been coming back as "10783 bits of NRZ at 5335 baud" now says *4-level FSK, 5334 baud, no frame sync recognised*, which is both true and useful. Where a frame sync word does appear, the system is named outright. ### Protocols that can be read in full Two carry their own framing and checksums, so a frame either passes or it does not — and one that passes is not a guess: **POCSAG** paging, at 512, 1200 or 2400 baud. Nothing in the signal announces which rate it is, so all three are tried and the one whose 32-bit sync word turns up is the right one. Every codeword is checked — and a single bit error corrected — against the BCH code the standard puts there for exactly that. The address, function letter and message text all come out. **AX.25 / APRS** on 1200 baud AFSK. The frame check has to come out right before a frame is reported at all. The sender's callsign, the digipeater path and the payload are shown — and the callsign goes onto the map with everyone else. ```bash bandsaunter analyze capture.cf32 --rate 48000 # decode a file you already have bandsaunter scan --no-decode-data # turn it off saunterbrowse # decoded packets sit where a transcript would ``` ### Believing a decode This is the hard half. A decoder that always returns *something* is worse than useless: noise sliced at a threshold produces runs, and runs produce bits. Three things guard against that. - **The runs have to fit.** A decode whose runs do not quantise to the line code's own grid is thrown away. - **Most of the capture has to agree.** A data signal is data all the way through. One lucky window in eight is a coincidence — and that is exactly what SSB voice produced before this check existed. - **The packet has to repeat.** Much the strongest of the three. These transmitters send the same thing three to ten times over, and bits that come back identical every time did not come from noise. A bare reading with none of that behind it — where the run lengths merely happened to land on a grid — is reported as **nothing at all**, rather than as a bit string with a low number beside it that somebody will read anyway. Across 27 recordings of speech, music, static, a bare carrier, Morse and PSK, the decoder returns nothing 27 times. And a decode that *does* have repeats or a checksum behind it outranks the content check. A burst of keying demodulated as FM audio is a buzz, and the speech detector likes a buzz — but a frame whose own checksum came out right is not a statistic. ### Trunked systems and their control channels Police, fire and most large business radio in the US runs on *trunked* systems. Rather than giving each department a frequency of its own, the system owns a pool of channels and hands one out per conversation. For that to work, one frequency is given over entirely to a data stream that runs day and night telling every radio in the fleet where to go next. That frequency is the **control channel**. It is the worst thing a scanner can find: loud, perfectly steady, never silent, and with nothing on it to hear — just a harsh buzz. Left to itself a scanner parks on it for the whole record limit, saves the file, and finds it again on the next sweep, for as long as it runs. bandsaunter recognises one and moves on, usually within a second or two: ``` TRUNK 856.561096 MHz -- Motorola SMARTNET / SmartZone (Type I/II) control channel, 3600 baud -- skipping ``` In the live display the recording panel turns yellow and says `TRUNK:` with the system name instead of `REC`, and the end-of-run summary lists every control channel found and where it was. What identifies one is a constant-envelope data stream that never pauses, at a symbol rate belonging to a known trunking standard: | Symbol rate | Levels | System | |---|---|---| | 3600 baud | 2 | Motorola SMARTNET / SmartZone (Type I/II) | | 9600 baud | 2 | EDACS / ProVoice | | 1200 baud | 2 | MPT-1327 | | 4800 baud | 4 | P25 or DMR Tier III | | 2400 baud | 4 | NXDN / NEXEDGE | The first two are called immediately — nothing else transmits at those rates without pausing. The rest share their shape with an ordinary digital voice call on the same system, so they are only judged to be a control channel once the carrier has run unbroken for `--control-seconds` (20 s by default), which is longer than a real conversation goes without taking a breath. Raise it if digital voice is being skipped by mistake. Sitting in a band where trunking is common raises confidence but is never required — trunking is licensed on business pairs all over the spectrum, so the shape of the signal has to be enough on its own. ```bash bandsaunter scan -b 800-trunked # control channels named and skipped bandsaunter scan -b 800-trunked --keep-control # record them (for a decoder) bandsaunter scan -b 800-trunked --lockout-control # never look at them again ``` `--lockout-control` adds each one to the lock-out list as it is found; with lock-out saving on (the default) that list is written to your settings file and survives a restart. ### CW / Morse Keyed carriers are decoded to text. The speed is measured from the signal, so nothing has to be configured, and anything from about 8 to 40 WPM reads reliably: ``` 144.1 MHz 12.0s SNR 50.8 dB CW / Morse at 18 WPM CW "VVV DE W1AW FN31" ``` The decoder runs its own CW detector over the captured IQ, so Morse is found even when the recording itself was made in FM or SSB — and it is run over **every** capture once it has finished, whatever the classifier called it. #### Short bursts, which is most of it Most of the Morse on the air is not a conversation. It is a repeater, a beacon or an unattended transmitter saying who it is and stopping — four to six characters, over in a second or two: ``` 147.06 MHz 5.3s SNR 31.2 dB CW / Morse at 20 WPM CW "DE K1AA" K1AA Newington Radio Club — Newington, CT · FN31pr ``` That burst is a fraction of a capture the classifier named after whatever filled the rest of it, so waiting for the label to say "CW" missed it. Short is now the normal case rather than the awkward one, and a decode of two or three characters gets in on its timing alone: - every element within a third of a unit of one or three - every character resolving to something in the table - **and the keyed tone at least 20 dB above the rest of its band** The last one is what separates an ident from a blip, and it is not decoration. With four elements the dot length is fitted to those very elements, so they land on the grid whatever produced them — a third of a second of white noise decodes as a perfectly timed `V`. Measured over 200 noise blocks the loudest bin never rose 13 dB above the median of its band, while keying at 3 dB SNR sits above 40, so 20 dB has room on both sides. One keyed element is refused outright: a single pulse is an `E` or a `T` whether a person sent it or the squelch opened on a click. #### What the capture window cut off A capture opens when the squelch does, which is in the middle of an element as often as not. Half a character is not a smaller reading of what was sent — it is a different one. A `K` missing its first dash is an `A`; a `W` missing its first dot is an `M`. So the character at a sliced end is dropped, and so is the rest of the word it was in, because what is left of that word can read as a whole one: **`K1AA` caught halfway through is `K1A`, which belongs to somebody else.** The full text is still shown; it is the *identification* that is held to the stricter standard. Over 1805 truncated captures of four different messages, that turns 107 invented callsigns into none, while still recovering 550 correct ones. ## Pictures Three of the things a receiver can hear are images rather than sounds. All three are analogue, all three encode brightness as a frequency, and all three arrive as the audio the scanner already records — so they are looked for in every recording and written out as PNG beside it. | | Where | What it is | |---|---|---| | **SSTV** | 14.230 MHz, 144.5 MHz | amateur slow-scan television, in colour — Martin M1/M2, Scottie S1/S2/DX, Robot 36/72 | | **APT** | 137–138 MHz | the NOAA weather satellites, one continuous picture per fifteen-minute pass | | **HF fax** | 2–20 MHz, single sideband | the marine weather charts, 60–240 lines a minute | ``` 147.06 MHz 118.0s SNR 24.1 dB SSTV (Martin M1) picture: SSTV Martin M1 320x256 → ~/bandsaunter/0014.230000MHz--2026-08-29_14_02_11-usb.png ``` **None of the three is guessed at**, which is what makes it safe to try them on every recording. SSTV announces itself with a VIS header that says which mode follows. APT carries two different sync patterns exactly 1040 words apart. Fax opens with twenty seconds of phasing — black lines with a pulse at the start of each — that nothing else on the air sends. A decoder without one of those draws static beautifully, and a directory of beautifully rendered static is worse than an empty one. Measured over noise, tones, speech and frequency-swept whistles: **no false pictures in 295 attempts**. Accuracy, against transmissions built from the published specifications: | | 30 dB SNR | 12 dB | 6 dB | |---|---|---|---| | SSTV (all seven modes) | 96–98% of pixels exact | 91–98% | — (header lost below 9 dB) | | APT | 0.97 correlation | 0.94 | 0.88 | | HF fax | 0.998 correlation | 0.99 | 0.97 | Each mode's line timing is checked against its published line time — 446.446 ms for Martin M1, 428.220 for Scottie S1, and so on — because a line a few milliseconds long walks the picture off the bottom of the screen within ten lines, and that is a thing worth failing a test over rather than noticing in a PNG. **A picture keeps its capture whatever the content check made of it.** A satellite is a steady tone with a wobble on it and an SSTV transmission is a whistle: neither is speech, neither has symbol structure, and both were being discarded as "no signal content" *having already been recognised*. Pictures take minutes rather than seconds, so `--record` has to be long enough or what arrives is the top of one. A partial picture is kept and labelled partial rather than thrown away — most SSTV captures are partial, and half a picture is still a picture. > **GRIB** is sometimes asked about in the same breath and is not a modulation: > it is the binary format weather models are published in, and it travels by > satellite data link and by e-mail rather than as something a receiver > demodulates. Where a decoded byte stream begins with its magic number it is > named; nothing here fetches or renders one. ## Waterfalls Most of what a scanner records cannot be turned into words. A data burst, a keyed carrier, a pager, a trunking control channel, a stretch of something unidentified — the classifier names what it can and the rest is a WAV file that tells you nothing until you open it in something else. A waterfall says something about every signal there is, because it shows the shape of the thing rather than its meaning: how wide it is, how long it lasted, whether it was keyed, swept, hopping or steady, and whether it was one signal or three side by side. So **every capture that produced no readable words gets one drawn beside it** as a PNG — no voice, or voice that came back from the recogniser with fewer than five characters, which is what a recogniser handed something that is not speech reliably does. A capture with Morse in it never counts as readable, however much the recogniser made of it. A station identifying itself in CW over an FM carrier comes back as a string of digits, one per tone — sixteen characters of nothing, sailing past any bar you set. The ident is in the Morse text; the signal itself is only visible as a picture. ```bash bandsaunter waterfall # draw a directory already recorded bandsaunter waterfall --all # including the ones that read fine bandsaunter waterfall --check-morse # listen again before believing them bandsaunter waterfall --redraw recordings/ ``` `--check-morse` is for recordings made before the CW decoder could hear an ident over an FM carrier: their sidecars call a repeater readable, because the recogniser turned its tones into digits. It runs the decoder over the recordings that would otherwise be skipped, draws the ones that turn out to have an ident in them, and writes the ident into the sidecar so the browser shows it and the next run needs no second listen. Time runs down the picture and frequency across it, which is the way a receiver draws one. The frequency scale is on top, the seconds down the left, and the caption underneath says what the capture was. **It says which picture it is, and that matters.** Where the raw IQ was kept (`--save-iq`) this draws the radio spectrum around the tuned frequency — the waterfall an operator would have been watching. Where only the audio was kept, which is the usual case, it draws the demodulated audio instead: after an FM detector the frequency axis is no longer radio frequency, and a picture that did not say so would be a lie told in a convincing font. The caption ends in `RF SPECTRUM` or `DEMODULATED AUDIO` accordingly. The PNG is written the same way the SSTV and satellite pictures are — from zlib and struct, with no imaging library — including the 5×7 font the axis labels are drawn with, so a machine with nothing installed but numpy draws the same picture as one with everything. `saunterbrowse` marks the capture `waterfall`, gives the path in full, and `o` prints it: there is no listening to a data burst. A decoded pager message or a Morse ident still wins the panel, because a waterfall is a view of a signal rather than a reading of one. Pictures are around half a megabyte each — 542 of them for one directory of 677 recordings came to 302 MB, against 1.8 GB of audio. `--no-waterfall` turns it off. ## Aircraft ```bash bandsaunter adsb # listen on 1090 MHz until interrupted bandsaunter adsb --frames # print every frame as it arrives bandsaunter adsb --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. ### A window, while it happens ```bash bandsaunter adsb --window # or the menus: 5, then r ``` The terminal board says what is overhead; this says **where**. A real map, the aircraft moving on it as the frames arrive, and beside each one a box with everything known about the flight — type and registration, who operates it, where it came from and where it is going — each end with its country's flag — altitude with a climb or descent rate, speed and heading, how far away and on what bearing, its position, how many frames it has sent and how long since the last one. ![the realtime window](docs/realtime.png) The boxes are placed so they cover neither each other nor another aircraft's symbol: the eight spots beside the aircraft are tried first, then rings outward, and a leader line runs to the near edge of the box rather than through it. Altitude is the colour, low warm to high cold, the same ramp the GIFs use. Long names are folded rather than allowed to stretch the box — a route between two airports with their full names runs to sixty characters, which would otherwise make one box wider than the map under it. A route breaks at the arrow first, so the two ends of the flight stay whole and sit under one another where they read as a pair. **A box that has to move swings there rather than jumping.** Two things make one move: an aircraft flies into the space its neighbour's box was using, or a new one arrives and claims it. Recomputing the layout every frame and drawing the answer means boxes teleport, and the eye reads a thing that teleports as a different thing — on a busy screen several do it at once. So a box keeps the spot it has for as long as that spot still works, and is only moved when something genuinely takes it. On a real evening's log that is about a third as many moves as placing each box afresh every frame. The moves that are left are eased over about half a second, and the picture redraws at thirty frames a second for as long as anything is actually moving, dropping back to its usual five the moment everything has settled. The spot a box is *heading for* is what the next box is laid out against, not the place it has got to so far — laying out against a box in mid-swing would move its neighbours too, and move them back when it arrived, and the screen would never settle. A box on the move is drawn last, over the ones standing still, so it stays readable while it crosses them. The box is remembered as an offset from its aircraft, so one crossing the window carries its box along without that counting as a move at all. Thirty frames a second is affordable because **the ground is dimmed once and kept**. Cutting the view out of the fetched map, dimming it and looking every level up in the palette is about 70 ms over two megapixels, and none of it changes between one frame and the next unless the view, the window, the brightness or the map itself has. Doing it every frame put a ceiling of a dozen frames a second on the window at 1920×1080 and spent a whole core holding it there; keeping it takes the same window from 87 ms a frame to 14. **The line from a box to its aircraft is dashed, and is its own colour**, on the animated pictures as well as here. It used to be drawn in the aircraft's own colour, which made it the same colour as that aircraft's trail — and a straight solid line running out of an aeroplane, in the colour of the path behind the aeroplane, reads as more path. On a busy picture that is a heading nobody flew. Dashes and a neutral colour say "this box belongs to that aeroplane" instead, which is all it was ever meant to say. Qt measures a dash pattern in multiples of the pen's width, so each pass of the glow divides the pattern by its own width; without that the halo's dashes are three times the core's and the line comes out as beads. **The animation had no such line at all** until now — a label pushed out into one of the outward rings by a crowd had nothing tying it to the aeroplane it was about. It has one now, dashed and in the same colour, walked along the line's own length rather than along whichever axis is longer so that a nearly horizontal leader and a nearly vertical one get dashes of the same length instead of one of them turning into a dotted line. **A red flag stands where the receiver is**, on the window and on the animated pictures alike, from the coordinates in the settings (`--at`, or **Receiver position** in the menu). The foot of the pole is the position and the pennant flies up and to the right of it, so nothing the flag is made of covers the place it points at. It is pure red in every theme — "you are here" is the one mark whose meaning must not change with the colours, and pure red is both the brightest red there is and the one furthest from every altitude colour in every theme. A softer red sat close enough to a low aeroplane on the default map, and to a mid-altitude one on the red theme, to be taken for one. The flag is drawn **only where the receiver was actually told where it is**. Without a position the middle of the picture is worked out from whatever flew past, which is not a place anybody is standing, and a flag on it would say that somebody is. **Range rings** put faint discs at a quarter, a half and three quarters of the radius, concentric on the receiver and each labelled with its distance. They are translucent and they stack, so the ground inside the innermost is lifted three times, the next twice, the outer once. What that gives is a sense of how far away something is without measuring anything: an aircraft two shades in is about halfway to the edge of what this receiver hears. An indexed picture cannot blend, so "translucent" in the animation means moving the ground under the disc a step or two up its own ramp of shades — which keeps the coastline and the roads visible through it, where a flat wash of one colour would not. The window has real alpha and simply paints one. They need a receiver position and a radius and are not drawn without both, and each is a separate setting: `--rings` / `--no-rings` for the pictures, `--window-rings` / `--no-window-rings` for the window, both also in the ADS-B options menu. A picture is studied and a window is glanced at, and the rings help one more than the other depending which you are doing. **The aerodromes are marked here too**, in the same colour and with the same square as the animation draws them. They are asked for once per area, on the thread that fetches the tiles but not behind them — they used to be fetched only in the same pass as a piece of map, which meant they queued behind a hundred and twenty tiles coming off a network, and once the map was in hand there were no more passes and they were never fetched at all. `d` cycles the detail — full box, just height and speed, or symbols alone — for when the sky is busy. `t` toggles trails, `g` the map underneath, `[`/`]` its brightness, `+`/`-` the range, `q` closes it. Aircraft fade out here too, over `--fade` seconds, keeping their symbol and losing their box as they go — a box at a tenth of its colour is something in the way of the aircraft still flying. One that has gone quiet stops being counted as overhead, since saying it is would be saying more than was heard. The map holds still. It is fetched an eighth larger than the window in every direction, and the middle of the view settles once and then stays put rather than being recomputed as aircraft come and go — otherwise the view shifts by a fraction of a mile every few seconds, throws away the tiles fetched for the old one, and the ground blinks out while new ones arrive. **Closing the window leaves exactly the files a passive capture does**: the same log, the same report, the same KML and animation, because it is the same code with a different thing watching it. The receiver runs on its own thread, so a slow repaint cannot cost a frame and a slow tile fetch cannot stop the picture moving. Qt is asked for and not required — PyQt6, PyQt5, PySide6 and PySide2 are all tried, since distributions disagree about which to package. Without any of them you lose this window and nothing else, and the program says how to get one rather than failing. **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, `p` starts a passive capture, `r` opens the realtime window 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_