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>
Eagerly load map-data plugins (AIS, APRS, VDES, HF-APRS) on startup and
buffer any decode history or live SSE messages that arrive before plugin
handlers register. Each plugin drains its pending buffer on init.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
When a schedule entry has `exclusive: true`, the scheduler stays on that
entry's bookmark for the entire time window without interleaving with
other overlapping entries. Useful for WEFAX and satellite passes where
switching away mid-reception would lose data.
Backend: first exclusive active entry wins outright in timespan_active_entry.
Frontend: "Excl." checkbox in inline edit disables interleave input;
interleave status shows exclusive entry as sole active entry.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Include rig dial frequency and mode in WEFAX image filenames, matching
fldigi's approach of capturing tuning at save time. Images are saved to
~/.cache/trx-rs/wefax/. Server passes current rig state to the decoder
via set_tuning() before each processing block.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Phasing-only signals (no APT start tone) should not trigger image
decoding. Only APT start tones and signal-level variance detection
can start reception.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Per-entry caching in _ensureDecoderToggles prevents stale guard from
blocking re-scan. Direct syncWefaxToggle path ensures dataset.enabled
stays current for bookmark prefill.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
decoder.reset() now finalises and saves any partially-received image
before returning to Idle. The server emits the completion event so the
image appears in the frontend history and is persisted to disk.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Add signal-level detection that monitors luminance variance to auto-start
receiving when tuning in mid-image (~3s of sustained modulated signal),
matching fldigi's "strong image signal" detection. Reduce APT sustain
to 1.0s (2 windows) matching fldigi. Emit initial "Idle — scanning"
state event so the frontend shows the decoder is processing audio.
Add tracing instrumentation for luminance stats and tone analysis.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Dynamic plugin scripts can execute before deferred app.js, causing
bookmarks.js to miss the onDecoderRegistryReady callback and never
build decoder checkboxes. Rebuild from the registry each time the
form opens so checkboxes always reflect the current registry state.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Emit WefaxProgress events with a state label on each decoder state
transition (APT Start, Phasing, Receiving) so the frontend can display
the current decoder phase instead of just "listening for packets".
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
APT start/stop signals are not audio-frequency tones — they are
black↔white transition rates in the FM-demodulated output (300, 675,
450 transitions/s). The Goertzel detector was running on the raw ~1900 Hz
carrier where no energy exists at those frequencies, so APT detection
never fired on real HF WEFAX signals.
Replace the Goertzel approach with transition-counting on demodulated
luminance (matching fldigi's decode_apt), and swap the processing order
so FM demodulation runs before APT detection.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Two issues prevented bookmark decoder toggles from working:
1. bmPrefillFromStatus() did not prefill decoder checkboxes from the
current toggle button state, so bookmarks were saved with an empty
decoders array even when decoders were active.
2. The bookmark apply code fetched /status without the remote parameter,
comparing against the wrong rig's decoder state in multi-rig setups.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
When tuning into a WEFAX station after the APT start tone has already
passed, the decoder stayed in Idle forever. Add an idle_phasing detector
that continuously runs phasing detection on demodulated luminance while
in Idle state, allowing the decoder to lock onto ongoing transmissions
without requiring the 300/675 Hz start tone.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Normalize button styling between <a> and <button> elements by using
inline-flex with centered alignment instead of inline-block. Add
align-items to the container and box-sizing to the buttons.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
All decoder toggle endpoints (APRS, HF-APRS, CW, FT8, FT4, FT2, WSPR,
LRPT, WEFAX) read the enabled flag from the global default state watch
instead of the target rig's state. When controlling a non-active rig the
toggle reads the wrong rig's flag and sends the wrong enable/disable
value, causing the button to have no effect or invert the state.
Add resolve_rig_state() helper that looks up the per-rig watch via
context.rig_state_rx() and falls back to the global default, matching
the pattern already used by the /status endpoint.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
BackgroundDecodeManager.send_audio_cmd used the global active_rig_id()
to route virtual channel commands. During a rig switch, Remove commands
for the old rig's channels were sent to the new rig's audio pipeline,
leaving orphaned virtual channels on the previous rig's server.
Replace send_audio_cmd with send_audio_cmd_to_rig that takes an explicit
rig_id, derived from the channel's own rig_id field. Both Remove and
SubscribeBackground commands now reach the correct rig.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Replace per-sample circular-buffer processing with block-based linear
buffers in the FM discriminator and polyphase resampler. This eliminates
modular indexing in FIR inner loops, enabling compiler auto-vectorisation.
Also fix O(n²) drain pattern in the line slicer.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>