Radio Log

A multi-channel VHF receiver, recorder and transcriber running on a Raspberry Pi 5.

Operator and maintenance guide · last revised 26 October 2026

1. What it does

The watchkeeper listens to several VHF radio channels at once, notices when someone transmits, records each transmission as a separate audio file, transcribes it, and puts the result in a log you can read on a phone.

It is a watchkeeper, not a scanner. A scanner stops on one channel at a time and you miss whatever happens on the others. This receives the whole band continuously and splits it into channels in software, so nothing is missed while something else is talking.

The band is marine VHF, 156–158 MHz, narrowband FM: boats, harbour control, vessel traffic services and the Coast Guard.

2. Using the log

The log is a web page. Open it on a phone, a tablet or a laptop — there is nothing to install and no password. Every transmission the receiver hears appears at the top of the list within a few seconds of the speaker letting go of the button.

Newest is first. The page refreshes itself, so leave it open and it stays current.

Reading a message

Each row is one transmission. Reading left to right:

What you seeWhat it means
Coloured bar Which channel. The colours match the legend in the settings panel.
8:06 How long ago it was heard, counting up live. After fifteen minutes the row dims slightly, so a glance tells you what is current.
6.0s How long the transmission lasted.
Ch16 Distress The channel name.
93% How clearly it came through. Not volume — see below.
The text What the computer thinks was said.

Tap anywhere on a row to play the recording. Tap it again to stop. The row you are listening to is highlighted.

The download arrow on the right saves that recording and its transcript together, which is what you want if you need to send a clip to someone or keep it before it expires.

Badges and what they are telling you

BadgeMeaning
DISTRESS in the header Urgent traffic in the last thirty minutes. Tap it and the page jumps straight to the message, even if your filters would normally hide it.
LISTEN on a row This message used distress or urgency wording. It does not mean the text is wrong and it does not mean there is an emergency — it means play the audio rather than trusting what is written.
A map pin A position was read out. Tap it to open the location in a map. The number beside it is how far away that is from the boat.
THROTTLED The Raspberry Pi is hot and has slowed itself down. Transcripts will lag behind the audio until it cools.
AUTO Autoplay is on — new messages will play themselves as they arrive.
The dot by the title Green means the page is talking to the receiver. If it fades, the connection has dropped and what you are reading may be stale.
A map pin with "hemisphere assumed"

Radio operators often read out a position without saying north or west. When that happens the receiver assumes you are in the hemisphere the boat is in. It is almost always right and it is labelled so you can tell.

The settings panel

Tap the header — anywhere along the title bar — to open it. Tap again to close. Everything in here affects only your own screen; it changes nothing on the receiver and nothing for anyone else looking at the same log.

ControlWhat it does
Quality slider Hides messages below a clarity threshold. Dragging it to about 60% removes most of the noise and keeps most of the speech. At 0 you see everything.
Length slider Hides transmissions shorter than the chosen number of seconds. Good for clearing out squelch clicks and keyed microphones.
Hide No Speech Hides rows where nothing intelligible was found. The recording is still there; it is only hidden from the list.
AutoPlay Plays each new message as it arrives. It respects your filters, so a muted channel stays silent.
Channel legend Tap a channel to mute it — useful when one channel is busy and you only care about another. The count beside a muted channel keeps running, so you can see what you are missing.
Coordinate converter Paste a position in any format and get it back in the others, plus a map link.

The panel also holds the links to the other pages: Search, Export, AI brief, Thermal report, this guide, and Top of log.

Filters hide, they never delete

Nothing you do with the sliders or the channel legend removes anything. The counts at the top of the page always show the true total alongside how many are currently hidden, so you can tell at a glance that the quiet log in front of you is a filter rather than a quiet afternoon.

Finding something you heard earlier

The main log holds roughly the last three days. Search goes back through everything the receiver has ever heard.

Search matches the words in transcripts, so searching for a vessel name, a place, or a phrase like taking on water will find it. Each result offers:

Export produces a file of many messages at once — a day of Channel 16, say — for mailing to someone or keeping a record.

Recordings do not last forever

Audio is kept in memory to spare the memory card, which means every recording is lost when the Pi reboots or loses power, and older ones are discarded as new traffic arrives. The written log survives; the sound does not.

If a clip matters, download it the day you hear it.

The AI brief

AI brief asks a language model to read the last day of traffic and write a short summary — what the Coast Guard has been broadcasting, anything that happened near the boat, and what the weather is doing. It takes a minute or so and costs a small amount against the account key.

It is a convenience, not a source. It is summarising transcripts that are themselves approximate, so treat anything surprising in it as a prompt to go and read the underlying messages.

What the percentage actually measures

The figure beside each channel name is readability — how speech-like the recording is — not how strong the signal was.

A distant, hissy transmission that is nevertheless clear speech can score high. A powerful burst of interference scores low. This is deliberate: what matters when you are skimming a log is whether there are words in there, not whether the transmitter was close.

The recording is the record

Transcripts are produced by a speech model working on noisy radio audio. They are sometimes wrong in ways that read perfectly well — a vessel name, a channel number or a position can come out plausible and incorrect. On anything that matters, play the audio. This principle shapes much of the design described later in this document.

3. Getting it running

Two programs, run independently. Neither needs the other, though the web interface has nothing to show until the watchkeeper has recorded something.

# the receiver — holds the USB dongle
~/venv_wx/bin/python watchkeeper.py --preset boat

# the web interface — reads the database, serves port 8080
~/venv_wx/bin/python webui.py

Then open http://<the-pi>:8080 in a browser.

Over SSH you will want both to survive the connection dropping, which on a boat's wifi it will:

tmux new -s wk
# start the watchkeeper, then Ctrl-B then D to detach
tmux new -s web
# start the web interface, detach the same way

tmux attach -t wk     # come back to it later
tmux ls               # list sessions

On a system that is left unattended — which is the normal case on the boat — install them as services instead and they come up on their own after a power cut. See Running at boot. The manual commands above are still how you test a configuration change before committing it to the service.

One radio, one program

The RTL-SDR can only be opened by one process at a time. If the watchkeeper is running, gqrx and rtl_power will fail to start, and vice versa. Stop one before starting the other. pgrep -a rtl_sdr shows what currently holds it.

4. Hardware

PartDetailNotes
ComputerRaspberry Pi 5, 8 GB Debian 13 (trixie). Memory is not a constraint; heat was, until 4 Oct. Replaced a 16 GB board — the extra memory was never the limit.
ReceiverRTL-SDR Blog V4 Rafael Micro R828D tuner, 1 PPM TCXO. USB.
CoolingAluminium dual-fan case, custom curve Chosen from a five-way comparison. Roughly halved the rise above ambient — see below. Essential; see troubleshooting.
StoragemicroSD Recordings live in RAM to spare it — see files.
AntennaMarine VHF The single biggest influence on transcription quality.
AI HAT+ 2Hailo-10H NPU — evaluated, then removed Benchmarked and rejected for speech, then taken out: it blocks the cooler's airflow. See known limits.

What the cooler was worth, measured

Both figures below are the same workload and the same preset, running at the slip. The cabin was hotter on the second day.

CabinSoC meanSoC peak Rise above ambient
Pi 5 active cooler, fan pinned, 3 Oct 32.9 °C84.1 °C87.0 °C 51.2 °C
Dual-fan case, 4 Oct 34.3 °C58.6 °C70.5 °C 24.3 °C

The rise above ambient more than halved, which at roughly 10 W works out near 2.4 °C/W against the old 5. Six hours, 128 messages, zero seconds held for heat, and the thermal governor never fired once — against 122 holds a day before the HAT came out.

Read the rise, not the absolute temperature

The cabin swings twelve degrees between a cool morning and a hot afternoon, so 80 °C means something completely different at each end. On 3 October the governor was retuned three times against a moving ambient and made things worse every time, because thermal_resume_below had quietly become unreachable and nobody noticed. The ambient trace on the thermal report exists to stop that happening again.

Why the antenna matters more than anything in software

Measured on this system: transcripts recorded eleven miles inland were largely unusable regardless of which speech model ran. The same code at the dock produced coherent, correct traffic. Model choice moved the error rate by a few percent; location moved it from unusable to good.

If transcripts are poor, look at the antenna and its position before changing any setting described in this document.

5. Theory of operation

5.1 The signal path

antenna→RTL-SDR→rtl_sdr→raw I/Q, 2.048 MSPS→channelizer→one receiver per channel→squelch gate→message→WAV + database row→transcriber→web interface

The dongle is tuned once, to a centre frequency chosen so every channel of interest falls inside the 2.048 MHz it can capture at once. That capture is read in quarter-second chunks by a dedicated thread, so the USB stream never stalls waiting for processing.

5.2 The channelizer

Each channel is extracted from the wideband capture by mixing it down to baseband and then decimating in two stages:

2.048 MHz  ÷16 →  128 kHz  ÷4 →  32 kHz (IF)  ÷2 →  16 kHz (audio)

Two stages rather than one because a single filter sharp enough to go straight from 2 MHz to 32 kHz would be enormously expensive. Splitting it costs a fraction of the work for the same rejection. Measured adjacent channel rejection is about 58 dB, so a strong signal on one channel does not bleed into its neighbours.

Each receiver costs roughly 10% of one CPU core. That is the practical limit on how many channels a preset can watch: eight voice channels plus two references is about as far as a Pi 5 goes while also transcribing.

5.3 Reference channels and calibration

Every preset includes two channels marked role = "reference", placed on frequencies that carry no traffic — usually 12.5 kHz off the standard channel grid. They are never recorded. They exist to measure what "nothing" sounds like right now.

The squelch thresholds are then derived from that measurement rather than hard-coded, so the system adapts to a new location, a different antenna, or a change in gain without being retuned by hand. Calibration takes about twenty seconds after startup, and the gate stays shut until it completes.

The reference channels do two more jobs. If they read a level outside a sane range, calibration is refused and the previous thresholds are kept — that usually means something is transmitting on a reference frequency. And if their absolute power drops sharply, the log raises an antenna alarm, because a 25 dB drop in received noise almost always means a connector has come loose.

5.4 Squelch

Detecting a transmission on FM is less obvious than it looks. An FM receiver's output is the same loudness whether it is receiving speech or pure noise — that is what the modulation does, and it is why an unsquelched FM radio hisses at full volume. Measured on this receiver, carrier present and carrier absent both produce about −5.9 dBFS of audio. So audio level tells you nothing about whether anyone is transmitting.

Instead the system measures energy above the voice band in the discriminator output, before de-emphasis. With no carrier this reads about 0.86; with a carrier it collapses to nearly zero. The metric ignores amplitude entirely, which makes it immune to gain changes and to signal strength — a weak but clean carrier reads the same as a strong one.

There is a second stage within the first. Marine weather stations transmit a continuous carrier for minutes at a time, so carrier detection alone would produce one enormous recording. Within an open carrier a speech gate segments the audio into individual transmissions on pauses.

5.5 Squelch tail and dropouts

When a carrier drops, the FM discriminator has nothing to demodulate and outputs full-scale noise. That is the burst of static at the end of every transmission, and the speech model reliably transcribes it as BANG! or similar.

Two separate settings deal with it, and conflating them was a real bug worth understanding:

Beyond the tail, the audio is muted outright at any moment the carrier is absent — ramped over each frame so the transition does not click. On a fluttering signal this removes bursts of static from the middle of a recording, not merely the end.

5.6 Audio conditioning

Once a message is complete it is processed in a fixed order, and the order matters:

  1. Trim the tail silence.
  2. Apply a 40 ms raised-cosine fade at each end.
  3. Normalise to a target RMS of target_rms — 0.2, about −14 dBFS — capped at max_norm_gain_db, 26 dB.
  4. Apply a look-ahead limiter, after normalisation, with a ceiling relative to the target level.

Both figures are set in the configuration file, at [radio] for a global default or inside a preset to override it for one band. They were 0.1 and 20 dB until 26 September, when they were raised 6 dB together because Coast Guard broadcasts on Ch22A arrive quiet.

Move the two together. Raising target_rms on its own makes quiet transmissions relatively worse. A quiet clip is by definition the one already pinned at max_norm_gain_db, so lifting the target moves it further out of that clip's reach while every clip that was already reaching the target simply gets louder. The spread widens, which is the opposite of what raising the level was meant to achieve.

The limiter ceiling needs no adjustment: it is limiter_crest × target_rms, clamped at 0.9, so it tracks the target automatically and cannot reach full scale.

Putting the limiter before normalisation is a mistake that looks harmless and is not: the gain applied afterwards puts the peaks straight back. The key-up transient measured +12.9 dB and clipped. With the limiter in the right place it is +5.3 dB with no full-scale samples, and inaudible.

5.6.1 Why the tuner gain is so low

The gain has come down in stages — 36.4 for the original cabin dipole, then 25, then 19.7, then 16. Each drop costs sensitivity, so it is worth recording what forced them. The working value is now 22, found by raising the gain until channel bleedover appeared and backing off: the comb below is visible on a spectrum display long before it is audible in a transcript, so the failure has a margin you can watch for.

Watching the live spectrum in GQRX on 26 September settled a question that months of readability statistics had only circled. When Ch14 (156.700 MHz, the LA/LB vessel traffic service) keys up, the entire 2 MHz window lifts at once, and a comb of false carriers appears on a 200 kHz spacing — 156.10, 156.30, 156.50, 156.70 and so on to the top of the band. Each tooth carries real, audible voice.

A signal audible on two frequencies simultaneously is not coming from two transmitters. These are third-order intermodulation products, and three measurements place them in the tuner's own LNA and mixer rather than anywhere in this program:

That 3:1 law also explains why the problem arrived with the masthead antenna. The new antenna is roughly 20 dB better than the cabin dipole. The wanted signals gained 20 dB — which is real and audible — but the intermodulation gained 60, taking it from below the noise floor to well above it. The dipole was not more linear; it was simply too deaf to drive the front end anywhere near compression.

What does not help. Moving the centre frequency changes only the IF filter and the ADC window, both downstream of where the products are made. No filter can reject 156.700 while passing 156.800 — 100 kHz of skirt at VHF needs a loaded Q in the tens of thousands. Narrowing the tuner IF bandwidth cuts what reaches the ADC but sits after the mixer, so it does nothing for an in-band aggressor.

What would. A bandpass filter feeding a high-IIP3 preamp ahead of a dongle run at low gain, which fixes both ends of the squeeze: the preamp sets the noise figure, and the dongle's poor gain stages never handle the large signals. Or a receiver with better front-end linearity. Or, for the one or two channels that matter most, a conventional superhet — a marine set applies its selectivity at a fixed 455 kHz IF, where 70 dB of adjacent-channel rejection is a cheap ceramic filter rather than an impossibility.

5.6.2 The pre-transcription gate

The encoder always processes one padded 30-second window, so a one-second clip costs nearly as much to decode as a twenty-five-second one. On a Pi 5 under a HAT that is CPU and heat spent on nothing, and skipping the hopeless clips is worth doing.

Two gates, both off at 0, both checked before the job is ever queued:

KeyMeaning
min_transcribe_quality Readability below which the decode is skipped. 55 on the boat.
min_transcribe_s Clips shorter than this are mic pops. 2.0 s on the boat.
always_transcribe Channel names exempt from both. ["Ch16 Distress", "Ch22A USCG Info"] on the boat.
keep_above_quality Readability at or above which a transcript is never discarded outright, only trimmed. 60 on the boat. Must exceed min_transcribe_quality.
transcribe_channels Whitelist of channels the decoder may touch at all; empty means every voice channel, which is the default. Everything excluded is still received, recorded and playable, and shows as "not transcribed". Added 4 Oct as a blunt instrument for heat, then measured and found not to be worth using — see the warning below.
The gate is dead code when every voice channel is exempt

With only Ch16 and Ch22A configured and both listed in always_transcribe, min_transcribe_quality applies to nothing and every message reaches the decoder whatever it scores. That was the state on 3 Oct, and it is why the per-channel cost query showed 0% gated on those two channels while Ch09 and Ch12 were being filtered at 35% and 63%.

It also explains a 19.7-second data burst that scored 95% readability and consumed 26 seconds of decode: nothing was ever going to stop it. The gate comes back to life the moment a non-exempt channel is added.

The audio is always kept. Only the decode is skipped. The row stays in the log with status = 'gated', displayed as "not transcribed" with the reason, the WAV is still served, and the clip can be played back or re-transcribed later. Nothing disappears.

The thresholds came from listening, not from statistics, and the distinction matters. A query over one day's log suggested a quality gate was unaffordable — gating at 60 would have discarded 5 of 21 "successful" transcripts. But replaying those clips showed that anything scoring below roughly 50–60 is unintelligible, which means the text Whisper produced for them was invention. Discarding it is the purpose of the gate, not its cost. Any metric that counts hallucinations as successes will argue against its own filter.

Validated by listening over a full 100-message log the same evening: every clip the gate rejected was static, including those scoring only 5–10 points below the cut, and everything at or above it was intelligible with a reasonable transcript. 55 is the right number for this site and this antenna. It is not a universal constant — it depends on where the noise floor of the readability metric sits, which varies with the band and the front end, so re-derive it by listening after any change to either.

Ch16 and Ch22A are exempt via always_transcribe and always get a decode attempt whatever they score. The names must match a configured channel exactly; a typo would fail silently and in the worst direction, so the receiver logs a warning at startup for any name it does not recognise.

Two traps are worth recording:

5.7 Transcription

Recording and transcription are deliberately decoupled. The WAV and the database row exist before the transcriber ever sees them, so a slow or failed transcription degrades to "tap to listen" rather than a lost message.

Two engines run, and they are chosen for opposite strengths.

RoleModelRuns on
Primary nemo-parakeet-tdt-0.6b-v2, int8, via onnx-asr Every transmission.
Second pass medium.en, int8, via faster-whisper Only transmissions that look like they contain a position. Loaded on demand and released after 900 s idle.

Why a transducer is the primary

Parakeet is a transducer (RNN-T/TDT). It consumes encoder frames in order and stops when the frames run out. There is no mechanism by which it can emit text after the audio has ended — not a tendency, an architectural fact.

Whisper is an autoregressive encoder–decoder working on a window padded to a fixed thirty seconds. When the audio is silence or noise, the decoder is still asked what comes next, and it answers from its training distribution: Thanks for watching!, The End, subtitle credits, invented narration. Every phrase filter this program used to carry existed to clean up after that one behaviour.

Measured head-to-head over 80 logged clips, the same audio for both:

Parakeetmedium.en
Invented passages012 of 80
Decode time ≈ 0.13 × duration ≈ 11.9 s + 1.17 × duration
Short clipsabout 30× faster
Overallabout 11× faster

The fixed 11.9 seconds in the Whisper fit is the padded encoder window: a one-second clip costs nearly as much as a twenty-five-second one. The transducer has no such floor, which is most of where the speed comes from.

In service over 551 messages and 155 minutes of audio in 24 hours, the real-time factor is 0.28 median and 0.38 at the 90th percentile, for about 82 minutes of decoding — roughly half of one core.

SettingValueWhy
Threads2Traffic is sparse; peak power is heat.
Beam size1Greedy. Roughly half the work of 2.
Chunk length30 s Caps encoder memory. See 5.7.1.
QueueNewest first, depth 12 When behind, the top of the log stays current.
Token cap5 per second of audio Applies to the second pass only, which is the engine that can loop. Maximum 14 — see 5.7.2.
What the primary cannot do

onnx-asr runs Parakeet with numpy and onnxruntime alone — no PyTorch, no NeMo, no FFmpeg. That is why it fits comfortably on a Pi, and it is also why keyword biasing is not available. NeMo supports phrase boosting for this model, but it is a feature of the NeMo decoder and needs the full stack. Vocabulary that would benefit from biasing — station names, prowords — is handled after the fact instead; see 5.8.

5.7.1 Encoder memory grows with the square of clip length

The Conformer encoder's attention is quadratic in input length. This is not a gentle curve. Feeding a 373-second recording in one piece raised resident memory by 3.2 GB in a single step and pushed the real-time factor from 0.12 to 0.43; a benchmark run that did this repeatedly was killed by the kernel at roughly 6 GB.

Audio is therefore split into chunks of primary_chunk_s (30 s). The cut does not land on a fixed boundary: the splitter searches ±2 s around each nominal point for the quietest 100 ms window and cuts there, so a chunk boundary falls in a pause rather than mid-word.

Resident memory will not fall back

ONNX Runtime's allocation arena does not return memory to the operating system, so the process keeps whatever the largest clip of the session demanded. Expect resident size to climb in steps as longer broadcasts arrive and then sit flat. A figure that rises steadily between long broadcasts is a leak and worth investigating; one that plateaus is the arena and is harmless.

5.7.2 The token cap is sized to the window, not the clip

This is subtle enough to have shipped as a bug, so it is worth stating plainly: max_new_tokens is applied by faster-whisper per encoder window, not per clip. The encoder works in padded 30-second windows and slides that window along anything longer, so a 90-second bulletin is three windows of 30, not one pass of 90.

The cap was originally computed from clip length:

cap = int(len(audio) / sr * MAX_TOKENS_PER_SECOND) + 16

That was harmless while max_message_s was 30, because 30 × 8 + 16 = 256, comfortably under the model's 448-token context. When max_message_s on the boat preset was raised to 90 to catch the long Coast Guard bulletins, the same expression asked for 736 tokens. faster-whisper validates that figure against max_length = 448 and raises ValueError rather than clamping, so the decode threw before producing anything.

The failure was invisible in the obvious place. Short traffic kept working perfectly, so the log looked healthy; only messages over about 54 seconds failed, and they failed as transcription failed rather than no speech found. On 3 October a nine-minute Ch22A broadcast landed as six consecutive 90-second rows, every one of them an error, at readability 92–100%.

Clip lengthRequested tokensResult
30 s256fine
53 s440fine
54 s448ValueError
90 s736ValueError

The fix clamps the cap to one window:

WHISPER_WINDOW_S = 30.0
cap = int(min(len(audio) / sr, WHISPER_WINDOW_S) * MAX_TOKENS_PER_SECOND) + 16

This corrects a second, quieter defect at the same time. Before the clamp, a 90-second clip authorised 736 tokens inside a single 30-second window — 24 tokens per second of audio, three times the intended limit. The guard against runaway loops had stopped guarding on exactly the long clips where a loop is most expensive. After the clamp it is a flat 256 tokens per window, which is the 8 tokens/second the setting advertises.

Consequence for configuration: max_tokens_per_second now has a hard ceiling of 14. The cap is 30 × max_tokens_per_second + 16, so 14 gives 436 and 15 gives 466. Above 14 every message fails, not just the long ones. There is no startup check for this yet.

Rows that failed this way keep their audio but are not re-queued; there is no retry path for an errored row. Recovering them means pulling the WAVs aside before they age out of the ring and decoding them by hand.

5.7.3 When the second pass runs, and who wins

The second pass exists for one thing: digits. The primary reads prose better and never invents, but on a dense numeric readback the older model sometimes recovers a coordinate the primary mangles. So a transcript is re-decoded only when it looks like it contains a position — two or more numbers sitting next to a coordinate word. That fires on about one clip in twenty.

When both engines produce coordinates, they are compared rather than trusted in a fixed order:

RuleDecides
Agreement within 100 mEither; they read the same point.
Set overlap On a multi-corner broadcast, the reading that shares more corners with the other.
Fuller reading The engine that found more coordinates coherently. A 450-second Navy bulletin had the primary reading five corners cleanly while the second pass lost the first digit and the thread.
Assumed hemisphere Only above 50 km. A guessed hemisphere never produces a 5 km error, so this rule must not be allowed to arbitrate a digit-level disagreement.
OtherwiseThe second pass, which is why it was run.

A coordinate that disagrees with the rest of its own message — more than 150 km from every other position in the same broadcast, and on a hemisphere the parser inferred rather than heard — is dropped. Both conditions are required, and at least three coordinates must be present, so there is a cluster to be an outlier from.

The winning set is stored in positions_json on both the message ring and the permanent history, and the reason line names which engine won. The web interface reads the stored coordinate rather than re-parsing the displayed transcript, which is the primary engine's — without that, a coordinate the second pass was run specifically to recover would never reach the map.

Why it no longer runs on silence

It used to also run when the primary returned nothing, on the theory that Parakeet might have been too conservative. Measured over a full day, that path fired 49 times and produced essentially nothing but invention: Thanks for watching! on five separate 40-second broadcasts, The End on four, spelled-out letter runs, and several paragraphs of confident narration about nothing. Of 49 firings, none recovered content worth keeping.

No threshold rescued it either — the two highest-scoring survivors were themselves fabrications. second_pass_on_empty is therefore false, and silence from the primary is reported as silence.

Silence is usually correct

Of 140 empty results in a representative day, 86 arrived below 2 dB signal-to-noise and only three above 9 dB. The one clean strong example turned out to be a constant tone — which scores 100% readability, because a pure tone is maximally structured, and contains no words at all. The primary was right to say nothing.

5.8 Prowords, and why there is no phrase filter

This program used to carry a substantial filter chain: a blocklist of phrases Whisper is known to invent, a subtitle-credit corpus, a repetition detector, a trailing-boilerplate trimmer and a salvage pass. All of it is gone.

It existed to clean up after one behaviour — an autoregressive decoder asked what follows silence — and the primary engine does not have that behaviour. Keeping the chain meant paying its cost without its benefit, and the cost was real: on a Channel 16 broadcast the repetition filter removed a long run of genuine text because a station had legitimately repeated itself, which is exactly what radio procedure requires. A filter that removes real traffic to catch a fault that no longer occurs is a net loss.

What remains is strip_annotations, which removes bracketed markers such as [MUSIC]. That is not a phrase filter; it removes notation, not content.

A poor transcript beats a fabricated one

This is the principle the whole design now rests on. A garbled reading looks garbled and you go and listen. An invented one reads perfectly and you believe it.

Proword normalisation

The one thing that is rewritten is the preamble. Safety and urgency calls open with a phrase fixed by regulation and repeated three times — "Sécurité, sécurité, sécurité, all stations, all stations, all stations" — and an English-trained model has no idea what to do with the French. Twenty days of this receiver's own log produced, among others:

Security, security, security      Carrot take, carrot take
Sure Disk, Cure Disk, Cure Day    Air safe, AirTays, Curitay
Skirte, Skirte, Securite          New Jersey, New Jersey

Each repetition garbles independently, and so does the second half — off stations, both stations, All Station, All Stations off Stations, off station's off station's off station.

Recognising a fixed procedural phrase is not guessing at what someone said, so the preamble — and only the preamble — is rewritten to its canonical form. Everything after it is left exactly as decoded, and raw_transcript keeps the original either way.

Two tiers, and the difference matters:

When the shape of a preamble is there but nothing matches it, the message is flagged unidentified safety preamble — listen to the recording rather than guessed at. Mistaking a sécurité for a PAN PAN is a safety error, and saying "I do not know" is the whole value of the flag.

"Hello all stations" is not a mis-decode

A routine scheduled marine information broadcast may open "Hello all stations, hello all stations, hello all stations" with no proword at all — that is correct procedure for a routine bulletin, and it is left alone. The layer stays quiet on phrasing it does not recognise rather than stamping a safety proword onto a broadcast that never said one.

5.9 Why there is no decoder prompt

Whisper accepts an initial_prompt that conditions it on vocabulary. The marine presets deliberately supply none.

The reason is worth understanding, because supplying one is an obvious idea. A prompt does not merely make hallucination more likely — it makes it plausible. Unprompted, the model invents generic filler that the filters above catch easily. Prompted with marine vocabulary, it invents fluent marine phraseology that nothing can distinguish from real traffic.

With distress words in the prompt, four seconds of noise decoded as "Securite securite, vessel mayday, this is the United States Coast..." — a fabricated distress call. A poorly transcribed real transmission is recoverable: the audio is kept and the text is marked unclear. A fabricated one that reads correctly is not.

5.10 Distress flagging

Distress vocabulary in a transcript raises a flag. The flag does not mean "this is an emergency" — it means do not trust this text, play the audio.

The difficulty is volume. Matching the vocabulary alone fired on 105 of 411 decoded messages in a representative day — 26% of all traffic — because sécurité is itself distress vocabulary and the Coast Guard broadcasts scheduled weather and navigation bulletins all day long. Every one carried the same red badge as a mayday. An alarm that fires on a quarter of everything is not an alarm.

It is therefore tiered, using the canonical proword that 5.8 has already identified:

What was foundTreated as
Canonical PAN PAN or MAYDAY Urgent.
Canonical SECURITE Routine safety broadcast. No badge.
Distress vocabulary with no canonical proword Urgent.

That last row is the one that matters and the one it would be easiest to get wrong. Real incidents are worked conversationally, not announced:

Vessel taking on water, this is Coast Guard Sector San Diego
  on Channel 16. What is your current position? Over

Roger Sir, we understand you're taking on water.
  We'll send help right away. Stand by

Vessel that hailed May Day over the radio on Channel 16 just
  south of the Coronado Bridge...

Not a proword anywhere in them. A formal preamble is what you can afford to tier; an unceremonious call for help is not. The tiering cuts 105 flags a day to 19 and the three real incidents in that sample all survive it.

The capitalised forms are matched case-sensitively on purpose. No decoder writes SECURITE in capitals by itself — that spelling only exists because the proword layer was confident enough to canonicalise it. A lower-case securite means the layer was not confident, and that uncertainty should reach you rather than be filtered away.

The header banner

A badge on a row is no use once the row has scrolled away, and on a busy channel that takes minutes. So any urgent message in the last thirty minutes also lights a red DISTRESS chip in the page header, showing the age of the most recent one. Tapping it jumps to that message — relaxing the quality slider and unmuting the channel if your filters would otherwise have hidden it, which is the case that matters most, since a weak distress call is exactly what a quality filter removes.

On a day with two active incidents the chip was lit for 253 minutes across five episodes, about 18% of the day. That is not noise: Sector San Diego relayed PAN PAN every fifteen minutes for nearly three hours, and the final broadcast of the sequence was the resolution — "the previously reported vessel taking on water has been located".

5.11 The readability score

Each message carries a score from 0 to 100, shown as a percentage beside the channel name and driving the filter slider.

It is derived from the spectral flatness of the recording: the geometric mean of the voice-band spectrum divided by its arithmetic mean. Speech has formants, so its spectrum is lumpy and the ratio sits low (0.01–0.15). Noise and digital modes are flat and sit near 0.55.

A slider cut around 60% separates the usable from the not, which is what the score is for. Its role is now only that: a human-facing filter. It no longer gates anything internally — there is no discard threshold since the phrase filters went, and the second pass is no longer triggered by an empty result.

It measures structure, not speech

A constant tone scores 100%. It is maximally structured and contains no words whatever. This is not a fault in the metric but it is a trap when reading the log: a high score means "something with structure was received", not "someone was talking".

The converse also holds. A 9-second transmission scoring 46% held real, intelligible voice. Any threshold used to decide whether audio is worth processing will throw away traffic.

Why not signal strength?

The obvious measure would be signal-to-noise, and it does not work on FM. The capture effect means a 40 dB change in carrier strength moves the discriminator noise metric by about 7 dB — and more fundamentally, it measures the carrier at this antenna, which says nothing about whether the person at the other end was intelligible. A strong carrier carrying a keyed microphone scores well and contains nothing.

Both numbers are recorded, and both are shown. Readability answers "are there words in here"; signal-to-noise answers "how well did it arrive". They are different questions.

5.12 Positions

The Coast Guard reads out positions constantly, in degrees and decimal minutes — "position 33-01.923 north, 117-22.636 west". Those are the most actionable thing in the log and the least readable, so any position found in a transcript gets a map pin beside the download button, and appears in the exported text file with a link.

The difficulty is that the decoder renders the same spoken position a dozen ways. All of these are real, from this receiver, and all mean the same point:

33-01.923 north, 117-22.636 west          digits, clean
33 hyphen, 01 decimal, 9-2-3 north        fraction read out singly
33 hyphen, 01 decimal, 9 or 23 north      "923" misheard as "9 or 23"
33 hypin 42 decimal 686 north             "hyphen" misheard
34-hyphen 01, decimal 750 north           dash and the spoken word both
34-01-75 North                            decimal point heard as a hyphen
Three-four, hyphen zero one, decimal
  seven five zero north                   every digit as a word

All of them are normalised to the same string before matching. Two traps are worth knowing about, because both appear in ordinary traffic:

Tens and teens read as words — "thirty three", "forty seven" — are expanded too. hundred is deliberately not: in this traffic it is almost always a time. Niner is understood; tree and fife are not, because both are ordinary English and mapping them would let the parser manufacture a position out of prose.

When a message looks like it carries a position, a second engine reads it too and the two are compared — see 5.7.3.

A position is only accepted with an explicit hemisphere, minutes below 60, latitude within 90 and longitude within 180. That combination rejects the frequency announcements that otherwise look identical — "channel 22 Alpha, 1022, 157 decimal 1 megahertz" has the numbers but no hemisphere.

The distance filter

home_lat and home_lon say where the receiver is, and anything further off than coord_max_nm (500 nautical miles by default) is ignored. This is not only tidiness: a badly garbled transcript can produce a syntactically valid position, and the distance check is what keeps it off the map. With no home position configured the feature is off entirely, because there is then no way to tell a real position from a plausible one.

Why the map link looks the way it does

Google's documented link format, maps/search/?api=1&query=…, has no zoom parameter and always opens tight on the pin. For judging a position that is exactly the wrong behaviour. So when a home position is known the link uses the older maps/place/<target>/@<centre>,<zoom>z form instead, centred between the two points with a zoom worked out from the distance:

metres per pixel = 156543.03 × cos(latitude) / 2^zoom

Solved for the separation filling a little over half a 640-pixel map. Both of those are guesses at a typical screen, which is fine — the result only has to put both points on screen with some margin, and it does, from half a mile out to the 500 nm limit.

That URL form is long-standing but is not part of Google's documented API, so the exported text file carries both links: the framed one and the plain api=1 pin, which will keep working whatever Google changes. On deck the phone's own blue dot marks where you are, so both positions end up on the map without the link having to carry a second marker.

Taking the position from the boat's GPS

A fixed figure in the config file is fine at anchor, but under way it is wrong exactly when it matters: a Point Conception advisory reads differently twelve miles off it than tied up in Long Beach. home_source takes a URL returning the vessel's position as JSON and polls it in the background. Signal K serves the right shape directly:

home_source = "http://<cerbo>:3000/signalk/v1/api/vessels/self/navigation/position"
home_refresh_s = 300

Signal K offers a per-sensor breakdown alongside the reading; the top-level value is taken, because Signal K has already chosen between the sources. A plainer endpoint returning {"lat": …, "lon": …} works too.

The poll runs on its own thread, so nothing serving the page ever waits on the boat's network — which on marina wifi is not a safe thing to do. A live fix always beats home_lat/home_lon, which remain the fallback until the first one arrives. After that the last good fix is kept rather than discarded: a receiver that has lost contact with its GPS has not moved, and a position a few hundred metres stale changes nothing about a 500 nm radius. Unreachable sources and malformed replies are logged once and then only occasionally, so a boat with the instruments off does not fill the journal.

The settings panel shows the position in use and where it came from, so a GPS feed that has quietly stopped is visible rather than leaving you wondering why pins stopped appearing. A live fix older than half an hour is flagged.

The home position also resolves a lost hemisphere. "west" comes through as "Texas" often enough that it is worth handling, so a longitude with no hemisphere is placed in whichever one is nearer home — and still has to pass the distance check. Those are marked hemisphere assumed in the export and in the pin's tooltip.

5.13 Retention

The database keeps the most recent max_messages rows — 100 by default, 2000 on the boat since 4 October — and deletes the oldest by message id. Nothing else is considered: status, transcription outcome and quality score play no part. Deleting a row deletes its WAV with it. Pruning runs once a minute.

Why it is not cleverer than that

It used to evict by value: empty and errored messages first, then low-confidence ones, then the oldest good transcript. The reasoning was that a burst of noise should not push out traffic worth keeping.

That was wrong, and the way it failed is worth recording. A message is inserted as pending, which ranked alongside ok — the top band — so it appeared in the log at once. When transcription finished and the status became low or empty, its rank dropped below every good row in the table however old, and the next prune deleted it. Messages showed up in the log and vanished about a minute later, including ones the operator had already played.

Any policy that ranks rows by a field written after insertion has this defect. A log that cannot be trusted to still hold what it just displayed is worse than one that occasionally spends a slot on noise, so age is now the only key, and the id is the only clock that never disagrees with itself.

If noise is genuinely crowding out real traffic, the answer is a larger max_messages rather than a smarter eviction rule — bearing in mind that recordings live in RAM, so raising it means raising the tmpfs size too, at roughly 1 MB per thirty-second message.

Two numbers that have to move together

max_messages sets what the database keeps. webui.py has its own fetch limit, and the page shows only what it asks for. Raise one without the other and the log silently displays a fraction of what is retained, with nothing to indicate it.

Second pair: max_messages and the size of the tmpfs. The ring has to fit in the mount, and the rule is

max_messages  ×  mean duration  ×  32 KB/s   ≤   tmpfs size

Measured 7 October: mean duration 12.3 s, so 386 KB a recording and 771 MB for a 2000-message ring. The mount was 768 MB and it filled.

The sizing figure goes stale on its own

Mean recording size drifts with what the receiver is hearing and with every change to the squelch, the channel list or the gate. It has moved by more than 50% inside a week, which was enough to overrun a mount sized from the earlier figure. Re-measure it after any such change rather than trusting a number written here.

Mean recording size is not a constant of the installation. It rises whenever the receiver hears more or hears longer: a larger speech model keeps marginal traffic that used to be discarded, extra channels add their own traffic, and a bigger max_message_s raises the mean directly. Each of those happened here between the two measurements.

Re-measure after any of them:

sqlite3 ~/watchkeeper/watchkeeper.db \
  "SELECT ROUND(AVG(duration_s),1) AS mean_s,
          ROUND(AVG(duration_s)*32/1024,3) AS mean_MB,
          ROUND(2000*AVG(duration_s)*32/1024) AS ring_MB
     FROM history WHERE started_at >= datetime('now','-7 days');"
df -h ~/watchkeeper/audio

Leave real headroom rather than fitting exactly: the mount is now 1 GB against a ring that wants about 790 MB at max_message_s = 450.

Briefs follow the same shape with their own number. keep_briefs under [ai] caps how many are kept in ~/watchkeeper/briefs/, and the oldest goes first — the rule every pruner in this system follows, so that the thing you lose is always the thing you were least likely to want. They are small enough that the cap is about tidiness rather than space.

5.14 History and the thermal series

Two tables sit alongside messages and are never pruned with it. Both exist because the live log is the wrong shape for analysis.

messages is capped at max_messages and every row whose WAV has gone is dropped at startup — which, with recordings on tmpfs, means the entire table after a reboot. That is correct for a log you play back and useless for comparing yesterday against today. Two days of gain experiments were lost to it before this existed.

history

One row per message, permanent, with no audio path: channel, time, duration, SNR, status, transcript, quality, tuner gain, rtf, SoC temperature and thermal wait. About half a kilobyte each.

Rows are written at INSERT and updated in place as the transcript and timings arrive, rather than copied when a message is deleted. That is deliberate: refresh.sh empties messages with raw SQL from outside the program, so anything hooked to prune() or reconcile() would miss precisely the data an experiment produces.

The natural key is (started_at, channel), not the message id. SQLite restarts INTEGER PRIMARY KEY from 1 once a table has been emptied, so ids from different sessions collide. Inserts use INSERT OR IGNORE, so replaying the same message is harmless.

Every history write is best-effort and swallows its errors. The live log is the product; this is the notebook, and it must never cost a recording.

thermal

SoC temperature, fan RPM, load average and transcription queue depth, every thermal_sample_s seconds — 10 by default. Rows older than thermal_history_days (7) are dropped on the same once-a-minute schedule as message pruning. Roughly 8,600 rows a day and 3 MB a week.

Sampling on a fixed interval rather than per message is the whole point. A per-message temperature only exists when traffic happens, so a quiet hot hour and a busy hot hour look identical and a quiet cool hour is invisible. Ten seconds because the chip moves faster than that: the governor polls every two seconds, a decode runs 10–30 s, and at a 30-second cadence a burst that drove the SoC from 65 to 80 and back could fall entirely between samples.

The report page

/report, linked at the foot of the log. Temperature and message rate share one time axis, with presets for the last hour, 6 hours, 24 hours, 3 days and a week, plus a custom range. Bucketing happens in SQL, so a week of samples is reduced before it crosses the wire; the view auto-refreshes except on a custom range, which stays where you put it.

Reading the two series together is what the page is for. A cooler that looks worse may simply have had a busier hour, and the message-rate bars are what tell the two apart. The amber marks along the bottom are decodes held for heat.

Hovering anywhere on the plot snaps a crosshair to the nearest bucket and shows its exact figures — temperature with its peak, message count, fan RPM, seconds held and queue depth. The cursor position is scaled from screen pixels back into viewBox units before the time axis is inverted, so it stays accurate at any window width, and it works by drag on a touchscreen.

5.15 Thermal governor

Transcription is the hottest thing the program does.

Since the cooler change on 4 October the governor is a safety net rather than a regulator: 79/75/20 against a measured peak of 70.5 °C, and it has not fired once. That is the state it should be in.

What 3 October taught, at some cost

The governor was retuned three times in one afternoon and every change made things worse. All three failures share a cause worth stating plainly: the settings were reasoned about instead of measured, against an ambient that had moved twelve degrees.

1. An unreachable resume target silently disables it. With the cabin at 33 °C the SoC could not get below about 77 even at idle, so thermal_resume_below = 75 was never reached. Every hold ran the full timeout and then decoded anyway — all of the latency, none of the cooling. The log said so on every line and nobody read it:

held transcription 180s at 80.4 C
held transcription 180s at 79.8 C
held transcription 180s at 77.7 C

2. A long max_wait collapses the queue. Raising thermal_max_wait_s to 180 cut throughput to about 16 messages an hour against 34 arriving. The backlog filled to its cap of 12 and began evicting. At 45 seconds it had been roughly 75 an hour and kept up comfortably.

3. Disabling it entirely starts a runaway. Reasoning that throttled silicon does more work per watt than duty-cycled silicon — which is true — the band was pushed to 88/85 so the hardware's own throttle could regulate. The SoC pinned at 84–87 °C, the channelizer starved, the squelch began opening on noise, and the message rate went from about 12 an hour to 61, nearly all of it empty. More spurious messages meant more decode, which meant more heat. The throughput argument was sound in isolation and wrong here, because it assumed the DSP had headroom it did not have.

The lesson is the ordering: fix the ambient first. No governor setting compensates for a cooler that cannot shed the heat, and every attempt to tune around that made the system worse than leaving it alone.

Above thermal_pause_above the transcriber waits before starting the next decode, resuming below thermal_resume_below, with any single stall capped by thermal_max_wait_s. Latency is nearly free here — traffic is sparse and the log is read after the fact — while throttling slows the DSP as well and risks dropped chunks. Because the queue is newest-first, whatever piles up during a pause is the oldest and least interesting.

6. Files

Programs

FilePurpose
watchkeeper.pyThe receiver. DSP, squelch, recording, transcription, storage.
webui.pyThe web interface. Reads the database, serves port 8080.
watchkeeper.tomlAll configuration: presets, channels, thresholds.
docs.htmlThis document.
brief_prompt.txt The prompt the AI brief sends. A loose file on purpose: edit the wording and the next run uses it, with no patch and no restart.
*_API_Key.txt API keys, one per provider — GEMINI_API_KEY.txt, xai_API_Key.txt. Found by name, read on each run, never logged. These are secrets: they do not belong in the repository, and they are the reason watchkeeper.toml does not hold the key itself.
refresh.shWipes the log and recordings for a clean test. See §10.

Service units

Copied into /etc/systemd/system/; the originals are kept alongside the programs so they travel with the rest of the software.

FilePurpose
watchkeeper@.serviceStarts the receiver at boot. See Running at boot.
watchkeeper-web@.serviceStarts the web interface at boot.

Tools

FilePurpose
test_dsp.pyDSP test suite. Run after any change to the signal path.
make_truth.pyArchive recordings off the tmpfs mount onto the card, and transcribe by ear for comparison.
bench_models.pyCompare speech models against that corpus.
bench_hailo.pyThe same, on the NPU.
run_bench.pyRuns the whole benchmark suite unattended.
analyse_bench.pyRe-cuts saved benchmark results without re-running.

Data

PathContents
~/watchkeeper/watchkeeper.db SQLite, WAL mode. Messages, transcripts, reception context.
~/watchkeeper/audio/ One WAV per message. On tmpfs — see below.
~/wx_corpus/ Archived recordings, and hand transcripts where any have been made. On the card, persistent.
~/watchkeeper/briefs/ One JSON file per AI brief, named for the UTC minute it was written, holding the report, its KML, and what was asked. Small — tens of kB each.
~/run.log Only if --log was used on a hand-started run. Append-only, no rotation — it grows without limit on the card. The services do not write it; they log to the journal instead, which does rotate. See Reading the log.
Recordings live in RAM

The audio directory is a tmpfs mount, so recordings do not survive a reboot. This is deliberate: audio is about 1.25 MB per minute against roughly 10 KB for the database, so putting it in RAM removes about 99% of the writes to the microSD card.

The database is on the card and persists. At startup the watchkeeper drops any row whose WAV has gone, so the log never lists a message it cannot play — a line reading removed N row(s) whose audio is gone after a reboot is normal, not a fault.

Anything worth keeping must be copied to the card first: make_truth.py --archive does that.

The mount is configured in /etc/fstab:

tmpfs /home/pi/watchkeeper/audio tmpfs \
      defaults,nofail,noatime,size=1536M,uid=1000,gid=1000,mode=0755 0 0

nofail matters: without it a malformed line drops the machine into emergency mode at boot rather than carrying on, which on a headless Pi means it never comes back.

7. Configuration

Everything lives in watchkeeper.toml. It is searched for in this order, first match wins:

  1. ./watchkeeper.toml
  2. ~/watchkeeper.toml
  3. ~/watchkeeper/watchkeeper.toml

The file has three kinds of section. Settings are validated at load, so a typo or an out-of-range value is reported at startup rather than misbehaving later.

[storage]

KeyMeaning
base_dirSets the database and audio paths together.
audio_dirOverrides just the audio path — this is what points it at tmpfs.
db_pathOverrides just the database path.

[radio] — global defaults

KeyMeaning
gainTuner gain in dB. "0" selects the tuner's own AGC. On the boat this is 15.7 — see 5.6.1 for why it is so low.
target_rms Output level as a fraction of full scale. 0.2 is about −14 dBFS.
max_norm_gain_db Cap on the lift applied to a quiet clip, so near-silence is not amplified into hiss. Raise it with target_rms, never alone.
min_transcribe_quality, min_transcribe_s Skip the decode for clips below this readability or length. Audio is still kept. See 5.6.2. 0 disables.
always_transcribe Channel names exempt from both gates.
ppmCrystal error correction. The V4's TCXO needs none.
primary_model, primary_quant The transducer that reads every message. See 5.7.
primary_chunk_s Chunk length fed to the encoder. Caps memory — see 5.7.1.
second_model, second_idle_release_s The coordinate second pass and how long it stays resident.
second_pass_on_empty Whether to re-decode a silent result. false — see 5.7.3.
cpu_threads, beam_sizeDecoder cost.
temperaturesFallback ladder. A single entry disables retries.
max_messagesRows kept, oldest deleted first. Default 100.
home_lat, home_lon Where the receiver is, in decimal degrees. Enables position links.
coord_max_nm Positions further off than this are ignored. Nautical miles, default 500.
home_source, home_refresh_s URL of a live position feed (Signal K), and how often to poll it.
low_conf_avg_logprob, low_conf_no_speech Where a transcript is marked unclear.

[preset.NAME] — one per band or location

A preset sets the centre frequency, the mode, its channel list, and may override almost any radio or DSP setting for that preset alone. Anything it does not set falls back to the [radio] value, and settings never leak between presets.

Squelch and segmentation

KeyModeMeaning
fm_open_noise / fm_close_noiseFM Noise metric below which the carrier counts as present / absent.
squelch_dbfsAM Absolute guard rail. Must sit clear of the floor-relative threshold or it silently becomes the operative one.
open_margin_db / close_margin_dbAM dB above and below the measured floor.
hang_sAMRide through brief dropouts.
carrier_tail_keep_sFM How much post-carrier audio reaches the file. Keep short.
min_message_s / max_message_sboth Message length bounds.
min_snr_dbboth Discard threshold. Set to 0 on marine presets — on a distress channel a weak transmission may be the one that matters.

Transcription

KeyMeaning
vad_filter Whisper's own speech detector. false on marine: it rejects weak transmissions outright, and the carrier squelch has already established that something transmitted.
no_speech_threshold, log_prob_threshold Raised on marine to keep marginal speech.
max_tokens_per_secondOutput cap, in tokens per second of audio. 8 is generous. Maximum 14. The value handed to the decoder is 30 × this + 16, against Whisper's 448-token context; faster-whisper raises rather than clamping, so 15 makes every message fail. See 5.7.1.
promptDecoder priming. Absent on marine — see §5.9.
thermal_pause_above etc.Thermal governor. 0 disables.

Channels

channels = [
  { name = "Ch16 Distress", freq = 156800000 },
  { name = "ref-a", freq = 156237500, role = "reference" },
]

Every channel must fall within about 800 kHz of the centre frequency to stay inside the flat part of the capture. Choose a centre that is clear of every channel by at least 37.5 kHz and off the 25 kHz channel grid, so the tuner's DC spike lands in empty spectrum.

8. Presets

PresetModeCentreFor
boatNFM156.7625 At the slip. Built from a survey taken there.
marineNFM156.7625 Standard watch set for use underway.
Marine_HomeNFM156.5875 Built from a survey eleven miles inland. Shore stations only.
socal_lowAM124.8250 LAX area approach and ground.
socal_highAM133.6000 Centre and tower.
wxNFM162.2625 NOAA weather radio.

--list-presets prints the current list with channels. Preset names are case-sensitive.

Building a preset for a new location

Do not guess. Survey the band, then choose from what is actually there.

# two hours at 5-second resolution; stop the watchkeeper first
rtl_power -f 156M:158M:5k -g 36.4 -i 5 -e 2h ~/scan.csv
A spectrum survey cannot tell traffic from interference

Both look like energy on a channel. At one location the two busiest channels in a two-hour survey turned out to be static from something in the marina, and a preset was built around them before anyone listened.

The tell was in the log, not the scan: every message arrived within half a decibel of the same level, with durations all under a couple of seconds. Real voice does not do that. Listen to a new channel before trusting it.

9. Command line reference

watchkeeper.py

FlagEffect
--preset NAMEWhich preset to run.
--list-presetsShow all presets and exit.
--config PATHUse a specific config file.
--log PATHAppend all output to a file as well as the console.
--monitorPrint live squelch metrics instead of recording. Ends with a per-channel summary.
--monitor-interval NSeconds between monitor reports. Default 5.
--record PATHAlso write the raw I/Q stream while running.
--replay PATHRead from a recorded I/Q file instead of the dongle.
--gain DBOverride tuner gain. 0 for the tuner's AGC.
--am-audio-hz HZTop of the AM audio passband.
--verboseDebug logging.

Record and replay — the most useful pair

Comparing two settings on live radio compares two different minutes of traffic, which tells you very little. Record once, then replay the same I/Q as many times as you like:

~/venv_wx/bin/python watchkeeper.py --preset boat --record ~/cap.iq --log ~/rec.log
# then, with the dongle free:
~/venv_wx/bin/python watchkeeper.py --replay ~/cap.iq --monitor --log ~/a.log
# change a setting
~/venv_wx/bin/python watchkeeper.py --replay ~/cap.iq --monitor --log ~/b.log

Every run sees identical RF, so a difference in the summary is the setting and nothing else. About 2.5 GB per ten minutes. Tuner gain is the one thing this cannot test, since it is baked into the capture.

webui.py

FlagEffect
--port NDefault 8080.
--host ADDRDefault 0.0.0.0, all interfaces.
--preset NAMEPin the channel legend to one preset.

10. The web interface

Served on port 8080, read-only, and completely independent of the watchkeeper — it opens the database read-only, so it can be started, stopped and restarted without disturbing reception.

The header

Live indicator, message counts, and three chips that stay visible with the settings panel closed: AUTO when autoplay is on, THROTTLED when the Pi is running below full speed, and a red DISTRESS when urgent traffic has been heard in the last thirty minutes. The last of these is a button — it jumps to the message, and relaxes the filters first if they would have hidden it. See 5.10.

Responses are gzipped. The live log polls the whole message ring every three seconds, which is 909 KB of JSON uncompressed and about 150 KB compressed; over a metered uplink the difference is roughly 35 GB a day against 6 for a single open tab. Audio is excluded, as are partial (206) responses, because compressing a byte range breaks playback.

Search — /search

Finds text across every transcript in the permanent history table, not just the recordings still in the live log. Terms are combined with AND; a phrase in double quotes matches whole. % and _ are escaped, so searching 100% finds a hundred percent rather than everything. A channel filter and the query are both in the URL, so a result set can be bookmarked or sent to someone.

Each result offers play, in log and zip — but only while the recording still exists. Otherwise it reads audio aged out, which is the honest state of affairs: the transcript is permanent, the WAV is not. Three things remove one:

So if a search turns up something whose audio matters, download the zip immediately. Once it is gone the transcript is all that remains, and the transcript is not something to rely on alone.

in log jumps to that message in the main log so it can be read in context. The quality and length sliders and the mute set are relaxed only if they would otherwise hide the target — a 44% radio check is exactly what one searches for and exactly what the quality filter removes — and the relaxation is not saved, so a reload restores your own settings.

Timestamps on this page are wall clock, with a relative hint beneath, because results span days. The main log shows elapsed time. The two conventions sit one click apart and look alike, which is why the hint is there.

The settings panel

Collapsed by default, state remembered per browser.

It also carries the page links — Search, Export, AI brief, Thermal report, the operator guide, and Top of log. They used to sit in the footer, which on a phone meant scrolling past every message to reach them.

Message rows

Colour bar, age, duration, channel, quality percentage, transcript. Tap to play; tap again to pause. Amber quality below 60%. A red LISTEN badge marks distress vocabulary.

The map pin

A pin appears above the download button when the message contained a position within coord_max_nm, with the distance from the receiver under it in nautical miles — tenths close in, whole numbers further off. It opens Google Maps framed to show that point and where you are, so the scale tells you whether something is close or on the far side of the bight — a pin on its own opens at street level and says nothing useful. Hovering shows the position in degrees and decimal minutes. See §5.12 for what is and is not recognised.

The coordinate converter

At the bottom of the settings panel is a box for typing a position in the format the Coast Guard reads them out — 33-01.923 north, 117-22.636 west — which opens Google Maps at that point. It accepts anything the log itself accepts, including the spoken forms, because it asks the receiver to do the parsing rather than repeating the work in the page. Press Enter or tap Open.

The download button

The small arrow at the right of each row downloads a zip containing the WAV and a text file — a single zip because mobile browsers commonly block the second of two downloads from one gesture. The text file carries reception context, decoder diagnostics, and an audio analysis:

RECEPTION
  Readability         : 93% (from spectral flatness)
  Carrier             : 16.4 dB below the no-carrier noise reading
  Noise floor         : -56.5 dB
  Peak before limiter : 0.676

TRANSCRIPTION
  Confidence          : empty
  avg_logprob         : -0.39
  Flags               : coordinate: second (default)
  Second pass said    : "...32-33.300 north, 117-11.000 west" 

AUDIO ANALYSIS
  spectral flatness   : 0.084   (voice 0.01-0.15, noise ~0.55)
  squelch muted       : 0.02 s of carrier dropout
  content             : speech-dominated

Discarded because and Decoder said are the two most useful lines when a message reads "no speech found" — they show whether the decoder returned nothing or said something that a filter removed, and which filter did it.

Export page — /export

A form over the endpoint below, reached from Export in the settings panel. It exists because the useful exports are the narrow ones, and typing a correct query string on a phone is miserable.

The URL it is building is shown as you work, and beneath it a live count of how many messages match. The count is the same query with limit=1, debounced, so moving a control does not fire a database query per keystroke at a receiver that is already demodulating six channels. When the match count exceeds the row cap, the page says so rather than letting you download a silently truncated file.

Date range is entered in the browser's local time and sent with that offset attached, so the window matches the clock you typed. The log records UTC, and the endpoint reads an unzoned stamp as UTC — a range typed into a hand-built URL without an offset will be out by however far you are from Greenwich. The page's own filters echo in the result is the quickest way to confirm which window actually ran.

Download JSON saves the file; Open in a tab shows it formatted; Copy URL gives you the same query for curl or a script. Over plain HTTP on the LAN the clipboard API is usually unavailable — it needs a secure context — so the button falls back to selecting the text for you.

JSON export — /api/export

The machinery behind the export page, and usable on its own: a plain HTTP GET that returns the whole log as JSON for analysis elsewhere. It reads history, not messages, so it reaches everything ever recorded rather than the last max_messages, and it carries the engineering columns the live feed leaves out: gain_db, rtf, soc_temp_c, thermal_wait_s, noise_floor_db, norm_gain, peak_pre_limit and the active preset.

Every filter is optional. With none, the newest 5,000 rows come back.

ParameterMeaning
channel One, several, or all. Repeat the parameter or comma-separate. A channel can be named by number (16, 22A, 09), by short form (ch16) or by its full label (Ch16 Distress); the reference receivers answer to ref-a and ref-b.
from, to Absolute window, inclusive. ISO 8601 or epoch seconds: 2026-10-04, 2026-10-04T18:30, 2026-10-04T18:30:00Z, 1759600000. A stamp with no zone is read as UTC, which is what the watchkeeper records.
hours Relative age — everything within the last N hours. Fractions allowed.
limit Row cap. Default 5,000, maximum 100,000.
order desc (default, newest first) or asc.
audio 1 returns only rows whose WAV is still on disk.
text 1 drops every row with no transcript — gated, failed, and anything the decoder returned as whitespace. On a quiet channel this is most of what gets logged.
min_quality Readability floor, 0–100. See 5.6.2 for what the number means.
min_duration Shortest transmission to include, in seconds.
positions 1 runs the coordinate parser over each transcript. Off by default because it is a regex pass per row.
pretty 1 indents the JSON.
download 1 adds a Content-Disposition header so a browser saves the file instead of displaying it.

hours and from/to both set the time window, so asking for both is rejected with a 400 rather than resolved. A scripted export that silently covered the wrong span would be worse than one that failed.

A row with no recorded value passes a threshold. Messages archived before the quality column existed have no readability score, and rather than invent a second convention the export follows the one the log's sliders, the pre-transcription gate and the search page already use: unknown is never hidden. It is the right default for a watchkeeper — losing a distress call to a missing column would be the worse failure — but it means min_quality=60 does not guarantee every row scored 60. The response says so in filters.threshold_note, and quality: null on the row itself is how to find them. Filter those out downstream if the analysis cannot tolerate them.

# everything
curl -s http://apogeerpivhf.local:8080/api/export | jq '.total'

# Ch16 and Ch22A, last six hours, oldest first
curl -s 'http://apogeerpivhf.local:8080/api/export?channel=16,22A&hours=6&order=asc'

# one calendar day, saved to a file
curl -sOJ 'http://apogeerpivhf.local:8080/api/export?from=2026-10-04&to=2026-10-05&download=1'

# what channels exist, with first and last sighting
curl -s http://apogeerpivhf.local:8080/api/export/channels | jq

The envelope reports what was asked and what was done:

{
  "total": 48213,         # rows matching the filters
  "returned": 5000,       # rows in this response, capped by limit
  "truncated": true,      # total > returned, so raise limit or narrow
  "order": "desc",
  "limit": 5000,
  "filters": { ... },     # the filters as understood, after parsing
  "generated_at": "2026-10-05T13:12:02+00:00",
  "messages": [ ... ]
}

filters is the parsed form, not the raw query, so a misunderstanding shows up in the output rather than in the numbers. A channel term that matched nothing appears there as ignored_channel_terms while the rest of the export proceeds; a request where no term matched is a 400 that lists the keys that do exist. /api/export/channels is the cheap way to find those without guessing.

Each row carries msg_id and audio_available. When the WAV is still present, /audio/<msg_id> plays it and /download/<msg_id> returns the zip; once it has aged out msg_id is null. The transcript is permanent, the audio is not — the three ways a recording disappears are listed under Search.

Aliases /api/export.json and /export.json are the same endpoint, for tools that insist on an extension.

AI traffic brief — /brief

Hands the last 24 hours of Ch16 and Ch22A traffic to a language model and asks for a safety brief: what happened, what is still active, what is within 100 nm, and what the weather is doing. Reached from AI brief in the settings panel. One button, one call, a few minutes.

It is a summary of transcripts, not a source of truth. Everything in it descends from machine transcripts that are wrong often enough to matter, so each incident links back to the recording it came from. Listen to the clip before acting on anything.

What it sends

The prompt lives in brief_prompt.txt beside webui.py; edit it and the next run picks it up, no restart and no patch. The data is whatever export_query returns from /api/export, by default:

channel=16,22a&hours=24&text=1&min_quality=55&min_duration=3

Two substitutions happen before the prompt goes out.

What comes back

One non-streaming POST to an OpenAI-compatible chat endpoint; the reply is an HTML fragment, optionally followed by a KML document between <<<KML>>> markers. The server splits the two, saves them together, and serves the KML at /brief/<id>/vhf-alert-locations.kml — then rewrites whatever link the model wrote to point there, so the download works whatever filename it guessed.

The HTML is stripped before any browser sees it. Scripts, iframes, embedded objects, inline on* handlers and javascript: URLs are removed server-side. This is not hypothetical caution: the input to this system is a VHF receiver, so the transcripts are unauthenticated text from strangers, and the prompt separately tells the model to treat them as data and to report any transcript that appears to contain instructions as an unusual transmission.

Running it

A run is started by POST /api/brief/run — POST rather than GET so a link prefetcher, a chat client unfurling a URL or a link checker cannot spend money by accident. Only one runs at a time; a second request joins the first rather than paying twice. The page polls /api/brief/status every two seconds and shows an elapsed timer and the current step. Closing the page does not stop the run: the elapsed time comes from the server, so re-opening mid-run picks the job up at its true age rather than restarting the clock.

Briefs are kept under <base_dir>/briefs/, newest first in the dropdown, oldest pruned when the count passes keep_briefs — the same oldest-first rule the message stack uses.

[ai] in the config file

Every key is optional. This block is read by webui.py alone, fresh on each run, so changing it needs neither a patch nor a watchkeeper restart.

KeyDefaultMeaning
modelgrok-4.7 Model ids move. Check the live list before pinning one.
endpoint https://api.x.ai/v1/chat/completions Any OpenAI-compatible chat endpoint.
timeout_s600 Reasoning models sit for a while. This is not the HTTP request the browser made — that returned immediately.
temperature0.3
key_fileblank Blank means look beside webui.py for a file whose name matches xai*key*.txt, case-insensitively.
prompt_filebrief_prompt.txt Relative to webui.py.
export_query Ch16+22A, 24 h, with text, q ≥ 55, ≥ 3 s Any query /api/export accepts.
max_messages1200 Row cap, applied only when export_query sets no limit.
keep_briefs30
brief_dirblank Blank means <base_dir>/briefs.
base_urlblank Blank means whatever host the browser used, so clip links come back over Tailscale when you asked over Tailscale. Set it if briefs are read somewhere other than where they are run.

The API key is read from its file on each run and never logged, never written into a brief, and never returned by any endpoint. The only thing a failed call puts on the page is the status line and body the endpoint returned.

When it fails

Nothing is saved and the log is untouched — the receiver has no part in this. The page shows the reason: a key that was not found, an HTTP status from the endpoint with its body, an unreachable host, or no matching traffic at all, which is the usual one after a quiet night with min_quality=55 in force.

11. Routine administration

Health checks

vcgencmd measure_temp                     # SoC temperature
vcgencmd get_throttled                    # 0x0 is clean
cat /sys/devices/platform/cooling_fan/hwmon/*/fan1_input   # fan RPM
df -h ~/watchkeeper/audio                 # should say tmpfs
free -h
pgrep -a rtl_sdr                          # who holds the dongle

Throttle flags: bits 0–3 mean it is happening now; bits 16–19 mean it happened at some point since boot. 0x80000 is historical only. 0xe0008 includes bit 3, which is live.

Reading the log

Under the services there is no ~/run.log. Everything both programs print goes to the systemd journal instead, which is a real on-disk log with rotation and indexing — nothing is lost, it is just read with a different command.

journalctl -u watchkeeper@pi -f                 # live tail, Ctrl-C to leave
journalctl -u watchkeeper@pi -n 200             # the last 200 lines
journalctl -u watchkeeper@pi -p warning         # warnings and errors only
journalctl -u watchkeeper@pi --since "1 hour ago"
journalctl -u watchkeeper@pi -b                 # since this boot
journalctl -u watchkeeper@pi -b -1              # the boot before this one

-p warning is the one to reach for when something is wrong: it hides the routine per-message traffic and leaves the complaints. -b -1 is how you investigate a crash you were not watching, after the Pi has already come back.

Spotting a crash loop

Restart=always means a program that dies immediately keeps coming back, and systemctl is-active will keep saying active for a second or two at a time. It looks healthy and is not. The honest check is:

systemctl status watchkeeper@pi

Active: activating (auto-restart), or an uptime that resets every few seconds, means it is failing. The traceback sits in the journal just above each restart line.

Check that the journal is persistent

journalctl --disk-usage names the directory it is using. If it says /run/log/journal, the journal is volatile — held in RAM and wiped at every boot, which is often the default on Raspberry Pi OS. That is exactly the wrong behaviour here: a fault that takes the whole machine down leaves no evidence of itself. If it says /var/log/journal, it is on the card and survives reboots, and -b -1 works.

To make it persistent, capped so it cannot fill the card:

sudo mkdir -p /var/log/journal
sudo systemd-tmpfiles --create --prefix /var/log/journal
sudo mkdir -p /etc/systemd/journald.conf.d
printf '[Journal]\nSystemMaxUse=200M\nSystemMaxFileSize=20M\n' | sudo tee /etc/systemd/journald.conf.d/size.conf
sudo systemctl restart systemd-journald

200 MB is weeks of this workload. The writes are small and appended, nothing like the recordings that were moved to RAM in the first place.

Radio

rtl_test -t                               # find the dongle (ignore "No E4000")
~/venv_wx/bin/python watchkeeper.py --list-presets
~/venv_wx/bin/python watchkeeper.py --preset boat --monitor

After changing the signal path

python3 test_dsp.py                       # must end "all DSP tests passed"

Database

# which channels actually produce readable text
~/venv_wx/bin/python -c "
import sqlite3,os
c=sqlite3.connect(os.path.expanduser('~/watchkeeper/watchkeeper.db'))
for r in c.execute('''select channel, count(*),
  sum(status=\"ok\"), sum(status=\"low\"), sum(status=\"empty\")
  from messages group by channel order by count(*) desc'''):
    ch,n,ok,low,emp = r[0],r[1],r[2] or 0,r[3] or 0,r[4] or 0
    print(f'{ch:<20}{n:5d} msgs{100*(ok+low)/n:6.0f}% useful')
"

Run that after a day. A channel at 0% useful is carrying digital traffic or interference and is costing a receiver slot and 10% of a core.

Archiving audio before it evaporates

~/venv_wx/bin/python make_truth.py --corpus ~/wx_corpus --archive --limit 200

Copies every current recording to the card along with what the decoder made of each. Do this before a reboot if anything is worth keeping.

Starting from a clean slate

After a software change it is often easier to judge the result against an empty log than to pick new messages out of old ones. ~/refresh.sh does that:

#!/bin/sh
echo "Deleting Message log and audio files"

sqlite3 ~/watchkeeper/watchkeeper.db "delete from messages;"
rm -f ~/watchkeeper/audio/*

echo "Ready to run"

Two things worth knowing about it:

The -f on the rm suppresses the "No such file or directory" complaint when the directory is already empty, which it will be after any reboot.

Running at boot

Both programs ship as systemd units, so the Pi can be powered up unattended and come back on its own after a power cut. The units are templated: the part after the @ is the username they run as, which is how the same file works for any account without editing paths.

Installing

sudo cp watchkeeper@.service watchkeeper-web@.service /etc/systemd/system/
sudo systemctl daemon-reload

sudo systemctl enable --now watchkeeper@pi
sudo systemctl enable --now watchkeeper-web@pi

enable makes it start at boot; --now also starts it immediately so you do not have to reboot to test. Check both:

systemctl status watchkeeper@pi
journalctl -u watchkeeper@pi -f      # live log, Ctrl-C to leave
journalctl -u watchkeeper@pi -n 50   # the last 50 lines
journalctl -u watchkeeper@pi -b      # everything since this boot

Day to day

CommandWhat it does
sudo systemctl restart watchkeeper@piPick up a code or config change
sudo systemctl stop watchkeeper@piRelease the radio (see the warning below)
sudo systemctl start watchkeeper@piStart it again
sudo systemctl disable watchkeeper@piStop it coming back at the next boot

The web interface is a separate unit and restarts independently — swapping webui.py does not interrupt capture, and vice versa. Substitute watchkeeper-web@pi in any of the above.

An enabled service holds the radio from boot

This is the same "one radio, one program" rule as before, but now it applies without you having started anything. rtl_power, gqrx and --record will all fail to open the device with the service running. Before a spectrum survey:

sudo systemctl stop watchkeeper@pi
# ... run the survey ...
sudo systemctl start watchkeeper@pi

stop is temporary — it does not affect the next boot.

What the units do beyond starting the program

The preset is named in ExecStart. To change which one runs at boot, edit that line in /etc/systemd/system/watchkeeper@.service, then sudo systemctl daemon-reload and restart the service.

Deployment

scp watchkeeper.py pi@raspberrypi.local:/home/pi/

If the Pi is on a boat, it will move between networks and its LAN address will change with each one. Putting it on a Tailscale tailnet gives it a name that works from anywhere, including over cellular, without opening anything to the internet:

scp watchkeeper.py pi@<host>.<tailnet>.ts.net:/home/pi/

The web interface then follows at the same name on port 8080.

12. Troubleshooting

Nothing is being received

  1. systemctl status watchkeeper@pi — is it actually running? journalctl -u watchkeeper@pi -n 50 shows why not if it is restarting in a loop.
  2. pgrep -a rtl_sdr — is something else holding the dongle? If the boot service is enabled, it holds the radio from power-on, which is the usual reason a survey or gqrx cannot open the device.
  3. rtl_test -t — is the dongle found at all? "No E4000 tuner found" is expected and not a fault; this receiver has an R828D.
  4. Check the log for squelch calibrated. Nothing records during the twenty seconds before it appears.
  5. --monitor for five minutes and read the peak column. Peaks near the floor mean the band genuinely is quiet.
  6. Check the antenna connector. A large drop in reference channel power raises an alarm in the log.

Transcripts are garbled

Check the quality score first. Below about 60% the audio does not contain recoverable speech and no setting will change that — the fix is the antenna or the location. Above 80% with poor text is worth investigating.

Download the message and read spectral flatness and squelch muted. A large muted figure means the carrier was breaking up and the decoder was working from fragments.

A message reads "no speech found" but there was clearly speech

Download it and read Discarded because and Decoder said. That names the filter and shows what it removed. If a filter is discarding real traffic, the raw text is the evidence needed to fix it.

Above 65% readability this should no longer happen: a transcript that clean is trimmed rather than discarded. If you see it anyway, the readability figure in the same export is the first thing to check — and the pairing of a high score with a discarded transcript is worth reporting, because it means a filter is firing on something it should not.

The Pi is throttling

In order of effect:

  1. Check the fan is actually spinning. It is easy to leave the connector half-seated after any work on the case.
  2. Remove the AI HAT if one is fitted. It is not used, it sits directly over the SoC blocking the cooler's airflow, and it was measured costing several degrees of headroom at idle. This receiver no longer has one.
  3. Drop a channel. Each receiver is roughly 10% of a core.
  4. Lower cpu_threads to 2, or temperatures to a single entry.
  5. Lower thermal_pause_above so the governor intervenes sooner.

Compare idle temperature against ambient. Roughly 30 °C above ambient at idle is normal for this configuration; 40 °C above suggests a cooling fault rather than a workload problem.

"DSP behind real time: dropped N chunks"

The channelizer could not keep up and capture was lost. Usually a symptom of throttling rather than a cause. Treat it as a thermal problem first, then reduce the channel count.

The web interface shows nothing

  1. Is the watchkeeper running and past calibration?
  2. Open the browser console. A JavaScript error stops the page updating entirely while still rendering the header.
  3. curl -s localhost:8080/api/messages | head -c 300 — if the API returns data, the problem is in the page, not the database.

Broken-pipe messages in the web interface log are normal: a browser closing an audio stream mid-transfer does that every time you seek or navigate away.

Messages seem to be missing, or the list is not moving

First establish whether the row is in the database or only absent from the page, because they are different faults:

sqlite3 ~/watchkeeper/watchkeeper.db "select id,status,quality from messages order by id desc limit 20;"
curl -s localhost:8080/api/messages | python3 -c "import json,sys; d=json.load(sys.stdin); print(len(d['messages']),'rows')"

Gaps in the id sequence near the newest rows mean something deleted them. Retention only ever deletes the oldest ids, so recent gaps point at reconcile() — rows whose WAV had gone, which is expected after a reboot but not otherwise.

If the rows are in the API but not on the page, it is the page's own state. The relevant values are plain globals, so the browser console will say which:

msgs.length, shown.length, document.querySelectorAll('.row').length
+snrCut.value        // 0 means the quality slider is filtering nothing
hideEmpty            // true hides "no speech found" rows
[...muted]           // channels switched off from the legend

Muting persists. Clicking a channel in the legend stores it in localStorage, so a stray click weeks ago still hides that channel's traffic on that browser. muted.clear(); applyFilters(); render(); in the console clears it.

A stalled page is the other cause, and the tell is the header: it reads offline — no connection to the receiver and the pip beside the title goes dim when a poll has failed. Over a tailnet or marina wifi, ERR_CONNECTION_TIMED_OUT on /api/messages is common; the page keeps the last good snapshot until a poll succeeds. Reload if the indicator does not clear.

The audio mount is full

This does not announce itself as a disk error. What you see is the receiver apparently falling over at random:

ERROR   session crashed
Traceback (most recent call last):
  ...
  File "watchkeeper.py", line ..., in _emit
    sf.write(path, msg.audio, AUDIO_RATE, subtype="PCM_16")
soundfile.LibsndfileError: Error opening '...wav': System error.
WARNING restarting capture in 60s

Each one costs a reopened dongle, a squelch recalibration and a backoff that doubles to a 60 second ceiling, so the log grows gaps rather than stopping. On 7 October this happened 49 times in five hours before anyone noticed, because nothing in the message mentions storage.

df -h ~/watchkeeper/audio
journalctl -u watchkeeper@$USER --since "24 hours ago" | grep -c "session crashed"

The immediate fix resizes the mount in place, with no restart and nothing lost:

sudo mount -o remount,size=1G ~/watchkeeper/audio

Then make it survive a reboot by editing the size= field of the tmpfs line in /etc/fstab — keeping nofail, without which a failed mount stops a headless Pi reaching a login prompt. Size it with the rule in 5.13 rather than by eye.

Two guards were added on 7 October so it cannot be silent again. A failed write now costs that one recording and logs recording lost, still receiving instead of restarting the session, and the prune tick warns once the mount passes 90% full. A clause narrowed to OSError would catch neither: soundfile raises LibsndfileError, which derives from RuntimeError.

After a reboot

removed N row(s) whose audio is gone is expected — recordings are in RAM. If findmnt -n -o FSTYPE ~/watchkeeper/audio does not say tmpfs, the mount failed and writes are going to the card.

If the services did not come back, systemctl is-enabled watchkeeper@pi should say enabled. A unit that starts by hand but not at boot is nearly always the audio mount: the service waits for it, and if /etc/fstab is wrong the wait never ends. systemctl status home-pi-watchkeeper-audio.mount shows whether the mount itself came up.

gqrx will not let you change anything

The dropdowns are dead because the Qt popup rendering is broken over the remote desktop, not because of anything in gqrx. Other dropdowns in the I/O dialog fail the same way. GTK menus work, which localises it to Qt. A Debian security update to the Qt6 stack is the suspected cause.

gqrx is not required. --monitor gives noise floor, per-channel peaks and open counts; rtl_power gives a spectrum survey.

13. Known limits

Transcription accuracy

Word accuracy on dense numeric bulletins defeats both engines. Station names, telephone numbers and long coordinate readbacks come back as contact your daughter, Rain Surveillance Center, Motives and Mariners. Keyword biasing would help and is not reachable through this runtime — see 5.8.

Signal quality remains the binding constraint. No model recovers a transmission the receiver did not capture cleanly; none of the sixteen read Sécurité or PAN PAN correctly, and every distilled variant mangles positions. The difference the benchmark did show is that the gap between models widens exactly where it matters — a point apart on a strong clip, twenty-one apart on a weak one — which is why the larger model earns its cost on marginal traffic even though it looks like a rounding error on good traffic.

The AI brief

Two layers of guessing sit on top of each other, and the second cannot repair the first. Whisper decides what was said; the model then decides what it meant. On marginal traffic the first layer makes a position, a vessel name or a channel number a coin toss, and no amount of reasoning recovers a word the decoder never heard. The model is told to flag ambiguity rather than invent a fix, which helps and does not cure.

So the brief is a reading aid for the log, not a substitute for it. Every incident carries a link to the recording it came from, and that link is the point of the feature. Treat anything in a brief that would change what you do — a distress position, a closure, a cancellation — as unconfirmed until you have heard the clip or seen the original transcript.

The second limit is temporal. A brief is a snapshot of one window, written once. It does not update, it does not know what arrived after it ran, and a cancellation broadcast five minutes later will not appear in it. The log is live; the brief is not.

Things that sound like good ideas and are not

Two dongles

Channels more than about 800 kHz apart cannot share a capture. Watching 121.5 alongside 125.35, for instance, spans 4.25 MHz and needs a second receiver.