diff --git a/README.md b/README.md index ca9bbc6..172869d 100644 --- a/README.md +++ b/README.md @@ -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__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 diff --git a/bandsaunter/__init__.py b/bandsaunter/__init__.py index 92a998f..ccc5726 100755 --- a/bandsaunter/__init__.py +++ b/bandsaunter/__init__.py @@ -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}" diff --git a/packaging/build-deb.sh b/packaging/build-deb.sh new file mode 100755 index 0000000..5debe0e --- /dev/null +++ b/packaging/build-deb.sh @@ -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" <= 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" diff --git a/pyproject.toml b/pyproject.toml index c1b004b..9444693 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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*"] diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 0000000..359bba0 --- /dev/null +++ b/tests/test_version.py @@ -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