State deduplication via PartialEq + send_if_modified:
Derive PartialEq on the full RigState / RigSnapshot type tree
(Freq, Band, RigInfo, RigCapabilities, RigStatus, RigTxStatus,
RigRxStatus, RigControl, RigVfo, RigVfoEntry, RigFilterState,
RdsData, SpectrumData, RigState, RigSnapshot). Use
state_tx.send_if_modified() in refresh_remote_snapshot() so
WatchStream only wakes SSE /events subscribers when state
actually changed; with a stable rig this eliminates ~1.3
spurious JSON serialisations per second per connected client.
Cache-remote-rigs skip on unchanged list:
cache_remote_rigs() was rebuilding the Vec and cloning every
field on every 750 ms poll. Add a structural check (rig_id,
display_name, initialized, audio_port) and return early when
nothing has changed — the common steady-state case.
RDS JSON pre-serialised at ingestion:
SharedSpectrum.replace() now serialises the optional RDS object
once and stores it alongside the Arc<SpectrumData> frame.
Each /spectrum SSE client's 40 ms tick reads the cached string
instead of calling serde_json::to_string() per-client per-tick.
Add serde_json to trx-frontend Cargo.toml to support this.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Three hot-path optimizations in the client polling loop and SSE
spectrum stream:
- Set TCP_NODELAY on the client→server connection so each framed
JSON command is sent immediately instead of being held up to 40 ms
by Nagle's algorithm.
- Wrap SpectrumData in Arc<> inside SharedSpectrum. snapshot() was
cloning the full bin vector (~8 KB for 2048 f32 bins) for every SSE
/spectrum client on every 40 ms tick. With N clients that is N×8 KB
per tick; now replace() pays one Arc::new() and each client gets an
O(1) pointer clone.
- Eliminate the format!("{}\n", payload) intermediate String in the
three send_command / send_command_no_state_update / send_get_rigs
call sites. Push '\n' in-place on the serialised payload String
instead of allocating a second buffer.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Three causes of >30 s SSE stalls:
1. SPECTRUM_IO_TIMEOUT was 300 ms — transient server jitter triggered
false-positive spectrum failures and immediate TCP disconnects.
Raised to 1 s to tolerate brief load spikes.
2. reconnect_delay was never reset after a successful TCP connect, so
after a few spectrum-induced disconnects the backoff reached 10 s.
Each new disconnect then cost 10 s of stale SSE state, and several
cycles accumulated to >30 s. Reset to 1 s on every successful
TCP connect so recovery stays fast.
3. SSE pings were emitted as comments (": ping"), which EventSource
never exposes to onmessage. lastEventAt was therefore never updated
by pings, causing the JS heartbeat to force-reconnect every ~20 s
even on healthy, stable connections. Changed to a named "ping"
event and added es.addEventListener("ping", …) to update lastEventAt.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
inject_frontend_meta was calling serde_json::from_str + to_string on
every state update, adding two full JSON round-trips per SSE event.
Replace with string-level injection: strip the closing }, serialize only
the extra meta fields once, and re-close the object. The state JSON is
now serialized exactly once per update.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Replace the JSON f32 array (~7.5 KB/frame) with a named SSE event "b"
carrying base64-encoded i8 bins (~1.4 KB/frame, ~5x reduction):
event: b
data: {center_hz},{sample_rate},{base64_i8_bins}
1 dB per step covers the -128…+127 dBFS display range, sufficient for
visualization. RDS is stripped from the spectrum frame and emitted as a
separate named "event: rds" only when the payload changes. The JS
decoder uses atob() + sign-extension to reconstruct the float bin array.
A minimal inline base64 encoder is added server-side (no new crate).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Add flate2 dependency and a new AUDIO_MSG_HISTORY_COMPRESSED (0x0a)
wire type. The server gzip-compresses the full history blob before
sending; JSON history compresses ~10-20x so both transfer size and
client wait time drop significantly. The client decompresses and
dispatches sub-messages from the embedded framed stream. MAX_PAYLOAD_SIZE
is kept at 1 MB for normal messages; a separate 16 MB limit is applied
only to the compressed history type.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
- Add "Enable HF APRS" toggle button to the HF APRS tab (same style as
FT8/WSPR); button is disabled during TX like other decoder toggles
- app.js: sync button text/colour from SSE state updates
- hf-aprs.js: connect button click to /toggle_hf_aprs_decode
- bookmarks.js: add "HF APRS" checkbox to Add/Edit Bookmark decoder
section; bmReadDecoders/bmWriteDecoders handle "hf-aprs" key; bmApply
toggles the decoder to match bookmark preference on recall
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Assigns message type 0x09 to HF APRS decoded frames on the binary
audio TCP channel and wires it up in all three layers:
- trx-core: AUDIO_MSG_HF_APRS_DECODE = 0x09
- trx-server: emit 0x09 in the live dispatch match and include
hf_aprs history in the connection-open replay blob
- trx-client: recognise 0x09 and forward to the decode broadcast
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Adds a second APRS demodulator path tuned for the HF APRS standard
(300 baud Bell 103-style AFSK, mark=1600 Hz / space=1800 Hz), active
on RigMode::DIG. Shares AX.25 framing, APRS parsing, APRS-IS uplink,
and frontend display with the existing VHF stack.
- trx-aprs: parameterise Demodulator::new(); add AprsDecoder::new_hf()
- trx-core: HfAprs variant in DecodedMessage; hf_aprs_decode_enabled /
hf_aprs_decode_reset_seq in RigState/RigSnapshot; SetHfAprsDecodeEnabled
and ResetHfAprsDecoder commands; handlers.rs fallback arm updated
- trx-protocol: client command variants + bidirectional mapping; test
fixture updated
- trx-server: run_hf_aprs_decoder() task (activates on DIG mode);
hf_aprs history in DecoderHistories; rig_task command dispatch;
aprsfi uplink forwards HfAprs via OR-pattern
- trx-frontend: hf_aprs_history in FrontendRuntimeContext
- trx-frontend-http: prune/record/snapshot/clear helpers; SSE history
replay; toggle_hf_aprs_decode + clear_hf_aprs_decode endpoints;
/hf-aprs.js endpoint; HF APRS tab in web UI
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Safari stalls noticeably on gl.bufferData (which reallocates the GPU
buffer) when called multiple times per frame. Replace with a pre-
allocated scratch Float32Array and gl.bufferSubData, which only uploads
new data without reallocating. The GPU buffer is grown with bufferData
only when the scratch outgrows it (amortised doubling). Also eliminate
the per-draw-call `new Float32Array(vertices)` allocation in favour of
scratch.set() + subarray(), removing per-frame GC pressure.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Replace per-message write + flush-every-64 with a single in-memory blob
that is sent via one write_all + one flush. Add estimated_total_count()
to DecoderHistories for pre-allocation. Eliminates N/64 partial flushes
and repeated small writes that dominated replay latency for large APRS
histories.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
During history restore with thousands of APRS packets, the console.log
in addAprsPacket was called for every entry, slowing the replay down
significantly. Remove it entirely.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
ResetCwDecoder already bumped cw_decode_reset_seq but omitted the
history flush that APRS, FT8, and WSPR all perform. Wire
clear_cw_history() into the handler to match the pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
No reset event for CW is wired in rig_task.rs, so the method was
dead code and triggered a compiler warning.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Subscribe to the first sdr.channels[] entry configured as VDES or
MARINE instead of always using channel 0. The ChannelDsp IQ tap only
emits samples when its own mode is VDES/MARINE, so the two must agree.
Fix vdes_sr to mirror channel.rs pipeline_rates(): use
audio_sample_rate.max(96_000) as the target rather than the hardcoded
96_000. The two diverge when audio_sample_rate > 96_000, causing the
VdesDecoder to use the wrong symbol-to-sample ratio.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Replace the free-running phase counter in slice_pi4_qpsk_symbols with
linear interpolation to sample the IQ stream at exact symbol epochs.
Add estimate_differential_cfo() that uses the 4th-power method to
cancel pi/4-QPSK modulation phase, yielding a per-burst CFO estimate
that is removed before differential decoding.
At the ~1.25 samples/symbol IQ rate produced by the current decimation
pipeline, a closed-loop Gardner or Mueller-Müller TED requires at least
2 SPS and cannot be applied; the open-loop linear interpolation is the
best achievable without restructuring the IQ tap.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
History persistence lives in trx-server, not trx-client.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Move persistent history from trx-client to trx-server where decode
events originate. History for AIS, VDES, APRS, CW, FT8, and WSPR is
loaded from ~/.local/cache/trx-rs/history.db at startup and flushed
to disk every 60 seconds. CW events are now also stored in
DecoderHistories and replayed to connecting clients, consistent with
all other decoder types.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Add pickledb-backed persistent history store for all decoder types
(AIS, VDES, APRS, CW, FT8, WSPR). History is loaded from
~/.local/cache/trx-rs/history.db at startup and flushed to disk
every 60 seconds. On load, entry timestamps are reconstructed from
stored Unix ms values so 24h pruning continues to work correctly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Two problems prevented reliable recovery after a persistent overflow:
1. Restart storm: once read_error_streak >= 3, every subsequent read
failure triggered a deactivate/activate cycle, potentially preventing
the hardware from stabilising. Fix: after a successful restart, reset
read_error_streak to 1 so the stream gets 2 more failures before the
next restart attempt.
2. Stuck-deactivated stream: if activate() failed after overflow, the
stream was left deactivated. Subsequent reads returned non-overflow
errors which handle_read_error ignored (Ok(false)), so the stream was
never reactivated. Fix: add a high-streak fallback (>= 10 consecutive
errors of any kind) that also attempts a full deactivate/activate
restart, covering the stuck-deactivated case.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Two bugs triggered by a SoapySDR IQ overflow:
1. Spectrum dies permanently (trx-client): when GetSpectrum times out
(300ms SPECTRUM_IO_TIMEOUT), the error was silently swallowed and
the spectrum buffer cleared. The in-flight response remained in the
TCP receive buffer, desynchronising all subsequent reads so every
poll kept failing. Fix: propagate the error so handle_connection
returns and the outer loop reconnects, restoring TCP sync.
2. CTRL+C hangs trx-server: after IQ overflow, the sdr-iq-read thread
can get stuck in a blocking SoapySDR/USB call (deactivate/activate
with no timeout). Tokio received SIGINT and aborted async tasks, but
the process could not exit while the native thread was blocked in
uninterruptible I/O. Fix: call std::process::exit(0) after the
graceful shutdown sequence so the OS forcibly terminates all threads.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Server emits an SSE sentinel event (history_done) after replaying
stored history. Client buffers all incoming messages until the sentinel
arrives, then drains the buffer in 30-event chunks via setTimeout so
the browser can handle input between batches. Live events after the
sentinel are dispatched immediately as before.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Prepend the in-progress line to the bar render so characters appear
immediately rather than waiting for a newline or 5s gap.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Show a live decode bar on the overview strip when in CW/CWR mode,
matching the APRS and AIS bar pattern. Accumulates decoded characters
into lines (split on newline events or >5s gaps), keeps a 15-minute
rolling history, and shows up to 8 recent lines with timestamp and
WPM/tone metadata. Clears on resetCwHistoryView.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Add cwAutoLocalOverride flag in cw.js to block server-state snapshots
from overriding the checkbox while a user-initiated POST is in-flight.
Expose applyCwAutoUiFromServer for app.js render() to call instead of
applyCwAutoUi, preventing a racing SSE event carrying the old cw_auto
value from immediately undoing the user's toggle.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
- Reduce analysis window from 50ms to 10ms so the decoder can detect
dits at 25+ WPM (at 25 WPM a dit is 48ms, shorter than the old window)
- Fix dot/dash classification threshold from 2.0× to 1.5× unit_ms;
ITU Morse dah = 3× dit, so the midpoint boundary is 1.5×
- Replace O(n) on_durations.remove(0) with drain() to trim the window
- Remove pointless emit_text() wrapper; callers now call emit_event()
directly
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Add per-channel processing gating and disable hidden AIS channel DSP unless mode is AIS or MARINE, reducing continuous IQ read-loop CPU load in normal operation.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Add click-to-tune behavior on overview waterfall matching spectrum interactions, restore bookmark marker lines in the overlay using category colors, and keep current-tuned frequency marker visually distinct.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Skip IQ fanout buffer cloning when no subscribers exist and apply backoff on repeated zero-length reads to avoid hot-loop CPU spikes.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>