The same window the aircraft use, given marks that are not aeroplanes. It
already knew how to fetch a map, place an information box where it covers
nothing, glide it when its owner moves, draw range rings, put a flag where
the aerial stands and a scale along the bottom -- and not one of those has
anything to do with aviation. What it did not know is that a mark might not
fade, might not point anywhere, and might be coloured by what it is rather
than by how high it is. Those are three hooks rather than a second window,
and the aircraft map is untouched: every one of them defaults to exactly what
an aeroplane does.
Nothing fades, which is the difference asked for and the right one. An
aeroplane that stops transmitting has flown out of range, and drawing it an
hour later where it was would be drawing something that is certainly not
there. A fixed amateur station that stops transmitting is still exactly
where it was -- it beacons every half hour, and the gaps are silence rather
than absence. So the picture accumulates and an evening of listening fills a
map. Marks are ordered most-recently-heard first, because that is the order
the boxes are laid out in and an accumulating map has more marks on it than
it has room for boxes.
Marks are drawn by what they are: something moving as a body with a stalk
pointing where it is going, and anything fixed as a diamond, which is the one
shape on the picture with no front -- a house that beacons twice an hour is a
place, and a triangle would have it pointing north for no reason. Colours
come off the altitude ramp, not because a station has an altitude but because
that ramp is the one set of colours all five themes define: warm to cold on
the default map, dim to bright on the phosphor ones, so a digipeater stays
distinguishable from a car everywhere without a colour being named here.
The box says what the station is, how far off and in which bearing, what it
is doing if it is moving, its altitude, its weather, its status, the
digipeaters it came through, how many packets and how many of those arrived
directly, and how strongly. The heading prints the callsign once: an
aeroplane has two names and the heading was built for that.
Reachable both ways, as everything here is -- --window on the command line, w
in the menu -- with the same eight map settings the aircraft side has.
Forty new tests against nine deliberately broken builds. Two of them survived
the first attempt, and both for the same reason: they asked whether the
rendered frames differed rather than whether the symbol did, and the strip
along the top carries a running clock, so two frames taken a millisecond apart
differ by a few hundred pixels whatever is on the map. They now crop to the
mark. That is the second time this session that a ticking header has made a
test pass for the wrong reason. Full suite 2652 passed. Built as
2026-09-21_01.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
The region was in the menu, in the receiver group, between the sample rate and
the invented channel. That is the wrong place for it. It is the setting that
decides whether anything is heard at all, and being on the wrong one sounds
exactly like having no aerial, so it does not belong a level down among the
things that make a working receiver work slightly better.
It is now on the front page of the APRS menu, named as well as numbered, and
so is the Listen line: "north-america 144.39 MHz" rather than "144.39 MHz",
because the number alone does not say whether it is the right one and the name
alone does not say what will be tuned. A channel that is no region's says so
rather than claiming one.
The region and the frequency are separate settings -- somebody may want a
local packet network on neither -- which means they can be made to disagree.
Every place that chooses a region now goes through one function, so they
cannot.
And there is a search. `bandsaunter aprs --find-channel`, or f in the menu,
listens on each region's channel in turn and prints what was on each, then
offers to use the busiest. This answers the one question about APRS that
cannot be answered on any single frequency, because the answer *is* a
frequency: somebody who has just plugged a dongle in cannot tell a wrong
channel from a dead aerial, and that is worth a minute of listening rather
than an evening of doubt.
What it does not do is claim more than it found. A quiet channel is not proof
of an empty one -- a fixed station beacons every half hour -- so what it finds
is traffic, and when every channel comes back silent it says that this is not
the same as an empty band and points at the aerial instead.
The invented channel now honours tuning, and only carries its stations on its
own frequency. Without that the search would pass on a simulated band having
never searched anything, which is the kind of test that is worse than none.
Sixteen new tests against six deliberately broken builds. The three that
searched a whole simulated band were doing the same expensive thing three
times over and now build their results directly, leaving one real end-to-end
search; that file went from nine and a half minutes to two. Full suite 2612
passed. Built as 2026-09-20_03.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
A third section, alongside the aircraft and the weather sensors, and for the
same reason as both: a scan stops on a signal, records it and moves on, while
APRS is a two-second transmission every few minutes from a hundred stations
sharing one frequency. A sweep catches whichever happened to key up as it
passed. `bandsaunter aprs` parks on the channel and catches all of them;
`bandsaunter packets` reads a log back.
Four layers, three of them new.
The link layer was already here, opportunistically, in the generic decoder --
a correlator, NRZI, HDLC and a checksum, run on whatever a scan happened to
record. It is now a receiver. What had to change is the state that survives
a block boundary: the tail of the audio so the correlators see no edge, the
phase of the sampling loop so a bit is not lost where one block meets the
next, the tone the line was last at, and the bits themselves. A packet is
most of a second and a block is about one, so frames straddling the boundary
are not an edge case, they are most of them.
Above that, the APRS information field, which is not one format but about
twenty, chosen by the first character and accreted over thirty years.
Positions uncompressed and compressed; Mic-E, which every Kenwood and Yaesu
mobile sends and which hides the latitude inside the destination callsign
because in 1995 those six bytes were carrying the word "APRS" and nothing
else; weather with a position and without; messages, acknowledgements,
rejections and bulletins; objects and items; status; telemetry; third-party
traffic, credited to whoever originally sent it rather than to the gateway.
Course and speed, altitude, power and antenna height, range and the precision
extension, all of which ride in the comment. Every one has a writer beside
its reader, so a packet goes in and the same packet comes out.
Above that the section: a registry of who is out there and what each last
said of each kind, distances and bearings from --at, a log keeping the whole
frame under whatever was made of it, a spreadsheet, a map, and a channel full
of stations that are not there for --simulate.
One rule is worth naming because it is the difference between a decoder and a
liar. A packet whose format does not match what its first character promised
comes back as unparsed with its text intact. Thirteen characters of a
*malformed* uncompressed position are perfectly good base-91, so trying one
format and falling back to the other does not fail on a bad packet -- it
succeeds, as a confident and completely different place, usually a thousand
miles away. The specification makes the two unambiguous, a leading digit
always meaning uncompressed, and the rule is read rather than guessed at.
Two faults found by building it, both by measurement rather than by reading
the code again. The framer handed back frames it had already reported,
because it trimmed its buffer to before them rather than after -- every packet
counted twice, for ever, which only shows up once the same signal is read
across more than one block. And the invented channel truncated a
transmission at the end of the block it began in rather than carrying the
remainder over, which was invisible for as long as the simulated clock
advanced in exact seconds and put every transmission at a block boundary; the
moment it was paced against a real clock, nothing decoded at all.
204 new tests against seven deliberately broken builds, one of which survived
until a test was written for the case it actually breaks. Full suite 2597
passed. Built as 2026-09-20_02.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
A consumer weather station is two things. The display on the kitchen wall is
one of them; the other is a plastic box on a fence post that says what it can
see every sixteen seconds, in the clear, to anyone who happens to be
listening. This reads the box.
A section of its own, like the aircraft one, and for the same reason: it does
not fit through the scanner. A sensor message is a burst of a carrier
switched on and off, a fifth of a second long, and the scan path is a squelch
and a recorder -- it would record the bursts as clicks in a WAV file and
decode nothing. `bandsaunter weather` listens, `bandsaunter readings` reads
a log back, `bandsaunter sensors` says what is out there. Item 6 in the main
menu is the same thing without a command line.
Five families: the Tower 592TXR, the 5-in-1, the 6045M lightning detector,
the 609TXC and the 606TX. Temperature, humidity, wind speed and direction,
rainfall, strike counts, how far off the storm is, and battery state from all
of them. Every one is implemented from its published description and checked
against frames built from the same description, which proves the framing, the
parity, the checksums and the arithmetic and is not the same as having held
one of each.
The naming is the point. A sensor broadcasts an identity, and that identity
is a number that came out of a hat in a factory; it tells one sensor from
another and is no use at all for telling which is which. So press n while
listening: the display comes down, the sensors are listed, you name one, and
it goes back up, with the receiver running throughout. That is the moment it
is possible -- the sensor is on the screen saying 3.1 degrees, and the person
watching is the one who knows that the cold one is the shed. An hour later it
is a list of hexadecimal again. Names are written the instant they are given
rather than at exit, to a neighbouring file renamed over the old one, and one
given before a sensor has ever been heard waits under its identity and moves
across when the first message says which model it is.
Four things keep the neighbours' doorbells off the display. The checks the
message carries; a second copy, for the two models that carry only one byte
of check between them; a plausibility range, because a checksum can be
satisfied by a message the hardware could not send; and where in the burst
the message sits. That last one is the one that is easy to miss: a seven-byte
message read out of the front of a real eight-byte one is made of that
message's own payload bytes, whose parity is already correct, so the parity
bits contribute nothing and one byte of sum is all that is left -- and
corroboration cannot help, the three copies being identical. What gives that
window away every time is that it ends a whole byte before the burst does.
The Atlas is nine bytes like the lightning detector and lays its payload out
differently, so every decoder insists on a message type it knows. Anything
else that frames correctly is reported with its identity and no weather,
because wrong weather under somebody's sensor name is a worse answer than
none.
ism.py now delegates to this rather than keeping a second implementation of
the tower sensor, which fixes the channel letters -- A is 3, B is 2, C is 0,
and there is no D -- and the battery bit, which is set while the battery is
good. The two thinly-checked models are not reported from a scan at all: a
scan hears one burst, and they need two.
The option menus are now handed the module that owns the options rather than
importing the aircraft one, so one set of screens drives both sections and
will drive a third.
169 new tests, checked against nineteen deliberately broken builds; two of the
tests were too weak to notice their own mutation and were rewritten. Full
suite 2252 passed. Built as 2026-09-07_01.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
The map brightness setting could not make the map visible on a vector
theme, which is the one place it was needed. Those themes want the ground
well out of the way -- a tinted photograph of a county behind the vectors is
the one thing that stops a vector display looking like one -- and that was
done by multiplying the setting by about a quarter. A multiplier is a
ceiling: turned the whole way up, the setting still gave a map at a tenth
the brightness the default theme gives, which is to say invisible, and no
amount of turning it up did anything about that.
It is a curve now rather than a ceiling. The theme raises the setting to a
power, so the middle of the range is still quiet -- seventy per cent lands
where the old quarter did, which is the look these themes are for -- and the
top of the range is a full-brightness map on every theme there is. On the
green phosphor the setting now spans a luminance of six to seventy where it
used to stop at twenty-one.
And the options are in six groups rather than one list: receiver, listening,
aircraft, animation, the map, labels. Thirty-three of them on one screen is
a wall rather than a menu. A number opens a group and a number inside it
changes an option, with the numbers still being each option's place in the
whole list so that the same number means the same option wherever it is
typed -- which meant reordering the list so that every group is contiguous,
and there is a test that says so.
A group menu makes a known option harder to reach than a flat list did, so
the name works too: typing "map brightness" at the top goes straight to it,
and part of a name lists everything it could mean. A name that matches
exactly wins outright, so "speed" reaches the setting called speed rather
than that one and every other whose description happens to mention the word.
One thing to know: a bare number at the top of the menu now opens a group
where it used to edit the option of that number. The tests that drove the
menu that way would have gone on silently editing whatever option shared the
number, so they ask by name now, and one of them checks that a group number
changes nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
The terminal board says what is overhead. This says where: a real map
with the aircraft moving on it as the frames arrive, and beside each one a
box carrying everything known about the flight -- type and registration,
who operates it, where it came from and where it is going, height with a
rate of climb, speed and heading, how far away and on what bearing, its
position, how many frames it has sent and how long since the last one.
Qt is asked for and not required. Four bindings are tried, the module
imports on a machine with none of them, and asking for the window without
one gets the instructions rather than a traceback -- before the receiver
is opened, since nothing is gained by taking the dongle for a window that
cannot be drawn.
In the menu, "listen now" is now "passive capture" with a realtime
display beside it. Closing the window leaves exactly the files pressing
control-C leaves, because listen and watch share one read loop and one
finishing step; the receiver runs on its own thread, so a slow repaint
cannot cost a frame and a slow tile fetch cannot stall the picture.
The animation's labels grew to match: flight level and speed, type and
registration, and both ends of the route, each with a small flag of the
country its airport is in. The flags are a table rather than a network --
twelve pixels by eight, where a flag is the arrangement that makes one
recognisable rather than a rendering of the real thing -- and a country
not in the table is named by its two letters, since a flag that is nearly
another country's is worse than none. Where a route arrives as bare
codes the country comes from the ICAO prefix.
Four things found on the way. The window ignored --seconds, so "listen
for ten minutes" meant something different with a window open; it closes
itself now. The register was being asked twice per aircraft, once for
labels and once for airport positions. Cached routes had no country in
them, so the first real redraw drew no flags at all -- routes are
versioned now. And past fourteen aircraft on one frame the labels go
back to the callsign, the height and the speed, because five lines beside
each of three hundred aircraft is a page of overlapping text with a map
somewhere behind it.
Long names are folded rather than allowed to stretch a box, breaking at
the arrow of a route so the two ends stay whole; and the animation's
label placement gained the same ring search the window uses, having only
ever tried four spots.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
Four things the ADS-B mode was missing, and one it was actively getting
wrong.
The band plan lists 1090 MHz because that is where ADS-B is, so choosing
it from the band plan is the obvious thing to do -- and it records the
bursts as clicks in a WAV file and decodes nothing, silently. Both the
scanner and the menus now say so, before the sweep starts, and name the
mode that does decode it. It is not refused: looking at the raw spectrum
is a fair thing to want.
Menu 5, Aircraft (ADS-B), is the whole mode without a command line. Every
option on one screen with a line saying what it does, ?N for the long
version and the flag it corresponds to, l to listen, m to draw a map from
any log, s to keep the options. The listening and the drawing moved into
bandsaunter/aircraft.py so the menus and the command line run the same
code.
While it listens the screen is a live board: one line per aircraft in the
order first heard, the counter climbing as frames arrive, height coloured
low warm to high cold with an arrow for climb or descent, the age of the
last report going green to red, and the line removed once nothing has been
heard for --hold seconds, everything below moving up. The registers are
asked while it runs, so registration, type, operator and route fill
themselves in as the answers arrive.
--speed-unit knots|mph|kph changes the heading of that board, the speed
beside every aircraft on the map and the speeds in the report, and moves
the distances with it so that one picture never carries two different
miles. The log stays in knots, which is what the aircraft broadcast.
And there is a real map under the flight paths: {z}/{x}/{y} tiles fetched
once, cached in ~/.cache/bandsaunter/tiles, reprojected from Web Mercator
pixel by pixel, inverted and dimmed so the aircraft stay the brightest
thing on the picture. The PNGs are decoded here -- zlib and the five row
filters from the specification, checked byte for byte against Pillow on
real tiles -- so nothing new is depended on. Tiles are cached and never
re-fetched, every request says who is asking, and the attribution is drawn
onto the picture, because a GIF travels without its readme.
conftest now fails any test that reaches for a tile server or a register.
It caught four of these on the way in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
Hexadecimal is a true answer to "what did that say" and not a useful one.
This is the work of turning the rest of what a receiver hears into
something a person can read, and most of it is pictures.
PICTURES
Three of the things on the air are images rather than sounds, and all three
arrive as the audio a scan already records:
SSTV 14.230 and 144.5 MHz Martin M1/M2, Scottie S1/S2/DX, Robot 36/72
APT 137-138 MHz the NOAA weather satellites
HF fax 2-20 MHz, sideband the marine weather charts
Each is written from its published specification, and the generators used to
test them are written from the same specification without reference to the
decoders -- so a picture that comes back matching the one that went in is
evidence about the format. Every SSTV mode reproduces its published line
time exactly, which is worth failing a test over: a line a few milliseconds
long walks the picture off the screen inside ten lines. Against synthetic
transmissions at 30 dB SNR, SSTV is 96-98% of pixels exact, APT correlates
at 0.97 and fax at 0.998; all three still read at 6-12 dB.
None of the three is guessed at, and that is what makes it safe to try them
on every recording. SSTV needs its VIS header, APT needs both line syncs at
the right distance from each other, fax needs the phasing signal. No false
pictures in 295 attempts over noise, tones, speech and swept whistles.
Two things had to be got right beyond the arithmetic. A band-pass does not
switch between two tones, it slides between them, so every edge is measured
at the midpoint of the slide rather than at the first sample past a
threshold -- the earlier version was reading the coarse search stride back
as the edge and shifting Martin M1 sideways by a whole colour bar. And a
picture now keeps its capture whatever the content check made of it: a
satellite is a steady tone with a wobble on it and SSTV is a whistle, so
both were being discarded as "no signal content" having already been
recognised.
PNG is written here rather than pulled in from Pillow. A scanner that
cannot start because an imaging library is missing is worse than one that
cannot draw.
saunterbrowse marks a picture in the list, gives its path in full -- wrapped
rather than cut off, because half a path opens nothing -- and moves or
deletes the PNGs with the recording. o prints the picture's path, not the
audio's.
GRIB is not a modulation and is not pretended to be one. It is the format
weather models are published in and it travels by satellite link and by
e-mail; where a decoded byte stream begins with its magic number it is
named, and that is all.
AIRCRAFT
`bandsaunter adsb` parks the receiver on 1090 MHz and reads Mode S extended
squitter: address, callsign, altitude, position, speed. A command of its
own because a megabit a second will not go through a channel twelve and a
half kilohertz wide. Every frame carries a 24-bit checksum so there is no
threshold anywhere in it -- with one trap, which is that a frame of all
zeros satisfies that checksum and silence is exactly that. Positions round
trip exactly through compact position reporting, and a pair straddling a
longitude-zone boundary is refused rather than resolved against two grids.
METERS AND SENSORS
Itron ERT utility meters on 900 MHz and AcuRite weather sensors on 433 MHz
are named rather than reported as hex, and neither is believed without its
own checksum -- BCH(255,239) for the meter, a checksum and four parity bits
for the sensor. Both are implemented from published descriptions and
checked against frames built from the same descriptions, which proves the
framing and the arithmetic and is not the same as having held a meter.
HEX INTO WORDS
Everything else that decodes to bits now gets its fields named where the
shape is standard, its text read out where there is text, and its bytes laid
out in groups with the printable characters beside them.
The text search is where the care went, because printability is not
evidence. Forty framings of each packet, and seven-bit values printable
three in four, meant a bar set on printability called 64% of random payloads
text. Real text is nearly all one case where random letters are half and
half, two fifths vowels where random is a fifth, and mostly alphanumeric
where random draws punctuation one time in four. Together: under 0.5%,
measured in the suite.
CALLSIGNS
The licensed address is recorded in full -- the street, not merely the town
-- and goes into the KML with everything else. US amateur records are
public by law and carry it; holding it and not saying so is worse than
either showing it or not asking, and --no-lookup asks for none of it.
Also here: Morse is decoded again from the whole recording where the capture
was made in cw mode. The first pass works from the classifier's buffer,
which holds a few seconds -- enough to say "this is Morse", not enough to
catch a callsign whole between two word gaps, so a beacon repeating every
eight seconds through an eight-second window was never identified.
And classify._psk_order took the logarithm of zero on a silent block.
1318 tests, up from 1161.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
Most of what identifies itself on the air identifies itself in Morse. A
repeater, a beacon, an unattended transmitter: four to six characters, over
in a second or two, and no speech anywhere in the capture. Every one of
those was being thrown away, in three separate places.
The callsign book and the map were built inside the transcription branch,
on the reasoning that callsigns come out of transcripts. They also come out
of Morse and out of APRS headers, neither of which involves a speech
recogniser -- so a receiver with none installed found none of them, and a CW
ident reached the sidecar and stopped there. Both are now built whenever
classification is on, and all three sources go through one place.
The CW decoder only ran where the classifier had already said cw, ook or
carrier. A two-second ident is a fraction of a capture named after whatever
filled the rest of it. Every capture is offered to it now, once it has
finished; a decode does not relabel a capture that plainly holds speech.
And the decoder's own gates were written for a paragraph. Three characters,
eight elements, and any repeated character refused -- which read VVV, DE, AR
and K correctly and then discarded them. Short is the normal case now, on a
second bar: perfect timing, nothing undecoded, and the keyed tone at least
20 dB over its band. That last is not decoration. With four elements the
dot length is fitted to those very elements, so noise lands on the grid as
neatly as keying does; a third of a second of white noise decodes as a
perfectly timed V. Over 200 noise blocks the loudest bin never rose 13 dB
above the median while keying at 3 dB SNR sits above 40. One keyed element
is still refused: a single pulse is an E or a T whether a person sent it or
the squelch opened on a click. 288 non-Morse cases, no false positives.
Feeding that text to a callsign lookup made truncation matter. A capture
opens when the squelch does, halfway through an element as often as not, and
half a character is not a smaller reading -- a K missing its first dash is
an A. So the sliced character is dropped, and so is the rest of its word,
because what is left can read as a whole one: K1AA caught halfway through is
K1A, which is somebody else. Across 1805 truncated captures that is 107
invented callsigns down to none, with 550 correct ones still found.
Phonetics, which is the other half of the ask. A recogniser has never heard
of the alphabet -- it writes what the words sounded like:
Whiskey-One-Alpha-Whiskey hyphenated
WhiskeyOneAlphaWhiskey run together
Whiskey1AlphaWhiskey and half in digits
wiskey one alfa whisky spelled the way it sounded
whiskey one alpha, uh, whiskey with the hesitation written down
All read back to W1AW now. A word is only taken apart when it is phonetic
all the way through, which is what keeps it off "kilometre" and "victorious".
Two bugs found on the way, both of which invented a callsign:
- Nothing is joined across a slash any more. The beacon W1AW/B came back
as W1AWB, which belongs to nobody, and W1AW-4 came back as nothing at all.
- Nor across a gap the sender chose. A transcript's spacing is the
recogniser's guess and may be closed up; a word gap in Morse is seven dot
units, so "KU0W K" is a station signing off, not a longer callsign.
Also here:
- saunterbrowse gives Morse a panel of its own, with the licence under it,
searchable with / and readable with t.
- --simulate no longer looks anything up or writes a map. The demo band is
invented but W1AW is the ARRL's own station, and it would have been
pinned to the same map a real scan writes.
- classify._psk_order took the logarithm of zero on a silent block.
- The demo band has a repeater ident in it, and its Morse no longer runs
one repeat into the next.
- conftest refuses a real licence lookup from any test.
1161 tests, up from 1014.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
A night's scan leaves hundreds of files, most worth nothing and a few of
them the reason it was left running. Sorting that out meant leaving the
browser and going at the directory with mv and rm.
Five keys, meant to be pressed once each going down the list:
S I N file it into saved/, investigate/ or noise/
u put the last one filed back
d delete it and its sidecars, for good -- asks first
m lock the frequency out, so no later scan stops on it
Each of these acts on the whole capture -- the .wav, the JSON sidecar, the
IQ, the transcript and the decoded data -- because a recording in one
directory and its transcript in another is a pair nothing will ever put
back together. A move that cannot be finished puts back whatever already
moved. The cursor stays on the row it was on, which is now the next
recording, since a cursor that jumped would make one-key-per-recording
impossible.
m writes to the lock-out list in the settings file, the same one the
scanner's own l key maintains, so a birdie found while reading last night's
recordings is gone from tonight's. It says "the next scan": one already
running read its settings when it started.
The subdirectories sit under the recordings directory, so a scan writing
there never looks in them, and saunterbrowse ~/bandsaunter/saved reads one
back.
Also here, because this is the first part of the browser that writes:
- The help screen is back inside eighty by twenty-four. It had grown past
the bottom of an ordinary window, which puts "q quit" off the screen.
- The footer drops keys in a deliberate order when the window is narrow,
rather than ellipsising whichever happened to be at the end.
- Moving or deleting what is playing stops the player first.
- The pty harness accepted an env and ignored it, so a test aimed at a
throwaway settings directory wrote to the real one. It honours it now,
and conftest redirects the settings directory for every test besides.
1014 tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
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.
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>
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>