Resizing the terminal during a scan left the screen full of wreckage:
box corners in the middle of a line, borders twice the width of the
window, a "receiver" header printed eight times down the left edge. Four
separate defects, which is why it looked so bad.
Live rendering works by moving the cursor back over the frame it drew
last time and overwriting it. That is only correct while the frame is
still where it was put, and none of these programs noticed when it was
not.
1. Nothing detected a resize. Both the scan display and saunterbrowse
now compare the console size on every frame and clear the screen when
it changes -- polled rather than handled as a signal, because the
display is redrawn several times a second anyway and a signal handler
that runs in the middle of a write has to be right about far more than
this does. Anything printed before the scan started scrolls away at
that point, which the manual now says.
2. The layout's model of its own height was wrong, in two places that
cancelled. The sweep panel was counted as one line shorter than it
is, the hit list as one line taller. The sum came out right whenever
both were drawn and wrong on a terminal too short for the hit list --
where the frame then overflowed by one line on every refresh and the
top of it marched down the screen. That is what the eight headers
were. Each panel height is a named constant now, and a test checks
every one of them against what is actually rendered.
3. Lines inside the panels could wrap. A band name, a long status line
or a decoded message made a panel a row taller than the arithmetic
allowed for, with the same result. Every one is drawn on a single
line and ellipsised now. The receiver panel drops its optional parts
instead, keeping the tuner and the flags: "SIMULATED" disappearing off
the end of a narrow line is how somebody comes to believe they are
listening to the air.
4. saunterbrowse's full-screen views did not fill the screen. Nothing
erases the alternate screen between frames -- the cursor is sent home
and the new frame written over the old one -- so pressing t or ? on a
tall window left most of the recording list visible underneath. Both
are wrapped in a layout now, which fills the terminal exactly.
The layout also gives up the receiver panel on a very short terminal,
which it previously had no way to do: on eight rows the smallest frame it
could describe was nine lines.
Testing this by rendering to a wide Console and reading the text back
cannot work -- whether the cursor lands where it should is a property of
the terminal, not of the renderable. So tests/terminal.py runs the
program in a pty, resizes the window underneath it the way a window
manager does, and feeds what it writes to a terminal emulator whose
screen is then read. Every fix above has a test that fails without it,
checked by reverting each one in turn. pyte is a dev dependency and
those tests skip without it; the arithmetic ones need nothing.
Also: t now opens the reader for a capture that carries decoded data
rather than speech, because the decoded panel already told the reader to
press it.
869 -> 949 tests.
Much of what a scanner finds is not speech. Doorbells, tyre-pressure
sensors, weather stations, remote controls, paging and packet radio all
carry something a receiver can read, and until now the answer was "OOK /
ASK data burst" and a WAV file. Now the bits come out.
The observation the whole thing is built on is that 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 reduce to a run-length train 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: PWM
(EV1527, PT2262, and nearly every 433 MHz remote), PPM, Manchester, and
plain NRZ. Four-level FSK is recognised as such and read as symbols
rather than sliced down the middle, which produces bits that mean
nothing; where a frame sync word appears the system is named outright.
Two protocols carry their own framing and checksums and so are read in
full. POCSAG paging: all three rates tried because nothing in the signal
says which it is, every codeword checked and single-bit errors corrected
against the BCH code, and the address, function letter and message text
reported. AX.25 as APRS uses it: the frame check has to come out right
before a frame is reported at all, and the sender's callsign goes onto
the map with everyone else's.
The hard half is refusing what is not data. Noise sliced at a threshold
produces runs and runs produce bits, so three things guard against it:
the runs have to quantise to the line code's own grid; most of the bursts
in a capture have to decode the same way, because one lucky window in
eight is a coincidence and that is exactly what SSB voice produced; and,
much the strongest, the packet has to repeat, because bits that come back
identical six times did not come from noise. A reading with none of that
behind it 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 it
returns nothing 27 times.
A firm decode also outranks the content check, which is statistical: 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. Such a capture is kept and filed as data, not as voice.
What comes out is written to a _data.txt beside the recording, shown on
the live display and in the line-per-hit output, and takes the place of
the transcript at the top of saunterbrowse -- where it is searchable, so
"which page mentioned engine 4" is a question that can be asked.
`bandsaunter analyze` decodes a file you already have.
The simulator gained two honest transmitters to test against: a
pulse-width remote that repeats a real payload, and a pager that sends
real POCSAG batches with real BCH check bits. Random keying exercises
the classifier but leaves a decoder nothing to get right. The POCSAG
encoder lives next to the decoder rather than in the test helpers, so a
bug shared by both cannot hide.
Fixed along the way:
- Rich reads a square bracket as markup, and a decoded page is arbitrary
text off the air. "[/x]" in a message ended the live display with a
MarkupError; so did typing "[/" at saunterbrowse's search prompt.
Everything that did not come from this program is escaped now.
- Otsu returned the first bin of a plateau. Two populations with nothing
between them -- silence and full carrier, which is what on-off keying
is -- make every threshold in the gap equally good, and taking the
first put it hard against the lower population with the hysteresis band
outside the data entirely, so nothing sliced at all.
- Estimating the symbol clock by counting along a cumulative grid is a
fixed point: a unit two per cent small produces two per cent more
symbols and reproduces itself exactly. Rounding each run on its own
converges instead, because every run votes independently. The grid is
then the right way to extract the bits, where rounding runs one at a
time drifts.
- A clipped first repeat used to truncate every other repeat to its
length. The consensus is taken over the commonest length now.
761 -> 869 tests.
Two additions, both about turning a number into something meaningful.
A band column. Next to every frequency -- on the live display, in the
line-per-hit output, in saunterbrowse's list and details -- is the name of
the band it falls in. 421 MHz is the 70 cm amateur band, and being told
so is quicker than remembering where the edges are.
The names come from the existing preset table, so there is one band plan
to keep right rather than two, but naming is not the job that table was
shaped for: several presets cover any frequency, some of them whole-tuner
sweeps that say nothing. So the candidates are ranked. Sweeps and the
"-complete" duplicates are dropped outright. The narrowest of what is
left wins, because it says the most -- 146.52 MHz comes back as the 2 m
simplex calling channel rather than as the whole 2 m band. Two exceptions
where the narrowest would be the wrong answer: ISM yields to the
allocation it shares (433.92 is 70 cm first, 915 is 33 cm first), and
shortwave broadcast yields to amateur where the two overlap, because
3.9-4.0 and 7.2-7.3 MHz are Region 1 and 3 broadcast but Region 2 amateur,
and this plan is documented as Region 2. 6 MHz really is 49 m shortwave
and is left alone.
The name is written into each capture's sidecar, so it travels with the
recording and an edit to the plan later cannot rewrite history, and
saunterbrowse searches on it: /70 cm finds the band without anyone having
to remember 420-450 MHz.
A map. A licence says where its holder is, so a list of callsigns is also
a map. Callsigns heard during a scan are now looked up as the transcripts
come in, announced on the display, and written to callsigns.kml in the
output directory; saunterbrowse --kml builds the same file from recordings
already on disk, and the two continue one map rather than starting two.
One placemark per station, not one per transmission: the same repeater
heard twenty times in an evening is one operator, and twenty pins on one
rooftop would say less than one. Each pin carries the callsign, the
licensee, the town, the grid square, and every frequency and time it was
heard on. The file is read back on open and added to, so later scans
build it up rather than replacing it.
Where a licence has no coordinates the grid square's centre is used and
the placemark says so -- a square is kilometres across where an address is
a street. A callsign with no licence at all is still recorded, in a
folder that starts switched off, because that a station was heard is worth
keeping even when nothing says where. A file already there that is not
readable as KML is never overwritten.
Also fixed along the way:
- The hit list's "no signals recorded yet" placeholder was one cell short
of its row, so it landed in the SNR column and wrapped, making the panel
taller than the layout had budgeted for and scrolling the display off a
short terminal. The identification column can no longer wrap either,
which is what _hit_capacity has always assumed.
- Licence lookups now record coordinates. The cache is versioned so that
entries written before this are asked about again, rather than pinning
every station to its grid square for good.
- CallsignBook.wait dropped joined threads; an all-night scan calls it
after every transcript and the list only ever grew.
- Tests redirect XDG_CACHE_HOME, so a run no longer reads or writes the
real lookup cache.
676 -> 761 tests.
Under the transcript, headed DETECTED CALLSIGNS:, every callsign heard in
it with the name and location on its licence.
Finding them is not one regular expression over the text as written. A
speech recogniser is poor at callsigns -- they are not words, they are
said one character at a time -- so it breaks them wherever the speaker
paused and writes the phonetic alphabet down verbatim. The recording that
prompted this has "Alright, KU 0W" in it, with a space; spelled out it
would have been "kilo uniform zero whiskey". All three forms read back to
KU0W.
Not inventing them matters more. A run of words is accepted only when
none of its parts is an ordinary English word: "or 3. Can you open 4" and
"CC1 boy", both from real transcripts here, fit the shape once the
punctuation is gone and are not callsigns. A single token said in one
breath is still trusted, because W1BOY is a perfectly good callsign, and a
lone "a" or "i" cannot start a join or "a B4U player" becomes AB4U.
Across the 126 transcripts in the recordings directory that turns three
candidates into the one that was actually said.
Lookups use the FCC's own licence data at callook.info -- no account, no
key, the callsign the only thing sent. They never delay the display: the
entry reads "looking up" and fills itself in, and results are cached under
~/.cache so a net recorded night after night is looked up once.
--no-lookup contacts nothing and still describes a callsign from its own
structure, the ITU prefix giving the country and the digit the US
district, which is also all there is to say for callsigns outside the US.
--callsigns prints everyone who identified themselves and where they were
heard.
Also asked: are transcripts appended to, or overwritten, when another
transmission arrives on the same frequency? Neither could be shown from
reading the code alone, so there are now three tests that run real scans
and look at the files. By default each transmission has a transcript of
its own -- the timestamp is in the name, so two overs cannot land on one
file. With --combine there is one recording per frequency and therefore
one transcript, opened for append with the time of each over; a second
scan into the same directory adds to it rather than starting it over,
which is the case the last of the three tests covers.
The browser and callsign tests refuse to reach the network at all. One
test did, quietly, and passed -- visible only because the assertion it
failed printed a real operator's address.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
A long scan leaves hundreds of recordings, each with a JSON sidecar of
measurements and, where a recogniser heard speech, a transcript. Reading
that meant opening files one at a time and guessing which were worth
playing.
saunterbrowse is a second executable in the same package. Arrow keys move
through the recordings; the transcript of whichever is highlighted fills
the top of the screen, because that is the part anyone actually wants to
read. Enter plays it, handing the file to whichever player is installed
-- the recordings are ordinary WAVs, every desktop already has something
that plays them, and a browser that cannot start would be worse than one
that cannot play. t opens the whole transcript full screen when it is
longer than the panel, and says so rather than cutting the end off
silently. / filters on the frequency, the name, the identification, or
anything that was said, which is the point of it: "was the repeater
mentioned" is a question about content.
Sidecars are read only for the rows on screen, so a directory of ten
thousand recordings opens instantly. Where there is no transcript the
panel says which of the reasons applies -- Morse (decoded, and shown),
data, a bare carrier, or speech never offered to a recogniser -- because
those want different things done about them. It only ever reads.
Two things were only found by driving it through a real terminal.
sys.stdin.read(1) goes through a buffered text wrapper, which in cbreak
mode waits for more bytes than one keypress provides: the program drew its
first frame and then hung, while tests against a stand-in stream object
passed. It reads the file descriptor now, and the tests drive a pty. And
stopping playback signalled only the direct child, so a player that is a
wrapper script kept the sound going with nothing on screen to stop it; the
whole process group is signalled instead, which is what start_new_session
was there for.
man saunterbrowse ships beside man bandsaunter, and the two point at each
other.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
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>
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>
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>