Existing 4 tests covered only invalidate_main_decoder_windows_on_freq_change for PKT/DIG/WFM. Add coverage for the remaining modes that the freq-change table cares about (USB, CW, CWR), the no-op modes (AM/LSB/FM/SAM), the S-meter offset table, lock-state precedence, TX meter extraction, RX/TX delta detection, and the desired_machine_state transitions (Disconnected / Initializing / PoweredOff / Ready / Transmitting).
19 new tests; trx-server suite now reports 134 passed (was 130).
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Extract build_history_blob from handle_audio_client so the blob construction can be exercised in isolation. The function is called once per audio client connect and was previously buried inside an async TCP handler.
Integration tests assert: empty histories produce an empty blob; record types appear with the correct AUDIO_MSG_* type bytes in the documented decoder iteration order; build → split_history_chunks → gzip → decompress recovers the original blob byte-for-byte and every chunk fits under MAX_HISTORY_PAYLOAD_SIZE; decoders with no history are skipped; the returned count matches estimated_total_count().
5 new tests; trx-server suite now reports 130 passed (was 110).
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Cover write_audio_msg / read_audio_msg / write_vchan_* / parse_vchan_* with round-trip and edge-case tests over an in-memory buffer (no sockets). Catches regressions in the wire format used by trx-server's audio listener and the trx-client audio reader.
Tests: type+length+payload round-trip, empty payload, consecutive frames, EOF before header / mid-payload, oversize normal frame rejected, history-compressed frame allowed up to its larger cap, history cap still enforced past 16 MiB, vchan UUID + audio frame round-trips, short-payload rejection, AudioStreamInfo JSON round-trip, write_audio_msg_buffered byte-equivalence.
15 new tests in trx-core.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Cover read_limited_line and ConnectionTracker — both are pure logic that can be exercised without a real TCP socket. Existing TCP-bind integration tests stay ignored as they need network privileges; not migrated as part of this work.
read_limited_line: empty EOF, single line with/without trailing newline, multiple lines in one buffer, line exactly at the cap, oversize within a chunk and across reads, invalid UTF-8. ConnectionTracker: per-IP limit enforced, release frees a slot, distinct IPs are independent, release of unknown IP is a no-op, double-release does not underflow. Plus a default-values check for ListenerTimeouts.
16 new tests; trx-server suite now reports 110 passed (was 94).
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Split StreamErrorLogger::log into a thin tracing wrapper and a testable log_at(err, now) -> LogAction core. The LogAction enum surfaces what was emitted (Error / Suppressed / Recurring) and whether a prior class transition flushed a repeated-count summary, so tests can assert behaviour without reaching for a tracing subscriber.
Tests cover: first call emits Error; same class within the interval is Suppressed; same class past the interval emits Recurring with the accumulated count; the recurring summary restarts the suppression window; class transitions flush prior repeats only when there were any; and classify_stream_error keying really collapses different ALSA strings into one class.
7 new tests; trx-server suite now reports 94 passed (was 87).
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Extract a generic prune_by_age<T> helper from the 11 duplicated typed prune_* methods. The helper takes an explicit Instant so tests drive time deterministically, and it uses checked_sub so an early monotonic clock cannot panic on cutoff computation.
Tests cover: prune_by_age (empty / fresh / stale-front / all-stale / out-of-order); record/snapshot round-trip; auto-assignment of ts_ms when None; CRC-failed APRS packets are dropped; capacity eviction at MAX_HISTORY_ENTRIES; estimated_total_count tracks records and survives clear; adjust_total_count saturates on underflow; concurrent recorders converge to a consistent count.
16 new tests; trx-server suite now reports 87 passed (was 71).
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
The compressed history blob sent on each audio client connect could exceed the client's MAX_HISTORY_PAYLOAD_SIZE (16 MiB) once enough decoder records accumulated, causing the client to reject the frame, drop the connection, and reconnect — producing a 1 Hz reconnect storm.
Walk the framed blob at message boundaries and emit one AUDIO_MSG_HISTORY_COMPRESSED per ~8 MiB uncompressed chunk. The split is wire-compatible: clients already loop on read_audio_msg and process each history frame independently.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Per-line cross-correlation slant tracking (d487711) shifted every line by up to \u00b16 samples with a 0.01 deadband, so image-content and shot-noise variance dominated the drift estimate and garbled the output. The unverified-reception verifier (76f9953) then silently dropped the entire capture at line 40 when correlation never settled. Together they made valid transmissions look like decoder failures.
Revert both: fixed-period extraction restored, carrier-loss watchdog ungated, transition_to_receiving no longer takes a verified flag. Phasing timeout fallback and variance-based auto-start kept. This returns the decoder to fldigi-equivalent behaviour.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
DSP: 400 ms attack / 1.0 s decay IIR on IQ power (block-rate corrected).
JS: asymmetric EMA (α=0.08 attack, α=0.03 decay) with rAF coalescing.
CSS: bar transition 150 ms → 300 ms ease-out.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
50 ms attack was still too twitchy for WFM — block-to-block power
noise in the constant-envelope FM signal made the meter jitter.
200 ms attack (~6 frames) and 600 ms decay (~18 frames) give a
smooth, traditional meter feel.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
With block-rate correction the 2 ms IARU attack τ saturated α to ~1.0,
making the meter track raw block power with no smoothing. 50 ms gives
~3-frame settling at 30 Hz refresh — responsive but visually stable.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
The per-sample attack/decay alphas from smeter_alphas() were applied
once per decimated block (~200 samples), inflating effective time
constants by ~200x and causing a sluggish "pumping" meter. Correct
with α_block = 1 − (1 − α)^N to preserve the intended IARU R.1
time constants (~2 ms attack, ~300 ms decay).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Adds a dedicated /meter SSE stream that wraps the per-rig meter watch
and emits one compact JSON frame per update with no equality gating, so
30 Hz samples reach the browser unthrottled. Registered as a Read-access
route. app.js opens a dedicated EventSource on /meter alongside /events,
writing directly to the signal bar and value on each frame with no
requestAnimationFrame coalescing, starts/stops with connect/disconnect,
and reconnects on rig switch.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Adds rig_meters: map of per-rig watch::Sender<Option<MeterUpdate>> to
RigRoutingContext with a lazy rig_meter_rx helper. run_meter_supervisor
polls for known short names and spawns one SubscribeMeter TCP connection
per rig; reconnect loop sets TCP_NODELAY and pushes samples into the
per-rig watch so slow SSE readers automatically skip intermediate frames.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Adds a per-rig meter broadcast channel on RigHandle and threads it
through run_rig_task. SDR meter tick drops from 100 ms to 33 ms; every
tick publishes a MeterUpdate while RigState is only updated on
>=0.25 dB deltas so rigctl/JSON-TCP frontends keep working without
amplifying state churn. Listener handles SubscribeMeter by converting
the TCP connection into a one-way JSON-line stream; TCP_NODELAY is
enabled on every accepted socket for low-latency frame delivery.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Adds a dedicated one-way JSON-line stream for high-rate signal-strength
samples so meter updates bypass full-RigState diffing in the control path.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Add tests for decoded_to_spot (FT8, WSPR, CW rejection), maidenhead
grid computation for known cities, callsign/locator validation, padding,
string encoding, and directed FT8 message parsing.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Cover disabled config, template date token substitution, logger
initialization, and JSON Lines write+read verification for FT8 and
APRS payloads.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
The toggle_ft8_decode handler requires FrontendRuntimeContext for
multi-rig state resolution, but the test did not register it.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
png::Writer::write_image_data only validates the total byte count,
so if individual scan lines were pushed at the wrong width the total
could still match and the resulting PNG would be silently skewed.
Explicitly check each row against pixels_per_line before encoding
and bail with a descriptive error if any row disagrees.
Also log the final file path, dimensions, and byte size at debug
level so corrupted-image reports have something concrete to look at.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Extend the between-poll meter refresh that was previously SDR-only
to also run on CAT backends. CAT rigs now poll the S-meter every
150 ms (SDR remains at 100 ms), so the frontend bar moves in near
real-time instead of updating only on the 500 ms full-state poll.
The fast path calls get_signal_strength_db() first (SDR), then
falls back to the coarse get_signal_strength() + map_signal_strength
path for CAT rigs. It is skipped while powered off, transmitting,
or while a full poll is paused after a CAT write.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When a bookmark switches to a mode that a currently-enabled toggle
decoder doesn't support (e.g. moving from DIG to FM while FT8 is on),
turn the incompatible decoder off. Previously bmApply only touched
decoders that were compatible with the new mode, leaving stale
decoders running against modulation they can't handle.
Compatible decoders keep their existing behaviour: if the bookmark
specifies a decoder set, toggles are driven to match it; otherwise
they're left as-is.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Relying on Drop to write the PNG IEND trailer and flush BufWriter
silently swallows I/O errors, which can leave truncated/corrupted
image files on disk. Explicitly call writer.finish() to surface
encoding errors and sync_all() the File so bytes are durable before
WefaxEvent::Complete is emitted with the path (the frontend may read
the file immediately). The intermediate BufWriter is dropped since
we already buffer all rows and write them in a single call.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Track sample-clock drift between transmitter and receiver by
cross-correlating each new scan line against the previous one at
shifts of ±6 samples. The best-matching shift nudges the slicer's
extraction cursor, keeping adjacent lines aligned and removing the
diagonal skew that would otherwise accumulate over an 800-line image.
A small correlation-peak deadband prefers d=0 on quiet lines, and a
minimum-variance guard skips flat reference lines where drift
estimation is meaningless. Enabled by default via
WefaxConfig::slant_correction.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PhasingDetector is strict (needs 10 phasing lines with low pulse-
position variance). On real-world signals with noise, tuning error,
or non-standard phasing, it can fail to converge — leaving the state
machine wedged in Phasing forever after a successful APT start
detection.
Add a ~30 s timeout: if phasing alignment doesn't lock after
PHASING_TIMEOUT_LINES worth of samples, fall through to Receiving
with phase_offset=0 and verified=false. The correlation verifier
then decides whether the content is real imagery (commit, eventually
save) or not (drop, back to Idle). The image will be horizontally
misaligned since we never locked phase, but it's better than a
stuck state that produces nothing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Previously, the variance-based auto-start entered State::Receiving
directly and committed to saving whatever came out, relying on a
100-line minimum as a crude filter. This let any sustained tone or
noise burst allocate an image buffer and emit state events.
Replace that filter with real verification. Each entry into Receiving
is now tagged verified (phasing-driven) or unverified (variance
auto-start). Unverified receptions must produce 5 consecutive lines
of r >= 0.5 correlation within the first 40 lines to commit. Otherwise
the buffered content is dropped silently and the decoder returns to
Idle — no image saved, no history entry, no carrier-lost event.
The carrier-loss watchdog is now gated on verified==true so it can
only ever finalize genuine captures. Phasing-driven receptions (APT
start tone + phasing pulses) enter verified and don't wait on the
correlation streak.
The 100-line minimum in finalize_image is removed — verification is
a cleaner semantic gate. A very short but genuinely phasing-validated
capture will now save.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
The variance-based auto-start in Idle state is permissive and fires on
any sustained modulated audio — tones, beeps, noise bursts. When that
happens mid-transmission, the decoder enters Receiving, the correlation
watchdog trips after exactly 31 lines (1 seed + 30 low-correlation),
and we end up saving a sliver of garbage to disk and the history.
Gate finalize_image() on a 100-line minimum. A real WEFAX chart is
hundreds of lines; anything shorter is almost certainly a false
auto-start and gets dropped silently (with a debug log). This doesn't
change start-tone / phasing-driven captures, only filters out the
noise-triggered entries.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
WEFAX images were only saved to disk and recorded in history when an APT
stop tone was detected or the decoder was explicitly reset. If the
transmission broke (carrier dropout, tuning drift, noise masking the
stop tone), the decoder stayed in Receiving state forever and the
partial image was never flushed.
Add a line-to-line Pearson correlation watchdog modelled on fldigi's
wefax automatic stop: real imagery has highly correlated adjacent scan
lines, while noise does not. After 30 consecutive low-correlation lines
(~15s at 120 LPM, ~30s at 60 LPM) the decoder finalizes the image,
emits WefaxEvent::Complete, and returns to Idle — so partial
transmissions show up in the web UI history like completed ones.
Flat lines with near-zero variance are treated as "undefined" and
leave the counter unchanged, so solid black/white image bands don't
falsely reset or trip the watchdog.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Dynamic scripts (map-core.js etc.) are effectively async and can execute
before the defer'd app.js that creates window.trx. When map-core.js is
served from browser cache it loads instantly and crashes on
`const T = window.trx` (undefined), preventing window.trx.map from
ever being set — the map never initialises and Ctrl+R is needed.
Move eager plugin loading from the inline script to app.js, triggered
via window.loadEagerPlugins() after window.trx is fully populated.
This guarantees the namespace exists before any plugin script runs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Replace the plain centered text with the same overlay card style used
by the connection-lost screen (decode-history-overlay + content-overlay).
Toggle visibility via the is-hidden class for a smooth fade transition.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
map-core.js is loaded as a dynamic script (effectively async) while
leaflet.js is a static <script defer>. When the user clicks the map tab,
Leaflet may not have executed yet, causing initAprsMap() to silently
bail on the `typeof L === "undefined"` guard with no retry. Introduce
_initMapWhenReady() that polls at 100ms intervals until both Leaflet
and map-core.js are available, showing the loading message in the
meantime. Also update autoInitIfVisible() for direct /map navigation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
About-tab element refs were cached at script load time but the elements
live inside a <template> that hasn't been cloned yet, so all refs were
null. Convert to lazy resolution via _resolveAboutEls() called on first
about-tab render. Also extract _wireSubTabBar() so sub-tab click
listeners are attached after template cloning (the about Client sub-tab
was unreachable). Decoder status elements use the same lazy pattern.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Move template cloning into navigateToTab() so deferred <template>
content is materialized before any tab-specific initialization runs.
Previously the document-level template cloner fired after navigateToTab
due to event propagation order, causing initAprsMap() and
scheduleStatsRender() to target elements that did not yet exist. Also
defer statistics control wiring until the first render so event
listeners are attached after the template is cloned.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
- Display "Loading map…" placeholder on first map tab click while
map-core.js is still loading; hide it once the module initializes.
- Sync receiver marker highlight when switching rigs so the map
reflects the currently active rig immediately.
- Add "Actions" header to recorder files table and match button
sizing to bookmarks table style.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Show the auth gate instead of silently blocking navigation to non-main
tabs when not logged in. Also fix recorder file table layout so the file
column takes full width and action buttons are right-aligned.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Widen the recordings filter input (remove max-width cap, set min-width),
add search icon placeholder, use type=search for native clear button.
Also fix download/remove button size mismatch with explicit height.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Batch offsetWidth reads before writes to prevent layout thrashing, and
position chips via transform instead of left to avoid sub-pixel jitter.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
The decode SSE stream and history endpoint are unfiltered and carry data
for all rigs. Reconnecting them on rig switch needlessly tore down the
entire decode state and re-fetched identical data. Also removed the
FT8/FT4/FT2/WSPR history table clearing since that data is shared.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>