A trunked system keeps one frequency transmitting a data stream around
the clock so its radios know where each conversation has been put. There
is no speech on it and it never stops, which makes it the strongest and
most useless signal in the band: the scanner parked on 856.561 MHz for
the full record limit, saved four minutes of buzzing, and found it again
on the next sweep.
Five signatures, matched against a constant-envelope stream that never
pauses: 3600 baud two-level (Motorola SMARTNET/SmartZone), 9600 (EDACS),
1200 (MPT-1327), 4800 four-level (P25 or DMR Tier III), 2400 (NXDN).
The first two are believed at once -- nothing else sends at those rates
without pausing. The rest share their shape with a digital voice call on
the same system, so they wait for the carrier to run unbroken past
--control-seconds, longer than a conversation goes without a breath.
Being in a trunked allocation raises confidence but is never required;
trunking is licensed on business pairs all over the spectrum.
One is named on screen, abandoned within a second or so, and its capture
deleted. --keep-control records them for a decoder; --lockout-control
writes them into the lock-out list.
Three things had to be fixed to get there.
The simulator's "pseudo-random" symbols were a counter: multiplying the
symbol index by an odd constant and taking it modulo the level count
returns the low bits, so two-level FSK came out 0,1,0,1. Every FSK test
in the suite was measuring a tone. Its FSK is now shaped the way GFSK
and C4FM shape a stream, too, square-edged keying being a signal no
licensed transmitter would radiate.
The symbol-rate estimator locked onto harmonics -- 3600 baud read as
18000 -- because a transition impulse train is a comb of equal lines; it
now walks down to the fundamental. The squared envelope is no longer a
candidate: it is not a transition signal, and its DC lobe made every
random OOK signal measure ninety baud. The search starts at 200 Hz
rather than 40, below which it was reading drift, which is how a bare
carrier was awarded a symbol rate. And a clean two-level signal counted
zero discriminator levels, because its modes land in the first and last
histogram bin, where find_peaks cannot see them.
Separately: locking out a frequency wrote to the settings file even under
--no-config, which has no settings file by definition. It now writes
only where it read from, and --simulate never writes at all -- an
invented frequency would sit in a real config for ever, skipping whatever
genuine signal happened to land near it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
Every setting now carries a paragraph saying what it is in everyday terms
and why someone who does not already speak radio would turn it up, down,
on or off: what the squelch knob actually is, why automatic gain is a bad
idea for scanning, why a bias tee can damage equipment, why setting the
transcription language matters on noisy audio. The menus and
`config --describe` show it alongside the existing technical detail.
packaging/make-man.py generates bandsaunter(1) from that same table, so the
manual cannot document a setting the program lacks or miss one it has --
tests check both, that the page renders through groff without a single
warning, and that the guidance survives into the rendered output. Around
it are hand-written sections on the commands, entering frequencies, the
band plan, lock-outs, the keys during a scan, HF, single sideband, files,
environment variables and worked examples.
The .deb regenerates and installs it rather than shipping a copy, so an
installed manual always matches the installed program.
The README picks up what the last few commits added: the plain display as
a saved setting, what the settings tests now guarantee, and where to read
the manual before installing.
Version is the day's build: 2026-08-22_01.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pressing `l` during a scan locked a frequency out for that run only, so the
same birdie had to be locked out again on every later one. It now writes
back to the settings file the run started from -- only that one key, since
a scan's config also holds whatever was passed on the command line for this
run and saving all of it would quietly make those permanent. The file is
read, its lock-outs replaced, the rest left as it was. `save_lockouts`
turns it off for anyone who would rather their config were never touched.
Lock-outs were also single frequencies only. They are now a list of
frequencies and spans -- "162.55M, 450M-455M, 88M to 108M" -- which is what
a pager band or a noisy stretch of spectrum actually is. A point is still
widened by the lock-out width; a span is taken exactly as written, because
whoever typed it already said how wide it is.
The scanner matched lock-outs by rounding a frequency into a bucket of the
lock-out width, which cannot express a span and was never exact at the
edges. It now holds intervals and tests them directly.
Settings files that predate this hold a bare number per lock-out, and still
mean the same thing: Lockout.coerce takes numbers, strings, pairs and dicts,
so old profiles load untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every setting was checked by setting all of them to something other than
their default, saving, loading, and comparing: all 54 survived, ranges kept
every field of their own, and the default settings file behaved the same as
a named profile. Those checks are now tests rather than a one-off, along
with a check that no setting is saved but never read, and that a profile
written by an older version still loads.
Two gaps were real.
The plain display could only be asked for per invocation. It was a
hand-written flag rather than an entry in the settings table, so unlike
`quiet` -- which does the same sort of thing -- it could not be saved,
leaving anyone who runs over ssh to pass --plain every time. It is a
setting now, which gives it a menu entry and --no-plain for free.
Gain displayed as "28.5 dB" but refused to parse "28.5 dB" back, because it
was the one numeric setting that called float() instead of the unit-aware
parser every other one uses. The menu shows a value as the example of how
to type it, so what it shows has to be something it accepts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An SSB capture was tuned to the centroid of the detected energy, which is
what every other mode wants and the one thing SSB cannot use. Its
demodulator is a filter that opens at the suppressed carrier, so centring
on the middle of the voice filtered off its lower half and shifted the
rest down by the error. Measured against known transmitters that error was
+2445 Hz on 20 m and -2486 Hz on 80 m, against 35 Hz for NFM and 9 Hz for
AM, which do not care either way. On real speech the difference is a clean
transcript versus nothing recognisable at all.
Speech puts most of its power just above the carrier, so the occupied band
leans towards it. ssb_alignment() reads that lean: it locates the carrier
to within about 100 Hz and names the sideband at the same time, without
recourse to any convention. The offset is applied inside the demodulator
at the IF rate, where it costs a fraction of what shifting the full-rate
stream would, and the reported frequency becomes the carrier -- the
frequency an operator would dial in.
The sideband was also decided by "LSB below 10 MHz, USB above", which
overrode a band plan that already knew better and demodulated 60 m as LSB.
The measurement decides now, the band plan when the signal has no lean to
read, and the convention only when neither has anything to say.
The simulator was transmitting SSB unfiltered, several times wider than
anything on the air, because it applied no transmit audio filter -- and
that filter is what makes a signal single-sideband. Its absence hid the
whole problem from the tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A transmitter seeded itself from the builtin hash() of its label, and that
is salted per interpreter, so every run produced different synthetic
speech and keying. A test that failed could not be made to fail again --
the one thing needed to fix it. crc32 gives the same content every run.
The hang test then compounded it by listening for a 1 s burst in a 6 s
cycle over three sweeps: it is the release after the transmission that is
under test, but a missed burst failed it just the same, with "nothing
recorded". Now 2 s in 5 s over six sweeps, so only the ending can fail it.
The neighbouring test had the opposite fault: asserting only that nothing
was truncated, it passed whether or not the burst was ever heard. It now
requires the recording it is drawing a conclusion from.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Debian ships the pocketsphinx tools and library but not the Python bindings,
so "apt install pocketsphinx" did not give a working engine. It comes from
pip like the others.
Also explains in the README why no recogniser can be a package dependency:
Policy forbids anything in the archive from requiring software outside it,
and a postinst that fetched from PyPI would break offline and reproducible
installs. Notes that a pip --user install still works with a .deb-installed
bandsaunter, since the user site directory is on the system interpreter's
path -- checked against the built package rather than assumed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Sweeps any set of frequency ranges, records what it finds, and works out
what kind of signal it was.
- Frequency ranges entered by hand or picked from a 135-entry US band plan,
including whole-band and all-CW sweeps that resolve the demodulator per
segment.
- Detection calibrated against the peak-hold detector's own noise statistics,
so the threshold means real margin over static rather than over the floor.
- A content gate: captures are kept only if they carry voice, decodable CW,
or an identified digital keying scheme. Speech is recognised by a pitch
track that drifts, which static cannot imitate.
- Identification of NFM/WFM/AM/SSB, CW with Morse decoded to text, P25, DMR,
NXDN, D-STAR, POCSAG, FLEX, ACARS, AIS, APRS, n-FSK and n-PSK.
- Gapless streaming capture, with the signal path fast enough to keep up in
real time, so recordings play back at the right speed.
- Optional one-file-per-frequency recording with spoken timestamps, and
speech-to-text transcription.
- Menus and command line generated from one settings table, so neither can
offer something the other cannot; settings persist in ~/.config.
367 tests, run against synthetic signals, a built-in receiver simulator, and
real hardware.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>