Add packaging, date-based versioning, and full install docs

Versions are now the release date and a revision within that day, padded to
two digits so they sort as text: 2026-08-21_01.

Neither packaging system accepts that form, so it is converted at the edge
rather than kept as a second version string that could drift out of step:
PEP 440 forbids dashes and underscores in a release segment, and a Debian
version may not contain an underscore at all. The date and revision in
__init__.py are the single source; pyproject reads the converted form, and
the tests check that pip and dpkg both order releases correctly.

packaging/build-deb.sh builds a .deb with plain dpkg-deb. Every dependency
is already in Debian, so apt resolves the lot; the package also blacklists
the DVB-T driver that would otherwise claim the receiver. Deliberately not
debhelper: the payload is pure Python with nothing to compile, and this way
the build needs nothing installed beyond dpkg.

The speech recognisers are not packaged for Debian and can only come from
pip, so they are suggested rather than depended on -- transcription is off
by default and reports plainly when no recogniser is present.

README now documents every dependency with its package name on Debian,
Fedora and Arch, how to let a user reach the receiver, and how to check the
install worked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
The Dust Council 2026-08-21 21:05:51 -07:00
parent db3e0c79b9
commit 52fe16123f
5 changed files with 313 additions and 30 deletions

125
README.md
View file

@ -28,45 +28,118 @@ decoded to text.
## Install
### From a package (Debian, Ubuntu, Mint)
```bash
sudo apt install rtl-sdr librtlsdr0 espeak-ng # Debian/Ubuntu
./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.
### From source
```bash
sudo apt install rtl-sdr librtlsdr0 espeak-ng # Debian, Ubuntu, Mint
pip install -e .
```
### Dependencies
## Dependencies
| | Package | Needed for |
|---|---|---|
| **required** | `librtlsdr0` (`rtl-sdr`) | talking to the dongle at all |
| **required** | `numpy`, `scipy` | all signal processing |
| **required** | `rich` | the menus and the live display |
| **required** | `PyYAML` | the settings file and profiles |
| *recommended* | `espeak-ng` | clearer spoken timestamps |
| *optional* | `faster-whisper` (pip) | transcribing speech to text |
Everything required is packaged in Debian, Fedora and Arch, so nothing has to
be built.
`espeak-ng` is a **recommendation, not a requirement**: without it the spoken
timestamps are produced by a built-in formant synthesiser, so the feature
works on a machine with nothing else installed. With it they are clearer and
render about three times faster. Any of `espeak`, `pico2wave`, `flite` or
macOS `say` is used if found instead.
| | 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* | faster-whisper | pip only | pip only | AUR | transcribing speech to text |
| *optional* | Matplotlib | `python3-matplotlib` | `python3-matplotlib` | `python-matplotlib` | nothing yet; reserved for plots |
For a Debian package that maps to `Depends: librtlsdr0, python3-numpy,
python3-scipy, python3-rich, python3-yaml`, `Recommends: espeak-ng,
rtl-sdr` and `Suggests: python3-vosk` — transcription is off by default and
the scan reports it plainly when no recogniser is installed. `rtl-sdr` itself only supplies command-line tools bandsaunter does not
call — the library is what matters.
Two notes on the optional ones:
`bandsaunter scan --combine --dry-run` reports which engine will be used.
**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.
If the dongle is not accessible, the DVB-T kernel driver has usually claimed
it:
**Speech recognition is not packaged for Debian** and can only come from pip.
Transcription is off by default and says so plainly when no recogniser is
present, so this never blocks an install:
```bash
echo 'blacklist dvb_usb_rtl28xxu' | sudo tee /etc/modprobe.d/blacklist-rtl.conf
sudo rmmod dvb_usb_rtl28xxu
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
```
Check with `bandsaunter devices --test`.
### 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

View file

@ -1,8 +1,40 @@
"""bandsaunter -- a signal scanner for RTL-SDR receivers.
Sweeps arbitrary frequency ranges (or US band-plan presets), records what it
finds, and identifies the modulation -- including decoding CW/Morse to text.
finds, and identifies the modulation -- including decoding CW/Morse to text
and transcribing speech.
"""
__version__ = "1.0.0"
__all__ = ["__version__"]
# Versions are the release date and a revision within that day, so
# 2026-08-21_02 is the second build made on the 21st. The revision is padded
# to two digits so versions sort as text.
VERSION_DATE = "2026-08-21"
VERSION_REVISION = 1
__version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}"
__all__ = ["__version__", "VERSION_DATE", "VERSION_REVISION",
"pep440_version", "debian_version"]
def pep440_version() -> str:
"""The version as Python packaging tools require it.
PEP 440 allows only digits and dots in the release segment, so the
displayed form cannot be used directly: pip rejects both the dashes and
the underscore. ``2026-08-21_01`` becomes ``2026.8.21.1``, which sorts
the same way and round-trips back to the same date and revision.
"""
year, month, day = VERSION_DATE.split("-")
return f"{int(year)}.{int(month)}.{int(day)}.{VERSION_REVISION}"
def debian_version() -> str:
"""The version as dpkg requires it.
Debian permits alphanumerics and ``. + - : ~`` in an upstream version but
not underscores, so the displayed form cannot be used there either. Dots
keep the zero padding, which makes dpkg's comparison agree with a plain
text sort.
"""
return f"{VERSION_DATE.replace('-', '.')}.{VERSION_REVISION:02d}"

87
packaging/build-deb.sh Executable file
View file

@ -0,0 +1,87 @@
#!/bin/sh
# Build a .deb that pulls in every dependency through apt.
#
# Deliberately plain dpkg-deb rather than debhelper: everything this package
# needs is already in Debian, the payload is pure Python with no compiled
# parts, and this way the build needs nothing installed beyond dpkg itself.
set -eu
here=$(cd "$(dirname "$0")/.." && pwd)
version=$(cd "$here" && python3 -c 'import bandsaunter; print(bandsaunter.debian_version())')
revision=${DEB_REVISION:-1}
arch=all
stage=$(mktemp -d)
trap 'rm -rf "$stage"' EXIT
pkgdir="$stage/bandsaunter_${version}-${revision}_${arch}"
site="$pkgdir/usr/lib/python3/dist-packages"
mkdir -p "$site/bandsaunter" "$pkgdir/usr/bin" "$pkgdir/DEBIAN" \
"$pkgdir/usr/share/doc/bandsaunter"
cp "$here"/bandsaunter/*.py "$site/bandsaunter/"
cp "$here/README.md" "$pkgdir/usr/share/doc/bandsaunter/"
gzip -9n "$pkgdir/usr/share/doc/bandsaunter/README.md"
cat > "$pkgdir/usr/bin/bandsaunter" <<'EOF'
#!/usr/bin/python3
import sys
from bandsaunter.cli import main
sys.exit(main())
EOF
chmod 755 "$pkgdir/usr/bin/bandsaunter"
# Every one of these is in Debian, so apt resolves the lot. The speech
# recognisers are not packaged for Debian and can only come from pip, so they
# are suggested rather than depended on -- transcription is off by default and
# says so plainly when no recogniser is present.
cat > "$pkgdir/DEBIAN/control" <<EOF
Package: bandsaunter
Version: ${version}-${revision}
Section: hamradio
Priority: optional
Architecture: ${arch}
Depends: python3 (>= 3.10), python3-numpy, python3-scipy, python3-rich,
python3-yaml, librtlsdr0
Recommends: espeak-ng
Suggests: rtl-sdr
Maintainer: bandsaunter
Installed-Size: $(du -ks "$pkgdir" | cut -f1)
Description: signal scanner and recorder for RTL-SDR receivers
Sweeps any set of frequency ranges, or presets from a built-in US band plan,
records what it finds and works out what kind of signal it was.
.
Captures are kept only if they carry voice, decodable CW or an identified
digital keying scheme, so static and interference are discarded rather than
filling the disk. CW is decoded to text, and speech can be transcribed when
a recogniser is installed.
EOF
cat > "$pkgdir/DEBIAN/postinst" <<'EOF'
#!/bin/sh
set -e
if [ "$1" = configure ]; then
# The DVB-T driver claims the dongle on sight and has to be kept off it.
if [ ! -e /etc/modprobe.d/blacklist-rtlsdr.conf ]; then
echo 'blacklist dvb_usb_rtl28xxu' > /etc/modprobe.d/blacklist-rtlsdr.conf
echo 'bandsaunter: blacklisted dvb_usb_rtl28xxu; unplug and replug the'
echo ' receiver, or run: sudo rmmod dvb_usb_rtl28xxu'
fi
fi
exit 0
EOF
chmod 755 "$pkgdir/DEBIAN/postinst"
cat > "$pkgdir/DEBIAN/prerm" <<'EOF'
#!/bin/sh
set -e
if [ "$1" = remove ] || [ "$1" = purge ]; then
rm -f /etc/modprobe.d/blacklist-rtlsdr.conf
fi
exit 0
EOF
chmod 755 "$pkgdir/DEBIAN/prerm"
out=${1:-$here/dist}
mkdir -p "$out"
fakeroot dpkg-deb --build "$pkgdir" "$out" >/dev/null
echo "$out/bandsaunter_${version}-${revision}_${arch}.deb"

View file

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "bandsaunter"
version = "1.0.0"
dynamic = ["version"]
description = "Signal scanner, recorder and identifier for RTL-SDR receivers"
readme = "README.md"
requires-python = ">=3.10"
@ -33,6 +33,12 @@ transcribe-small = ["vosk>=0.3"]
[project.scripts]
bandsaunter = "bandsaunter.cli:main"
[tool.setuptools.dynamic]
# The displayed version (2026-08-21_01) is not valid PEP 440, so packaging
# tools are given the converted form. One source of truth, converted at the
# edge, rather than two version strings that can drift apart.
version = {attr = "bandsaunter.pep440_version"}
[tool.setuptools.packages.find]
include = ["bandsaunter*"]

85
tests/test_version.py Normal file
View file

@ -0,0 +1,85 @@
"""The date-and-revision version scheme, and the forms packaging tools need."""
import re
import subprocess
import pytest
import bandsaunter
def test_displayed_version_is_date_and_padded_revision():
assert re.fullmatch(r"\d{4}-\d{2}-\d{2}_\d{2,}", bandsaunter.__version__), \
bandsaunter.__version__
def test_the_revision_is_padded_to_two_digits():
assert bandsaunter.__version__.split("_")[1] == \
f"{bandsaunter.VERSION_REVISION:02d}"
assert len(bandsaunter.__version__.split("_")[1]) >= 2
def test_the_date_is_a_real_date():
from datetime import date
year, month, day = bandsaunter.VERSION_DATE.split("-")
date(int(year), int(month), int(day)) # raises if not
def test_versions_sort_as_text_in_release_order():
"""Padding is the point: without it, revision 10 sorts before revision 2."""
def displayed(d, r):
return f"{d}_{r:02d}"
ordered = [displayed("2026-08-21", 1), displayed("2026-08-21", 2),
displayed("2026-08-21", 10), displayed("2026-09-01", 1),
displayed("2026-12-31", 3), displayed("2027-01-01", 1)]
assert sorted(ordered) == ordered
def test_the_pep440_form_is_valid_where_the_displayed_one_is_not():
from packaging.version import InvalidVersion, Version
with pytest.raises(InvalidVersion):
Version(bandsaunter.__version__) # dashes and underscore
assert Version(bandsaunter.pep440_version())
def test_the_pep440_form_keeps_release_order():
from packaging.version import Version
assert Version("2026.8.21.1") < Version("2026.8.21.2")
assert Version("2026.8.21.2") < Version("2026.8.21.10")
assert Version("2026.8.21.10") < Version("2026.9.1.1")
assert Version("2026.12.31.1") < Version("2027.1.1.1")
def test_the_debian_form_is_acceptable_to_dpkg():
"""Debian versions may not contain underscores."""
debian = bandsaunter.debian_version()
assert "_" not in debian
assert re.fullmatch(r"[0-9][0-9.+~:-]*", debian), debian
@pytest.mark.skipif(not __import__("shutil").which("dpkg"),
reason="dpkg not available")
def test_dpkg_agrees_the_debian_versions_order_correctly():
def older(a, b):
return subprocess.run(["dpkg", "--compare-versions", a, "lt", b]
).returncode == 0
assert older("2026.08.21.01", "2026.08.21.02")
assert older("2026.08.21.02", "2026.08.21.10")
assert older("2026.08.21.10", "2026.09.01.01")
assert older("2026.12.31.01", "2027.01.01.01")
def test_all_three_forms_describe_the_same_release():
date_part = bandsaunter.VERSION_DATE
rev = bandsaunter.VERSION_REVISION
assert bandsaunter.__version__ == f"{date_part}_{rev:02d}"
year, month, day = date_part.split("-")
assert bandsaunter.pep440_version() == \
f"{int(year)}.{int(month)}.{int(day)}.{rev}"
assert bandsaunter.debian_version() == \
f"{date_part.replace('-', '.')}.{rev:02d}"
def test_the_installed_package_reports_the_displayed_version():
out = subprocess.run(["python3", "-m", "bandsaunter", "--version"],
capture_output=True, text=True, timeout=60)
assert bandsaunter.__version__ in out.stdout