A multi-channel VHF receiver, recorder and transcriber running on a Raspberry Pi 5.
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.
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.
Each row is one transmission. Reading left to right:
| What you see | What 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.
| Badge | Meaning |
|---|---|
| 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. |
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.
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.
| Control | What 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.
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.
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.
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.
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.
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.
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.
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.
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.
| Part | Detail | Notes |
|---|---|---|
| Computer | Raspberry 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. |
| Receiver | RTL-SDR Blog V4 | Rafael Micro R828D tuner, 1 PPM TCXO. USB. |
| Cooling | Aluminium dual-fan case, custom curve | Chosen from a five-way comparison. Roughly halved the rise above ambient — see below. Essential; see troubleshooting. |
| Storage | microSD | Recordings live in RAM to spare it — see files. |
| Antenna | Marine VHF | The single biggest influence on transcription quality. |
| AI HAT+ 2 | Hailo-10H NPU — evaluated, then removed | Benchmarked and rejected for speech, then taken out: it blocks the cooler's airflow. See known limits. |
Both figures below are the same workload and the same preset, running at the slip. The cabin was hotter on the second day.
| Cabin | SoC mean | SoC peak | Rise above ambient | |
|---|---|---|---|---|
| Pi 5 active cooler, fan pinned, 3 Oct | 32.9 °C | 84.1 °C | 87.0 °C | 51.2 °C |
| Dual-fan case, 4 Oct | 34.3 °C | 58.6 °C | 70.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.
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.
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.
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.
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.
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.
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.
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:
hang decides when a transmission has ended. It needs
to be generous (0.5 s) so a brief dropout does not split one message
into two.carrier_tail_keep_s decides how much of that hang reaches
the recording. It wants to be as short as the fade allows
(0.05 s).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.
Once a message is complete it is processed in a fixed order, and the order matters:
target_rms — 0.2, about
−14 dBFS — capped at max_norm_gain_db,
26 dB.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.
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:
test_fm_wide_spacing_rejection), so it is not filter
leakage.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.
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:
| Key | Meaning |
|---|---|
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. |
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:
quality is None, not 0, when the metric
has no opinion. A steady carrier has constant frame energy, so
the loud-frame filter inside spectral_flatness keeps nothing
and the function returns None. A naive
if quality < threshold raises; a naive
if not quality silently drops every continuous
transmission. None always passes the gate.FLATNESS_NOISE = 0.55
was calibrated against. So 55 sits nearer the bottom of the real range
than the 0–100 scale suggests, and recalibrating that endpoint
against measured noise-only clips would spread the scale usefully.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.
| Role | Model | Runs 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. |
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:
| Parakeet | medium.en | |
|---|---|---|
| Invented passages | 0 | 12 of 80 |
| Decode time | ≈ 0.13 × duration | ≈ 11.9 s + 1.17 × duration |
| Short clips | about 30× faster | |
| Overall | about 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.
| Setting | Value | Why |
|---|---|---|
| Threads | 2 | Traffic is sparse; peak power is heat. |
| Beam size | 1 | Greedy. Roughly half the work of 2. |
| Chunk length | 30 s | Caps encoder memory. See 5.7.1. |
| Queue | Newest first, depth 12 | When behind, the top of the log stays current. |
| Token cap | 5 per second of audio | Applies to the second pass only, which is the engine that can loop. Maximum 14 — see 5.7.2. |
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.
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.
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.
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 length | Requested tokens | Result |
|---|---|---|
| 30 s | 256 | fine |
| 53 s | 440 | fine |
| 54 s | 448 | ValueError |
| 90 s | 736 | ValueError |
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.
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:
| Rule | Decides |
|---|---|
| Agreement within 100 m | Either; 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. |
| Otherwise | The 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.
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.
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.
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.
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.
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:
sec… family, the
pan/pawn/pon/bon family, mayday. Two occurrences near the head of a
message is enough on its own.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.
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.
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.
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 found | Treated 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.
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".
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
/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.
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.
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.
| File | Purpose |
|---|---|
watchkeeper.py | The receiver. DSP, squelch, recording, transcription, storage. |
webui.py | The web interface. Reads the database, serves port 8080. |
watchkeeper.toml | All configuration: presets, channels, thresholds. |
docs.html | This 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.sh | Wipes the log and recordings for a clean test. See §10. |
Copied into /etc/systemd/system/; the originals are kept
alongside the programs so they travel with the rest of the software.
| File | Purpose |
|---|---|
watchkeeper@.service | Starts the receiver at boot. See Running at boot. |
watchkeeper-web@.service | Starts the web interface at boot. |
| File | Purpose |
|---|---|
test_dsp.py | DSP test suite. Run after any change to the signal path. |
make_truth.py | Archive recordings off the tmpfs mount onto the card, and transcribe by ear for comparison. |
bench_models.py | Compare speech models against that corpus. |
bench_hailo.py | The same, on the NPU. |
run_bench.py | Runs the whole benchmark suite unattended. |
analyse_bench.py | Re-cuts saved benchmark results without re-running. |
| Path | Contents |
|---|---|
~/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. |
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.
Everything lives in watchkeeper.toml. It is searched for in
this order, first match wins:
./watchkeeper.toml~/watchkeeper.toml~/watchkeeper/watchkeeper.tomlThe 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]| Key | Meaning |
|---|---|
base_dir | Sets the database and audio paths together. |
audio_dir | Overrides just the audio path — this is what points it at tmpfs. |
db_path | Overrides just the database path. |
[radio] — global defaults| Key | Meaning |
|---|---|
gain | Tuner 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. |
ppm | Crystal 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_size | Decoder cost. |
temperatures | Fallback ladder. A single entry disables retries. |
max_messages | Rows 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 locationA 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.
| Key | Mode | Meaning |
|---|---|---|
fm_open_noise / fm_close_noise | FM | Noise metric below which the carrier counts as present / absent. |
squelch_dbfs | AM | Absolute guard rail. Must sit clear of the floor-relative threshold or it silently becomes the operative one. |
open_margin_db / close_margin_db | AM | dB above and below the measured floor. |
hang_s | AM | Ride through brief dropouts. |
carrier_tail_keep_s | FM | How much post-carrier audio reaches the file. Keep short. |
min_message_s / max_message_s | both | Message length bounds. |
min_snr_db | both | Discard threshold. Set to 0 on marine presets — on a distress channel a weak transmission may be the one that matters. |
| Key | Meaning |
|---|---|
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_second | Output 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. |
prompt | Decoder priming. Absent on marine — see §5.9. |
thermal_pause_above etc. | Thermal governor. 0 disables. |
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.
| Preset | Mode | Centre | For |
|---|---|---|---|
boat | NFM | 156.7625 | At the slip. Built from a survey taken there. |
marine | NFM | 156.7625 | Standard watch set for use underway. |
Marine_Home | NFM | 156.5875 | Built from a survey eleven miles inland. Shore stations only. |
socal_low | AM | 124.8250 | LAX area approach and ground. |
socal_high | AM | 133.6000 | Centre and tower. |
wx | NFM | 162.2625 | NOAA weather radio. |
--list-presets prints the current list with channels. Preset
names are case-sensitive.
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
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.
watchkeeper.py| Flag | Effect |
|---|---|
--preset NAME | Which preset to run. |
--list-presets | Show all presets and exit. |
--config PATH | Use a specific config file. |
--log PATH | Append all output to a file as well as the console. |
--monitor | Print live squelch metrics instead of recording. Ends with a per-channel summary. |
--monitor-interval N | Seconds between monitor reports. Default 5. |
--record PATH | Also write the raw I/Q stream while running. |
--replay PATH | Read from a recorded I/Q file instead of the dongle. |
--gain DB | Override tuner gain. 0 for the tuner's AGC. |
--am-audio-hz HZ | Top of the AM audio passband. |
--verbose | Debug logging. |
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| Flag | Effect |
|---|---|
--port N | Default 8080. |
--host ADDR | Default 0.0.0.0, all interfaces. |
--preset NAME | Pin the channel legend to one preset. |
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.
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.
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:
max_messages
— roughly a day at current traffic.refresh.sh, which clears messages and
the WAVs and deliberately leaves history alone.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.
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.
vcgencmd get_throttled has the full word.
Historical flags are deliberately not shown as a warning — they persist
until reboot, which trains you to reboot to clear a light rather than to
fix anything.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.
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.
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 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.
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.
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.
| Parameter | Meaning |
|---|---|
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.
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.
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.
<<Current Position>> becomes the receiver's
position and how it was obtained — a live Signal K fix
with its age, or the configured position with a warning that it is not a
fix, or an instruction not to compute distances at all when neither
exists. A brief that quietly measured distances from a week-old assumed
position would be worse than no brief.clip_url field,
built server-side from msg_id — the id the log
actually anchors on. The model is told to copy that field and never to
assemble a URL itself, which is what keeps the links working: the log
anchors on messages.id, not on hid, and a
message whose WAV has aged out gets no field and therefore no link.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.
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 fileEvery 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.
| Key | Default | Meaning |
|---|---|---|
model | grok-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_s | 600 |
Reasoning models sit for a while. This is not the HTTP request the browser made — that returned immediately. |
temperature | 0.3 | |
key_file | blank | Blank means look beside webui.py for a file whose name
matches xai*key*.txt, case-insensitively. |
prompt_file | brief_prompt.txt |
Relative to webui.py. |
export_query |
Ch16+22A, 24 h, with text, q ≥ 55, ≥ 3 s | Any query /api/export accepts. |
max_messages | 1200 |
Row cap, applied only when export_query sets no
limit. |
keep_briefs | 30 | |
brief_dir | blank | Blank means <base_dir>/briefs. |
base_url | blank | 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.
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.
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.
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.
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.
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.
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
python3 test_dsp.py # must end "all DSP tests passed"
# 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.
~/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.
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:
sudo systemctl stop watchkeeper@pi, or Ctrl-C if you
started it by hand). Nothing is corrupted if you do not — SQLite handles the concurrent access and the startup
reconciliation tidies up afterwards — but a recording deleted while the
transcriber is reading it comes back as an error row, which
is confusing when you are looking for the effect of a change.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.
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.
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
| Command | What it does |
|---|---|
sudo systemctl restart watchkeeper@pi | Pick up a code or config change |
sudo systemctl stop watchkeeper@pi | Release the radio (see the warning below) |
sudo systemctl start watchkeeper@pi | Start it again |
sudo systemctl disable watchkeeper@pi | Stop 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.
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.
--log file. Output goes to the journal, which rotates
itself. A --log file does not, and grows without limit on the
SD card. Use journalctl instead of tail ~/run.log.RequiresMountsFor on the audio directory. Without it the
watchkeeper can start before the tmpfs mount and write underneath the mount
point; the files then vanish from view when the mount appears, and the
database references recordings that cannot be found.OMP_NUM_THREADS and
OPENBLAS_NUM_THREADS stop the maths libraries spawning a thread
per core on top of the cpu_threads the transcriber already
asked for. That over-subscription was a real part of the thermal load.Nice. Capture runs at −5 and the web interface at
+5, so neither is starved by the transcriber, which runs flat out by
design.Restart=always with a 10 second delay, which covers the
cases the program's internal retries cannot — the dongle being unplugged,
for instance.KillSignal=SIGINT so a stop is the same clean shutdown
as Ctrl-C: the database closes properly and the in-progress recording is
flushed rather than left as a stub.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.
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.
systemctl status watchkeeper@pi — is it actually
running? journalctl -u watchkeeper@pi -n 50 shows why
not if it is restarting in a loop.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.rtl_test -t — is the dongle found at all? "No E4000 tuner
found" is expected and not a fault; this receiver has an R828D.squelch calibrated. Nothing records during
the twenty seconds before it appears.--monitor for five minutes and read the peak column. Peaks
near the floor mean the band genuinely is quiet.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.
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.
In order of effect:
cpu_threads to 2, or temperatures to a
single entry.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.
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.
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.
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.
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.
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.
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.
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.
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.
compression_ratio_threshold. On
short transmissions this rejects real Coast Guard broadcasts while
passing "Gun, gun, gun" — the metric assumes long-form text where a loop
dominates. It also only gates a retry, so with a single temperature it
does nothing at all.no_speech_prob. Measured
anti-correlated with quality on this traffic: the highest value on record
belongs to a correctly decoded Coast Guard securité broadcast.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.