No speech recogniser is in Debian, so installing bandsaunter from a .deb left transcription to a manual pip step on every machine. A repository of one's own is not bound by archive policy, so build-repo.sh now packages faster-whisper and the base.en model alongside the application: bandsaunter the application (Architecture: all) bandsaunter-transcribe faster-whisper, vendored (amd64) bandsaunter-model-base-en the model, so nothing reaches the network The wheels land in /usr/lib/bandsaunter/vendor rather than dist-packages, and transcribe.py appends that directory to sys.path -- appends, so an apt-managed numpy or PyYAML still wins and the vendor copy only fills the gap. Duplicates of what Debian already ships are stripped from the tree. resolve_model() turns a bare "base.en" into the packaged copy when one is installed, and leaves it alone to be downloaded when none is. The app package recommends the other two, so "apt install bandsaunter" brings the lot and --no-install-recommends still gets just the scanner. Its postinst explains how to add a recogniser only when there genuinely is not one -- including the case where apt has already unpacked the recogniser package but not yet configured it. Verified with the source tree hidden and no home directory: the packaged CLI runs, and a real recording transcribes offline from the vendored engine and packaged model while numpy still resolves to the system one. apt itself resolves the repository over HTTP and plans all three. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
875 lines
38 KiB
Markdown
875 lines
38 KiB
Markdown
# bandsaunter
|
|
|
|
A signal scanner, recorder and identifier for RTL-SDR receivers.
|
|
|
|
Give it any number of frequency ranges — typed in by hand or picked from a
|
|
built-in US band plan — and it sweeps them, stops on anything above the noise
|
|
floor, records it, and works out what kind of signal it was. CW/Morse is
|
|
decoded to text.
|
|
|
|
```
|
|
╭──────────────────────────────── receiver ────────────────────────────────╮
|
|
│ Rafael Micro R820T/R820T2 2.048 MS/s gain auto +0 ppm │
|
|
╰──────────────────────────────────────────────────────────────────────────╯
|
|
╭───────────────────────────────── sweep ──────────────────────────────────╮
|
|
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ step 4/6 146 MHz - 146.666667 MHz │
|
|
│ ▆ █ ▄▄▄ peak -18.0 dBFS │
|
|
│ recording cycle 2 hits 3 dropped 0 detections 7 up 0:04 │
|
|
╰──────────────────────────────────────────────────────────────────────────╯
|
|
╭──────────────────────────────────────────────────────────────────────────╮
|
|
│ REC 146.52 MHz [nfm] ███████████░░░░░░░░░ 11.4/30s SIGNAL SNR 27 dB │
|
|
╰──────────────────────────────────────────────────────────────────────────╯
|
|
╭───────────────────────────── recorded signals ───────────────────────────╮
|
|
│ 19:38:43 460.025 MHz 8.2s 27.0 P25 Phase 1 C4FM digital voice │
|
|
│ 19:38:43 144.1 MHz 8.2s 27.0 CW / Morse at 18 WPM "VVV DE…" │
|
|
│ 19:38:43 146.52 MHz 8.2s 27.0 Narrowband FM voice (CTCSS 100) │
|
|
╰──────────────────────────────────────────────────────────────────────────╯
|
|
```
|
|
|
|
## Install
|
|
|
|
### From a package (Debian, Ubuntu, Mint)
|
|
|
|
```bash
|
|
./packaging/build-deb.sh # writes dist/bandsaunter_<version>_all.deb
|
|
sudo apt install ./dist/bandsaunter_*.deb
|
|
```
|
|
|
|
apt pulls in every dependency itself, and the package blacklists the DVB-T
|
|
driver that would otherwise claim the receiver. Nothing else to do.
|
|
|
|
Speech transcription is the one part that cannot come from Debian, because no
|
|
speech recogniser is packaged there. Installed this way, everything else works
|
|
and the install prints a short note saying how to add one. To have that arrive
|
|
by apt as well, build the repository below instead.
|
|
|
|
### From your own apt repository
|
|
|
|
For installing on several machines, or on a fresh one, without hunting for the
|
|
recogniser afterwards. `build-repo.sh` builds three packages and an apt index:
|
|
|
|
| Package | Arch | Contents |
|
|
|---|---|---|
|
|
| `bandsaunter` | all | the application |
|
|
| `bandsaunter-transcribe` | amd64 | faster-whisper and its dependencies, in `/usr/lib/bandsaunter/vendor` |
|
|
| `bandsaunter-model-base-en` | all | the `base.en` model, in `/usr/share/bandsaunter/models` |
|
|
|
|
```bash
|
|
./packaging/build-repo.sh # writes dist/repo/
|
|
rsync -a dist/repo/ server:/var/www/html/bandsaunter/
|
|
```
|
|
|
|
Serve that directory over HTTP from anywhere on the LAN, then on each machine:
|
|
|
|
```bash
|
|
echo 'deb [trusted=yes] http://server/bandsaunter ./' \
|
|
| sudo tee /etc/apt/sources.list.d/bandsaunter.list
|
|
sudo apt update
|
|
sudo apt install bandsaunter
|
|
```
|
|
|
|
That single command brings the recogniser and its model too — they are
|
|
`Recommends`, which apt installs by default. `--no-install-recommends` gets
|
|
just the application. Nothing reaches the network afterwards: the model is on
|
|
disk, so the first transcription works offline.
|
|
|
|
`[trusted=yes]` skips signing, which is the sensible trade on a private LAN.
|
|
To sign it instead, run `gpg --clearsign` over `dist/repo/Release` to produce
|
|
`InRelease` and drop the `[trusted=yes]`.
|
|
|
|
The vendored packages are appended to `sys.path`, never prepended, so anything
|
|
apt provides — numpy, PyYAML — still wins; the vendor directory only fills the
|
|
gap Debian leaves. `BANDSAUNTER_VENDOR_DIR` and `BANDSAUNTER_MODEL_DIR`
|
|
override both locations.
|
|
|
|
Rebuilding for a new version is the same command; `apt upgrade` picks it up.
|
|
|
|
### From source
|
|
|
|
```bash
|
|
sudo apt install rtl-sdr librtlsdr0 espeak-ng # Debian, Ubuntu, Mint
|
|
pip install -e .
|
|
```
|
|
|
|
## Dependencies
|
|
|
|
Everything required is packaged in Debian, Fedora and Arch, so nothing has to
|
|
be built.
|
|
|
|
| | Package | Debian/Ubuntu | Fedora | Arch | Needed for |
|
|
|---|---|---|---|---|---|
|
|
| **required** | librtlsdr | `librtlsdr0` | `rtl-sdr` | `rtl-sdr` | talking to the receiver at all |
|
|
| **required** | NumPy | `python3-numpy` | `python3-numpy` | `python-numpy` | all signal processing |
|
|
| **required** | SciPy | `python3-scipy` | `python3-scipy` | `python-scipy` | filters, resampling, spectra |
|
|
| **required** | Rich | `python3-rich` | `python3-rich` | `python-rich` | menus and the live display |
|
|
| **required** | PyYAML | `python3-yaml` | `python3-pyyaml` | `python-yaml` | settings file and profiles |
|
|
| *recommended* | eSpeak NG | `espeak-ng` | `espeak-ng` | `espeak-ng` | clearer spoken timestamps |
|
|
| *optional* | rtl-sdr tools | `rtl-sdr` | `rtl-sdr` | `rtl-sdr` | `rtl_test` and friends for diagnosis |
|
|
| *optional* | a speech recogniser | **pip only** | **pip only** | AUR | transcribing speech to text |
|
|
| *optional* | Matplotlib | `python3-matplotlib` | `python3-matplotlib` | `python-matplotlib` | nothing yet; reserved for plots |
|
|
|
|
Two notes on the optional ones:
|
|
|
|
**eSpeak NG is a recommendation, not a requirement.** Without it the spoken
|
|
timestamps come from a built-in formant synthesiser, so that feature works on
|
|
a machine with nothing else installed. With it they are clearer and render
|
|
about three times faster.
|
|
|
|
**No speech recogniser is packaged for Debian.** `faster-whisper`, `vosk` and
|
|
the pocketsphinx Python bindings are all absent from the archive, so
|
|
transcription can only be installed with pip:
|
|
|
|
```bash
|
|
pip install faster-whisper # best on radio audio, ~120 MB
|
|
pip install vosk # ~10 MB plus a 40 MB model, weaker on noise
|
|
bandsaunter transcribe --engines
|
|
```
|
|
|
|
That is why the plain `.deb` cannot depend on one. Debian Policy forbids
|
|
anything in the archive from requiring software outside it, and a `postinst`
|
|
that reaches out to PyPI would break offline and reproducible installs — so a
|
|
package in the archive simply cannot pull these in. Transcription is therefore
|
|
off by default and reports plainly when no recogniser is present, rather than
|
|
the install failing or the feature appearing broken.
|
|
|
|
A repository of your own is not bound by that rule, which is what
|
|
[`build-repo.sh`](#from-your-own-apt-repository) exploits: it packages
|
|
faster-whisper and its model itself, into a private directory rather than into
|
|
`dist-packages`, and lets apt install them alongside. Nothing is downloaded at
|
|
install time, and nothing collides with an apt-managed module.
|
|
|
|
Mixing the two is nonetheless fine here. Modern Debian marks the system
|
|
Python as externally managed (PEP 668), so a pip install lands in your user
|
|
site directory:
|
|
|
|
```bash
|
|
pip install --user faster-whisper # ~/.local/lib/python3.x/site-packages
|
|
```
|
|
|
|
which is on `sys.path` for the system interpreter. A bandsaunter installed
|
|
from the `.deb` into `/usr/lib/python3/dist-packages` picks it up with no
|
|
further configuration — verified, not assumed. A virtual environment works
|
|
too, as long as bandsaunter runs inside it.
|
|
|
|
Getting these into Debian proper would be a different matter: it would mean
|
|
packaging ctranslate2, tokenizers, onnxruntime and their dependencies, several
|
|
of which are large C++ or Rust projects, each to archive standards. That is
|
|
why none of them are there, and why the local repository vendors the wheels
|
|
instead of trying to do it properly.
|
|
|
|
### Other distributions
|
|
|
|
```bash
|
|
sudo dnf install rtl-sdr python3-numpy python3-scipy python3-rich \
|
|
python3-pyyaml espeak-ng # Fedora
|
|
sudo pacman -S rtl-sdr python-numpy python-scipy python-rich \
|
|
python-yaml espeak-ng # Arch
|
|
brew install librtlsdr espeak-ng && pip install -e . # macOS
|
|
```
|
|
|
|
### Letting your user reach the receiver
|
|
|
|
The DVB-T television driver claims RTL dongles on sight and has to be kept
|
|
away from them. The `.deb` does this for you; from source:
|
|
|
|
```bash
|
|
echo 'blacklist dvb_usb_rtl28xxu' | sudo tee /etc/modprobe.d/blacklist-rtlsdr.conf
|
|
sudo rmmod dvb_usb_rtl28xxu # or just unplug and replug the receiver
|
|
```
|
|
|
|
If the device is found but cannot be opened, your user needs permission for
|
|
it. Most distributions ship a udev rule with `rtl-sdr`; failing that:
|
|
|
|
```bash
|
|
echo 'SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2838", MODE="0666"' \
|
|
| sudo tee /etc/udev/rules.d/20-rtlsdr.rules
|
|
sudo udevadm control --reload-rules && sudo udevadm trigger
|
|
```
|
|
|
|
### Check it worked
|
|
|
|
```bash
|
|
bandsaunter devices --test # opens the receiver and captures a test block
|
|
bandsaunter scan -b 2m --simulate # exercises everything without hardware
|
|
```
|
|
|
|
## Versioning
|
|
|
|
Releases are named for the day they were made and a revision within that day:
|
|
|
|
```
|
|
2026-08-21_01 first build on the 21st
|
|
2026-08-21_02 second build the same day
|
|
2026-09-01_01
|
|
```
|
|
|
|
The revision is padded to two digits so that versions sort correctly as text —
|
|
without it, revision 10 would sort before revision 2.
|
|
|
|
Packaging tools cannot use that form directly, so it is converted at the edge
|
|
rather than kept as a second version string that could drift:
|
|
|
|
| Where | Form | Why |
|
|
|---|---|---|
|
|
| the program, `--version` | `2026-08-21_01` | what you asked for |
|
|
| pip, `pyproject.toml` | `2026.8.21.1` | PEP 440 forbids dashes and underscores in a release |
|
|
| dpkg, the `.deb` | `2026.08.21.01-1` | Debian versions may not contain underscores |
|
|
|
|
`bandsaunter/__init__.py` holds the date and revision; the other two forms are
|
|
derived from it, and the test suite checks that all three describe the same
|
|
release and that both pip and `dpkg --compare-versions` order them correctly.
|
|
|
|
## Quick start
|
|
|
|
```bash
|
|
bandsaunter # the menus: set up and scan
|
|
bandsaunter scan -b 2m -b marine-vhf # band-plan presets
|
|
bandsaunter scan -r 144M-148M -r 420M-450M # your own ranges
|
|
bandsaunter scan -b 2m --simulate # try it without hardware
|
|
```
|
|
|
|
## Two ways to drive it
|
|
|
|
Everything is available both ways. Run `bandsaunter` with no arguments for the
|
|
menus, or pass flags for scripting — the two are generated from the same
|
|
table of settings, so neither can offer something the other cannot.
|
|
|
|
```
|
|
1 Frequency ranges 3 configured
|
|
2 Band plan 107 US presets
|
|
3 Settings record no limit, hang 6s, squelch +12 dB, keep voice, cw
|
|
4 Saved settings and profiles
|
|
h Help
|
|
s Start scanning
|
|
q Quit
|
|
```
|
|
|
|
Settings are grouped, show their current value against the built-in default,
|
|
and carry their own help:
|
|
|
|
```
|
|
# setting value what it does
|
|
1 Record for * no limit longest one signal may hold the receiver
|
|
2 Wait for quiet * 6 s quiet time before the sweep resumes
|
|
3 Absolute limit 900 s ceiling on one capture, even when 'Record for' is 0
|
|
* differs from the built-in default
|
|
|
|
Number to change it, ?N for help on one, d to reset the group, blank to go back.
|
|
```
|
|
|
|
`?2` explains a setting in full, including the command-line flag that does the
|
|
same thing. Typing a search term instead of a number finds settings by any
|
|
word in their name or description — `voice score` finds the speech threshold.
|
|
|
|
Values may be typed with their units: `5 s`, `2.048 MHz`, `12 dB`, `48k`, or
|
|
`no limit` for the settings that accept 0.
|
|
|
|
## Settings that persist
|
|
|
|
Settings are saved to `~/.config/bandsaunter/config.yaml` and picked up by every
|
|
later run. Save them from the menus (**4 → s**) or from the command line:
|
|
|
|
```bash
|
|
bandsaunter config # open the settings menu
|
|
bandsaunter config hang_seconds=6 record_seconds=0 # set and save directly
|
|
bandsaunter config --show # every setting, with defaults
|
|
bandsaunter config --describe hang_seconds # explain one in full
|
|
bandsaunter config --path # where the file lives
|
|
bandsaunter config --reset # back to defaults
|
|
```
|
|
|
|
Three layers apply in order, each overriding the last:
|
|
|
|
1. the saved settings file
|
|
2. a named profile, if `--profile` is given
|
|
3. any flags on the command line
|
|
|
|
So a saved squelch of 12 dB stays in force while `--hang 1.5` overrides just
|
|
the hang for one run. `--no-config` ignores the file entirely; `--save` stores
|
|
the resulting settings as the new default.
|
|
|
|
Named profiles live beside it in the same directory:
|
|
|
|
```bash
|
|
bandsaunter scan -b 2m -b 70cm --record 0 --hang 6 --save-profile local
|
|
bandsaunter scan -p local
|
|
bandsaunter profiles
|
|
```
|
|
|
|
## Entering frequencies
|
|
|
|
**By hand** — repeat `-r` as many times as you like; there is no limit on the
|
|
number of start/end pairs.
|
|
|
|
```bash
|
|
bandsaunter scan -r 144M-148M -r 462.5M-467.8M -r 929M-932M
|
|
```
|
|
|
|
A range is `start-end`, with optional `/step` and `@mode`:
|
|
|
|
| Form | Meaning |
|
|
|---|---|
|
|
| `144M-148M` | explicit start and end |
|
|
| `144-148M` | the unit carries over to the left end |
|
|
| `146.52M` | a single frequency |
|
|
| `144M-148M/25k` | with a channel step |
|
|
| `144M-148M/25k@nfm` | and a forced demodulator |
|
|
|
|
Units may be written `144M`, `144 MHz`, `144000k`, or plain Hz. A bare number
|
|
below 10000 is read as MHz, so `-r 162.4-162.55` does what you expect.
|
|
|
|
**From the US band plan** — 107 presets across 18 categories:
|
|
|
|
```bash
|
|
bandsaunter bands --categories # list categories
|
|
bandsaunter bands --category Aviation # everything in one category
|
|
bandsaunter bands pager # search
|
|
bandsaunter scan -b gmrs -b railroad -b noaa-weather
|
|
```
|
|
|
|
Each preset carries its own channel spacing, demodulator and bandwidth, so
|
|
`-b marine-vhf` scans 25 kHz channels in NFM while `-b fm-broadcast` uses
|
|
200 kHz WFM, without being told.
|
|
|
|
Each amateur band also has a **complete** entry that covers the whole band and
|
|
picks the demodulator per segment, because a band is not one mode:
|
|
|
|
```bash
|
|
bandsaunter scan -b 2m-complete
|
|
```
|
|
|
|
scans 144-148 MHz as CW below 144.1, SSB to 144.3 and FM above it, so a CW
|
|
beacon at the bottom is decoded to text while a repeater at the top is
|
|
demodulated as FM — in one sweep. There is one for every band from 160 m to
|
|
33 cm: `160m-complete`, `80m-complete`, ... `70cm-complete`, `33cm-complete`.
|
|
|
|
Where an amateur band overlaps another service the amateur reading wins inside
|
|
a complete-band sweep — 433 MHz is treated as 70 cm rather than as the ISM
|
|
band it shares — while scanning `-r 433.9M` on its own still treats it as ISM.
|
|
|
|
A few presets stand for a *set* of others, so scattered segments can be picked
|
|
in one go:
|
|
|
|
```bash
|
|
bandsaunter scan -b all-cw --record 0 --hang 6
|
|
```
|
|
|
|
`all-cw` covers every CW allocation in the plan — 160, 80, 40, 30, 20, 17, 15,
|
|
12, 10, 6 and 2 metres — as eleven separate ranges rather than one span from
|
|
1.8 to 144 MHz. That is 1.25 MHz of spectrum in total, so a full pass takes
|
|
under a second and CW gets decoded to text as it turns up. Direct sampling
|
|
switches itself on for the HF segments and off again from 12 m upward; the HF
|
|
part needs an HF antenna to be worth anything.
|
|
|
|
The menus do both: browse the band plan by category, or type in start/end
|
|
pairs one after another. Ranges can be listed, removed, toggled on and off,
|
|
and have their demodulator changed from the ranges menu.
|
|
|
|
## The two dwell settings
|
|
|
|
These are the settings that decide how the scanner behaves when it finds
|
|
something:
|
|
|
|
| Setting | Flag | What it does |
|
|
|---|---|---|
|
|
| Record for X seconds before continuing | `--record 30` | The longest a single signal may hold the receiver. `0` means stay as long as it keeps transmitting. |
|
|
| Wait for X seconds of no signal before continuing | `--hang 3` | How long the channel must stay quiet before the sweep resumes. **Gaps shorter than this are recorded straight through.** |
|
|
|
|
```bash
|
|
bandsaunter scan -b 2m --record 30 --hang 3
|
|
```
|
|
|
|
Whichever comes first wins, and that is worth being clear about: **`--record`
|
|
overrides `--hang`.** A transmission still in progress at the record limit is
|
|
cut off there, however long the hang time is. If a recording keeps ending at
|
|
exactly 30 seconds, that is the default record limit doing it, not the hang —
|
|
set `--record 0`. The scan reports it when this happens.
|
|
|
|
Both are measured in **samples**, not wall-clock time, so a 30 second setting
|
|
produces a 30.0 second recording.
|
|
|
|
Supporting settings:
|
|
|
|
- `--min-record 0.5` — discard anything shorter, so brief noise spikes leave
|
|
nothing behind on disk.
|
|
- `--revisit 8` — ignore a frequency for this long after recording it, so a
|
|
busy repeater does not monopolise the sweep.
|
|
- `--max-record 900` — absolute ceiling on one capture, applied even when
|
|
`--record` is 0.
|
|
- `--threshold 8` — squelch, in dB above the measured noise floor.
|
|
|
|
### Capturing both sides of a conversation
|
|
|
|
`--hang` is what holds a recording open across the natural pauses in two-way
|
|
traffic. Set it longer than the gap between overs and the whole exchange lands
|
|
in one file:
|
|
|
|
```bash
|
|
bandsaunter scan -b 2m --record 0 --hang 6
|
|
```
|
|
|
|
`--record 0` is the important half. Without it the per-signal cap cuts the
|
|
exchange off mid-sentence no matter what the hang time is — the default 30
|
|
seconds is a common surprise. `--max-record` (default 900 s) still bounds an
|
|
unlimited capture so nothing runs away.
|
|
|
|
To make it the permanent default:
|
|
|
|
```bash
|
|
bandsaunter config record_seconds=0 hang_seconds=6
|
|
```
|
|
|
|
"Quiet" means *no real signal*, not merely a closed squelch. Silence, static
|
|
and interference all count towards the timer, so a burst of noise during a
|
|
pause does not reset it and park the receiver on a finished conversation.
|
|
Recognising that a signal carries nothing takes a couple of seconds of
|
|
evidence, so expect the tail to run a little past `--hang` in that case.
|
|
|
|
Once a capture has produced real content it is never abandoned as noise, since
|
|
a quiet spell between overs would otherwise throw the conversation away.
|
|
|
|
## Only real signals get recorded
|
|
|
|
A power threshold cannot tell a transmission from a hump of interference, so
|
|
every capture is checked for *content* before it is kept. Recording happens
|
|
only for:
|
|
|
|
| Category | What it means |
|
|
|---|---|
|
|
| `voice` | speech structure in the demodulated audio: a pitch track in the 70-400 Hz range that drifts the way intonation does, pauses between phrases, syllable-rate envelope modulation, and formants that move |
|
|
| `cw` | a keyed carrier whose timing resolves as Morse |
|
|
| `digital` | an identified keying scheme: discrete FSK levels, an M-PSK phase line, or on-off keying -- corroborated by a symbol rate |
|
|
|
|
Everything else -- static, hum, switch-mode power supply harmonics, clock
|
|
spurs, bare carriers -- is discarded, and the files it wrote are deleted.
|
|
|
|
The check runs *while* the capture is still going, so interference is dropped
|
|
after a second or two instead of holding the receiver for the whole record
|
|
time.
|
|
|
|
```bash
|
|
bandsaunter scan -b 2m # voice, CW and digital (default)
|
|
bandsaunter scan -b 2m --accept voice # voice only
|
|
bandsaunter scan -b ism-433 --accept digital # data bursts only
|
|
bandsaunter scan -b 2m --keep-carriers # also keep unmodulated carriers
|
|
bandsaunter scan -b 2m --keep-everything # no content check at all
|
|
```
|
|
|
|
Tuning knobs: `--min-voice-score` (0-1, default 0.45) sets how speech-like
|
|
audio must be; `--min-signal-score` sets the confidence needed to keep
|
|
anything; `--verify-max` caps how long a contentless capture is given to prove
|
|
itself.
|
|
|
|
### What makes this hold up against interference
|
|
|
|
Static is good at imitating most of the things that look like structure, so
|
|
each test is built so that noise cannot pass it:
|
|
|
|
- **A pitch track that moves gates the voice score**, rather than contributing
|
|
a share of it. Dynamics, syllable-rate modulation and energy in the voice
|
|
band are all things hiss does too; weighted alongside voicing they were
|
|
enough to carry noise over the line on their own. Speech is the only thing
|
|
here that produces a pitch period that drifts, so nothing is called voice
|
|
without one. A steady tone or mains hum has a perfectly stable "pitch" and
|
|
is rejected for exactly that reason. Measuring drift needs several voiced
|
|
frames, so the requirement eases for a short over that cannot supply them;
|
|
the steady-tone guard still applies.
|
|
- **A symbol rate has to hold still across the capture.** The estimator always
|
|
returns its best peak, so on noise it reports one rate for the first half of
|
|
a capture and a different one for the second. Real data keeps one.
|
|
- **Keying has to land on a grid.** On/off contrast alone is not evidence: a
|
|
signal fading across the squelch produces plenty of it, with run lengths
|
|
that fit no symbol period at all.
|
|
- **A phase line has to be created by the exponentiation.** An unmodulated
|
|
carrier -- including the gaps between phrases on an FM channel -- already
|
|
has a line at every power, and would otherwise look like textbook PSK.
|
|
- **The demodulator is chosen from the signal**, not from the band plan, by
|
|
probing briefly before recording. An AM signal inside a
|
|
band listed as FM would otherwise be recorded through the wrong detector,
|
|
giving audio that is useless to listen to and impossible to judge. Three
|
|
ratios settle it — how much the envelope varies, how far the tone swings,
|
|
and how much power sits in a carrier — because those hold steady over a
|
|
fraction of a second whatever is being said. Running the full classifier on
|
|
so short a probe was tried and is not reliable: speech makes any modulation
|
|
look bursty over half a second, and AM came back as on-off keying while FM
|
|
came back as AM. The probe is played into the recording rather than
|
|
discarded, so a short over does not lose its opening.
|
|
- **Content is judged only on the audio that was actually recorded**, and only
|
|
on the stretches the squelch called signal. Demodulating two ways and
|
|
keeping whichever scored higher is cherry-picking, and on noise one of the
|
|
two always flatters it.
|
|
|
|
The gate needs roughly two seconds of audio to judge speech reliably, so pair
|
|
it with `--record 5` or more rather than very short capture limits.
|
|
|
|
### Keeping up with the radio
|
|
|
|
An RTL-SDR only delivers samples while the host is actively reading. Anything
|
|
that arrives while the program is busy demodulating is discarded by the
|
|
driver, and a recording then holds *less* than really went by -- which plays
|
|
back too fast.
|
|
|
|
Two things keep that from happening:
|
|
|
|
- **Captures stream asynchronously.** A ring of USB transfers stays queued in
|
|
a background thread, so the dongle is never waiting for the host. Sweeping
|
|
still uses plain reads, because each dwell is an independent snapshot and a
|
|
gap between them costs nothing.
|
|
- **The signal path is fast enough to keep up.** Decimation computes only the
|
|
samples that survive, rather than filtering at the input rate and throwing
|
|
away seven of every eight outputs; the quarter-rate local oscillator is the
|
|
four-step cycle 1, -j, -1, +j and needs no trigonometry; and pitch tracking
|
|
runs through the FFT instead of a direct autocorrelation per frame. Together
|
|
those took the capture loop from 65% of the real-time budget to under 10%.
|
|
|
|
If the host does fall behind anyway, the scan reports how many samples were
|
|
lost rather than silently producing a fast recording.
|
|
|
|
### Why the threshold is what it is
|
|
|
|
The sweep uses peak-hold, which keeps the largest value each FFT bin reached
|
|
during the dwell. That finds bursty traffic that averaging would bury -- but
|
|
it also means noise alone rides several dB above the measured floor. On this
|
|
hardware, empty spectrum reaches 5-9 dB above a percentile floor with nothing
|
|
transmitting.
|
|
|
|
So `--threshold` is a margin over *noise*, not over the floor: the offset that
|
|
noise alone clears is computed from the detector (segment count and bin count)
|
|
and added automatically. A threshold of 8 means 8 dB of real headroom. It is
|
|
deliberately not measured from the spectrum -- a spread estimated from the
|
|
data reads five times higher across the packed broadcast FM band than on empty
|
|
spectrum, which would suppress exactly the stations you are looking for.
|
|
|
|
## Signal identification
|
|
|
|
Every recording is classified from its own IQ. The classifier measures
|
|
occupied bandwidth, envelope statistics, discriminator levels, phase
|
|
behaviour, spectral flatness and symbol rate, then combines those with the
|
|
frequency to name the signal:
|
|
|
|
| Family | Recognised as |
|
|
|---|---|
|
|
| Analogue voice | Narrowband FM (with CTCSS tone or DCS), wideband FM (stereo pilot detected), AM, SSB (USB/LSB) |
|
|
| CW | Keyed carrier, **decoded to text** with the speed in WPM |
|
|
| Digital voice | P25 C4FM, DMR (TDMA burst structure), NXDN, D-STAR |
|
|
| Data | POCSAG and FLEX paging, ACARS, AIS, APRS/AFSK1200, 2-FSK and 4-FSK, BPSK/QPSK/8-PSK |
|
|
| Other | Unmodulated carriers, OOK/ISM devices, ADS-B and UAT, DME/TACAN pulses, wideband OFDM/cellular |
|
|
|
|
Each result carries a confidence and the reasoning behind it:
|
|
|
|
```
|
|
146.520038 MHz 3.0s SNR 27.6 dB Narrowband FM voice (CTCSS 100.0 Hz) (88%)
|
|
4.9 kHz wide, 0.9 kHz rms deviation
|
|
```
|
|
|
|
Low SNR reduces confidence rather than producing a confident wrong answer.
|
|
|
|
### CW / Morse
|
|
|
|
Keyed carriers are decoded to text. The speed is measured from the signal, so
|
|
nothing has to be configured, and anything from about 8 to 40 WPM reads
|
|
reliably:
|
|
|
|
```
|
|
144.1 MHz 12.0s SNR 50.8 dB CW / Morse at 18 WPM CW "VVV DE W1AW FN31"
|
|
```
|
|
|
|
The decoder runs its own CW detector over the captured IQ, so Morse is found
|
|
even when the recording itself was made in FM or SSB.
|
|
|
|
## Output
|
|
|
|
Everything lands in one directory, named
|
|
`frequency--yyyy-mm-dd_hour_minute_second-modulation.wav`:
|
|
|
|
```
|
|
0014.058000MHz--2026-08-21_20_35_41-cw.wav
|
|
0098.299255MHz--2026-08-21_20_24_01-wfm.wav demodulated audio
|
|
0098.299255MHz--2026-08-21_20_24_01-wfm.json identification, features, timing
|
|
0098.299255MHz--2026-08-21_20_24_01-wfm.cf32 raw IQ (with --iq)
|
|
0098.299255MHz--2026-08-21_20_24_01-wfm.sigmf-meta SigMF sidecar (with --iq)
|
|
0098.299255MHz--2026-08-21_20_24_01-wfm_transcription.txt (with --transcribe)
|
|
0098.361991MHz--2026-08-21_20_24_18-wfm.wav
|
|
0146.520000MHz--2026-08-21_20_31_02-nfm.wav
|
|
1090.000000MHz--2026-08-21_20_38_12-raw.wav
|
|
scan_log.jsonl one line per hit
|
|
scan_log.csv the same, as a spreadsheet
|
|
```
|
|
|
|
Frequency leads and is padded to four digits, so a plain directory listing
|
|
sorts by frequency across the whole tuning range — unpadded, 1090 MHz would
|
|
sort before 146 MHz. Each channel's captures group together with the
|
|
timestamp ordering them. Every artefact of one capture shares a stem, and the
|
|
modulation suffix is what the signal was *identified* as, so the file is
|
|
renamed once the capture has been analysed.
|
|
|
|
### Where files go
|
|
|
|
The directory is asked for the first time bandsaunter is run and remembered
|
|
afterwards:
|
|
|
|
```
|
|
Recordings, transcripts and the scan log are all written to one directory.
|
|
Where would you like them?
|
|
|
|
recordings directory (~/bandsaunter):
|
|
```
|
|
|
|
It is an ordinary setting, so it can be changed at any time:
|
|
|
|
```bash
|
|
bandsaunter config output_dir=~/somewhere-else
|
|
bandsaunter scan -b 2m -o /tmp/just-this-once
|
|
```
|
|
|
|
The question is only asked when there is someone to answer it: a scan run from
|
|
a script or with output redirected uses the default rather than blocking, and
|
|
`--no-config` skips it entirely.
|
|
|
|
### One file per frequency
|
|
|
|
With `--combine`, each frequency gets a single file that every later reception
|
|
is appended to, so a whole watch on a channel plays back as one recording:
|
|
|
|
```bash
|
|
bandsaunter scan -b 2m --combine --record 0 --hang 6
|
|
```
|
|
|
|
```
|
|
recordings/
|
|
146.520000MHz.wav every transmission heard on 146.520, in order
|
|
147.100000MHz.wav
|
|
146.520692MHz--2026-08-21_13_21_20-nfm.json what each capture was
|
|
scan_log.csv
|
|
```
|
|
|
|
Each transmission is preceded by **its date and time read aloud**, so the file
|
|
says when everything was heard without needing the log open. Receptions within
|
|
`--combine-tolerance` (6.25 kHz by default) of each other count as the same
|
|
channel, which covers the few hundred hertz a detection wanders by.
|
|
|
|
The file is rewritten to stay valid after every append, so it can be opened
|
|
and played while the scan is still running, and a scan stopped part-way still
|
|
leaves a working recording. A later run continues the same file rather than
|
|
starting a new one.
|
|
|
|
By default the per-transmission WAVs are removed once they have been added, so
|
|
each recording exists in one place; `--keep-individual` keeps both. The `.json`
|
|
describing each capture is written either way.
|
|
|
|
`--no-announce` leaves the timestamps out, and `--announce-frequency` reads the
|
|
frequency out as well.
|
|
|
|
### Where the speech comes from
|
|
|
|
The announcements use an installed text-to-speech program if there is one
|
|
(`espeak-ng`, `espeak`, `pico2wave`, `flite`, `say`) and fall back to a
|
|
built-in formant synthesiser otherwise, so the feature works with nothing else
|
|
installed. `--announce-engine builtin` forces the built-in one; the scan plan
|
|
reports which is in use.
|
|
|
|
The two are given different wording. An installed engine gets ordinary text,
|
|
where punctuation is what produces the phrasing — and the obvious spellings
|
|
are traps: espeak-ng reads `14:38:05` as "fourteen thirty, eight zero five",
|
|
and an ISO date as "two thousand and twenty six dash zero eight dash twenty
|
|
one". It is given `August 21, twenty twenty six, at 14 38 and 05 seconds`
|
|
instead. The built-in synthesiser gets the word list it has pronunciations
|
|
for. Both are level-matched, so switching between them does not change how
|
|
loud the announcements sit against the recordings.
|
|
|
|
It only has to say numbers, month names and a handful of words, which makes
|
|
direct synthesis practical: a glottal source through a cascade of three
|
|
formant resonators, with the formant tracks interpolated between phonemes.
|
|
The test suite checks by LPC analysis that all ten vowels come out within
|
|
130 Hz of their intended first formant and 250 Hz of their second — summing
|
|
the resonators in parallel rather than cascading them loses the first formant
|
|
entirely and makes every vowel sound the same.
|
|
|
|
### Transcribing speech to text
|
|
|
|
With `--transcribe`, anything the content check identified as **voice** is
|
|
passed to a speech recogniser and the words are written beside the recording:
|
|
|
|
```bash
|
|
bandsaunter scan -b 2m --transcribe
|
|
```
|
|
|
|
```
|
|
146.520000MHz--2026-08-21_12_18_38-nfm.wav
|
|
146.520000MHz--2026-08-21_12_18_38-nfm_transcription.txt
|
|
146.520000MHz--2026-08-21_12_18_38-nfm.json
|
|
```
|
|
|
|
Only voice is transcribed — running a recogniser over Morse or a data burst
|
|
costs seconds and produces nothing. CW is decoded separately and appears in
|
|
the metadata as text already.
|
|
|
|
**A capture with nothing recognisable in it produces no file.** Music, a
|
|
carrier with an open mic, a fragment too short to make out: nothing is
|
|
written, rather than a directory of placeholders. The transcript is also
|
|
copied into the capture's `.json`, which names it only once it exists — so the
|
|
metadata never points at a file that was never created. The scan reports the
|
|
tally at the end:
|
|
|
|
```
|
|
2 transcript(s) written, 1 with no recognisable speech
|
|
```
|
|
|
|
Recognition takes seconds per capture, far longer than a capture itself, so it
|
|
runs on its own thread and the scan never waits for it; anything still queued
|
|
is finished when the scan stops. When recordings are being combined by
|
|
frequency there is one transcript per frequency too, each line stamped with
|
|
the time:
|
|
|
|
```
|
|
[2026-08-21 12:18:38] this is what the first transmission said
|
|
[2026-08-21 12:24:02] and this is the second
|
|
```
|
|
|
|
### Getting a recogniser
|
|
|
|
Unlike the spoken announcements, this needs an installed engine — recognition
|
|
depends on a trained model, so there is no built-in fallback.
|
|
|
|
```bash
|
|
bandsaunter transcribe --engines # what is installed
|
|
sudo apt install bandsaunter-transcribe # from your own repository
|
|
pip install faster-whisper # or straight from PyPI
|
|
```
|
|
|
|
| Engine | Notes |
|
|
|---|---|
|
|
| `faster-whisper` | best on radio audio; ~120 MB of dependencies, model downloads on first use |
|
|
| `whisper` | the original; heavier |
|
|
| `whisper-cli` | whisper.cpp, no Python dependencies |
|
|
| `vosk` | ~10 MB plus a 40 MB model, fully offline, but weaker on noisy audio |
|
|
| `pocketsphinx` | tiny; poor on radio audio |
|
|
|
|
The difference is easy to measure. Both engines on the same 26-second
|
|
off-air recording:
|
|
|
|
```
|
|
faster-whisper 3.4s "August 21, 2026, at 13.42 and 28 seconds,
|
|
96.108 megahertz, 13.42 and 47 seconds, ..."
|
|
vosk 11.2s "august twenty one twenty twenty six at thirteen
|
|
forty two i'm twenty eight seconds ... forty
|
|
family factories ..."
|
|
```
|
|
|
|
Whisper is both more accurate and three times faster, and it punctuates.
|
|
Vosk's advantage is size and that it needs nothing after its model is
|
|
downloaded once.
|
|
|
|
`--transcribe-model` selects the size (`tiny.en`, `base.en`, `small.en`,
|
|
`medium.en`) and `--transcribe-language` fixes the language — worth setting,
|
|
since on a short noisy clip automatic detection often guesses wrong and
|
|
returns nonsense in another language.
|
|
|
|
Existing recordings can be transcribed after the fact:
|
|
|
|
```bash
|
|
bandsaunter transcribe recordings/ # every WAV in a directory
|
|
bandsaunter transcribe one.wav --stdout
|
|
```
|
|
|
|
Re-examine anything later:
|
|
|
|
```bash
|
|
bandsaunter analyze recordings/2026-08-19/.../iq.cf32 # identify
|
|
bandsaunter analyze recordings/2026-08-19/.../audio.wav # decode CW
|
|
```
|
|
|
|
## Live controls
|
|
|
|
The display sizes itself to the terminal, giving up the spectrum row, then the
|
|
hit list, then the key hints as space runs short. A frame taller than the
|
|
terminal cannot be redrawn in place, so an oversized one would leave a copy of
|
|
itself behind on every refresh.
|
|
|
|
For the same reason the driver's own messages are suppressed while a scan
|
|
runs: librtlsdr writes them straight to file descriptor 2 from C — including
|
|
`Allocating 15 zero-copy buffers` on *every* capture — and they draw over the
|
|
display and break its cursor tracking. `bandsaunter devices` still shows them,
|
|
since that is the command to run when something is wrong, and
|
|
`BANDSAUNTER_DRIVER_MESSAGES=1` restores them everywhere.
|
|
|
|
|
|
| Key | Action |
|
|
|---|---|
|
|
| `q` | stop |
|
|
| `p` | pause / resume |
|
|
| `s` | skip this signal, resume sweeping |
|
|
| `l` | lock out this frequency for the rest of the run |
|
|
| `+` / `-` | adjust the squelch threshold |
|
|
|
|
## Built-in help
|
|
|
|
Press `h` in the menus for topics covering setup, how the sweep works, why
|
|
nothing (or too much) is being recorded, capturing conversations, where files
|
|
go, HF reception and the keys available during a scan. Typing a setting name
|
|
there explains that setting instead.
|
|
|
|
From the command line, `bandsaunter config --describe <setting>` does the same,
|
|
and `bandsaunter scan --help` lists every flag grouped the same way as the menus.
|
|
|
|
## HF
|
|
|
|
Frequencies below 24 MHz need direct sampling, which most RTL-SDR dongles
|
|
support on the Q branch. It is selected automatically:
|
|
|
|
```bash
|
|
bandsaunter scan -b 40m-cw --record 60 # 40 m CW, decoded to text
|
|
bandsaunter scan -b am-broadcast
|
|
```
|
|
|
|
You will need an HF antenna; the tuner is bypassed in this mode, so there is
|
|
no front-end filtering or gain.
|
|
|
|
## How the sweep works
|
|
|
|
- The band is covered in steps of `sample_rate x usable_fraction / 2`. The
|
|
local oscillator is parked *below* the span each step covers, so the
|
|
RTL2832's DC spike never lands inside the frequencies being searched.
|
|
- The noise floor is measured per FFT bin as a sliding low percentile, which
|
|
follows the receiver's passband shape and steps over signals. There is no
|
|
warm-up period, and a station that transmits constantly does not learn
|
|
itself into the floor.
|
|
- The sweep uses peak-hold rather than averaging across each dwell, so bursty
|
|
traffic — CW, packet, a short over — is not averaged into the noise.
|
|
- On a hit, the receiver retunes with a quarter-rate LO offset (moving the DC
|
|
spike off the signal), probes once at ~60 Hz resolution to measure the real
|
|
occupied bandwidth, and picks the demodulator from that plus the band plan.
|
|
|
|
## Without hardware
|
|
|
|
`--simulate` swaps in a synthetic receiver carrying one of each interesting
|
|
signal type, which is also what the test suite runs against:
|
|
|
|
```bash
|
|
bandsaunter scan -r 144M-148M --simulate
|
|
```
|
|
|
|
## Testing
|
|
|
|
```bash
|
|
python -m pytest
|
|
```
|
|
|
|
Covers DSP invariants, frequency parsing, the classifier against synthetic
|
|
signals at several SNRs and random seeds, Morse decoding from 8 to 40 WPM, the
|
|
voice detector against synthetic speech and against noise, tones and hum, and
|
|
full scan runs through the simulator checking that `--record` and `--hang` are
|
|
obeyed, that static and bare carriers are never written to disk, and that
|
|
audio, IQ and metadata are correct.
|
|
|
|
The simulator's voice transmitters carry synthesised speech -- glottal pulses
|
|
through moving formants, compressed the way a real transmitter compresses,
|
|
then gated into syllables and phrases -- because sine tones would not exercise
|
|
the speech detector at all.
|
|
|
|
## Legal note
|
|
|
|
Receiving is not the same as being allowed to use or divulge what you hear. In
|
|
the US, the ECPA prohibits intercepting cellular and other private
|
|
communications, and rebroadcasting or acting on what you receive is separately
|
|
restricted. Check your local rules.
|